Initial Commit
Some checks failed
Deploy / Update K8s Apps / Detect changed K8s tfvars (push) Failing after 13s
Deploy / Update Apps / Detect changed tfvars files (push) Failing after 13s
Test / Static Analysis (push) Failing after 11s
Test / Unit Tests — Docker Stack (push) Has been skipped
Test / Unit Tests — K8s Stack (push) Has been skipped
Deploy / Update K8s Apps / Deploy ${{ matrix.tfvars }} (push) Has been skipped
Deploy / Update K8s Apps / Destroy ${{ matrix.tfvars }} (push) Has been skipped
Deploy / Update Apps / Deploy ${{ matrix.tfvars }} (push) Has been skipped
Deploy / Update Apps / Destroy ${{ matrix.tfvars }} (push) Has been skipped
Test / Integration Test — K8s (k3d) (push) Has been skipped

This commit is contained in:
2026-03-06 19:17:15 +01:00
commit 3bf5960302
36 changed files with 5859 additions and 0 deletions

View File

@@ -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

208
.gitea/workflows/deploy.yml Normal file
View File

@@ -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

172
.gitea/workflows/test.yml Normal file
View File

@@ -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

39
.gitlab-ci.yml Normal file
View File

@@ -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/<app-name> — SSH private key for Docker host
# infra/kubeconfigs/<app-name> — kubeconfig for K8s cluster
# apps/<app-name>/db_password — PostgreSQL password
# apps/<app-name>/git_token — optional git token for git_clone mode
# apps/<app-name>/rabbitmq_password — RabbitMQ password (K8s stack)
# apps/<app-name>/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

View File

@@ -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/<app-name> — kubeconfig for the target cluster
# apps/<app-name>/rabbitmq_password — RabbitMQ admin password
# apps/<app-name>/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

View File

@@ -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

View File

@@ -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

23
.terraform.lock.hcl generated Normal file
View File

@@ -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",
]
}

View File

@@ -0,0 +1,870 @@
<a name="v3.9.0"></a>
## [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))
<a name="v3.8.0"></a>
## [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))
<a name="v3.7.0"></a>
## [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))
<a name="v3.6.2"></a>
## [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))
<a name="v3.6.1"></a>
## [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))
<a name="v3.6.0"></a>
## [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))
<a name="v3.5.0"></a>
## [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))
<a name="v3.4.0"></a>
## [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))
<a name="v3.3.0"></a>
## [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))
<a name="v3.2.0"></a>
## [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))
<a name="v3.1.2"></a>
## [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))
<a name="v3.1.1"></a>
## [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))
<a name="v3.1.0"></a>
## [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))
<a name="v3.0.2"></a>
## [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))
<a name="v3.0.1"></a>
## [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))
<a name="v3.0.0"></a>
## [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))
<a name="v2.25.0"></a>
## [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))
<a name="v2.24.0"></a>
## [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.
<a name="v2.23.1"></a>
## [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))
<a name="v2.23.0"></a>
## [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))
<a name="v2.22.0"></a>
## [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))
<a name="v2.21.0"></a>
## [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.
<a name="v2.20.3"></a>
## [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))
<a name="v2.20.2"></a>
## [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))
<a name="v2.20.1"></a>
## [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))
<a name="v2.20.0"></a>
## [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))
<a name="v2.19.0"></a>
## [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))
<a name="v2.18.1"></a>
## [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))
<a name="v2.18.0"></a>
## [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))
<a name="v2.17.0"></a>
## [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))
<a name="v2.16.0"></a>
## [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
<a name="v2.15.0"></a>
## [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))
<a name="v2.14.0"></a>
## [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))
<a name="v2.13.0"></a>
## [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))
<a name="v2.12.2"></a>
## [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
<a name="v2.12.1"></a>
## [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
<a name="v2.12.0"></a>
## [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))
<a name="v2.11.0"></a>
## [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
<a name="v2.10.0"></a>
## [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
<a name="v2.9.0"></a>
## [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
<a name="v2.8.0"></a>
## 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

View File

@@ -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.

View File

@@ -0,0 +1,117 @@
<a href="https://docker.com">
<img src="https://raw.githubusercontent.com/kreuzwerker/terraform-provider-docker/master/assets/docker-logo.png" alt="Docker logo" title="Docker" align="right" height="100" />
</a>
<a href="https://terraform.io">
<img src="https://raw.githubusercontent.com/kreuzwerker/terraform-provider-docker/master/assets/terraform-logo.png" alt="Terraform logo" title="Terraform" align="right" height="100" />
</a>
<a href="https://kreuzwerker.de">
<img src="https://raw.githubusercontent.com/kreuzwerker/terraform-provider-docker/master/assets/xw-logo.png" alt="Kreuzwerker logo" title="Kreuzwerker" align="right" height="100" />
</a>
# 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)

123
CLAUDE.md Normal file
View File

@@ -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=<run_block_name> # 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/<appname>.tfstate` (Docker) and `apps-k8s/<appname>.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 (`<appname>-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.

174
Makefile Normal file
View File

@@ -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."

958
README.md Normal file
View File

@@ -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/<appname>.tfstate` |
| Kubernetes | `apps-k8s/<appname>.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/<appname>.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/<appname>.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/
│ │ └── <appname> # SSH private key for the remote Docker host
│ └── kubeconfigs/
│ └── <appname> # kubeconfig for the target K8s cluster
└── apps/
└── <appname>/
├── 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/<appname>` 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
└── <appname>-network (bridge)
├── <appname>-openresty → port <external> on host
├── <appname>-postgres (internal only)
└── <appname>-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/<appname>.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/<appname>
gopass insert apps/<appname>/db_password
gopass insert apps/<appname>/git_token # only if using git_clone with a private repo
```
4. Commit and push `apps/<appname>.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: <appname>
├── Deployment: <appname>
│ ├── 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: <appname>-data ← shared between init, app, and alloy
├── Service: <appname> ← ClusterIP port 1880 (configurable)
├── Middleware: strip-prefix
├── IngressRoute: <appname> ← PathPrefix(/<appname>) → strips prefix → Service
└── (optional) StatefulSet: <appname>-rabbitmq
├── Service: <appname>-rabbitmq-headless (for StatefulSet DNS)
├── Service: <appname>-rabbitmq (ClusterIP, AMQP + management)
├── Middleware: rabbitmq-strip-prefix
└── IngressRoute: <appname>-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 <init_data_src_path>/ (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/<filename>.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://<appname>-rabbitmq.<namespace>.svc:5672/<vhost>
```
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/<appname>.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/<appname>
gopass insert apps/<appname>/rabbitmq_password # if enable_rabbitmq = true
gopass insert apps/<appname>/loki_token # if Loki requires auth
```
4. Commit and push `k8s/apps/<appname>.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/<appname>.tfstate`)
5. `tofu workspace select <appname>` 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/<appname>.tfstate`)
5. `tofu workspace select <appname>` 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-<your-stack>/ # 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 `<stack>/apps/*.tfvars`:
- Gitea: `.gitea/workflows/deploy-<stack>.yml` (copy and adapt `deploy.yml`)
- GitLab: `.gitlab/workflows/deploy-<stack>.gitlab-ci.yml` + add the `include:` line to `.gitlab-ci.yml`
4. Add per-app `.tfvars` files under `<stack>/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 (`<appname>-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` |

24
apps/example-dev.tfvars Normal file
View File

@@ -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"

27
apps/example-prod.tfvars Normal file
View File

@@ -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"

50
backend.tf Normal file
View File

@@ -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 = <seaweedfs-access-key>
# AWS_SECRET_ACCESS_KEY = <seaweedfs-secret-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/<workspace>/terraform.tfstate
# Commit .gitignore entries for *.tfstate and *.tfstate.backup.
# Not suitable for concurrent CI/CD runs.

View File

@@ -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.

30
k8s/backend.tf Normal file
View File

@@ -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 = <seaweedfs-access-key>
# AWS_SECRET_ACCESS_KEY = <seaweedfs-secret-key>
#
# Uncomment to enable:
#
# terraform {
# backend "s3" {}
# }
# OPTION B — Local backend (default, for local development)
# ─────────────────────────────────────────────────────────────────────────────
# State stored at k8s/terraform.tfstate.d/<workspace>/terraform.tfstate
# Add k8s/**/.terraform and k8s/terraform.tfstate* to .gitignore.

76
k8s/main.tf Normal file
View File

@@ -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
}

View File

@@ -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."
}
}

143
k8s/variables.tf Normal file
View File

@@ -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"
}

64
main.tf Normal file
View File

@@ -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
}
}

View File

@@ -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]
}

View File

@@ -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: <name>.<namespace>.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 : ""
}

View File

@@ -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"
}

View File

@@ -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,
]
}

View File

@@ -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 : ""
}

View File

@@ -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:<token>@.
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"
}

85
scripts/setup-backend.sh Normal file
View File

@@ -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."

View File

@@ -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]
}

127
variables.tf Normal file
View File

@@ -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"
}