diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 88c383f..fa89c90 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,7 +4,7 @@ ## Команды -**Taskfile.yaml — источник правды.** Смотри `task --list` для актуального списка команд. +**Taskfile.yaml — источник правды.** Смотри `task --list-all` для актуального списка команд и классов побочных эффектов. Основные: - `task up` — полный setup (init + bootstrap + app-of-apps) @@ -16,7 +16,12 @@ Sealed Secrets: - `task seal:openrouter` — создать SealedSecret для OpenRouter API - `task seal:github` — создать SealedSecret для GitHub репо -- `task seal:postgree` — создать SealedSecret для PostgreSQL +- `task seal:postgres` — создать SealedSecret для PostgreSQL +- `task seal:postgree` — legacy-команда, сохраняющая имя Secret `chat-postgree` + +`seal:*` — опциональные операторские утилиты, они не вызываются активными +dev/smoke workflow. Значения читаются из переменных окружения и не передаются в +аргументах процесса. Старый формат `KEY=value` намеренно отклоняется. ## Переменные окружения @@ -27,6 +32,9 @@ Sealed Secrets: - `GITHUB_PAT` / `GITHUB_USERNAME` / `GITHUB_REPO_URL` — для ArgoCD Image Updater - `CLOUDFLARE_TOKEN` — токен Cloudflare API для cert-manager и external-dns +`task app-of-apps` по умолчанию использует `app-poly-gitops-k8s@v1.5.0`. +Переопределение `GITOPS_REVISION` принимает только release tag или полный SHA. + ## Структура ``` diff --git a/.env.example b/.env.example index 5820590..e61fbcd 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ TF_VAR_node_count=1 # TF_VAR_max_cost_rub=80 # === Application Secrets === +# Optional operator inputs for `task seal:*`. These are not consumed by the +# active dev/smoke roots unless the rendered SealedSecret is reviewed and added +# to the GitOps repository. Values are read from the environment, never argv. # OpenRouter API OPENROUTER_API_KEY=sk-or-xxx @@ -30,5 +33,5 @@ GITHUB_PAT=github_pat_xxx GITHUB_USERNAME=justgithubaccount GITHUB_REPO_URL=https://github.com/justgithubaccount/app-poly-gitops-helm -# PostgreSQL (when ready) +# PostgreSQL (optional; retained for future external database integration) # DATABASE_URL=postgresql://user:pass@host:5432/db diff --git a/.github/workflows/application-live-smoke.yaml b/.github/workflows/application-live-smoke.yaml index 2dfec65..337c578 100644 --- a/.github/workflows/application-live-smoke.yaml +++ b/.github/workflows/application-live-smoke.yaml @@ -29,6 +29,10 @@ jobs: (github.event_name == 'push' && contains(github.event.head_commit.message, '[live-smoke]')) runs-on: ubuntu-latest timeout-minutes: 20 + env: + CHAT_BACKEND_REVISION: 6686997d9c4e8d6815886790341d5e185f86bf1a + CREWAI_REVISION: 0835d3102555e6cef4fb700c56969ecddc6f6ddd + UI_REVISION: 06575c95fe30eadc85b3441df26792eef8631683 steps: - name: Checkout smoke harness uses: actions/checkout@v7 @@ -38,28 +42,28 @@ jobs: uses: actions/checkout@v7 with: repository: justgithubaccount/app-poly-gitops-fastapi - ref: v1.1.2 + ref: ${{ env.CHAT_BACKEND_REVISION }} path: sources/fastapi persist-credentials: false - name: Checkout CrewAI release uses: actions/checkout@v7 with: repository: justgithubaccount/app-poly-gitops-crewai - ref: v0.1.3 + ref: ${{ env.CREWAI_REVISION }} path: sources/crewai persist-credentials: false - name: Checkout reviewed UI implementation uses: actions/checkout@v7 with: repository: justgithubaccount/app-poly-gitops-ui - ref: feat/crewai-automation + ref: ${{ env.UI_REVISION }} path: sources/ui persist-credentials: false - name: Verify audited source revisions before exposing the key run: | - test "$(git -C sources/fastapi rev-parse HEAD)" = 6686997d9c4e8d6815886790341d5e185f86bf1a - test "$(git -C sources/crewai rev-parse HEAD)" = 0835d3102555e6cef4fb700c56969ecddc6f6ddd - test "$(git -C sources/ui rev-parse HEAD)" = 2852b17530f7eac756c3e4b6a3e35b1ef8f7837b + test "$(git -C sources/fastapi rev-parse HEAD)" = "${CHAT_BACKEND_REVISION}" + test "$(git -C sources/crewai rev-parse HEAD)" = "${CREWAI_REVISION}" + test "$(git -C sources/ui rev-parse HEAD)" = "${UI_REVISION}" - uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 95272e4..7329462 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,23 +14,31 @@ jobs: - uses: actions/checkout@v4 - name: Install Task - uses: arduino/setup-task@v2 + uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 with: - version: 3.x + version: 3.54.0 - name: Validate Taskfile - run: task --list + run: task --list-all --json > /dev/null + + - name: Install offline test dependencies + run: | + python3 -m venv .venv-ci + .venv-ci/bin/pip install PyYAML==6.0.2 + + - name: Test Taskfile and live-smoke contracts + run: .venv-ci/bin/python -m unittest discover -s tests -p 'test_*.py' -v - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd with: - terraform_version: "1.6" + terraform_version: "1.13.5" - name: Terraform Format Check run: terraform fmt -check -recursive terraform/ - name: Terraform Init (no backend) - run: terraform -chdir=terraform/timeweb init -backend=false + run: terraform -chdir=terraform/timeweb init -backend=false -input=false - name: Terraform Validate run: terraform -chdir=terraform/timeweb validate @@ -49,10 +57,11 @@ jobs: document-start: disable - name: Check shell scripts - uses: ludeeus/action-shellcheck@master + uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 with: scandir: './scripts' severity: warning + version: v0.11.0 - name: Test disposable GitOps workflow run: bash tests/test_ephemeral_smoke.sh @@ -60,9 +69,6 @@ jobs: - name: Test Timeweb floating IP cleanup workflow run: bash tests/test_timeweb_floating_ip_cleanup.sh - - name: Test live application smoke budget guard (offline) - run: python3 -m unittest discover -s tests -p test_application_live_smoke.py -v - security: name: Security Scan runs-on: ubuntu-latest @@ -78,8 +84,9 @@ jobs: GITLEAKS_ENABLE_COMMENTS: false - name: tfsec - uses: aquasecurity/tfsec-action@v1.0.3 + uses: aquasecurity/tfsec-action@b466648d6e39e7c75324f25d83891162a721f2d6 with: working_directory: terraform/timeweb - soft_fail: true + version: v1.28.14 + soft_fail: false github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 3c717e6..fdb9e87 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ kubeconfig* .env secrets/ *secret* +!scripts/render-secret-json.py # IDE .idea/ diff --git a/README.md b/README.md index 6db6a66..03cf0e0 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ when debugging is complete. ## Local provisioning -Requirements: Terraform 1.6+, Task, kubectl, Helm 3, and a Timeweb API token. +Requirements: Terraform 1.13.5, Task 3.54.0, kubectl, Helm 3, curl, jq, and a +Timeweb API token. Python 3 and kubeseal are additionally required only for the +optional SealedSecret helper. ~~~bash cp .env.example .env @@ -53,6 +55,12 @@ task up `~/.kube/timeweb-config`, installs Argo CD, and applies the root application. Review the plan, selected presets, and cost estimate first. +Task descriptions begin with a side-effect class: `[read-only]`, +`[local-write]`, `[mutating]`, `[destructive]`, or `[secret-sensitive]`. Use +`task --list-all` to review the full interface before execution. The default +GitOps root is `app-poly-gitops-k8s@v1.5.0`; an operator may supply another +release tag or full commit SHA with `GITOPS_REVISION=...`. + Useful read and cleanup commands: ~~~bash @@ -82,6 +90,14 @@ persistent environment, copy `terraform/timeweb/backend.s3.tf.example` to a Argo CD chart values are in `bootstrap/argocd/values.yaml`. The smoke root is `bootstrap/argocd/gitops-smoke-root.yaml`. +The `seal:*` tasks are optional manifest-rendering utilities and are not called +by active dev/smoke workflows. They read secret values from named environment +variables and write the rendered SealedSecret to stdout. The corrected +`seal:postgres` name is canonical; `seal:postgree` remains a compatibility +entrypoint that preserves the existing `chat-postgree` Secret name consumed by +the GitOps repository. Legacy `KEY=value` command arguments are rejected because +they expose secret values through process listings. + ## Repository structure - `terraform/timeweb/` — Timeweb provider, preset selection, cost guard, and module call @@ -96,13 +112,16 @@ Argo CD chart values are in `bootstrap/argocd/values.yaml`. The smoke root is Offline repository checks: ~~~bash -task --list +task --list-all --json > /dev/null +python3 -m venv .venv-ci +.venv-ci/bin/pip install PyYAML==6.0.2 +.venv-ci/bin/python -m unittest discover -s tests -p 'test_*.py' -v terraform fmt -check -recursive terraform -terraform -chdir=terraform/timeweb init -backend=false +terraform -chdir=terraform/timeweb init -backend=false -input=false terraform -chdir=terraform/timeweb validate bash tests/test_ephemeral_smoke.sh bash tests/test_timeweb_floating_ip_cleanup.sh -python3 -m unittest discover -s tests -p test_application_live_smoke.py -v +tfsec terraform/timeweb ~~~ Do not run `task up`, Terraform apply/destroy, or the Timeweb workflows as a @@ -111,6 +130,7 @@ documentation check. ## Documentation - [Architecture](docs/ARCHITECTURE.md) +- [Taskfile and operator contracts](docs/TASKFILE.md) - [Tenant applications](docs/TENANT-APPS.md) ## Related repositories diff --git a/Taskfile.yaml b/Taskfile.yaml index 477e2a7..4325659 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -2,6 +2,7 @@ version: '3' vars: ARGOCD_VERSION: "9.1.6" + GITOPS_REVISION: "v1.5.0" TF_DIR: terraform/timeweb KUBECONFIG_PATH: "{{.HOME}}/.kube/timeweb-config" @@ -12,30 +13,30 @@ env: tasks: default: - desc: "Show available tasks" + desc: "[read-only] Show available tasks" cmds: - - task --list + - task --list-all # Infrastructure init: - desc: "Initialize terraform and create cluster" + desc: "[mutating] Initialize Terraform and create cluster" cmds: - terraform -chdir={{.TF_DIR}} init - terraform -chdir={{.TF_DIR}} apply -auto-approve -refresh=true - task: kubeconfig refresh: - desc: "Refresh Terraform outputs (after code changes)" + desc: "[mutating] Refresh Terraform state and outputs" cmds: - terraform -chdir={{.TF_DIR}} apply -refresh-only -auto-approve plan: - desc: "Show terraform plan" + desc: "[read-only] Show Terraform plan" cmds: - terraform -chdir={{.TF_DIR}} plan kubeconfig: - desc: "Get kubeconfig from Terraform output" + desc: "[local-write] Write kubeconfig from Terraform output" cmds: - mkdir -p {{.HOME}}/.kube - terraform -chdir={{.TF_DIR}} output -raw kubeconfig > {{.KUBECONFIG_PATH}} @@ -44,13 +45,7 @@ tasks: # Argo CD bootstrap bootstrap: - desc: "Bootstrap ArgoCD in cluster" - vars: - HELM_CMD: >- - helm template argocd argo/argo-cd - --namespace argocd - --version {{.ARGOCD_VERSION}} - --values bootstrap/argocd/values.yaml + desc: "[mutating] Bootstrap Argo CD in cluster" cmds: - >- kubectl --kubeconfig={{.KUBECONFIG_PATH}} @@ -58,34 +53,55 @@ tasks: kubectl --kubeconfig={{.KUBECONFIG_PATH}} apply -f - - helm repo add argo https://argoproj.github.io/argo-helm - helm repo update - - '{{.HELM_CMD}} | kubectl --kubeconfig={{.KUBECONFIG_PATH}} apply -f - || {{.HELM_CMD}} | kubectl --kubeconfig={{.KUBECONFIG_PATH}} apply -f -' + - | + set -eu + render_file="$(mktemp)" + trap 'rm -f "${render_file}"' EXIT + helm template argocd argo/argo-cd \ + --namespace argocd \ + --version {{.ARGOCD_VERSION}} \ + --values bootstrap/argocd/values.yaml > "${render_file}" + if ! kubectl --kubeconfig={{.KUBECONFIG_PATH}} apply -f "${render_file}"; then + echo "Initial Argo CD apply failed. Retrying the same rendered manifest once." >&2 + kubectl --kubeconfig={{.KUBECONFIG_PATH}} apply -f "${render_file}" + fi argocd-password: - desc: "Get ArgoCD admin password" + desc: "[secret-sensitive] Print Argo CD admin password" cmds: - kubectl --kubeconfig={{.KUBECONFIG_PATH}} -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d && echo argocd-port-forward: - desc: "Port-forward ArgoCD UI to localhost:8080" + desc: "[read-only] Port-forward Argo CD UI to localhost:8080" cmds: - kubectl --kubeconfig={{.KUBECONFIG_PATH}} port-forward svc/argocd-server -n argocd 8080:443 # GitOps app-of-apps: - desc: "Deploy root Application (app-of-apps)" - cmds: - - kubectl --kubeconfig={{.KUBECONFIG_PATH}} apply -f https://raw.githubusercontent.com/justgithubaccount/app-poly-gitops-k8s/main/platform/core/cluster-bootstrap/app-of-apps.yaml + desc: "[mutating] Apply the versioned GitOps root Application" + cmds: + - | + set -eu + revision='{{.GITOPS_REVISION}}' + if ! printf '%s\n' "${revision}" | grep -Eq '^(v[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f]{40})$'; then + echo "GITOPS_REVISION requires a release tag or 40-character commit SHA" >&2 + exit 2 + fi + curl -fsSL "https://raw.githubusercontent.com/justgithubaccount/app-poly-gitops-k8s/${revision}/platform/core/cluster-bootstrap/app-of-apps.yaml" | + kubectl --kubeconfig={{.KUBECONFIG_PATH}} create --dry-run=client -f - -o json | + jq --arg revision "${revision}" '.spec.source.targetRevision = $revision' | + kubectl --kubeconfig={{.KUBECONFIG_PATH}} apply -f - # Cleanup destroy: - desc: "Destroy infrastructure (DANGER!)" + desc: "[destructive] Destroy all Terraform-managed infrastructure" prompt: "This will destroy all infrastructure. Are you sure?" cmds: - terraform -chdir={{.TF_DIR}} destroy -auto-approve # Full workflow up: - desc: "Full setup: init + bootstrap + app-of-apps" + desc: "[mutating] Create cluster, bootstrap Argo CD, and apply GitOps root" cmds: - task: init - task: bootstrap @@ -94,65 +110,70 @@ tasks: # Diagnostics status: - desc: "Check ArgoCD status (usage: task status -- [app-name])" + desc: "[read-only] Check Argo CD status (usage: task status -- [app-name])" cmds: - ./scripts/argocd-status.sh {{.CLI_ARGS}} sync-wait: - desc: "Wait for ArgoCD app sync (usage: task sync-wait -- app-name)" + desc: "[read-only] Wait for Argo CD app sync (usage: task sync-wait -- app-name)" cmds: - ./scripts/argocd-sync-check.sh {{.CLI_ARGS}} nodes: - desc: "Check cluster nodes" + desc: "[read-only] List cluster nodes" cmds: - - ./scripts/check-nodes.sh + - kubectl --kubeconfig={{.KUBECONFIG_PATH}} get nodes -o wide # Secrets seal: - desc: "Create SealedSecret (usage: task seal -- name namespace KEY=value)" + desc: "[secret-sensitive] Render SealedSecret (usage: task seal -- name namespace KEY[:ENV_VAR])" cmds: - ./scripts/seal.sh {{.CLI_ARGS}} seal:openrouter: - desc: "Create OpenRouter SealedSecret from .env" + desc: "[secret-sensitive] Render OpenRouter SealedSecret from environment" cmds: - - ./scripts/seal.sh chat-openrouter chat-api OPENROUTER_API_KEY=$OPENROUTER_API_KEY + - ./scripts/seal.sh chat-openrouter chat-api OPENROUTER_API_KEY seal:github: - desc: "Create GitHub repo SealedSecret from .env" + desc: "[secret-sensitive] Render GitHub repository SealedSecret from environment" + cmds: + - ./scripts/seal.sh chat-github argocd --repo url:GITHUB_REPO_URL username:GITHUB_USERNAME password:GITHUB_PAT + + seal:postgres: + desc: "[secret-sensitive] Render PostgreSQL SealedSecret from environment" cmds: - - ./scripts/seal.sh chat-github argocd --repo url=$GITHUB_REPO_URL username=$GITHUB_USERNAME password=$GITHUB_PAT + - ./scripts/seal.sh chat-postgres chat-api DATABASE_URL seal:postgree: - desc: "Create PostgreSQL SealedSecret from .env" + desc: "[secret-sensitive] Legacy PostgreSQL Secret name (chat-postgree)" cmds: - - ./scripts/seal.sh chat-postgree chat-api DATABASE_URL=$DATABASE_URL + - ./scripts/seal.sh chat-postgree chat-api DATABASE_URL # Timeweb CLI twc:versions: - desc: "List available K8s versions" + desc: "[read-only] List available Kubernetes versions" cmds: - twc k8s list-k8s-versions twc:presets: - desc: "List K8s presets (master/worker)" + desc: "[read-only] List Kubernetes presets (master/worker)" cmds: - twc k8s list-presets twc:list: - desc: "List K8s clusters" + desc: "[read-only] List Kubernetes clusters" cmds: - twc k8s list twc:kubeconfig: - desc: "Download kubeconfig for cluster (usage: task twc:kubeconfig -- CLUSTER_ID)" + desc: "[local-write] Download kubeconfig (usage: task twc:kubeconfig -- CLUSTER_ID)" cmds: - twc k8s kubeconfig {{.CLI_ARGS}} -o {{.KUBECONFIG_PATH}} - chmod 600 {{.KUBECONFIG_PATH}} - echo "Kubeconfig saved to {{.KUBECONFIG_PATH}}" twc:show: - desc: "Show cluster info (usage: task twc:show -- CLUSTER_ID)" + desc: "[read-only] Show cluster info (usage: task twc:show -- CLUSTER_ID)" cmds: - twc k8s show {{.CLI_ARGS}} diff --git a/bootstrap/argocd/values.yaml b/bootstrap/argocd/values.yaml index 45a2674..7a33fe3 100644 --- a/bootstrap/argocd/values.yaml +++ b/bootstrap/argocd/values.yaml @@ -48,6 +48,8 @@ extraObjects: valueFiles: - $values/bootstrap/argocd/values.yaml - repoURL: https://github.com/justgithubaccount/app-poly-gitops-infra + # Keep main until a release newer than v0.1.0 carries an immutable + # self-reference. v0.1.0 itself points back to main. targetRevision: main ref: values syncPolicy: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 98a2cf4..d601618 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,6 +48,17 @@ The Taskfile and cloud workflow install Argo CD chart `9.1.6` with server Ingress disabled and `server.insecure=true`. The public Ingress, if enabled by the dev profile, belongs to the GitOps repository. +Local bootstrap renders the chart once, applies that immutable temporary +render, and retries the same file once on a transient apply failure. The +self-managed Argo CD values source intentionally remains on `main` until a +release newer than `v0.1.0` contains an immutable self-reference. Pinning the +Application to `v0.1.0` is unsafe because that tag's own values file points back +to `main`, allowing the Application to reconcile its source revision away from +the requested pin. This exception applies only to Argo CD self-management. The +local GitOps root defaults to `app-poly-gitops-k8s@v1.5.0`, and the Taskfile +rejects branch names; an operator can override it only with a semantic release +tag or full commit SHA. + After bootstrap, one root application selects the environment path: - `clusters/smoke` for disposable acceptance; @@ -75,7 +86,7 @@ Terraform destroy. ### Runner-local application smoke -**Application live OpenAI smoke (no cloud)** starts pinned source revisions on an +**Application live OpenAI smoke (no cloud)** starts pinned commit revisions on an Actions runner. A local transport holds the provider key and enforces request, model, input, output-token, and retry bounds. This validates application wiring but not Kubernetes, Argo CD, DNS, or cluster telemetry. diff --git a/docs/TASKFILE.md b/docs/TASKFILE.md new file mode 100644 index 0000000..f9c1355 --- /dev/null +++ b/docs/TASKFILE.md @@ -0,0 +1,60 @@ +# Taskfile and operator contracts + +`Taskfile.yaml` is the supported local operator interface. Every entrypoint has +an explicit side-effect class. `task --list-all` shows the class before any +command runs; CI also verifies task names, dependencies, referenced scripts, +kubeconfig use, immutable revisions, and critical tool versions. + +## Side-effect matrix + +| Class | Tasks | Contract | +| --- | --- | --- | +| `read-only` | `default`, `plan`, `argocd-port-forward`, `status`, `sync-wait`, `nodes`, `twc:versions`, `twc:presets`, `twc:list`, `twc:show` | Reads local configuration or remote status; does not intentionally change infrastructure. Port-forward holds a local listener. `plan` may refresh remote data/state in memory. | +| `local-write` | `kubeconfig`, `twc:kubeconfig` | Writes `~/.kube/timeweb-config` and sets mode `0600`; does not mutate the cluster. | +| `mutating` | `init`, `refresh`, `bootstrap`, `app-of-apps`, `up` | Changes Terraform state/cloud resources or Kubernetes desired state. Review the command and pins first. | +| `destructive` | `destroy` | Destroys resources tracked by the selected Terraform state after an interactive prompt. | +| `secret-sensitive` | `argocd-password`, `seal`, `seal:openrouter`, `seal:github`, `seal:postgres`, `seal:postgree` | Prints or transforms sensitive material. `seal:*` reads values from environment variables and outputs a SealedSecret; it does not apply it. | + +All Kubernetes entrypoints use `KUBECONFIG_PATH`, which defaults to +`~/.kube/timeweb-config`. Terraform entrypoints use `terraform/timeweb`. + +## Compatibility and consolidation + +| Previous interface | Current interface | Compatibility / rollback | +| --- | --- | --- | +| `nodes` called missing `scripts/check-nodes.sh` | Direct read-only `kubectl get nodes -o wide` | Revert the Taskfile command only if a maintained script with additional behavior is restored. | +| `argocd-sync-check.sh` duplicated polling logic | Compatibility wrapper delegates to `wait-for-argocd-application.sh` | Existing path, default kubeconfig (`~/.kube/timeweb-config`), explicit `KUBECONFIG`, defaults (`dev-cluster`, 120 seconds), and success/failure exit behavior remain. Revert the wrapper to restore legacy formatting only. | +| Bootstrap rendered the chart twice on retry | Render once; retry the same temporary file | Apply behavior and one retry remain. Revert the bootstrap command to restore the old re-rendering behavior. | +| Root application fetched `main` | `GITOPS_REVISION=v1.5.0`, guarded to a release tag or full SHA | Override with a reviewed release/SHA; revert the variable and command to resume branch tracking. | +| `seal:*` passed `KEY=value` via argv | `KEY[:ENV_VAR]`; value read from environment and piped to kubeseal | `KEY=value` is intentionally rejected. `seal:postgres` creates `chat-postgres`; legacy `seal:postgree` retains `chat-postgree` for the existing GitOps consumer. | + +The repository workflows call `wait-for-argocd-application.sh` directly. The +legacy `argocd-sync-check.sh` path and misspelled PostgreSQL task are retained +because external manual callers cannot be proven absent. SealedSecret helpers +remain optional: no active dev/smoke workflow invokes them, and their output is +not applied automatically. + +The self-managed Argo CD values source remains on `main` as a documented +temporary exception. The only existing release, `v0.1.0`, also points that +source to `main`, so selecting the tag does not establish a stable self-pin. A +future release can replace this exception after its tagged values contain the +new immutable revision. + +## Offline validation + +The following checks do not provision infrastructure or contact Kubernetes: + +~~~bash +task --list-all --json > /dev/null +python3 -m unittest discover -s tests -p 'test_*.py' -v +bash tests/test_ephemeral_smoke.sh +bash tests/test_timeweb_floating_ip_cleanup.sh +terraform fmt -check -recursive terraform +terraform -chdir=terraform/timeweb init -backend=false -input=false +terraform -chdir=terraform/timeweb validate +tfsec terraform/timeweb +~~~ + +Do not use `task up`, `task init`, `task refresh`, `task bootstrap`, +`task app-of-apps`, `task destroy`, Terraform plan/apply/destroy, or a Timeweb +workflow as a documentation or CI validation step. diff --git a/scripts/argocd-sync-check.sh b/scripts/argocd-sync-check.sh index 7c99856..2a38e6c 100755 --- a/scripts/argocd-sync-check.sh +++ b/scripts/argocd-sync-check.sh @@ -1,92 +1,10 @@ -#!/bin/bash -# Usage: ./scripts/argocd-sync-check.sh [app-name] [timeout-seconds] +#!/usr/bin/env bash +# Compatibility entrypoint; use wait-for-argocd-application.sh for new callers. -set -e +set -euo pipefail -KUBECONFIG="${KUBECONFIG:-$HOME/.kube/timeweb-config}" +KUBECONFIG="${KUBECONFIG:-${HOME}/.kube/timeweb-config}" export KUBECONFIG -APP_NAME="${1:-dev-cluster}" -TIMEOUT="${2:-120}" -INTERVAL=5 - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" -echo -e "${BLUE} ArgoCD Sync Check: ${APP_NAME} ${NC}" -echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" -echo "" - -INITIAL_REV=$(kubectl get application "$APP_NAME" -n argocd -o jsonpath='{.status.sync.revision}' 2>/dev/null | cut -c1-7) -echo -e "${YELLOW}Initial revision:${NC} $INITIAL_REV" -echo -e "${YELLOW}Waiting for sync (timeout: ${TIMEOUT}s)...${NC}" -echo "" - -START_TIME=$(date +%s) -SYNCED=false - -while true; do - CURRENT_TIME=$(date +%s) - ELAPSED=$((CURRENT_TIME - START_TIME)) - - if [ $ELAPSED -ge $TIMEOUT ]; then - echo -e "\n${RED}✗ Timeout reached after ${TIMEOUT}s${NC}" - break - fi - - SYNC_STATUS=$(kubectl get application "$APP_NAME" -n argocd -o jsonpath='{.status.sync.status}' 2>/dev/null) - HEALTH_STATUS=$(kubectl get application "$APP_NAME" -n argocd -o jsonpath='{.status.health.status}' 2>/dev/null) - CURRENT_REV=$(kubectl get application "$APP_NAME" -n argocd -o jsonpath='{.status.sync.revision}' 2>/dev/null | cut -c1-7) - OPERATION=$(kubectl get application "$APP_NAME" -n argocd -o jsonpath='{.status.operationState.phase}' 2>/dev/null) - - printf "\r [%3ds] Sync: %-10s | Health: %-12s | Rev: %s | Op: %s " \ - "$ELAPSED" "${SYNC_STATUS:-Unknown}" "${HEALTH_STATUS:-Unknown}" "${CURRENT_REV:-N/A}" "${OPERATION:-None}" - - if [ "$SYNC_STATUS" == "Synced" ] && [ "$HEALTH_STATUS" == "Healthy" ]; then - SYNCED=true - echo "" - echo "" - echo -e "${GREEN}✓ Application synced and healthy!${NC}" - break - fi - - if [ "$HEALTH_STATUS" == "Degraded" ]; then - echo "" - echo "" - echo -e "${RED}✗ Application is degraded${NC}" - - echo -e "${YELLOW}Conditions:${NC}" - kubectl get application "$APP_NAME" -n argocd -o jsonpath='{range .status.conditions[*]} [{.type}] {.message}{"\n"}{end}' 2>/dev/null - break - fi - - sleep $INTERVAL -done - -echo "" - -echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" -if [ "$SYNCED" == "true" ]; then - echo -e "${GREEN} Result: SUCCESS${NC}" - echo -e " Revision: $INITIAL_REV → $CURRENT_REV" - - echo "" - echo -e "${YELLOW}Deployed resources:${NC}" - kubectl get application "$APP_NAME" -n argocd \ - -o jsonpath='{range .status.resources[*]} {.kind}/{.name} [{.status}]{"\n"}{end}' 2>/dev/null | head -10 - - exit 0 -else - echo -e "${RED} Result: FAILED${NC}" - echo "" - - echo -e "${YELLOW}Resources status:${NC}" - kubectl get application "$APP_NAME" -n argocd \ - -o jsonpath='{range .status.resources[*]} {.kind}/{.name}: sync={.status} health={.health.status}{"\n"}{end}' 2>/dev/null | head -15 - - exit 1 -fi +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${script_dir}/wait-for-argocd-application.sh" "${1:-dev-cluster}" "${2:-120}" diff --git a/scripts/render-secret-json.py b/scripts/render-secret-json.py new file mode 100755 index 0000000..a04aa86 --- /dev/null +++ b/scripts/render-secret-json.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Build a Kubernetes Secret JSON document from named environment variables.""" + +import argparse +import base64 +import json +import os +import re +import sys + + +KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$") +ENV_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def fail(message: str) -> None: + print(message, file=sys.stderr) + raise SystemExit(2) + + +def main() -> None: + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--name", required=True) + parser.add_argument("--namespace", required=True) + parser.add_argument("--repo", action="store_true") + parser.add_argument("specs", nargs="+") + args = parser.parse_args() + + data = {} + for spec in args.specs: + if "=" in spec: + fail(f"invalid key mapping {spec.split('=', 1)[0]!r}; expected KEY[:ENV_VAR]") + key, separator, env_name = spec.partition(":") + if not separator: + env_name = key + if not KEY_RE.fullmatch(key) or not ENV_RE.fullmatch(env_name): + fail(f"invalid key mapping {spec!r}; expected KEY[:ENV_VAR]") + value = os.environ.get(env_name) + if not value: + fail(f"environment variable {env_name} is unset or empty") + data[key] = base64.b64encode(value.encode()).decode() + + metadata = {"name": args.name, "namespace": args.namespace} + if args.repo: + metadata["labels"] = {"argocd.argoproj.io/secret-type": "repository"} + json.dump( + {"apiVersion": "v1", "kind": "Secret", "metadata": metadata, "data": data}, + sys.stdout, + separators=(",", ":"), + ) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/seal.sh b/scripts/seal.sh index e16b8a1..3e04fef 100755 --- a/scripts/seal.sh +++ b/scripts/seal.sh @@ -1,120 +1,70 @@ -#!/bin/bash -# Usage: -# ./scripts/seal.sh chat-openrouter chat-api OPENROUTER_API_KEY=sk-or-xxx -# ./scripts/seal.sh chat-postgree chat-api DATABASE_URL=postgresql://... -# ./scripts/seal.sh chat-github argocd --repo url=https://... username=xxx password=xxx +#!/usr/bin/env bash +# Render a SealedSecret without placing secret values in process arguments. +# Usage: seal.sh NAME NAMESPACE [--repo] KEY[:ENV_VAR] [...] -set -e +set -euo pipefail -KUBECONFIG="${KUBECONFIG:-$HOME/.kube/timeweb-config}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +KUBECONFIG="${KUBECONFIG:-${HOME}/.kube/timeweb-config}" export KUBECONFIG -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -CONTROLLER_NAME="${SEALED_SECRETS_CONTROLLER_NAME:-sealed-secrets}" -CONTROLLER_NAMESPACE="${SEALED_SECRETS_CONTROLLER_NAMESPACE:-kube-system}" +controller_name="${SEALED_SECRETS_CONTROLLER_NAME:-sealed-secrets}" +controller_namespace="${SEALED_SECRETS_CONTROLLER_NAMESPACE:-kube-system}" usage() { - echo -e "${BLUE}Usage:${NC}" - echo " $0 [--repo] KEY1=VALUE1 [KEY2=VALUE2 ...]" - echo "" - echo -e "${BLUE}Examples:${NC}" - echo " # Simple secret" - echo " $0 chat-openrouter chat-api OPENROUTER_API_KEY=sk-or-xxx" - echo "" - echo " # Database connection" - echo " $0 chat-postgree chat-api DATABASE_URL='postgresql://user:pass@host:5432/db'" - echo "" - echo " # Argo Image Updater repo secret (adds label)" - echo " $0 chat-github argocd --repo url=https://github.com/org/repo username=user password=token" - echo "" - echo -e "${BLUE}Environment:${NC}" - echo " KUBECONFIG - kubeconfig path (default: ~/.kube/timeweb-config)" - echo " SEALED_SECRETS_CONTROLLER_NAME - controller name (default: sealed-secrets)" - echo " SEALED_SECRETS_CONTROLLER_NAMESPACE - controller namespace (default: kube-system)" - exit 1 + cat >&2 <<'EOF' +Usage: seal.sh NAME NAMESPACE [--repo] KEY[:ENV_VAR] [...] + +Secret values are read from environment variables, never from arguments. +KEY reads the same-named variable; KEY:ENV_VAR maps a variable to another key. + +Examples: + seal.sh chat-openrouter chat-api OPENROUTER_API_KEY + seal.sh chat-postgres chat-api DATABASE_URL + seal.sh chat-github argocd --repo url:GITHUB_REPO_URL username:GITHUB_USERNAME password:GITHUB_PAT +EOF + exit 2 } -if [ $# -lt 3 ]; then - usage +if (($# < 3)); then + usage fi -SECRET_NAME="$1" -NAMESPACE="$2" +secret_name="$1" +namespace="$2" shift 2 -IS_REPO_SECRET=false -if [ "$1" == "--repo" ]; then - IS_REPO_SECRET=true - shift -fi - -if ! command -v kubeseal &>/dev/null; then - echo -e "${RED}✗ kubeseal not found${NC}" - echo "" - echo "Install with:" - echo " wget https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.32.2/kubeseal-0.32.2-linux-amd64.tar.gz" - echo " tar -xvzf kubeseal-0.32.2-linux-amd64.tar.gz" - echo " sudo install -m 755 kubeseal /usr/local/bin/kubeseal" - exit 1 +repo_args=() +if [[ "${1:-}" == "--repo" ]]; then + repo_args=(--repo) + shift fi -echo -e "${YELLOW}Checking SealedSecrets controller...${NC}" >&2 -if ! kubectl get pods -n "$CONTROLLER_NAMESPACE" -l app.kubernetes.io/name=sealed-secrets --no-headers 2>/dev/null | grep -q Running; then - echo -e "${RED}✗ SealedSecrets controller not running in ${CONTROLLER_NAMESPACE}${NC}" >&2 - exit 1 +if (($# == 0)); then + usage fi -echo -e "${GREEN}✓${NC} Controller found in ${CONTROLLER_NAMESPACE}" >&2 -KUBECTL_ARGS=() -for arg in "$@"; do - if [[ "$arg" == *"="* ]]; then - KUBECTL_ARGS+=("--from-literal=$arg") - else - echo -e "${RED}✗ Invalid argument: $arg (expected KEY=VALUE)${NC}" >&2 - exit 1 - fi +for command_name in kubectl kubeseal python3; do + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "required command not found: ${command_name}" >&2 + exit 2 + fi done -if [ ${#KUBECTL_ARGS[@]} -eq 0 ]; then - echo -e "${RED}✗ No key=value pairs provided${NC}" >&2 - usage -fi - -echo -e "${YELLOW}Creating SealedSecret...${NC}" >&2 -echo -e " ${BLUE}Name:${NC} $SECRET_NAME" >&2 -echo -e " ${BLUE}Namespace:${NC} $NAMESPACE" >&2 -echo -e " ${BLUE}Keys:${NC} ${#KUBECTL_ARGS[@]}" >&2 -if [ "$IS_REPO_SECRET" == "true" ]; then - echo -e " ${BLUE}Type:${NC} ArgoCD Repository Secret" >&2 -fi -echo "" >&2 - -if [ "$IS_REPO_SECRET" == "true" ]; then - # Argo CD discovers repository credentials through this label. - kubectl create secret generic "$SECRET_NAME" \ - --namespace="$NAMESPACE" \ - "${KUBECTL_ARGS[@]}" \ - --dry-run=client -o json | \ - jq '.metadata.labels["argocd.argoproj.io/secret-type"]="repository"' | \ - kubeseal \ - --controller-name="$CONTROLLER_NAME" \ - --controller-namespace="$CONTROLLER_NAMESPACE" \ - --format yaml -else - kubectl create secret generic "$SECRET_NAME" \ - --namespace="$NAMESPACE" \ - "${KUBECTL_ARGS[@]}" \ - --dry-run=client -o json | \ - kubeseal \ - --controller-name="$CONTROLLER_NAME" \ - --controller-namespace="$CONTROLLER_NAMESPACE" \ - --format yaml +echo "Checking SealedSecrets controller in ${controller_namespace}..." >&2 +if ! kubectl get pods -n "${controller_namespace}" \ + -l app.kubernetes.io/name=sealed-secrets --no-headers 2>/dev/null | grep -q Running; then + echo "SealedSecrets controller is not running in ${controller_namespace}" >&2 + exit 1 fi -echo "" >&2 -echo -e "${GREEN}✓ SealedSecret created successfully${NC}" >&2 +echo "Rendering SealedSecret ${namespace}/${secret_name} from $# environment variable(s)..." >&2 +python3 "${script_dir}/render-secret-json.py" \ + --name "${secret_name}" \ + --namespace "${namespace}" \ + "${repo_args[@]}" \ + "$@" | + kubeseal \ + --controller-name="${controller_name}" \ + --controller-namespace="${controller_namespace}" \ + --format yaml diff --git a/tests/test_application_live_smoke.py b/tests/test_application_live_smoke.py index dca5951..8b8ffaf 100644 --- a/tests/test_application_live_smoke.py +++ b/tests/test_application_live_smoke.py @@ -1,14 +1,68 @@ """Offline behavior checks for the paid smoke guard; no provider calls.""" import json +import re import sys import unittest from pathlib import Path +import yaml + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts" / "live_smoke")) from budget import BudgetGate, GateError +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class SourceRevisionContractTests(unittest.TestCase): + def setUp(self): + workflow_path = REPO_ROOT / ".github/workflows/application-live-smoke.yaml" + self.workflow = yaml.load(workflow_path.read_text(), Loader=yaml.BaseLoader) + self.job = self.workflow["jobs"]["live"] + self.steps = self.job["steps"] + + def test_all_external_sources_use_one_immutable_revision_each(self): + expected = { + "justgithubaccount/app-poly-gitops-fastapi": "CHAT_BACKEND_REVISION", + "justgithubaccount/app-poly-gitops-crewai": "CREWAI_REVISION", + "justgithubaccount/app-poly-gitops-ui": "UI_REVISION", + } + + self.assertEqual(set(self.job["env"]), set(expected.values())) + for revision in self.job["env"].values(): + self.assertRegex(revision, re.compile(r"^[0-9a-f]{40}$")) + + external_checkouts = { + step["with"]["repository"]: step + for step in self.steps + if step.get("uses") == "actions/checkout@v7" + and "repository" in step.get("with", {}) + } + self.assertEqual(set(external_checkouts), set(expected)) + for repository, variable in expected.items(): + checkout = external_checkouts[repository] + self.assertEqual(checkout["with"]["ref"], "${{ env.%s }}" % variable) + self.assertEqual(checkout["with"]["persist-credentials"], "false") + + def test_revision_verification_precedes_provider_secret_exposure(self): + verify_index = next( + index + for index, step in enumerate(self.steps) + if step.get("name") == "Verify audited source revisions before exposing the key" + ) + secret_index = next( + index + for index, step in enumerate(self.steps) + if "OPENAI_API_KEY" in step.get("env", {}) + ) + self.assertLess(verify_index, secret_index) + + verify_command = self.steps[verify_index]["run"] + for variable in ("CHAT_BACKEND_REVISION", "CREWAI_REVISION", "UI_REVISION"): + self.assertIn('${%s}' % variable, verify_command) + + class BudgetTests(unittest.TestCase): def setUp(self): self.calls = [] diff --git a/tests/test_argocd_sync_compat.sh b/tests/test_argocd_sync_compat.sh new file mode 100755 index 0000000..3d92602 --- /dev/null +++ b/tests/test_argocd_sync_compat.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +cp "${repo_root}/scripts/argocd-sync-check.sh" "${tmp_dir}/argocd-sync-check.sh" +cat > "${tmp_dir}/wait-for-argocd-application.sh" <<'EOF' +#!/usr/bin/env bash +printf 'KUBECONFIG=%s\n' "${KUBECONFIG-}" +printf '%s\n' "$@" +EOF +chmod +x "${tmp_dir}/argocd-sync-check.sh" "${tmp_dir}/wait-for-argocd-application.sh" + +test_home="${tmp_dir}/home" +mapfile -t defaults < <(env -u KUBECONFIG HOME="${test_home}" "${tmp_dir}/argocd-sync-check.sh") +[[ "${defaults[*]}" == "KUBECONFIG=${test_home}/.kube/timeweb-config dev-cluster 120" ]] + +mapfile -t explicit < <(KUBECONFIG=/tmp/explicit-kubeconfig "${tmp_dir}/argocd-sync-check.sh" crewai 300) +[[ "${explicit[*]}" == "KUBECONFIG=/tmp/explicit-kubeconfig crewai 300" ]] diff --git a/tests/test_ephemeral_smoke.sh b/tests/test_ephemeral_smoke.sh index 7391b3d..c88104f 100755 --- a/tests/test_ephemeral_smoke.sh +++ b/tests/test_ephemeral_smoke.sh @@ -9,6 +9,8 @@ verify_workflow="${repo_root}/.github/workflows/timeweb-debug-verify.yaml" bash "${repo_root}/tests/test_wait_for_kubernetes.sh" bash "${repo_root}/tests/test_wait_for_argocd_application.sh" +bash "${repo_root}/tests/test_argocd_sync_compat.sh" +bash "${repo_root}/tests/test_seal.sh" bash "${repo_root}/tests/test_fastapi_runtime_smoke.sh" bash "${repo_root}/tests/test_verify_jaeger_trace.sh" bash "${repo_root}/tests/test_verify_vector_trace.sh" diff --git a/tests/test_seal.sh b/tests/test_seal.sh new file mode 100755 index 0000000..81663dc --- /dev/null +++ b/tests/test_seal.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +cat > "${tmp_dir}/kubectl" <<'EOF' +#!/usr/bin/env bash +printf '%q ' "$@" >> "${KUBECTL_LOG}" +printf '\n' >> "${KUBECTL_LOG}" +printf 'sealed-secrets-controller 1/1 Running\n' +EOF + +cat > "${tmp_dir}/kubeseal" <<'EOF' +#!/usr/bin/env bash +printf '%q ' "$@" >> "${KUBESEAL_LOG}" +printf '\n' >> "${KUBESEAL_LOG}" +cat > "${KUBESEAL_INPUT}" +printf 'kind: SealedSecret\nmetadata:\n name: sanitized\n' +EOF +chmod +x "${tmp_dir}/kubectl" "${tmp_dir}/kubeseal" + +export PATH="${tmp_dir}:${PATH}" +export KUBECTL_LOG="${tmp_dir}/kubectl.log" +export KUBESEAL_LOG="${tmp_dir}/kubeseal.log" +export KUBESEAL_INPUT="${tmp_dir}/secret.json" +export DATABASE_URL='postgresql://test-user:dummy-secret@test.invalid/db' + +stdout="${tmp_dir}/stdout" +stderr="${tmp_dir}/stderr" +"${repo_root}/scripts/seal.sh" chat-postgres chat-api DATABASE_URL >"${stdout}" 2>"${stderr}" + +grep -q 'kind: SealedSecret' "${stdout}" +if grep -R -F 'dummy-secret' "${stdout}" "${stderr}" "${KUBECTL_LOG}" "${KUBESEAL_LOG}"; then + echo 'secret value leaked to output or command arguments' >&2 + exit 1 +fi + +python3 - "${KUBESEAL_INPUT}" <<'PY' +import base64 +import json +import sys + +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert payload["metadata"]["name"] == "chat-postgres" +assert payload["metadata"]["namespace"] == "chat-api" +assert base64.b64decode(payload["data"]["DATABASE_URL"]).decode() == ( + "postgresql://test-user:dummy-secret@test.invalid/db" +) +PY + +( + cd "${repo_root}" + task seal:postgree >"${stdout}" 2>"${stderr}" +) +python3 - "${KUBESEAL_INPUT}" <<'PY' +import json +import sys + +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert payload["metadata"]["name"] == "chat-postgree" +assert payload["metadata"]["namespace"] == "chat-api" +PY + +export GITHUB_REPO_URL='https://github.com/example/repo' +export GITHUB_USERNAME='test-user' +export GITHUB_PAT='another-dummy-secret' +"${repo_root}/scripts/seal.sh" chat-github argocd --repo \ + url:GITHUB_REPO_URL username:GITHUB_USERNAME password:GITHUB_PAT \ + >"${stdout}" 2>"${stderr}" + +if grep -R -F 'another-dummy-secret' "${stdout}" "${stderr}" "${KUBECTL_LOG}" "${KUBESEAL_LOG}"; then + echo 'repository secret value leaked to output or command arguments' >&2 + exit 1 +fi + +python3 - "${KUBESEAL_INPUT}" <<'PY' +import json +import sys + +payload = json.load(open(sys.argv[1], encoding="utf-8")) +assert payload["metadata"]["labels"]["argocd.argoproj.io/secret-type"] == "repository" +assert set(payload["data"]) == {"url", "username", "password"} +PY + +if DATABASE_URL='' "${repo_root}/scripts/seal.sh" chat-postgres chat-api DATABASE_URL \ + >"${stdout}" 2>"${stderr}"; then + echo 'empty environment value was accepted' >&2 + exit 1 +fi +grep -q 'is unset or empty' "${stderr}" + +if "${repo_root}/scripts/seal.sh" chat-postgres chat-api 'DATABASE_URL=legacy-value' \ + >"${stdout}" 2>"${stderr}"; then + echo 'legacy KEY=value argument was accepted' >&2 + exit 1 +fi +grep -q 'KEY\[:ENV_VAR\]' "${stderr}" diff --git a/tests/test_taskfile_contract.py b/tests/test_taskfile_contract.py new file mode 100644 index 0000000..14ba31f --- /dev/null +++ b/tests/test_taskfile_contract.py @@ -0,0 +1,180 @@ +"""Offline contracts for Taskfile entrypoints and repository automation.""" + +import os +import re +import shutil +import subprocess +import unittest +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[1] +TASKFILE = REPO_ROOT / "Taskfile.yaml" + + +class TaskfileContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.document = yaml.load(TASKFILE.read_text(), Loader=yaml.BaseLoader) + cls.tasks = cls.document["tasks"] + + def test_every_entrypoint_has_an_explicit_side_effect_class(self): + expected = { + "default": "read-only", + "init": "mutating", + "refresh": "mutating", + "plan": "read-only", + "kubeconfig": "local-write", + "bootstrap": "mutating", + "argocd-password": "secret-sensitive", + "argocd-port-forward": "read-only", + "app-of-apps": "mutating", + "destroy": "destructive", + "up": "mutating", + "status": "read-only", + "sync-wait": "read-only", + "nodes": "read-only", + "seal": "secret-sensitive", + "seal:openrouter": "secret-sensitive", + "seal:github": "secret-sensitive", + "seal:postgres": "secret-sensitive", + "seal:postgree": "secret-sensitive", + "twc:versions": "read-only", + "twc:presets": "read-only", + "twc:list": "read-only", + "twc:kubeconfig": "local-write", + "twc:show": "read-only", + } + self.assertEqual(set(self.tasks), set(expected)) + for name, classification in expected.items(): + self.assertTrue( + self.tasks[name]["desc"].startswith(f"[{classification}] "), + f"{name} must be classified as {classification}", + ) + + def test_every_referenced_script_exists_and_is_executable(self): + references = set(re.findall(r"\./(scripts/[A-Za-z0-9._/-]+)", TASKFILE.read_text())) + self.assertTrue(references) + for reference in references: + path = REPO_ROOT / reference + self.assertTrue(path.is_file(), reference) + self.assertTrue(path.stat().st_mode & 0o111, reference) + + def test_nodes_is_direct_read_only_kubectl(self): + commands = "\n".join(self.tasks["nodes"]["cmds"]) + self.assertIn("kubectl --kubeconfig={{.KUBECONFIG_PATH}} get nodes -o wide", commands) + self.assertNotIn("scripts/", commands) + + def test_secret_tasks_pass_environment_names_not_values(self): + text = TASKFILE.read_text() + (REPO_ROOT / "scripts/seal.sh").read_text() + for forbidden in ("$OPENROUTER_API_KEY", "$GITHUB_PAT", "$DATABASE_URL", "--from-literal"): + self.assertNotIn(forbidden, text) + self.assertEqual( + self.tasks["seal:postgree"]["cmds"], + ["./scripts/seal.sh chat-postgree chat-api DATABASE_URL"], + ) + + def test_task_dry_runs_do_not_expand_secret_values(self): + task = shutil.which("task") + self.assertIsNotNone(task, "Task CLI must be installed for contract tests") + environment = { + **os.environ, + "OPENROUTER_API_KEY": "contract-dummy-openrouter-secret", + "GITHUB_PAT": "contract-dummy-github-secret", + "GITHUB_USERNAME": "contract-user", + "GITHUB_REPO_URL": "https://github.com/example/repo", + "DATABASE_URL": "postgresql://contract-dummy-database-secret", + } + for task_name in ("seal:openrouter", "seal:github", "seal:postgres", "seal:postgree"): + result = subprocess.run( + [task, "--dry", task_name], + cwd=REPO_ROOT, + env=environment, + text=True, + capture_output=True, + check=True, + ) + output = result.stdout + result.stderr + self.assertNotIn("contract-dummy", output) + + def test_bootstrap_renders_chart_once_and_retries_the_rendered_file(self): + commands = "\n".join(self.tasks["bootstrap"]["cmds"]) + self.assertEqual(commands.count("helm template"), 1) + self.assertIn("mktemp", commands) + self.assertIn("Retrying the same rendered manifest once", commands) + + def test_app_of_apps_uses_a_guarded_immutable_revision(self): + self.assertEqual(self.document["vars"]["GITOPS_REVISION"], "v1.5.0") + commands = "\n".join(self.tasks["app-of-apps"]["cmds"]) + self.assertNotIn("/main/", commands) + self.assertIn("{{.GITOPS_REVISION}}", commands) + self.assertIn(".spec.source.targetRevision", commands) + self.assertIn("requires a release tag or 40-character commit SHA", commands) + + def test_app_of_apps_rejects_a_branch_before_any_download(self): + task = shutil.which("task") + self.assertIsNotNone(task, "Task CLI must be installed for contract tests") + result = subprocess.run( + [task, "app-of-apps", "GITOPS_REVISION=main"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn( + "requires a release tag or 40-character commit SHA", + result.stdout + result.stderr, + ) + + def test_required_pins_and_workflows_are_preserved(self): + self.assertIn('ref=v0.1.0', (REPO_ROOT / "terraform/timeweb/main.tf").read_text()) + self.assertIn( + "targetRevision: v1.2.0", + (REPO_ROOT / "bootstrap/argocd/gitops-smoke-root.yaml").read_text(), + ) + self.assertIn( + "targetRevision: main", + (REPO_ROOT / "bootstrap/argocd/values.yaml").read_text(), + ) + workflows = list((REPO_ROOT / ".github/workflows").glob("*.yaml")) + self.assertEqual(len(workflows), 7) + + def test_ci_uses_pinned_tools_and_enforces_security_scan(self): + workflow = yaml.load( + (REPO_ROOT / ".github/workflows/ci.yaml").read_text(), + Loader=yaml.BaseLoader, + ) + steps = workflow["jobs"]["validate"]["steps"] + task_setup = next(step for step in steps if step.get("name") == "Install Task") + terraform = next(step for step in steps if step.get("name") == "Setup Terraform") + shellcheck = next(step for step in steps if step.get("name") == "Check shell scripts") + tfsec = next( + step for step in workflow["jobs"]["security"]["steps"] if step.get("name") == "tfsec" + ) + self.assertEqual( + task_setup["uses"], + "arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611", + ) + self.assertEqual(task_setup["with"]["version"], "3.54.0") + self.assertEqual( + terraform["uses"], + "hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd", + ) + self.assertEqual(terraform["with"]["terraform_version"], "1.13.5") + self.assertEqual( + shellcheck["uses"], + "ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38", + ) + self.assertEqual(shellcheck["with"]["version"], "v0.11.0") + self.assertEqual(tfsec["with"]["version"], "v1.28.14") + self.assertEqual( + tfsec["uses"], + "aquasecurity/tfsec-action@b466648d6e39e7c75324f25d83891162a721f2d6", + ) + self.assertNotEqual(tfsec["with"].get("soft_fail"), "true") + + +if __name__ == "__main__": + unittest.main()