From b0ebec9a4e9eff6af6bfa7708e5b68934188ae5e Mon Sep 17 00:00:00 2001 From: Jason Doyle <46789294+Jason-Doyle@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:02:27 -0700 Subject: [PATCH] Add Kubernetes delivery Add the hardened multi-architecture authority image, OCI Helm chart, kind smoke coverage, signed release automation, and 3.3.0 deployment documentation. --- .github/workflows/ci.yml | 56 +++- .github/workflows/publish.yml | 215 +++++++++++++- CHANGELOG.md | 18 ++ Dockerfile | 24 +- README.md | 11 +- deploy/helm/test/oidc-server.mjs | 95 +++++++ deploy/helm/test/oidc.yaml | 65 +++++ deploy/helm/test/values.yaml | 36 +++ deploy/helm/thimbledb/.helmignore | 5 + deploy/helm/thimbledb/Chart.yaml | 19 ++ deploy/helm/thimbledb/README.md | 84 ++++++ deploy/helm/thimbledb/templates/NOTES.txt | 14 + deploy/helm/thimbledb/templates/_helpers.tpl | 57 ++++ .../helm/thimbledb/templates/configmap.yaml | 46 +++ .../helm/thimbledb/templates/deployment.yaml | 109 ++++++++ deploy/helm/thimbledb/templates/ingress.yaml | 36 +++ deploy/helm/thimbledb/templates/service.yaml | 19 ++ .../thimbledb/templates/serviceaccount.yaml | 13 + deploy/helm/thimbledb/values.example.yaml | 34 +++ deploy/helm/thimbledb/values.schema.json | 137 +++++++++ deploy/helm/thimbledb/values.yaml | 118 ++++++++ docs/AUTHORITY-DEPLOYMENT.md | 9 +- docs/CONFIGURATION.md | 19 ++ docs/DEPLOYMENT-KUBERNETES.md | 262 ++++++++++++++++++ docs/NPM-PUBLISHING.md | 53 +++- docs/OPERATIONS.md | 14 + docs/README.md | 3 +- docs/SECURITY.md | 5 + docs/VERSIONING.md | 20 ++ package-lock.json | 4 +- package.json | 5 +- scripts/verify-kubernetes.mjs | 239 ++++++++++++++++ site/src/data/docs.ts | 8 + site/src/pages/llms.txt.ts | 1 + site/tests/site.spec.ts | 4 +- src/cloudflare-worker.ts | 19 ++ src/server.ts | 21 ++ tests/cloudflare-worker.test.ts | 33 +++ 38 files changed, 1910 insertions(+), 20 deletions(-) create mode 100644 deploy/helm/test/oidc-server.mjs create mode 100644 deploy/helm/test/oidc.yaml create mode 100644 deploy/helm/test/values.yaml create mode 100644 deploy/helm/thimbledb/.helmignore create mode 100644 deploy/helm/thimbledb/Chart.yaml create mode 100644 deploy/helm/thimbledb/README.md create mode 100644 deploy/helm/thimbledb/templates/NOTES.txt create mode 100644 deploy/helm/thimbledb/templates/_helpers.tpl create mode 100644 deploy/helm/thimbledb/templates/configmap.yaml create mode 100644 deploy/helm/thimbledb/templates/deployment.yaml create mode 100644 deploy/helm/thimbledb/templates/ingress.yaml create mode 100644 deploy/helm/thimbledb/templates/service.yaml create mode 100644 deploy/helm/thimbledb/templates/serviceaccount.yaml create mode 100644 deploy/helm/thimbledb/values.example.yaml create mode 100644 deploy/helm/thimbledb/values.schema.json create mode 100644 deploy/helm/thimbledb/values.yaml create mode 100644 docs/DEPLOYMENT-KUBERNETES.md create mode 100644 scripts/verify-kubernetes.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 380c6ad..9bdc604 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ permissions: jobs: verify: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Check out source @@ -67,10 +67,60 @@ jobs: - name: Build container run: | - docker build --tag thimbledb:ci . - docker run --rm thimbledb:ci node --input-type=module -e "await Promise.all([import('@aws-sdk/client-s3'), import('@azure/storage-blob')])" + docker build --provenance=false --tag thimbledb:ci . + docker run --rm --workdir /app thimbledb:ci node --input-type=module -e "await Promise.all([import('@aws-sdk/client-s3'), import('@azure/storage-blob')])" - name: Build AWS Lambda application runtime run: | docker build --file Dockerfile.aws --target runtime-base --tag thimbledb-aws:ci . docker run --rm thimbledb-aws:ci node --input-type=module -e "await import('@aws-sdk/client-s3'); try { await import('@azure/storage-blob'); process.exit(1) } catch (error) { if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error }" + + - name: Set up Helm + uses: azure/setup-helm@v5.0.1 + with: + version: v3.18.3 + + - name: Validate Helm chart + shell: bash + run: | + package_version="$(node -p "require('./package.json').version")" + chart_version="$(awk '$1 == "version:" { print $2 }' deploy/helm/thimbledb/Chart.yaml)" + app_version="$(awk '$1 == "appVersion:" { gsub(/"/, "", $2); print $2 }' deploy/helm/thimbledb/Chart.yaml)" + if [ "${chart_version}" != "${package_version}" ] || [ "${app_version}" != "${package_version}" ]; then + echo "Chart version ${chart_version}/${app_version} does not match package version ${package_version}." + exit 1 + fi + helm lint deploy/helm/thimbledb --values deploy/helm/test/values.yaml + helm template thimbledb deploy/helm/thimbledb --namespace thimbledb --values deploy/helm/test/values.yaml > /tmp/thimbledb-rendered.yaml + + - name: Create kind cluster + uses: helm/kind-action@v1.15.0 + with: + cluster_name: thimbledb + version: v0.33.0 + kubectl_version: v1.37.0 + wait: 180s + + - name: Load Kubernetes smoke image + run: | + kind load docker-image --name thimbledb thimbledb:ci + + - name: Deploy Helm chart to kind + shell: bash + run: | + kubectl create namespace thimbledb + kubectl --namespace thimbledb create configmap thimbledb-test-oidc \ + --from-file=server.mjs=deploy/helm/test/oidc-server.mjs + kubectl apply --filename deploy/helm/test/oidc.yaml + master_key="$(node -e "console.log(Buffer.alloc(32, 7).toString('base64'))")" + kubectl --namespace thimbledb create secret generic thimbledb-secrets \ + --from-literal=THIMBLE_MASTER_KEY="${master_key}" + helm upgrade --install thimbledb deploy/helm/thimbledb \ + --namespace thimbledb \ + --values deploy/helm/test/values.yaml \ + --wait \ + --timeout 5m + kubectl --namespace thimbledb rollout status deployment/thimbledb-test-oidc --timeout=2m + + - name: Test Kubernetes deployment + run: node scripts/verify-kubernetes.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3eaf3a4..36fca35 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish npm package +name: Publish release artifacts on: release: @@ -8,6 +8,10 @@ on: permissions: contents: read +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + jobs: package: runs-on: ubuntu-latest @@ -111,3 +115,212 @@ jobs: - name: Report existing version if: steps.registry.outputs.published == 'true' run: echo "Package version is already published; no registry change was made." + + container: + needs: package + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + packages: write + + steps: + - name: Check out source + uses: actions/checkout@v6 + + - name: Verify release tag + id: version + shell: bash + run: | + version="$(node -p "require('./package.json').version")" + if [ "${GITHUB_REF_NAME}" != "v${version}" ]; then + echo "Release tag ${GITHUB_REF_NAME} does not match package version ${version}." + exit 1 + fi + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + - name: Install Cosign + uses: sigstore/cosign-installer@v4.1.2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4.4.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4.4.1 + + - name: Log in to GHCR + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check image version + id: registry + shell: bash + run: | + image="ghcr.io/jason-doyle/thimbledb:${VERSION}" + if output="$(docker buildx imagetools inspect "${image}" 2>/dev/null)"; then + digest="$(printf '%s\n' "${output}" | awk '$1 == "Digest:" { print $2; exit }')" + if [ -z "${digest}" ]; then + echo "Unable to determine the existing image digest." + exit 1 + fi + if ! grep --quiet 'linux/amd64' <<<"${output}" || + ! grep --quiet 'linux/arm64' <<<"${output}"; then + echo "Existing image version is missing a required platform." + exit 1 + fi + echo "published=true" >> "${GITHUB_OUTPUT}" + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + else + echo "published=false" >> "${GITHUB_OUTPUT}" + fi + env: + VERSION: ${{ steps.version.outputs.version }} + + - name: Create image metadata + id: metadata + uses: docker/metadata-action@v6.2.0 + with: + images: ghcr.io/jason-doyle/thimbledb + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ !github.event.release.prerelease }} + + - name: Build and push image + id: image + if: steps.registry.outputs.published != 'true' + uses: docker/build-push-action@v7.4.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} + build-args: | + VERSION=${{ steps.version.outputs.version }} + REVISION=${{ github.sha }} + SOURCE=${{ github.server_url }}/${{ github.repository }} + provenance: mode=max + sbom: true + cache-from: type=gha,scope=release-image + cache-to: type=gha,mode=max,scope=release-image + + - name: Resolve image digest + id: digest + shell: bash + run: | + digest="${EXISTING_DIGEST:-${BUILT_DIGEST}}" + if [[ ! "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "Invalid image digest: ${digest}" + exit 1 + fi + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + env: + EXISTING_DIGEST: ${{ steps.registry.outputs.digest }} + BUILT_DIGEST: ${{ steps.image.outputs.digest }} + + - name: Report existing image + if: steps.registry.outputs.published == 'true' + run: echo "Image version already exists; no registry image was replaced." + + - name: Sign image + env: + DIGEST: ${{ steps.digest.outputs.digest }} + run: cosign sign --yes "ghcr.io/jason-doyle/thimbledb@${DIGEST}" + + chart: + needs: container + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + id-token: write + packages: write + + steps: + - name: Check out source + uses: actions/checkout@v6 + + - name: Set up Helm + uses: azure/setup-helm@v5.0.1 + with: + version: v3.18.3 + + - name: Install Cosign + uses: sigstore/cosign-installer@v4.1.2 + + - name: Verify chart version + id: version + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + chart_version="$(awk '$1 == "version:" { print $2 }' deploy/helm/thimbledb/Chart.yaml)" + app_version="$(awk '$1 == "appVersion:" { gsub(/"/, "", $2); print $2 }' deploy/helm/thimbledb/Chart.yaml)" + if [ "${chart_version}" != "${version}" ] || [ "${app_version}" != "${version}" ]; then + echo "Chart version ${chart_version}/${app_version} does not match ${version}." + exit 1 + fi + echo "version=${version}" >> "${GITHUB_OUTPUT}" + + - name: Log in to GHCR for OCI signatures + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to GHCR + shell: bash + run: | + echo "${GITHUB_TOKEN}" | helm registry login ghcr.io \ + --username "${GITHUB_ACTOR}" \ + --password-stdin + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Package chart + run: | + helm lint deploy/helm/thimbledb --values deploy/helm/test/values.yaml + mkdir release-chart + helm package deploy/helm/thimbledb --destination release-chart + + - name: Publish chart + shell: bash + run: | + chart_ref="ghcr.io/jason-doyle/charts/thimbledb" + chart_archive="release-chart/thimbledb-${VERSION}.tgz" + if existing_chart="$(helm show chart "oci://ghcr.io/jason-doyle/charts/thimbledb" \ + --version "${VERSION}" 2>/dev/null)"; then + existing_version="$(printf '%s\n' "${existing_chart}" | awk '$1 == "version:" { print $2; exit }')" + existing_app_version="$(printf '%s\n' "${existing_chart}" | awk '$1 == "appVersion:" { gsub(/"/, "", $2); print $2; exit }')" + if [ "${existing_version}" != "${VERSION}" ] || + [ "${existing_app_version}" != "${VERSION}" ]; then + echo "Existing chart metadata does not match release version ${VERSION}." + exit 1 + fi + echo "Chart ${VERSION} already exists; no registry change was made." + rm --force "${chart_archive}" + push_output="$(helm pull "oci://ghcr.io/jason-doyle/charts/thimbledb" \ + --version "${VERSION}" \ + --destination release-chart 2>&1)" + else + push_output="$(helm push "${chart_archive}" \ + "oci://ghcr.io/jason-doyle/charts" 2>&1)" + fi + echo "${push_output}" + digest="$(printf '%s\n' "${push_output}" | awk '$1 == "Digest:" { print $2; exit }')" + if [ -z "${digest}" ]; then + echo "Unable to determine the published chart digest." + exit 1 + fi + cosign sign --yes "${chart_ref}@${digest}" + gh release upload "${GITHUB_REF_NAME}" \ + "${chart_archive}" \ + --clobber + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 20209a5..e167850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format follows Keep a Changelog and the package uses semantic versioning. ## Unreleased +## 3.3.0 - 2026-09-28 + +- Added a secure OCI Helm chart for deploying the Node authority to + Kubernetes with an existing Secret, non-root execution, read-only root + filesystem, RuntimeDefault seccomp, dropped capabilities, optional Ingress, + and explicit local-development storage. +- Added release automation for signed `linux/amd64` and `linux/arm64` GHCR + images with provenance and an SBOM, plus a signed OCI Helm chart and + downloadable chart archive. +- Added unauthenticated `/healthz` and `/readyz` probes to the Node and + Cloudflare authorities with bounded no-store responses. +- Added required Helm rendering and `kind` smoke coverage for OIDC login, + capability discovery, an authenticated write, a mutation batch, and a read + bundle. +- Documented same-origin Kubernetes routing, cloud storage requirements, + workload identity, digest pinning, signature verification, upgrades, + scaling limits, and the one-time GHCR visibility step. + ## 3.2.0 - 2026-09-28 - Added opt-in bounded mutation batches for up to 20 documents and 1 MiB of diff --git a/Dockerfile b/Dockerfile index 6ab1b36..fcd27d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,18 @@ RUN AWS_SDK_VERSION="$(node -p "require('./node_modules/@aws-sdk/client-s3/packa FROM node:22-bookworm-slim AS runtime +ARG VERSION=dev +ARG REVISION=unknown +ARG SOURCE=https://github.com/Jason-Doyle/thimble + +LABEL org.opencontainers.image.title="ThimbleDB" \ + org.opencontainers.image.description="Encrypted browser-first JSON database authority" \ + org.opencontainers.image.url="https://thimbledb.com" \ + org.opencontainers.image.source="${SOURCE}" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.licenses="Apache-2.0" + ENV NODE_ENV=production ENV THIMBLE_HOST=0.0.0.0 ENV THIMBLE_PORT=8787 @@ -28,6 +40,16 @@ COPY --from=build /app/package.json /app/package-lock.json ./ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist +RUN mkdir -p /var/lib/thimbledb/data /var/lib/thimbledb/auth /var/lib/thimbledb/secrets \ + && chown -R node:node /var/lib/thimbledb + +WORKDIR /var/lib/thimbledb + +USER node + EXPOSE 8787 -CMD ["node", "dist/server/server.js"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["node", "--input-type=module", "-e", "const response = await fetch('http://127.0.0.1:8787/healthz'); if (!response.ok) process.exit(1)"] + +CMD ["node", "/app/dist/server/server.js"] diff --git a/README.md b/README.md index f0191e6..81d9cdd 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,8 @@ before horizontally scaling a Node authority. - Chromium, Firefox, and WebKit recovery tests - typed package exports for the browser/core and auth APIs - reusable Node and Cloudflare authority endpoint exports -- Docker, Wrangler, Bicep, and CloudFormation deployment paths +- signed multi-architecture container and OCI Helm chart +- Docker, Kubernetes, Wrangler, Bicep, and CloudFormation deployment paths The reference browser build is about 68.3 KB uncompressed and 18.7 KB gzip. It ships no database runtime or WASM module. @@ -281,6 +282,11 @@ In-app deployment minimises operations. A separate authority isolates secrets, releases, failures, and scaling. See [In-app and separate authority deployment](docs/AUTHORITY-DEPLOYMENT.md). +Existing Kubernetes clusters can install the separate Node authority from a +multi-architecture GHCR image and OCI Helm chart. The chart uses non-root, +read-only, capability-free defaults and an existing Secret. See +[Deploy to Kubernetes](docs/DEPLOYMENT-KUBERNETES.md). + After the authority session exists: ```ts @@ -389,7 +395,7 @@ layout decision thresholds. | [Quickstart](docs/QUICKSTART.md) | Package, authority, browser client, and verification setup | | [Configuration reference](docs/CONFIGURATION.md) | Authority options, environment variables, provider settings, defaults, and template coverage | | [Implementation prompts](docs/IMPLEMENTATION-PROMPTS.md) | Copy-paste integration, deployment, migration, and review prompts | -| [npm publishing](docs/NPM-PUBLISHING.md) | OIDC trusted publisher setup and release process | +| [Release publishing](docs/NPM-PUBLISHING.md) | npm, GHCR image, and OCI Helm chart release process | | [Use cases](docs/USE-CASES.md) | Fit criteria and application-specific guides | | [Architecture](docs/ARCHITECTURE.md) | Components, data flow, and scope model | | [In-app and separate authority deployment](docs/AUTHORITY-DEPLOYMENT.md) | Topologies, scaling opportunities, trust boundaries, and decision criteria | @@ -408,6 +414,7 @@ layout decision thresholds. | [Cloudflare deployment](docs/DEPLOYMENT-CLOUDFLARE.md) | Worker and R2 reference deployment | | [Azure deployment](docs/DEPLOYMENT-AZURE.md) | Container Apps and Blob Storage | | [AWS deployment](docs/DEPLOYMENT-AWS.md) | Lambda container and private S3 buckets | +| [Kubernetes deployment](docs/DEPLOYMENT-KUBERNETES.md) | Multi-architecture image, OCI Helm chart, secure defaults, routing, and scaling limits | | [Operations](docs/OPERATIONS.md) | Keys, backup, metrics, incidents, and cleanup | | [Website privacy](docs/WEBSITE-PRIVACY.md) | Static-site data handling and Cloudflare Web Analytics disclosure | diff --git a/deploy/helm/test/oidc-server.mjs b/deploy/helm/test/oidc-server.mjs new file mode 100644 index 0000000..af8ca13 --- /dev/null +++ b/deploy/helm/test/oidc-server.mjs @@ -0,0 +1,95 @@ +import { + createServer, +} from "node:http"; +import { + generateKeyPairSync, + sign, +} from "node:crypto"; + +const issuer = + process.env.OIDC_ISSUER ?? + "http://thimbledb-test-oidc.thimbledb.svc.cluster.local:8080"; +const audience = + process.env.OIDC_AUDIENCE ?? + "thimbledb-k8s"; +const { privateKey, publicKey } = + generateKeyPairSync("rsa", { + modulusLength: 2048, + }); +const publicJwk = { + ...publicKey.export({ format: "jwk" }), + alg: "RS256", + kid: "k8s-test-key", + use: "sig", +}; + +createServer((request, response) => { + const url = new URL( + request.url ?? "/", + issuer, + ); + if (url.pathname === "/healthz") { + return sendJson(response, { + status: "ok", + }); + } + if (url.pathname === "/jwks") { + return sendJson(response, { + keys: [publicJwk], + }); + } + if (url.pathname === "/token") { + const now = Math.floor(Date.now() / 1_000); + const subject = + url.searchParams.get("subject") ?? + "k8s-smoke"; + const header = encode({ + alg: "RS256", + kid: publicJwk.kid, + typ: "JWT", + }); + const payload = encode({ + aud: audience, + exp: now + 3_600, + iat: now, + iss: issuer, + roles: [ + "thimble.user", + "thimble.admin", + ], + scp: "thimble.access", + sub: subject, + tid: "k8s-smoke", + }); + const signature = sign( + "RSA-SHA256", + Buffer.from(`${header}.${payload}`), + privateKey, + ).toString("base64url"); + response.writeHead(200, { + "cache-control": "no-store", + "content-type": + "text/plain; charset=utf-8", + }); + response.end( + `${header}.${payload}.${signature}`, + ); + return; + } + response.writeHead(404).end(); +}).listen(8080, "0.0.0.0"); + +function encode(value) { + return Buffer.from( + JSON.stringify(value), + ).toString("base64url"); +} + +function sendJson(response, value) { + response.writeHead(200, { + "cache-control": "no-store", + "content-type": + "application/json; charset=utf-8", + }); + response.end(`${JSON.stringify(value)}\n`); +} diff --git a/deploy/helm/test/oidc.yaml b/deploy/helm/test/oidc.yaml new file mode 100644 index 0000000..d31c317 --- /dev/null +++ b/deploy/helm/test/oidc.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: thimbledb-test-oidc + namespace: thimbledb +spec: + replicas: 1 + selector: + matchLabels: + app: thimbledb-test-oidc + template: + metadata: + labels: + app: thimbledb-test-oidc + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + containers: + - name: oidc + image: thimbledb:ci + imagePullPolicy: Never + command: + - node + - /test-oidc/server.mjs + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: /healthz + port: http + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + volumeMounts: + - name: script + mountPath: /test-oidc + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: script + configMap: + name: thimbledb-test-oidc + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: thimbledb-test-oidc + namespace: thimbledb +spec: + selector: + app: thimbledb-test-oidc + ports: + - name: http + port: 8080 + targetPort: http diff --git a/deploy/helm/test/values.yaml b/deploy/helm/test/values.yaml new file mode 100644 index 0000000..934479c --- /dev/null +++ b/deploy/helm/test/values.yaml @@ -0,0 +1,36 @@ +replicaCount: 1 + +image: + repository: thimbledb + tag: ci + pullPolicy: Never + +existingSecret: thimbledb-secrets + +config: + provider: local + allowedOrigin: http://127.0.0.1:18787 + secureCookies: false + collectionLayouts: notes=snapshot + collections: notes + readBundles: true + mutationBatches: true + oidc: + providerId: k8s + issuer: http://thimbledb-test-oidc.thimbledb.svc.cluster.local:8080 + audience: thimbledb-k8s + jwksUri: http://thimbledb-test-oidc.thimbledb.svc.cluster.local:8080/jwks + requiredScope: thimble.access + extraEnv: + THIMBLE_DISABLE_IP_RATE_LIMIT: "true" + +localStorage: + enabled: true + sizeLimit: 512Mi + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 768Mi diff --git a/deploy/helm/thimbledb/.helmignore b/deploy/helm/thimbledb/.helmignore new file mode 100644 index 0000000..6d186ff --- /dev/null +++ b/deploy/helm/thimbledb/.helmignore @@ -0,0 +1,5 @@ +.git/ +.github/ +.DS_Store +*.tgz +ci/ diff --git a/deploy/helm/thimbledb/Chart.yaml b/deploy/helm/thimbledb/Chart.yaml new file mode 100644 index 0000000..2916b21 --- /dev/null +++ b/deploy/helm/thimbledb/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +name: thimbledb +description: Deploy the ThimbleDB Node authority to Kubernetes. +type: application +version: 3.3.0 +appVersion: "3.3.0" +kubeVersion: ">=1.28.0-0" +home: https://thimbledb.com +sources: + - https://github.com/Jason-Doyle/thimble +maintainers: + - name: Jason Doyle +annotations: + artifacthub.io/license: Apache-2.0 + artifacthub.io/links: | + - name: Documentation + url: https://thimbledb.com/docs/deployment-kubernetes/ + - name: Source + url: https://github.com/Jason-Doyle/thimble diff --git a/deploy/helm/thimbledb/README.md b/deploy/helm/thimbledb/README.md new file mode 100644 index 0000000..ddf11a8 --- /dev/null +++ b/deploy/helm/thimbledb/README.md @@ -0,0 +1,84 @@ +# ThimbleDB Helm chart + +This chart deploys the ThimbleDB Node authority. It does not provision object +storage, OIDC applications, DNS, certificates, or provider credentials. + +## Required inputs + +- `existingSecret`: a Kubernetes Secret containing `THIMBLE_MASTER_KEY` and + the selected provider credentials and bucket/container names +- `config.provider`: `s3`, `r2`, or `azure` for production +- `config.allowedOrigin`: the exact browser origin + +Ingress is disabled by default. Preserve one browser origin by routing +`/api/*` and optionally `/studio/*` through the application's existing +Ingress or gateway. + +## Install + +Use Helm 3.18 or newer. Chart and application versions remain aligned. + +```powershell +helm upgrade --install thimbledb ` + oci://ghcr.io/jason-doyle/charts/thimbledb ` + --version 3.3.0 ` + --namespace thimbledb ` + --create-namespace ` + --values .\thimbledb-values.yaml +``` + +The image defaults to `ghcr.io/jason-doyle/thimbledb:`. +Set `image.digest` to an immutable `sha256:` manifest digest when required. +The digest takes precedence over `image.tag`. + +## Secrets + +Create the Secret separately. Do not commit a rendered Secret or credentials. + +For S3-compatible storage, the Secret normally contains: + +- `THIMBLE_MASTER_KEY` +- `S3_BUCKET` +- `S3_AUTH_BUCKET` +- `AWS_ACCESS_KEY_ID` +- `AWS_SECRET_ACCESS_KEY` +- optional `AWS_SESSION_TOKEN` + +For R2, use the documented `R2_*` variables. For Azure, use +`AZURE_STORAGE_CONNECTION_STRING`, `AZURE_STORAGE_CONTAINER`, and +`AZURE_AUTH_STORAGE_CONTAINER`. + +`values.example.yaml` contains commented configuration examples. + +## Replicas and storage + +The default is one replica. Additional replicas require one shared cloud +object store and do not remove per-collection HEAD contention. + +The local provider requires `localStorage.enabled=true`, allows one replica, +and uses an ephemeral `emptyDir`. It exists for chart smoke tests and local +development, not production persistence. + +## Security defaults + +- non-root UID/GID 1000 +- read-only root filesystem +- RuntimeDefault seccomp +- all Linux capabilities dropped +- no service-account token mount +- Ingress disabled +- Recreate deployment strategy +- credentials loaded only from an existing Secret + +The chart creates no active cloud resources and contains no credential +placeholders. + +Set `serviceAccount.automount=true` only when the selected cloud workload +identity requires a projected ServiceAccount token. + +## First GHCR publication + +GitHub creates new container packages as private. After the first release, +open the package settings for both the image and chart and change visibility +to public. GitHub does not currently provide an API for automating that +one-time visibility change. diff --git a/deploy/helm/thimbledb/templates/NOTES.txt b/deploy/helm/thimbledb/templates/NOTES.txt new file mode 100644 index 0000000..8cbdba1 --- /dev/null +++ b/deploy/helm/thimbledb/templates/NOTES.txt @@ -0,0 +1,14 @@ +ThimbleDB is running behind: + + {{ include "thimbledb.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }} + +Keep the browser and authority on one public origin. Route /api/* and, when +enabled, /studio/* through the application Ingress or gateway. + +Check readiness: + + kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "thimbledb.fullname" . }} 8787:{{ .Values.service.port }} + curl http://127.0.0.1:8787/readyz + +The local provider is for one-pod development only. Production replicas must +share S3, R2, or Azure Blob storage. diff --git a/deploy/helm/thimbledb/templates/_helpers.tpl b/deploy/helm/thimbledb/templates/_helpers.tpl new file mode 100644 index 0000000..3bfdab0 --- /dev/null +++ b/deploy/helm/thimbledb/templates/_helpers.tpl @@ -0,0 +1,57 @@ +{{- define "thimbledb.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "thimbledb.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "thimbledb.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "thimbledb.labels" -}} +helm.sh/chart: {{ include "thimbledb.chart" . }} +{{ include "thimbledb.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "thimbledb.selectorLabels" -}} +app.kubernetes.io/name: {{ include "thimbledb.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "thimbledb.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "thimbledb.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{- define "thimbledb.validate" -}} +{{- if and (eq .Values.config.provider "local") (not .Values.localStorage.enabled) }} +{{- fail "localStorage.enabled must be true when config.provider is local" }} +{{- end }} +{{- if and (eq .Values.config.provider "local") (gt (int .Values.replicaCount) 1) }} +{{- fail "the local provider supports only one replica" }} +{{- end }} +{{- if and .Values.ingress.enabled (eq (len .Values.ingress.hosts) 0) }} +{{- fail "ingress.hosts must contain at least one host when ingress is enabled" }} +{{- end }} +{{- $oidc := .Values.config.oidc }} +{{- $oidcConfigured := or $oidc.providerId $oidc.issuer $oidc.audience $oidc.jwksUri $oidc.allowedTenants $oidc.requiredScope $oidc.requiredRole }} +{{- if and $oidcConfigured (not (and $oidc.providerId $oidc.issuer $oidc.audience $oidc.jwksUri (or $oidc.requiredScope $oidc.requiredRole))) }} +{{- fail "config.oidc requires providerId, issuer, audience, jwksUri, and at least one requiredScope or requiredRole" }} +{{- end }} +{{- end }} diff --git a/deploy/helm/thimbledb/templates/configmap.yaml b/deploy/helm/thimbledb/templates/configmap.yaml new file mode 100644 index 0000000..898467e --- /dev/null +++ b/deploy/helm/thimbledb/templates/configmap.yaml @@ -0,0 +1,46 @@ +{{- include "thimbledb.validate" . }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "thimbledb.fullname" . }} + labels: + {{- include "thimbledb.labels" . | nindent 4 }} +data: + THIMBLE_HOST: "0.0.0.0" + THIMBLE_PORT: "8787" + THIMBLE_PROVIDER: {{ required "config.provider is required" .Values.config.provider | quote }} + THIMBLE_ALLOWED_ORIGIN: {{ required "config.allowedOrigin is required" .Values.config.allowedOrigin | quote }} + THIMBLE_PREFIX: {{ .Values.config.prefix | quote }} + THIMBLE_SECURE_COOKIES: {{ .Values.config.secureCookies | quote }} + THIMBLE_HEAD_TTL_MS: {{ .Values.config.headTtlMs | quote }} + THIMBLE_KEY_VERSION: {{ .Values.config.keyVersion | quote }} + THIMBLE_READ_KEY_VERSIONS: {{ .Values.config.readKeyVersions | quote }} + THIMBLE_COLLECTION_LAYOUTS: {{ .Values.config.collectionLayouts | quote }} + THIMBLE_COLLECTION_INDEXES: {{ .Values.config.collectionIndexes | quote }} + THIMBLE_COLLECTIONS: {{ .Values.config.collections | quote }} + THIMBLE_DELETE_RETENTION_DAYS: {{ .Values.config.deleteRetentionDays | quote }} + THIMBLE_DELETE_GRACE_DAYS: {{ .Values.config.deleteGraceDays | quote }} + THIMBLE_MAINTENANCE_MODE: {{ .Values.config.maintenanceMode | quote }} + THIMBLE_STUDIO: {{ .Values.config.studio.enabled | quote }} + THIMBLE_READ_BUNDLES: {{ .Values.config.readBundles | quote }} + THIMBLE_MUTATION_BATCHES: {{ .Values.config.mutationBatches | quote }} + {{- if .Values.config.studio.origin }} + THIMBLE_STUDIO_ORIGIN: {{ .Values.config.studio.origin | quote }} + {{- end }} + {{- if .Values.config.oidc.providerId }} + OIDC_PROVIDER_ID: {{ .Values.config.oidc.providerId | quote }} + OIDC_ISSUER: {{ .Values.config.oidc.issuer | quote }} + OIDC_AUDIENCE: {{ .Values.config.oidc.audience | quote }} + OIDC_JWKS_URI: {{ .Values.config.oidc.jwksUri | quote }} + OIDC_ALLOWED_TENANTS: {{ .Values.config.oidc.allowedTenants | quote }} + OIDC_REQUIRED_SCOPE: {{ .Values.config.oidc.requiredScope | quote }} + OIDC_REQUIRED_ROLE: {{ .Values.config.oidc.requiredRole | quote }} + {{- end }} + {{- if .Values.localStorage.enabled }} + THIMBLE_LOCAL_DATA_ROOT: "/var/lib/thimbledb/data" + THIMBLE_LOCAL_AUTH_ROOT: "/var/lib/thimbledb/auth" + THIMBLE_LOCAL_SECRET_ROOT: "/var/lib/thimbledb/secrets" + {{- end }} + {{- range $name, $value := .Values.config.extraEnv }} + {{ $name }}: {{ $value | quote }} + {{- end }} diff --git a/deploy/helm/thimbledb/templates/deployment.yaml b/deploy/helm/thimbledb/templates/deployment.yaml new file mode 100644 index 0000000..66d38ef --- /dev/null +++ b/deploy/helm/thimbledb/templates/deployment.yaml @@ -0,0 +1,109 @@ +{{- include "thimbledb.validate" . }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "thimbledb.fullname" . }} + labels: + {{- include "thimbledb.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + {{- toYaml .Values.strategy | nindent 4 }} + selector: + matchLabels: + {{- include "thimbledb.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "thimbledb.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "thimbledb.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automount }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: thimbledb + {{- if .Values.image.digest }} + image: "{{ .Values.image.repository }}@{{ .Values.image.digest }}" + {{- else }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + {{- end }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: http + containerPort: 8787 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "thimbledb.fullname" . }} + - secretRef: + name: {{ required "existingSecret is required" .Values.existingSecret }} + {{- with .Values.extraEnvFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + livenessProbe: + httpGet: + path: /healthz + port: http + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + httpGet: + path: /readyz + port: http + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: tmp + mountPath: /tmp + {{- if .Values.localStorage.enabled }} + - name: local-storage + mountPath: /var/lib/thimbledb + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: tmp + emptyDir: {} + {{- if .Values.localStorage.enabled }} + - name: local-storage + emptyDir: + {{- if .Values.localStorage.sizeLimit }} + sizeLimit: {{ .Values.localStorage.sizeLimit }} + {{- end }} + {{- end }} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/thimbledb/templates/ingress.yaml b/deploy/helm/thimbledb/templates/ingress.yaml new file mode 100644 index 0000000..fc7872e --- /dev/null +++ b/deploy/helm/thimbledb/templates/ingress.yaml @@ -0,0 +1,36 @@ +{{- if .Values.ingress.enabled -}} +{{- include "thimbledb.validate" . }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "thimbledb.fullname" . }} + labels: + {{- include "thimbledb.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "thimbledb.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/thimbledb/templates/service.yaml b/deploy/helm/thimbledb/templates/service.yaml new file mode 100644 index 0000000..73edcd9 --- /dev/null +++ b/deploy/helm/thimbledb/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "thimbledb.fullname" . }} + labels: + {{- include "thimbledb.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "thimbledb.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/thimbledb/templates/serviceaccount.yaml b/deploy/helm/thimbledb/templates/serviceaccount.yaml new file mode 100644 index 0000000..cdda5bf --- /dev/null +++ b/deploy/helm/thimbledb/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "thimbledb.serviceAccountName" . }} + labels: + {{- include "thimbledb.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/deploy/helm/thimbledb/values.example.yaml b/deploy/helm/thimbledb/values.example.yaml new file mode 100644 index 0000000..ddebb59 --- /dev/null +++ b/deploy/helm/thimbledb/values.example.yaml @@ -0,0 +1,34 @@ +# existingSecret: thimbledb-secrets +# +# image: +# digest: sha256: +# +# config: +# provider: s3 +# allowedOrigin: https://app.example.com +# prefix: production +# collectionLayouts: notes=snapshot +# collectionIndexes: '{"notes":[{"name":"by-title","fields":["title"],"mode":"equality"}]}' +# readBundles: true +# mutationBatches: true +# oidc: +# providerId: application +# issuer: https://identity.example.com/ +# audience: thimbledb-api +# jwksUri: https://identity.example.com/.well-known/jwks.json +# requiredScope: thimble.access +# +# serviceAccount: +# automount: false +# annotations: {} +# +# ingress: +# enabled: true +# className: nginx +# hosts: +# - host: app.example.com +# paths: +# - path: /api +# pathType: Prefix +# +# replicaCount: 2 diff --git a/deploy/helm/thimbledb/values.schema.json b/deploy/helm/thimbledb/values.schema.json new file mode 100644 index 0000000..9f5a8af --- /dev/null +++ b/deploy/helm/thimbledb/values.schema.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": [ + "existingSecret", + "config" + ], + "properties": { + "replicaCount": { + "type": "integer", + "minimum": 1 + }, + "existingSecret": { + "type": "string", + "minLength": 1 + }, + "image": { + "type": "object", + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^(|sha256:[a-f0-9]{64})$" + }, + "pullPolicy": { + "type": "string", + "enum": [ + "Always", + "IfNotPresent", + "Never" + ] + } + } + }, + "config": { + "type": "object", + "required": [ + "provider", + "allowedOrigin" + ], + "properties": { + "provider": { + "type": "string", + "enum": [ + "local", + "azure", + "s3", + "r2" + ] + }, + "allowedOrigin": { + "type": "string", + "minLength": 1 + }, + "collectionIndexes": { + "type": "string" + }, + "extraEnv": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "number", + "boolean" + ] + } + } + } + }, + "localStorage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "sizeLimit": { + "type": "string" + } + } + }, + "ingress": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "hosts": { + "type": "array", + "items": { + "type": "object", + "required": [ + "host", + "paths" + ], + "properties": { + "host": { + "type": "string", + "minLength": 1 + }, + "paths": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "path", + "pathType" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "pathType": { + "type": "string", + "enum": [ + "Exact", + "Prefix", + "ImplementationSpecific" + ] + } + } + } + } + } + } + } + } + } + } +} diff --git a/deploy/helm/thimbledb/values.yaml b/deploy/helm/thimbledb/values.yaml new file mode 100644 index 0000000..3bc2624 --- /dev/null +++ b/deploy/helm/thimbledb/values.yaml @@ -0,0 +1,118 @@ +replicaCount: 1 + +image: + repository: ghcr.io/jason-doyle/thimbledb + pullPolicy: IfNotPresent + tag: "" + digest: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +existingSecret: "" + +serviceAccount: + create: true + automount: false + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + fsGroup: 1000 + fsGroupChangePolicy: OnRootMismatch + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + +strategy: + type: Recreate + +service: + type: ClusterIP + port: 8787 + annotations: {} + +ingress: + enabled: false + className: "" + annotations: {} + hosts: [] + tls: [] + +config: + provider: "" + allowedOrigin: "" + prefix: demo + secureCookies: true + headTtlMs: 1000 + keyVersion: 1 + readKeyVersions: "" + collectionLayouts: "" + collectionIndexes: "{}" + collections: "" + deleteRetentionDays: 30 + deleteGraceDays: 7 + maintenanceMode: false + studio: + enabled: false + origin: "" + readBundles: false + mutationBatches: false + oidc: + providerId: "" + issuer: "" + audience: "" + jwksUri: "" + allowedTenants: "" + requiredScope: "" + requiredRole: "" + extraEnv: {} + +localStorage: + enabled: false + sizeLimit: "" + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + +livenessProbe: + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +terminationGracePeriodSeconds: 30 + +nodeSelector: {} +tolerations: [] +affinity: {} +topologySpreadConstraints: [] + +extraEnvFrom: [] +extraVolumes: [] +extraVolumeMounts: [] diff --git a/docs/AUTHORITY-DEPLOYMENT.md b/docs/AUTHORITY-DEPLOYMENT.md index 8c16c0c..b702b6e 100644 --- a/docs/AUTHORITY-DEPLOYMENT.md +++ b/docs/AUTHORITY-DEPLOYMENT.md @@ -104,8 +104,8 @@ authority traffic need different scaling or release controls. ## Separate Worker or service The authority runs in its own Worker, container, Lambda function, Container -App, or Node service. The browser application remains a normal ThimbleDB -client. +App, Kubernetes Deployment, or Node service. The browser application remains +a normal ThimbleDB client. Typical public routing: @@ -160,6 +160,11 @@ For Node deployments, use a shared cloud object store before running multiple authority instances. The local filesystem provider is intentionally limited to one process and is not a scale-out storage backend. +Kubernetes deployments can use the published multi-architecture image and OCI +Helm chart. The chart does not provision storage or identity and does not +remove the collection HEAD contention boundary. See +[Deploy to Kubernetes](DEPLOYMENT-KUBERNETES.md). + ## Same-origin browser boundary The default ThimbleDB session cookie is `HttpOnly` and `SameSite=Strict`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e789c99..cbebec3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -166,6 +166,25 @@ Cloudflare uses a one-hour session lifetime and a bounded in-memory scope runtime cache. Those values are not environment-configurable in the current Worker authority. +## Kubernetes Helm mapping + +The Helm chart maps non-secret `config` values to the common and Node +environment settings. `existingSecret` supplies `THIMBLE_MASTER_KEY`, provider +settings, and any OIDC or workload-identity settings that should not enter a +ConfigMap. + +The chart: + +- binds `THIMBLE_HOST=0.0.0.0` and port `8787` +- keeps read bundles, mutation batches, Studio, and Ingress opt-in +- validates the local provider as one replica with ephemeral storage +- validates complete generic OIDC settings when that values block is used +- supports additional non-secret settings through `config.extraEnv` +- supports additional Secret or ConfigMap sources through `extraEnvFrom` + +See [Deploy to Kubernetes](DEPLOYMENT-KUBERNETES.md) for the complete Secret, +routing, security, scaling, and upgrade guidance. + ## Maintenance command settings These settings apply to command-line maintenance. They are not all authority diff --git a/docs/DEPLOYMENT-KUBERNETES.md b/docs/DEPLOYMENT-KUBERNETES.md new file mode 100644 index 0000000..9863d20 --- /dev/null +++ b/docs/DEPLOYMENT-KUBERNETES.md @@ -0,0 +1,262 @@ +# Deploy to Kubernetes + +ThimbleDB can run as a separate Node authority in Kubernetes. The release +distribution consists of: + +- a multi-architecture image at `ghcr.io/jason-doyle/thimbledb` +- an OCI Helm chart at `oci://ghcr.io/jason-doyle/charts/thimbledb` + +The chart deploys only the authority. It does not provision object storage, +an OIDC application, DNS, certificates, or cloud credentials. + +Use Kubernetes when the application already has a cluster and needs a +separate authority deployment, secret boundary, rollout, or scaling policy. +For one small application without an existing cluster, an in-app authority or +managed Worker is normally a smaller operational surface. + +## Requirements + +- Kubernetes 1.28 or newer +- Helm 3.18 or newer +- one private S3, R2, or Azure Blob data store +- one separate private authentication bucket or container +- an external OIDC provider +- a Kubernetes Secret containing the deployment master key and provider + settings +- an existing gateway or Ingress that preserves the browser application's + public origin + +The chart is validated in CI with Helm 3.18.3, kind 0.33.0, and Kubernetes +1.37.0. It also renders with Helm 4.3.0. + +## Prepare the Secret + +Create the namespace and Secret before installing the chart. Keep the source +secret file outside the repository and remove it after the platform secret +workflow has imported it. + +```powershell +kubectl create namespace thimbledb +kubectl --namespace thimbledb create secret generic thimbledb-secrets ` + --from-env-file=C:\secure\thimbledb-secrets.env +``` + +Every deployment needs: + +| Setting | Purpose | +| --- | --- | +| `THIMBLE_MASTER_KEY` | Base64 deployment master key with at least 32 decoded bytes | + +Add the settings for the selected provider: + +| Provider | Secret settings | +| --- | --- | +| S3 | `S3_BUCKET`, `S3_AUTH_BUCKET`, and credentials accepted by the AWS SDK | +| R2 | `R2_ACCOUNT_ID`, `R2_BUCKET`, `R2_AUTH_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | +| Azure | `AZURE_STORAGE_CONNECTION_STRING`, `AZURE_STORAGE_CONTAINER`, `AZURE_AUTH_STORAGE_CONTAINER` | + +Cloud workload identity can replace long-lived provider access keys. Annotate +the ServiceAccount as required by the platform and explicitly set +`serviceAccount.automount: true`. The default is `false`. The existing Secret +still holds `THIMBLE_MASTER_KEY` and non-credential provider settings. + +Do not put credentials or the master key in a values file, ConfigMap, rendered +manifest, or container image. + +## Configure values + +Copy `deploy/helm/thimbledb/values.example.yaml` and uncomment only the +required settings. The checked-in example is fully commented so applying it +cannot create an accidentally configured deployment. + +The minimum production shape is: + +```yaml +# existingSecret: thimbledb-secrets +# +# config: +# provider: s3 +# allowedOrigin: https://app.example.com +# prefix: production +# collectionLayouts: notes=snapshot +# readBundles: true +# mutationBatches: true +# oidc: +# providerId: application +# issuer: https://identity.example.com/ +# audience: thimbledb-api +# jwksUri: https://identity.example.com/.well-known/jwks.json +# requiredScope: thimble.access +``` + +The generic OIDC block requires `providerId`, `issuer`, `audience`, `jwksUri`, +and at least one required scope or role. Microsoft Entra settings can instead +be supplied through the existing Secret. + +## Install + +Chart and application versions remain aligned. Install an exact version: + +```powershell +helm upgrade --install thimbledb ` + oci://ghcr.io/jason-doyle/charts/thimbledb ` + --version 3.3.0 ` + --namespace thimbledb ` + --values .\thimbledb-values.yaml ` + --wait ` + --timeout 5m +``` + +The chart uses `ghcr.io/jason-doyle/thimbledb:` by default. Set +`image.digest` to an immutable `sha256:` digest when the deployment requires +digest pinning. A configured digest takes precedence over `image.tag`. + +## Preserve one browser origin + +Ingress creation is disabled by default. Route the authority through the +application's existing gateway: + +```text +https://app.example.com/ -> application +https://app.example.com/api/* -> service/thimbledb:8787 +https://app.example.com/studio/* -> service/thimbledb:8787, when enabled +``` + +The default session cookie is `HttpOnly`, `Secure`, and `SameSite=Strict`. +`config.allowedOrigin` must be the exact public application origin. A separate +Kubernetes Service must not automatically become a separate browser origin. + +The chart can create an Ingress when `ingress.enabled=true`, but it does not +create certificates or DNS records. Keep the option disabled when an existing +application gateway already owns these paths. + +## Security defaults + +The default Pod: + +- runs as UID and GID 1000 +- requires a non-root process +- uses `RuntimeDefault` seccomp +- drops every Linux capability +- blocks privilege escalation +- uses a read-only root filesystem +- mounts a writable `emptyDir` only at `/tmp` +- does not mount a ServiceAccount token +- loads credentials only from `existingSecret` + +The chart defaults to one replica and the `Recreate` deployment strategy. +`Recreate` avoids running mixed authority versions during an upgrade, at the +cost of a short outage. Change the strategy only after checking release +compatibility and migration requirements. + +## Health and readiness + +The authority exposes two unauthenticated probe routes: + +| Route | Meaning | +| --- | --- | +| `GET /healthz` | The process is serving requests | +| `GET /readyz` | Authority initialisation completed; the response names the configured provider | + +Both responses contain bounded status metadata and use `cache-control: +no-store`. Readiness does not perform a live object-store write or guarantee +that every provider operation will succeed. Monitor provider errors, session +creation, key grants, reads, writes, and conditional HEAD failures separately. + +## Replicas and storage + +Use S3, R2, or Azure Blob Storage before setting `replicaCount` above one. +Every replica must use the same object stores, master key, layouts, indexes, +and identity configuration. + +Additional authority replicas can add request capacity and failure isolation. +They do not partition a collection, remove conditional HEAD contention, or +make concurrent writes to one collection cheap. Keep write bursts bounded and +use explicit mutation batches when several documents should share one +revision. + +The chart does not create an HPA because useful thresholds depend on the +gateway, object-store latency, read/write mix, and collection contention. +Add autoscaling only after measuring those signals. + +The `local` provider is limited to one replica and requires +`localStorage.enabled=true`. It uses an ephemeral `emptyDir` and exists only +for chart tests and local development. It is not a durable Kubernetes storage +mode, even if the cluster itself is durable. + +## Upgrade and rollback + +Before an upgrade: + +1. Read `CHANGELOG.md` and [Versioning and compatibility](VERSIONING.md). +2. Complete any required metadata, index, layout, or key migration. +3. Keep the chart version and image version aligned. +4. Back up the object stores and deployment master key. +5. Record the current Helm revision and image digest. + +Upgrade with another exact chart version. If the release is compatible and no +forward-only maintenance operation has run, use `helm rollback` to return to a +previous revision. Object storage remains the source of truth, so a Pod +rollback does not undo published collection revisions. + +## Verify release signatures + +The release workflow signs the image manifest and chart manifest by immutable +digest through GitHub Actions OIDC. It also publishes image provenance and an +SBOM. + +Use the release-specific workflow identity when verifying either artifact: + +```text +https://github.com/Jason-Doyle/thimble/.github/workflows/publish.yml@refs/tags/v3.3.0 +``` + +Example image verification: + +```powershell +docker buildx imagetools inspect ghcr.io/jason-doyle/thimbledb:3.3.0 + +cosign verify ` + --certificate-identity "https://github.com/Jason-Doyle/thimble/.github/workflows/publish.yml@refs/tags/v3.3.0" ` + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ` + ghcr.io/jason-doyle/thimbledb@sha256: +``` + +`helm pull` prints the immutable chart digest: + +```powershell +helm pull oci://ghcr.io/jason-doyle/charts/thimbledb --version 3.3.0 + +cosign verify ` + --certificate-identity "https://github.com/Jason-Doyle/thimble/.github/workflows/publish.yml@refs/tags/v3.3.0" ` + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" ` + ghcr.io/jason-doyle/charts/thimbledb@sha256: +``` + +## First GHCR publication + +GitHub creates a new GHCR package as private. After the first release, change +the visibility of both the image package and chart package to public in their +package settings. This is a one-time manual step. The release workflow cannot +make the first package public through the current GitHub API. + +## CI smoke coverage + +Required CI builds the same authority image, creates a `kind` cluster, installs +the chart with the local ephemeral provider, and verifies: + +- Deployment readiness +- `/healthz` and `/readyz` +- OIDC token exchange and session creation +- configuration and capability discovery +- one authenticated document write +- one two-document mutation batch +- one read-bundle result + +This proves chart wiring and authority behaviour in Kubernetes. It does not +prove production object-store durability or regional performance. Use the +provider deployment and benchmark guidance for those decisions. + +See [In-app and separate authority deployment](AUTHORITY-DEPLOYMENT.md) for +the topology decision and [Operations](OPERATIONS.md) for keys, backups, +monitoring, maintenance, and incidents. diff --git a/docs/NPM-PUBLISHING.md b/docs/NPM-PUBLISHING.md index be6c132..0d0759b 100644 --- a/docs/NPM-PUBLISHING.md +++ b/docs/NPM-PUBLISHING.md @@ -1,7 +1,12 @@ -# npm trusted publishing +# Release publishing -ThimbleDB publishes through GitHub Actions and npm OpenID Connect trusted -publishing. The workflow does not use `NPM_TOKEN`. +ThimbleDB publishes the npm package, multi-architecture authority image, and +OCI Helm chart through `.github/workflows/publish.yml` after a GitHub release +is published. + +npm uses OpenID Connect trusted publishing without `NPM_TOKEN`. GHCR uses the +release-scoped `GITHUB_TOKEN`. The image and chart use keyless Sigstore +signatures through GitHub Actions OIDC. Trusted publishing requires npm CLI 11.5.1 or newer, Node.js 22.14 or newer, and a GitHub-hosted runner. The workflow uses Node.js 24 and @@ -58,22 +63,45 @@ unused write-capable npm automation tokens. ## Release process 1. Update `package.json`, `package-lock.json`, and `CHANGELOG.md` through a - pull request. + pull request. Keep `deploy/helm/thimbledb/Chart.yaml` at the same version. 2. Merge only after the protected `verify` check passes and repository branch protection requirements are satisfied. A repository administrator may use the documented bypass when the sole maintainer cannot self-approve. 3. Create a matching tag such as `vX.Y.Z`. 4. Publish a GitHub release for that tag. 5. The release event runs `publish.yml`. -6. The workflow verifies that the tag matches the package version, runs - type-checks and tests, builds the package, and uploads a one-day artifact. +6. The workflow verifies that the tag matches the package and chart versions, + runs type-checks and tests, builds the package, and uploads a one-day npm + artifact. 7. A separate OIDC-enabled job downloads only that artifact and calls `npm publish`. +8. Buildx publishes `linux/amd64` and `linux/arm64` images to + `ghcr.io/jason-doyle/thimbledb`, including provenance and an SBOM. +9. Cosign signs the immutable image manifest digest. +10. Helm packages and publishes + `oci://ghcr.io/jason-doyle/charts/thimbledb`. +11. Cosign signs the immutable chart manifest digest and the workflow attaches + the chart archive to the GitHub release. If the exact version already exists, the workflow exits successfully without attempting a duplicate publish. This supports the bootstrap release, which is published manually before the trusted publisher exists. +The chart publication path also checks for an existing version before pushing +it again. Release versions are immutable. Do not intentionally replace an +existing npm package, image version tag, or chart version with different +source. + +## First GHCR publication + +GitHub creates each new package as private. After the first workflow run, +change the image and chart package visibility to public in GitHub package +settings. The current GitHub API does not expose a supported first-publication +visibility switch for the workflow. + +No registry password or personal access token is required. The release job has +`packages: write` only for its duration. + ## Security properties - GitHub Actions receives a short-lived OIDC token. @@ -81,6 +109,8 @@ published manually before the trusted publisher exists. - Package dependencies and build tools run in a job without OIDC permission. - The workflow has read-only repository content access and `id-token: write`. - Publishing is tied to this repository and the exact `publish.yml` workflow. +- The image and chart are signed by immutable digest, not by a mutable tag. +- The image publishes Buildx provenance and an SBOM. - Protected `main` rules require CI and review before version changes merge, with an administrator bypass reserved for the sole-maintainer case. @@ -106,3 +136,14 @@ Duplicate version: - npm versions are immutable - bump the patch version through a pull request and create a new release + +Private GHCR package: + +- complete the one-time visibility change for both the image and chart + +Signature verification failure: + +- verify the immutable digest rather than a tag +- use the exact release workflow identity and + `https://token.actions.githubusercontent.com` issuer +- confirm the package and its signature artifact are publicly readable diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 88eb078..2a40dda 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -169,6 +169,20 @@ Record: Never log scope keys, raw session cookies, SAS tokens, connection strings, or decrypted document bodies. +## Health probes + +Node and Cloudflare authorities expose unauthenticated `GET /healthz` and +`GET /readyz` routes. Health reports that the process is serving. Readiness +reports completed authority initialisation and the configured provider. + +The routes return only bounded status metadata and do not test a live storage +write. Keep provider operation errors, session creation, key grants, read +latency, write latency, and conditional HEAD failures in the operational +signal set. + +Kubernetes probes use these routes. See +[Deploy to Kubernetes](DEPLOYMENT-KUBERNETES.md). + ## Source-IP rate limiting The Node authority uses the direct socket peer by default and ignores diff --git a/docs/README.md b/docs/README.md index 15d5afb..afc4ac5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ | [Implementation prompts](IMPLEMENTATION-PROMPTS.md) | Copy-paste integration, deployment, migration, and review prompts | | [Starter examples](EXAMPLES.md) | Checked-in examples, generated application, and maintained starter repositories | | [Frequently asked questions](FAQ.md) | Direct answers about fit, providers, identity, performance, and limits | -| [npm publishing](NPM-PUBLISHING.md) | OIDC trusted publisher setup and release process | +| [Release publishing](NPM-PUBLISHING.md) | npm, GHCR image, and OCI Helm chart release process | | [Use cases](USE-CASES.md) | Fit criteria and application-specific guides | | [Database comparisons](COMPARISONS.md) | Workload comparisons with D1, SQLite, Firestore, lowdb, and direct object storage | | [Architecture](ARCHITECTURE.md) | Components, data flow, scopes, and provider model | @@ -33,5 +33,6 @@ | [Cloudflare deployment](DEPLOYMENT-CLOUDFLARE.md) | Reference Worker and R2 deployment | | [Azure deployment](DEPLOYMENT-AZURE.md) | Container Apps and Blob Storage | | [AWS deployment](DEPLOYMENT-AWS.md) | Lambda container and private S3 buckets | +| [Kubernetes deployment](DEPLOYMENT-KUBERNETES.md) | Multi-architecture image, OCI Helm chart, secure defaults, routing, and scaling limits | | [Operations](OPERATIONS.md) | Keys, backup, metrics, incidents, and cleanup | | [Website privacy](WEBSITE-PRIVACY.md) | Static-site data handling and Cloudflare Web Analytics disclosure | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 7ae59a4..5e11758 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -128,6 +128,11 @@ Studio is served as static package assets and communicates only with authority endpoints. It starts read-only, requires explicit scope selection, and cannot use `thimble.admin` to bypass a missing scope grant. +`GET /healthz` and `GET /readyz` are intentionally unauthenticated for +platform probes. They return only process status and the configured provider, +set `cache-control: no-store`, and do not expose credentials, scope IDs, or +document data. + See [Authentication and identity](AUTHENTICATION.md). ## Revocation diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 575d1be..e27e7ee 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -123,6 +123,26 @@ The stored TDB1, Snapshot HEAD, Trie HEAD, and secondary-index formats are unchanged. Mutation batching is an optional authority and browser capability, so older clients continue using ordinary single writes. +## Version 3.3 + +Version 3.3 adds compatible Kubernetes and container distribution: + +- the Node authority image runs as a non-root user and supports a read-only + root filesystem +- release automation builds `linux/amd64` and `linux/arm64` images with an + SBOM, provenance, and keyless Sigstore signature +- an OCI Helm chart configures an existing Secret, secure Pod defaults, + optional Ingress, cloud storage, and explicit capabilities +- unauthenticated `/healthz` and `/readyz` routes support container and + Kubernetes probes +- required CI installs the chart into `kind` and verifies authenticated + single writes, mutation batches, and read bundles + +The TDB1 protocol, collection layouts, browser API, and object-store contract +are unchanged. The Helm chart and image are optional distribution surfaces. +Existing package, Worker, Lambda, and Container Apps deployments remain +compatible. + ## Object protocol version `TDB1` is stored in every object envelope. Protocol compatibility is separate diff --git a/package-lock.json b/package-lock.json index a0bf059..847a265 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "thimbledb", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "thimbledb", - "version": "3.2.0", + "version": "3.3.0", "license": "Apache-2.0", "dependencies": { "jose": "^6.1.0" diff --git a/package.json b/package.json index 2b1775c..f45d545 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thimbledb", - "version": "3.2.0", + "version": "3.3.0", "description": "Encrypted browser-first JSON database for small web apps, backed by object storage with in-app or separate Cloudflare and Node authorities.", "private": false, "license": "Apache-2.0", @@ -22,6 +22,9 @@ "database-admin", "browser-database", "json-database", + "kubernetes", + "helm", + "container", "indexeddb", "object-storage", "encryption", diff --git a/scripts/verify-kubernetes.mjs b/scripts/verify-kubernetes.mjs new file mode 100644 index 0000000..d05492d --- /dev/null +++ b/scripts/verify-kubernetes.mjs @@ -0,0 +1,239 @@ +import { + spawn, +} from "node:child_process"; + +const authorityUrl = "http://127.0.0.1:18787"; +const oidcUrl = "http://127.0.0.1:18790"; +const processes = [ + portForward( + "service/thimbledb", + "18787:8787", + ), + portForward( + "service/thimbledb-test-oidc", + "18790:8080", + ), +]; + +try { + await waitFor(`${authorityUrl}/readyz`); + await waitFor(`${oidcUrl}/healthz`); + + const health = await json( + `${authorityUrl}/healthz`, + ); + assert( + health.status === "ok", + "Authority health check failed", + ); + const ready = await json( + `${authorityUrl}/readyz`, + ); + assert( + ready.status === "ready" && + ready.provider === "local", + "Authority readiness check failed", + ); + const authConfig = await json( + `${authorityUrl}/api/auth/config`, + ); + assert( + authConfig.oidcProviders?.includes("k8s"), + "OIDC provider was not advertised", + ); + + const tokenResponse = await fetch( + `${oidcUrl}/token?subject=helm-smoke`, + ); + assert( + tokenResponse.ok, + "OIDC token request failed", + ); + const token = await tokenResponse.text(); + const login = await fetch( + `${authorityUrl}/api/auth/oidc/k8s/session`, + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + origin: authorityUrl, + }, + body: "{}", + }, + ); + assert(login.ok, "Authority login failed"); + const cookie = login.headers + .get("set-cookie") + ?.split(";", 1)[0]; + assert(cookie, "Authority login omitted its cookie"); + + const config = await requestJson( + `${authorityUrl}/api/config`, + { + headers: { + cookie, + }, + }, + ); + assert( + config.mutationBatchBaseUrl === + "/api/mutation-batches", + "Mutation batching was not advertised", + ); + assert( + config.readBundleBaseUrl === + "/api/read-bundles", + "Read bundles were not advertised", + ); + const mutationHeaders = { + cookie, + "content-type": "application/json", + origin: authorityUrl, + "x-thimble-csrf": config.csrfToken, + "x-thimble-layout-generation": + config.layoutGeneration, + "x-thimble-scope": config.scope.id, + }; + + const single = await requestJson( + `${authorityUrl}/api/collections/notes/documents/note-1`, + { + method: "POST", + headers: mutationHeaders, + body: JSON.stringify({ + id: "note-1", + title: "Single", + }), + }, + ); + assert( + single.document?.title === "Single", + "Single write verification failed", + ); + + const batch = await requestJson( + `${authorityUrl}/api/mutation-batches/notes`, + { + method: "POST", + headers: mutationHeaders, + body: JSON.stringify({ + version: 1, + documents: [ + { + id: "note-2", + title: "Batch two", + }, + { + id: "note-3", + title: "Batch three", + }, + ], + }), + }, + ); + assert( + batch.documents?.length === 2 && + batch.revision === 2, + "Mutation batch verification failed", + ); + + const read = await requestJson( + `${authorityUrl}/api/read-bundles/${encodeURIComponent(config.scope.id)}/notes/note-2`, + { + headers: { + cookie, + }, + }, + ); + assert( + read.document?.title === "Batch two", + "Read bundle verification failed", + ); + + console.log(JSON.stringify({ + health: health.status, + provider: ready.provider, + oidc: "k8s", + revision: batch.revision, + document: read.document.id, + })); +} finally { + for (const process of processes) { + process.kill(); + } +} + +function portForward(resource, ports) { + const child = spawn( + "kubectl", + [ + "--namespace", + "thimbledb", + "port-forward", + resource, + ports, + ], + { + stdio: [ + "ignore", + "pipe", + "pipe", + ], + }, + ); + child.stdout.on("data", (chunk) => + process.stdout.write(chunk), + ); + child.stderr.on("data", (chunk) => + process.stderr.write(chunk), + ); + return child; +} + +async function waitFor(url) { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.ok) { + return; + } + } catch { + // The port-forward or pod may still be starting. + } + await delay(1_000); + } + throw new Error(`Timed out waiting for ${url}`); +} + +async function json(url) { + const response = await fetch(url); + assert( + response.ok, + `${url} returned ${response.status}`, + ); + return response.json(); +} + +async function requestJson(url, init) { + const response = await fetch(url, init); + const body = await response.json(); + assert( + response.ok, + `${url} returned ${response.status}: ${JSON.stringify(body)}`, + ); + return body; +} + +function assert(value, message) { + if (!value) { + throw new Error(message); + } +} + +function delay(milliseconds) { + return new Promise((resolve) => + setTimeout(resolve, milliseconds), + ); +} diff --git a/site/src/data/docs.ts b/site/src/data/docs.ts index 840610a..b7328ee 100644 --- a/site/src/data/docs.ts +++ b/site/src/data/docs.ts @@ -314,6 +314,14 @@ export const docs: DocMeta[] = [ group: "Deploy and operate", order: 30, }, + { + id: "deployment-kubernetes", + title: "Deploy to Kubernetes", + description: + "Install the signed multi-architecture authority image and OCI Helm chart with secure defaults and shared cloud storage.", + group: "Deploy and operate", + order: 35, + }, { id: "operations", title: "Operations", diff --git a/site/src/pages/llms.txt.ts b/site/src/pages/llms.txt.ts index c7e8a1d..c68b1d2 100644 --- a/site/src/pages/llms.txt.ts +++ b/site/src/pages/llms.txt.ts @@ -39,6 +39,7 @@ scope and one collection. - [Security](${site.url}/security/): Threat model, encryption, key handling, and browser boundaries. - [Authentication](${site.url}/docs/authentication/): External OIDC identities and revocable sessions. - [Machine and service access](${site.url}/docs/service-access/): Entra roles, service principals, live viewers, and why there is no global admin key. +- [Kubernetes deployment](${site.url}/docs/deployment-kubernetes/): Signed multi-architecture image, OCI Helm chart, secure defaults, same-origin routing, and scaling limits. - [Object protocol](${site.url}/docs/protocol/): TDB1 envelopes, snapshots, tries, and conditional writes. - [Queries and indexes](${site.url}/docs/queries-indexes/): Typed predicates, developer-declared secondary indexes, and explicit covering projections. - [Deletion and retention](${site.url}/docs/deletion-retention/): Tombstones, restore windows, and physical collection. diff --git a/site/tests/site.spec.ts b/site/tests/site.spec.ts index 7feb633..c89ad0b 100644 --- a/site/tests/site.spec.ts +++ b/site/tests/site.spec.ts @@ -228,6 +228,7 @@ test("AI discovery routes publish explicit access and decision content", async ( expect(llmsText).toContain("In-app and separate authority deployment"); expect(llmsText).toContain("System diagrams"); expect(llmsText).toContain("Configuration reference"); + expect(llmsText).toContain("Kubernetes deployment"); const full = await request.get("/llms-full.txt"); expect(full.ok()).toBe(true); @@ -242,8 +243,9 @@ test("AI discovery routes publish explicit access and decision content", async ( ); expect(fullText).toContain("# System diagrams"); expect(fullText).toContain("# Configuration reference"); + expect(fullText).toContain("# Deploy to Kubernetes"); expect(fullText).toContain("# Website privacy"); - expect(fullText).toContain("## 3.2.0"); + expect(fullText).toContain("## 3.3.0"); await page.goto("/vibe-coded-apps/"); await expect( diff --git a/src/cloudflare-worker.ts b/src/cloudflare-worker.ts index 8ea3de7..9ba5af1 100644 --- a/src/cloudflare-worker.ts +++ b/src/cloudflare-worker.ts @@ -248,6 +248,25 @@ async function route( ): Promise { const url = new URL(request.url); + if ( + request.method === "GET" && + url.pathname === "/healthz" + ) { + return json({ + status: "ok", + }); + } + + if ( + request.method === "GET" && + url.pathname === "/readyz" + ) { + return json({ + status: "ready", + provider: "r2", + }); + } + if ( request.method === "GET" && url.pathname === "/api/auth/config" diff --git a/src/server.ts b/src/server.ts index 2a80710..84ce986 100644 --- a/src/server.ts +++ b/src/server.ts @@ -410,6 +410,27 @@ async function handleRequest( `http://${request.headers.host ?? "127.0.0.1"}`, ); + if ( + request.method === "GET" && + url.pathname === "/healthz" + ) { + sendJson(response, 200, { + status: "ok", + }); + return; + } + + if ( + request.method === "GET" && + url.pathname === "/readyz" + ) { + sendJson(response, 200, { + status: "ready", + provider: context.provider, + }); + return; + } + if ( request.method === "GET" && url.pathname === "/api/auth/config" diff --git a/tests/cloudflare-worker.test.ts b/tests/cloudflare-worker.test.ts index be0806b..fa6e606 100644 --- a/tests/cloudflare-worker.test.ts +++ b/tests/cloudflare-worker.test.ts @@ -8,6 +8,39 @@ import worker, { import type { R2BucketBinding } from "../src/cloudflare/r2-object-store.js"; describe("Cloudflare Worker request parsing", () => { + it("serves unauthenticated liveness and readiness probes", async () => { + const bucket = emptyBucket(); + const environment = { + DB: bucket, + AUTH_DB: bucket, + THIMBLE_MASTER_KEY: Buffer.alloc(32, 7).toString( + "base64", + ), + THIMBLE_ALLOWED_ORIGIN: + "https://db.example.test", + }; + + const authority = createCloudflareAuthority(); + const health = await authority.fetch( + new Request("https://db.example.test/healthz"), + environment as never, + ); + expect(health.status).toBe(200); + await expect(health.json()).resolves.toEqual({ + status: "ok", + }); + + const ready = await authority.fetch( + new Request("https://db.example.test/readyz"), + environment as never, + ); + expect(ready.status).toBe(200); + await expect(ready.json()).resolves.toEqual({ + status: "ready", + provider: "r2", + }); + }); + it("parses bounded JSON request bodies", async () => { await expect( readJsonRequest(