diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..9d43b50 --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,27 @@ +# Deploy Preview for deploy-platform service repos. +# +# Any PR touching platform/ gets a full render + validation and a sticky +# "Deploy Preview" comment with the SC-11 readiness scorecard. +name: Deploy Preview + +on: + pull_request: + paths: + - 'platform/**' + +permissions: {} + +jobs: + deploy-preview: + uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-validate.yml@4b444f9a50edf76e4aea4148d73407c34a1e43a4 # v0.12.1 + with: + deploy-dir: platform + schema-version: 0.16.0 + image-lock-path: platform/images.lock.json + context-ref: ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:64d00fe03a271dbd03a48005d0a0cc6cc5fe43df23c0e97b649c2f8b3e78b418 + environments: production + comment: true + permissions: + contents: read + packages: read + pull-requests: write diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2cf9159 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,312 @@ +# Tag-triggered publish flow for agent-runtime. +# +# Three container images are published: +# agent-gateway (Kotlin Spring Boot, port 8090) +# agent-runner (agent execution environment) +# agents-login (Node worker, port 8081) +# +# The deploy artifact uses agent-gateway and agents-login image aliases. +# agent-runner is published but is NOT part of the platform deploy contract +# (runner pods are managed by the gateway operator, not deploy-artifact). +# +# Boundary rule (R1-2): the image lock crosses the reusable-workflow boundary +# as an UPLOADED ARTIFACT, never as a runner-local path. +name: Publish + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish (e.g. v1.2.0)' + required: true + type: string + +permissions: {} + +jobs: + # -- 1. Build and push all container images -------------------------------- + publish-images: + name: Publish ${{ matrix.image }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - image: agent-gateway + dockerfile: services/agent-gateway/Dockerfile + - image: agent-runner + dockerfile: services/agent-runner/Dockerfile + - image: agents-login + dockerfile: services/agents-login/Dockerfile + outputs: + agent-gateway-digest: ${{ steps.build.outputs.digest }} + agents-login-digest: ${{ steps.build.outputs.digest }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + + - uses: actions/create-github-app-token@v3 + id: package-token + continue-on-error: true + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: | + agent-kit + agent-runtime + + - uses: docker/setup-buildx-action@v4 + + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: build-and-push + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + push: true + tags: | + ghcr.io/jorisjonkers-dev/agent-runtime/${{ matrix.image }}:${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} + github_actor=${{ github.actor }} + agent_kit_token=${{ steps.package-token.outputs.token || secrets.GITHUB_TOKEN }} + agent_kit_actor=${{ steps.package-token.outputs.app-slug && format('{0}[bot]', steps.package-token.outputs.app-slug) || github.actor }} + build-args: | + GIT_SHA=${{ github.sha }} + AGENT_KIT_VERSION=v1.1.0 + + - name: write-digest + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + mkdir -p /tmp/image-digest + printf '%s\n' "$IMAGE_DIGEST" > "/tmp/image-digest/${{ matrix.image }}.txt" + + - uses: actions/upload-artifact@v5 + with: + name: image-digest-${{ matrix.image }} + path: /tmp/image-digest/${{ matrix.image }}.txt + if-no-files-found: error + retention-days: 1 + + # -- 2. Resolve the image lock for the deploy artifact -------------------- + resolve-image-lock: + needs: [publish-images] + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + outputs: + images-lock-artifact: images-lock-${{ github.run_id }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + + - uses: actions/download-artifact@v4 + with: + pattern: image-digest-* + path: /tmp/image-digests + merge-multiple: true + + - name: lock-images + env: + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + run: | + set -euo pipefail + + gateway_digest="$(cat /tmp/image-digests/agent-gateway.txt)" + login_digest="$(cat /tmp/image-digests/agents-login.txt)" + + for digest in "$gateway_digest" "$login_digest"; do + case "$digest" in + sha256:*) ;; + *) echo "E_FLOATING_IMAGE: digest is not sha256-prefixed: $digest" >&2; exit 1 ;; + esac + done + + printf '{ + "agent-gateway": "ghcr.io/jorisjonkers-dev/agent-runtime/agent-gateway@%s", + "agents-login": "ghcr.io/jorisjonkers-dev/agent-runtime/agents-login@%s" + }\n' "$gateway_digest" "$login_digest" > platform/images.lock.json + + echo "Locked agent-gateway: ${gateway_digest}" + echo "Locked agents-login: ${login_digest}" + + - name: verify-lock + run: | + set -euo pipefail + for alias in agent-gateway agents-login; do + ref=$(jq -r --arg a "$alias" '.[$a]' platform/images.lock.json) + case "$ref" in + *@sha256:*) echo "Digest pin verified for $alias: $ref" ;; + *) echo "E_FLOATING_IMAGE: $alias lock ref is not digest-pinned: $ref" >&2; exit 1 ;; + esac + done + + - uses: actions/upload-artifact@v5 + with: + name: images-lock-${{ github.run_id }} + path: platform/images.lock.json + retention-days: 1 + + # -- 3. Render and publish the deploy artifact ---------------------------- + publish-deploy-artifact: + needs: [resolve-image-lock] + uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-artifact.yml@4b444f9a50edf76e4aea4148d73407c34a1e43a4 # v0.12.1 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + artifact-name: agent-runtime + artifact-version: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + schema-version: 0.16.0 + context-ref: ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:64d00fe03a271dbd03a48005d0a0cc6cc5fe43df23c0e97b649c2f8b3e78b418 + deploy-dir: platform + image-lock-artifact: ${{ needs.resolve-image-lock.outputs.images-lock-artifact }} + environments: production + permissions: + contents: read + packages: write + id-token: write + attestations: write + + # -- 4. Open/refresh the homelab-deploy registry PR ------------------------ + register-service: + needs: [publish-deploy-artifact] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: mint-app-token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: homelab-deploy + + - name: assert-token-minted + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ -z "$APP_TOKEN" ]; then + echo "E_APP_TOKEN_MISSING: App token was not minted for register-service." >&2 + exit 1 + fi + + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + + - name: build-registry-payload + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ARTIFACT_REF: ${{ needs.publish-deploy-artifact.outputs.artifact-ref }} + ARTIFACT_DIGEST: ${{ needs.publish-deploy-artifact.outputs.artifact-digest }} + SERVICE_NAME: agent-runtime + REPO_OWNER: ${{ github.repository_owner }} + SOURCE_REPOSITORY: ${{ github.repository }} + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + run: | + set -euo pipefail + branch="registry-update/${SERVICE_NAME}/${TAG}" + registry_path="registry/${SERVICE_NAME}.yaml" + repo="${REPO_OWNER}/homelab-deploy" + + if ! gh api "repos/${repo}/git/refs/heads/${branch}" >/dev/null 2>&1; then + main_sha="$(gh api "repos/${repo}/git/refs/heads/main" --jq '.object.sha')" + gh api --method POST "repos/${repo}/git/refs" \ + -f ref="refs/heads/${branch}" -f sha="$main_sha" >/dev/null + fi + + current_sha="" + if gh api "repos/${repo}/contents/${registry_path}?ref=${branch}" > current.json 2>/dev/null; then + current_sha="$(jq -r '.sha' current.json)" + jq -r '.content' current.json | base64 -d > current.yaml + fi + + python3 - "$SERVICE_NAME" <<'PY' + import os, sys, yaml + + service = sys.argv[1] + artifact = { + "ref": os.environ["ARTIFACT_REF"], + "digest": os.environ["ARTIFACT_DIGEST"], + "contractPath": "artifact-contract.yaml", + } + + if os.path.exists("current.yaml"): + doc = yaml.safe_load(open("current.yaml")) + doc["spec"]["artifact"] = artifact + else: + dep = yaml.safe_load(open("platform/deployment.yml")) + spec = dep.get("spec", {}) + platform = spec.get("platform") or {} + layer = platform.get("layer") + if not layer: + sys.exit("E_UNKNOWN_LAYER: deployment.yml spec.platform.layer is " + "required for first registration") + workloads = spec.get("workloads") or [] + health = (workloads[0].get("health") if workloads else None) or {} + doc = { + "apiVersion": "deployment.jorisjonkers.dev/deploy-unit-registration/v1", + "kind": "DeployUnitRegistration", + "metadata": { + "name": service, + "owner": os.environ["REPO_OWNER"], + }, + "spec": { + "artifact": artifact, + "namespace": spec["namespace"], + "layer": layer, + "sourceRepository": os.environ["SOURCE_REPOSITORY"], + "environments": spec.get("environments") or ["production"], + "healthClass": health.get("class") + or health.get("timeoutClass") + or "stateless", + "prune": {"default": True}, + "allowedClusterScope": [], + }, + } + + yaml.safe_dump(doc, open("registration.yaml", "w"), + sort_keys=False, default_flow_style=False) + PY + + if [ -n "$current_sha" ]; then + gh api --method PUT "repos/${repo}/contents/${registry_path}" \ + -f message="chore(registry): update ${SERVICE_NAME} to ${TAG}" \ + -f content="$(base64 -w0 registration.yaml)" \ + -f sha="$current_sha" -f branch="$branch" >/dev/null + else + gh api --method PUT "repos/${repo}/contents/${registry_path}" \ + -f message="chore(registry): register ${SERVICE_NAME}" \ + -f content="$(base64 -w0 registration.yaml)" \ + -f branch="$branch" >/dev/null + fi + + open_prs="$(gh api "repos/${repo}/pulls?head=${REPO_OWNER}:${branch}&state=open" --jq 'length')" + if [ "$open_prs" -eq 0 ]; then + gh api --method POST "repos/${repo}/pulls" \ + -f title="chore(registry): update ${SERVICE_NAME} to ${TAG}" \ + -f body="Automated registry update from publish.yml. Artifact: ${ARTIFACT_REF}" \ + -f head="$branch" -f base="main" >/dev/null + else + echo "Updated existing registry PR for branch ${branch}" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87c240d..252e7c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,188 +1,53 @@ -name: 'Release' - -'on': - 'push': - 'branches': - - 'main' - -'permissions': - 'contents': 'write' - 'pull-requests': 'write' - 'packages': 'write' - -'env': - 'GITHUB_ACTOR': '${{ github.actor }}' - 'GITHUB_TOKEN': '${{ secrets.GITHUB_TOKEN }}' - 'RELEASE_APP_CONFIGURED': "${{ secrets.RELEASE_APP_ID != '' && secrets.RELEASE_APP_PRIVATE_KEY != '' }}" - -'jobs': - 'release-please': - 'runs-on': 'ubuntu-latest' - 'outputs': - 'release_created': '${{ steps.rp.outputs.release_created }}' - 'tag_name': '${{ steps.rp.outputs.tag_name }}' - 'steps': - - 'uses': 'actions/create-github-app-token@v3' - 'id': 'app-token' - 'continue-on-error': true - 'with': - 'app-id': '${{ secrets.RELEASE_APP_ID }}' - 'private-key': '${{ secrets.RELEASE_APP_PRIVATE_KEY }}' - - 'uses': 'googleapis/release-please-action@v5' - 'id': 'rp' - 'with': - 'token': '${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }}' - 'config-file': 'release-please-config.json' - 'manifest-file': '.release-please-manifest.json' - - 'publish-images': - 'name': 'Publish ${{ matrix.image }}' - 'needs': 'release-please' - 'if': "${{ needs.release-please.outputs.release_created == 'true' }}" - 'runs-on': 'ubuntu-latest' - 'permissions': - 'contents': 'read' - 'packages': 'write' - 'strategy': - 'fail-fast': false - 'matrix': - 'include': - - 'image': 'agent-gateway' - 'dockerfile': 'services/agent-gateway/Dockerfile' - 'platforms': 'linux/amd64' - - 'image': 'agent-runner' - 'dockerfile': 'services/agent-runner/Dockerfile' - 'platforms': 'linux/amd64' - - 'image': 'agents-login' - 'dockerfile': 'services/agents-login/Dockerfile' - 'platforms': 'linux/amd64' - 'steps': - - 'uses': 'actions/checkout@v7' - 'with': - 'ref': '${{ needs.release-please.outputs.tag_name }}' - - 'uses': 'docker/setup-buildx-action@v4' - - 'uses': 'docker/login-action@v4' - 'with': - 'registry': 'ghcr.io' - 'username': '${{ github.actor }}' - 'password': '${{ secrets.GITHUB_TOKEN }}' - - 'uses': 'actions/create-github-app-token@v3' - 'id': 'package-token' - 'if': "${{ env.RELEASE_APP_CONFIGURED == 'true' }}" - 'continue-on-error': true - 'with': - 'app-id': '${{ secrets.RELEASE_APP_ID }}' - 'private-key': '${{ secrets.RELEASE_APP_PRIVATE_KEY }}' - 'owner': '${{ github.repository_owner }}' - 'repositories': | - agent-kit - agent-runtime - - 'uses': 'docker/build-push-action@v7' - 'id': 'build' - 'with': - 'context': '.' - 'file': '${{ matrix.dockerfile }}' - 'platforms': '${{ matrix.platforms }}' - 'push': true - 'tags': | - ghcr.io/jorisjonkers-dev/agent-runtime/${{ matrix.image }}:${{ needs.release-please.outputs.tag_name }} - ghcr.io/jorisjonkers-dev/agent-runtime/${{ matrix.image }}:sha-${{ github.sha }} - 'secrets': | - github_token=${{ secrets.GITHUB_TOKEN }} - github_actor=${{ github.actor }} - agent_kit_token=${{ steps.package-token.outputs.token || secrets.GITHUB_TOKEN }} - agent_kit_actor=${{ steps.package-token.outputs.app-slug && format('{0}[bot]', steps.package-token.outputs.app-slug) || github.actor }} - 'build-args': | - GIT_SHA=${{ github.sha }} - AGENT_KIT_RUNTIME_REF=ghcr.io/jorisjonkers-dev/agent-kit/runtime-home:v1.1.0 - AGENT_KIT_VERSION=v1.1.0 - - 'name': 'Write image digest ref' - 'env': - 'IMAGE_REF': 'ghcr.io/jorisjonkers-dev/agent-runtime/${{ matrix.image }}' - 'IMAGE_DIGEST': '${{ steps.build.outputs.digest }}' - 'RELEASE_TAG': '${{ needs.release-please.outputs.tag_name }}' - 'run': | - set -euo pipefail - - mkdir -p /tmp/image-digest - printf '%s:%s@%s\n' "$IMAGE_REF" "$RELEASE_TAG" "$IMAGE_DIGEST" > "/tmp/image-digest/${{ matrix.image }}.txt" - - 'uses': 'actions/upload-artifact@v7' - 'with': - 'name': 'image-digest-${{ matrix.image }}' - 'path': '/tmp/image-digest/${{ matrix.image }}.txt' - 'if-no-files-found': 'error' - 'retention-days': 7 - - 'image-refs': - 'name': 'Collect Image Refs' - 'needs': - - 'release-please' - - 'publish-images' - 'if': "${{ needs.release-please.outputs.release_created == 'true' }}" - 'runs-on': 'ubuntu-latest' - 'outputs': - 'images': '${{ steps.images.outputs.images }}' - 'steps': - - 'uses': 'actions/download-artifact@v8' - 'with': - 'pattern': 'image-digest-*' - 'path': '/tmp/image-digests' - 'merge-multiple': true - - 'name': 'Collect digest-pinned image refs' - 'id': 'images' - 'run': | - set -euo pipefail - - refs="$(cat /tmp/image-digests/*.txt | sort)" - { - echo 'images<> "$GITHUB_OUTPUT" - - 'publish-deploy-bundle': - 'name': 'Publish Deploy Bundle' - 'needs': - - 'release-please' - - 'image-refs' - 'if': "${{ needs.release-please.outputs.release_created == 'true' }}" - 'runs-on': 'ubuntu-latest' - 'permissions': - 'contents': 'read' - 'packages': 'write' - 'steps': - - 'uses': 'actions/checkout@v7' - 'with': - 'ref': '${{ needs.release-please.outputs.tag_name }}' - - 'name': 'Write digest-pinned image lock' - 'env': - 'IMAGE_REFS': '${{ needs.image-refs.outputs.images }}' - 'run': | - set -euo pipefail - - node - <<'NODE' - const fs = require('node:fs') - const images = process.env.IMAGE_REFS.split(/\r?\n/).map((line) => line.trim()).filter(Boolean) - fs.writeFileSync('/tmp/agent-runtime-images.json', `${JSON.stringify({ images }, null, 2)}\n`) - NODE - - 'name': 'Pack deploy bundle' - 'id': 'bundle' - 'uses': 'JorisJonkers-dev/github-workflows/actions/deploy-bundle@v0.8.0' - 'with': - 'package-version': '0.9.0' - 'version': '${{ needs.release-please.outputs.tag_name }}' - 'images': '/tmp/agent-runtime-images.json' - 'node-auth-token': '${{ secrets.GITHUB_TOKEN }}' - - 'uses': 'oras-project/setup-oras@v2' - - 'name': 'Log in to GHCR' - 'env': - 'GHCR_TOKEN': '${{ secrets.GITHUB_TOKEN }}' - 'run': 'oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin <<< "$GHCR_TOKEN"' - - 'name': 'Publish deploy bundle' - 'env': - 'BUNDLE_PATH': '${{ steps.bundle.outputs.bundle-path }}' - 'BUNDLE_VERSION': '${{ steps.bundle.outputs.bundle-version }}' - 'run': | - set -euo pipefail - - oras push "ghcr.io/jorisjonkers-dev/agent-runtime-deploy-bundle:${BUNDLE_VERSION}" "$BUNDLE_PATH:application/vnd.jorisjonkers.deployment.bundle.v1+tar" +# Platform-strict release flow for agent-runtime. +# +# The App token is MANDATORY — release-please must author the release tag with +# the App token so the resulting v*.*.* tag push triggers publish.yml. Tags +# created with the default GITHUB_TOKEN do not trigger downstream workflows. +# There is intentionally NO GITHUB_TOKEN fallback. +# +# Image publishing and deploy-artifact publishing now live in publish.yml +# (triggered by the v*.*.* tag). This file runs release-please only. +name: Release + +on: + push: + branches: [main] + workflow_dispatch: {} + +permissions: {} + +jobs: + release-please: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - name: mint-app-token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: assert-token-minted + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ -z "$APP_TOKEN" ]; then + echo "E_APP_TOKEN_MISSING: Release App token was not minted." >&2 + echo "Secrets RELEASE_APP_ID and RELEASE_APP_PRIVATE_KEY must be configured." >&2 + echo "GITHUB_TOKEN fallback is intentionally forbidden." >&2 + exit 1 + fi + echo "App token minted successfully (token prefix: ${APP_TOKEN:0:4}...)" + + - uses: googleapis/release-please-action@v5 + id: release + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/platform/deployment.yml b/platform/deployment.yml new file mode 100644 index 0000000..387eb74 --- /dev/null +++ b/platform/deployment.yml @@ -0,0 +1,30 @@ +apiVersion: deployment.jorisjonkers.dev/v2 +kind: Deployment +metadata: + name: agent-runtime +spec: + schemaVersion: 0.16.0 + namespace: agents-system + platform: + layer: apps-core + workloads: + - name: agent-gateway + image: agent-gateway + health: + path: /api/actuator/health/readiness + port: 8090 + timeoutClass: stateless + mandatory: true + rollbackTargetRetention: + minimumDays: 90 + acknowledged: true + - name: agents-login + image: agents-login + health: + path: /healthz + port: 8081 + timeoutClass: stateless + mandatory: true + rollbackTargetRetention: + minimumDays: 90 + acknowledged: true diff --git a/platform/images.lock.json b/platform/images.lock.json new file mode 100644 index 0000000..7d1d51d --- /dev/null +++ b/platform/images.lock.json @@ -0,0 +1,4 @@ +{ + "agent-gateway": "ghcr.io/jorisjonkers-dev/agent-runtime/agent-gateway@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "agents-login": "ghcr.io/jorisjonkers-dev/agent-runtime/agents-login@sha256:0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/platform/production.env b/platform/production.env new file mode 100644 index 0000000..7478862 --- /dev/null +++ b/platform/production.env @@ -0,0 +1,3 @@ +# Non-secret production environment values for agent-runtime. +# Secrets NEVER live here — they are delivered by the platform secret store. +# Rendered into the workload fragment by deploy-config-schema. diff --git a/platform/render-local.sh b/platform/render-local.sh new file mode 100755 index 0000000..43457f0 --- /dev/null +++ b/platform/render-local.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash +# render-local.sh — local CI-parity render for agent-runtime. +# +# Mirrors what deploy-validate.yml / deploy-artifact.yml do in CI: +# validate -> render -> kubeconform -> leak-scan -> scorecard +# +# Usage: +# ./platform/render-local.sh [--diff] [--context-dir PATH] [--scorecard-only] +# +# Options: +# --diff compare rendered output against committed fixtures; exit 1 on drift +# --context-dir use a local context directory (bypasses digest requirement for local dev) +# --scorecard-only skip install/pull/render and only evaluate the SC-11 scorecard +# (dry mode: works without the npm package installed) +# +# The scorecard functions are pure shell + jq so they stay testable without +# network access or the @jorisjonkers-dev/deploy-config-schema npm package. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +FLAG_DIFF=false +FLAG_SCORECARD_ONLY=false +CONTEXT_DIR="${CONTEXT_DIR:-}" +CONTEXT_REF="${CONTEXT_REF:-ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:64d00fe03a271dbd03a48005d0a0cc6cc5fe43df23c0e97b649c2f8b3e78b418}" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/out}" +RESOLVED_VERSION="" +NPM_PROVENANCE_VERIFIED="${NPM_PROVENANCE_VERIFIED:-true}" + +ENVS=(production) +FRAGMENTS=( + kubernetes-workload-fragment + traefik-route-fragment + gatus-endpoint-fragment + edge-catalog-fragment + image-metadata-fragment +) + +log() { echo "[render-local] $*" >&2; } +warn() { echo "[render-local] WARNING: $*" >&2; } +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +usage() { + cat <<'USAGE' +Usage: render-local.sh [--diff] [--context-dir PATH] [--scorecard-only] + +Options: + --diff compare rendered output against committed fixtures; exit 1 on drift + --context-dir use a local context directory (bypasses digest requirement for local dev) + --scorecard-only skip install/pull/render and only evaluate the SC-11 scorecard + -h, --help show this help +USAGE +} + +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --diff) + FLAG_DIFF=true + shift + ;; + --context-dir) + [ $# -ge 2 ] || fail "--context-dir requires a PATH argument" + CONTEXT_DIR="$2" + shift 2 + ;; + --scorecard-only) + FLAG_SCORECARD_ONLY=true + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + usage >&2 + fail "unknown option: $1" + ;; + esac + done +} + +resolve_schema_version() { + if [ -n "${SCHEMA_VERSION:-}" ]; then + RESOLVED_VERSION="$SCHEMA_VERSION" + log "Using SCHEMA_VERSION from env: $RESOLVED_VERSION" + elif [ -f "$REPO_ROOT/.platform/deploy-version" ]; then + RESOLVED_VERSION="$(tr -d '[:space:]' < "$REPO_ROOT/.platform/deploy-version")" + log "Using schema version from .platform/deploy-version: $RESOLVED_VERSION" + else + RESOLVED_VERSION="0.16.0" + log "Using baked-in schema version: $RESOLVED_VERSION" + fi +} + +install_schema() { + log "Installing @jorisjonkers-dev/deploy-config-schema@$RESOLVED_VERSION" + npm install --global "@jorisjonkers-dev/deploy-config-schema@$RESOLVED_VERSION" \ + --registry https://npm.pkg.github.com \ + || fail "failed to install deploy-config-schema@$RESOLVED_VERSION" +} + +npm_audit_and_scorecard_init() { + if npm audit signatures --scope @jorisjonkers-dev >/dev/null 2>&1; then + NPM_PROVENANCE_VERIFIED="true" + else + NPM_PROVENANCE_VERIFIED="false" + warn "npm audit signatures failed; npm_signatures_verified=fail" + fi +} + +require_digest_ref() { + case "$1" in + *@sha256:*) ;; + *) fail "E_CONTEXT_REF_NOT_PINNED: context ref must be digest-pinned (got: $1). Pass --context-dir for local dev." ;; + esac +} + +pull_or_use_local_context() { + if [ -n "$CONTEXT_DIR" ]; then + CONTEXT_PKG_DIR="$CONTEXT_DIR" + warn "--context-dir bypasses OCI digest requirement. Not suitable for CI." + else + require_digest_ref "$CONTEXT_REF" + log "Pulling public cluster context: $CONTEXT_REF" + mkdir -p "$OUT_DIR/context-pkg" + oras pull "$CONTEXT_REF" --output "$OUT_DIR/context-pkg" \ + || fail "failed to pull context package $CONTEXT_REF" + CONTEXT_PKG_DIR="$OUT_DIR/context-pkg" + fi +} + +render_all_fragments() { + local env fragment + for env in "${ENVS[@]}"; do + mkdir -p "$OUT_DIR/manifests/$env" "$OUT_DIR/metadata/$env" + for fragment in "${FRAGMENTS[@]}"; do + log "render $fragment ($env)" + deploy-config-schema render "$fragment" "$SCRIPT_DIR" \ + --env "$env" \ + --context "$CONTEXT_REF" \ + --context-dir "$CONTEXT_PKG_DIR" \ + --images "$SCRIPT_DIR/images.lock.json" \ + --output "$OUT_DIR/manifests/$env" \ + || fail "render failed for $fragment ($env)" + done + deploy-config-schema artifact emit-kustomization-health \ + --deployment "$SCRIPT_DIR/deployment.yml" \ + --env "$env" \ + --image-digests "$SCRIPT_DIR/images.lock.json" \ + --out "$OUT_DIR/metadata/$env/kustomization-health.yml" \ + || fail "emit-kustomization-health failed ($env)" + done +} + +validate_rendered_output() { + local env + log "kubeconform" + kubeconform -schema-location default \ + -strict "$OUT_DIR/manifests/" \ + || fail "kubeconform validation failed" + log "kustomize build dry-run" + for env in "${ENVS[@]}"; do + kustomize build "$OUT_DIR/manifests/$env" >/dev/null \ + || fail "kustomize build failed for $env" + done + if grep -rl 'kind: Secret' "$OUT_DIR/manifests/" 2>/dev/null | grep -q .; then + fail "E_FORBIDDEN_KIND: kind=Secret found in rendered manifests" + fi +} + +validate_raw_manifests_if_present() { + [ -d "$SCRIPT_DIR/raw-manifests" ] || return 0 + deploy-config-schema artifact validate-raw-manifests \ + --deployment "$SCRIPT_DIR/deployment.yml" \ + --root "$SCRIPT_DIR/raw-manifests" \ + --output-root "$OUT_DIR/raw-manifests" \ + --forbidden-kinds Secret,ClusterRole,ClusterRoleBinding,CustomResourceDefinition,Namespace \ + --out "$OUT_DIR/raw-manifests-guard.json" \ + || fail "raw-manifests validation failed" +} + +emit_contract() { + log "leak-scan (deployment-artifact deny-list)" + deploy-config-schema artifact leak-scan \ + --path "$OUT_DIR" \ + --mode deployment-artifact \ + || fail "E_LEAK_DETECTED: deny-list scan flagged rendered output" + + deploy-config-schema artifact emit-contract \ + --artifact-name "agent-runtime" \ + --schema-version "$RESOLVED_VERSION" \ + --context-ref "$CONTEXT_REF" \ + --environments "production" \ + --images "$SCRIPT_DIR/images.lock.json" \ + --provenance-verified "$NPM_PROVENANCE_VERIFIED" \ + --out "$OUT_DIR/artifact-contract.yaml" \ + || fail "emit-contract failed" +} + +deployment_source() { + sed 's/#.*$//' "$1" +} + +scorecard_images_pinned() { + local lock="$1" ref + [ -f "$lock" ] || { + echo "fail" + return + } + while IFS= read -r ref; do + case "$ref" in + *@sha256:*) ;; + *) + echo "fail" + return + ;; + esac + done < <(jq -r 'if type == "array" then map(.ref) else [.[]] end | .[]' "$lock") + echo "pass" +} + +scorecard_route_owner_authmode() { + local src="$1" + if ! grep -qE '^[[:space:]]*routes:' <<<"$src"; then + echo "not_applicable" + elif grep -qE '^[[:space:]]*owner:' <<<"$src" && grep -qE '^[[:space:]]*authMode:' <<<"$src"; then + echo "pass" + else + echo "fail" + fi +} + +scorecard_stateful_policy() { + local src="$1" + if ! grep -qE '^[[:space:]]*stateful:[[:space:]]*true' <<<"$src"; then + echo "not_applicable" + elif grep -qE '^[[:space:]]*migrationPolicy:' <<<"$src"; then + echo "pass" + else + echo "fail" + fi +} + +scorecard_raw_manifests() { + local src="$1" + if ! grep -qE '^[[:space:]]*rawManifests:' <<<"$src"; then + echo "not_applicable" + elif [ -f "$OUT_DIR/raw-manifests-guard.json" ]; then + echo "pass" + else + echo "fail" + fi +} + +compute_scorecard() { + local deployment="$1" lock="$2" + local src + src="$(deployment_source "$deployment")" + + local schema_pinned=fail context_pinned=fail health_declared=fail + local rollback=fail no_raw_secrets=pass npm_flag=fail + local workload_count health_count + + grep -qE '^[[:space:]]*schemaVersion:[[:space:]]*"?[0-9]' <<<"$src" && schema_pinned=pass + case "$CONTEXT_REF" in *@sha256:*) context_pinned=pass ;; esac + + workload_count="$(grep -cE '^[[:space:]]*-[[:space:]]*name:' <<<"$src" || true)" + health_count="$(grep -cE '^[[:space:]]*health:' <<<"$src" || true)" + if [ "$workload_count" -gt 0 ] && [ "$health_count" -ge "$workload_count" ]; then + health_declared=pass + fi + + if grep -qE '^[[:space:]]*rollbackTargetRetention:' <<<"$src" \ + && grep -qE '^[[:space:]]*acknowledged:[[:space:]]*true' <<<"$src"; then + rollback=pass + fi + + if [ -d "$SCRIPT_DIR/raw-manifests" ] \ + && grep -rl 'kind: Secret' "$SCRIPT_DIR/raw-manifests" 2>/dev/null | grep -q .; then + no_raw_secrets=fail + fi + if [ -d "$OUT_DIR/manifests" ] \ + && grep -rl 'kind: Secret' "$OUT_DIR/manifests" 2>/dev/null | grep -q .; then + no_raw_secrets=fail + fi + + [ "$NPM_PROVENANCE_VERIFIED" = "true" ] && npm_flag=pass + + mkdir -p "$OUT_DIR" + jq -n \ + --arg schema_pinned "$schema_pinned" \ + --arg context_pinned "$context_pinned" \ + --arg no_latest_images "$(scorecard_images_pinned "$lock")" \ + --arg health_declared "$health_declared" \ + --arg route_owner_authmode_declared "$(scorecard_route_owner_authmode "$src")" \ + --arg rollback_retention_acknowledged "$rollback" \ + --arg no_raw_secrets "$no_raw_secrets" \ + --arg stateful_policy_declared "$(scorecard_stateful_policy "$src")" \ + --arg raw_manifests_guarded "$(scorecard_raw_manifests "$src")" \ + --arg npm_signatures_verified "$npm_flag" \ + '{ + schema_pinned: $schema_pinned, + context_pinned: $context_pinned, + no_latest_images: $no_latest_images, + health_declared: $health_declared, + route_owner_authmode_declared: $route_owner_authmode_declared, + rollback_retention_acknowledged: $rollback_retention_acknowledged, + no_raw_secrets: $no_raw_secrets, + stateful_policy_declared: $stateful_policy_declared, + raw_manifests_guarded: $raw_manifests_guarded, + npm_signatures_verified: $npm_signatures_verified + }' > "$OUT_DIR/scorecard.json" +} + +write_scorecard_outputs() { + { + echo "# Deployment readiness scorecard — agent-runtime" + echo "" + echo "| Check | Status |" + echo "|-------|--------|" + jq -r 'to_entries[] | "| \(.key) | \(.value) |"' "$OUT_DIR/scorecard.json" + echo "" + echo "pass = ready · fail = blocks deployment · not_applicable = check does not apply" + } > "$OUT_DIR/scorecard.md" + cat "$OUT_DIR/scorecard.md" +} + +compare_against_fixtures() { + local drift + drift="$(diff -rq "$OUT_DIR" "$SCRIPT_DIR/fixtures" 2>&1 || true)" + if [ -n "$drift" ]; then + echo "ERROR: Rendered output differs from committed fixtures:" >&2 + echo "$drift" >&2 + echo "Run render-local.sh without --diff to regenerate fixtures, then commit." >&2 + exit 1 + fi + log "No drift detected between rendered output and committed fixtures." +} + +exit_based_on_scorecard() { + local fails + fails="$(jq '[to_entries[] | select(.value == "fail")] | length' "$OUT_DIR/scorecard.json")" + if [ "$fails" -gt 0 ]; then + echo "ERROR: Scorecard has $fails fail(s). See out/scorecard.md for details." >&2 + exit 1 + fi + log "All scorecard checks passed." +} + +main() { + parse_args "$@" + + if [ "$FLAG_SCORECARD_ONLY" = true ]; then + log "scorecard-only dry mode (npm package not required)" + compute_scorecard "$SCRIPT_DIR/deployment.yml" "$SCRIPT_DIR/images.lock.json" + write_scorecard_outputs + exit_based_on_scorecard + return + fi + + resolve_schema_version + install_schema + npm_audit_and_scorecard_init + pull_or_use_local_context + render_all_fragments + validate_rendered_output + validate_raw_manifests_if_present + emit_contract + compute_scorecard "$SCRIPT_DIR/deployment.yml" "$SCRIPT_DIR/images.lock.json" + write_scorecard_outputs + if [ "$FLAG_DIFF" = true ]; then + compare_against_fixtures + fi + exit_based_on_scorecard +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" +fi