diff --git a/.claude/skills/devbox b/.claude/skills/devbox new file mode 120000 index 0000000..343169c --- /dev/null +++ b/.claude/skills/devbox @@ -0,0 +1 @@ +../../skills/devbox \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5e1b0eb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Rendered chart output the chart tests compare against. Regenerate with +# `UPDATE_GOLDEN=1 bun run test:chart`; GitHub collapses them in diffs. +deploy/helm/devbox/tests/golden/*.json linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..6aa09b9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,62 @@ +name: Bug report +description: Something in DevBox doesn't work as documented. +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Security issues go to security@rudderstack.com, not here. See [SECURITY.md](https://github.com/rudderlabs/devbox/security/policy). + - type: textarea + id: what + attributes: + label: What happened + description: What you did, what you expected, what you got. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + placeholder: | + 1. bun run up + 2. ... + validations: + required: true + - type: dropdown + id: deploy + attributes: + label: How you run DevBox + options: + - Docker Compose + - Kubernetes (Helm) + - Other + validations: + required: true + - type: dropdown + id: model + attributes: + label: Model provider + options: + - OpenAI + - Anthropic + - Both + - type: dropdown + id: source + attributes: + label: Where the task came from + options: + - CLI + - Linear + - GitHub + - Not a task problem + - type: input + id: version + attributes: + label: DevBox version or commit + placeholder: v0.1.0 or the commit SHA + - type: textarea + id: logs + attributes: + label: Logs + description: Control plane or harness logs around the failure. Remove keys and tokens first. + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e24f819 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Questions and ideas + url: https://github.com/rudderlabs/devbox/discussions + about: Ask how to set it up, share what you built, or float an idea. + - name: Security issue + url: https://github.com/rudderlabs/devbox/security/policy + about: Report vulnerabilities privately, not as an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..65c1c0a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,21 @@ +name: Feature request +description: Something DevBox should do that it doesn't yet. +labels: ["enhancement", "needs-triage"] +body: + - type: textarea + id: problem + attributes: + label: The problem + description: What you're trying to do, and where DevBox gets in the way. + validations: + required: true + - type: textarea + id: idea + attributes: + label: What you'd like + validations: + required: true + - type: textarea + id: today + attributes: + label: What you do today instead diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b24a8b2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,94 @@ +# Weekly version updates, each held 7 days after release. Security updates +# ignore the cooldown and arrive in one grouped PR per ecosystem. +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + exclude: + - "rudderlabs/*" + - "rudderstack/*" + groups: + actions-security: + applies-to: security-updates + patterns: + - "*" + actions: + patterns: + - "*" + + - package-ecosystem: "bun" + directories: + - "/cli" + - "/control-plane" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + groups: + # A group covers version updates only, unless it sets applies-to. + bun-security: + applies-to: security-updates + patterns: + - "*" + bun-deps: + applies-to: version-updates + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "uv" + directory: "/harness" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + groups: + uv-security: + applies-to: security-updates + patterns: + - "*" + uv-deps: + applies-to: version-updates + patterns: + - "*" + update-types: + - "minor" + - "patch" + + - package-ecosystem: "docker" + directories: + - "/control-plane" + - "/harness" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + exclude: + - "rudderstack/*" + # The Bun base moves by hand, together with the other Bun pins + # (CI bun-version, MIN_BUN_VERSION, MIN_BUN). A lone FROM bump drifts. + ignore: + - dependency-name: "oven/bun" + groups: + docker-deps: + patterns: + - "*" + + - package-ecosystem: "docker-compose" + directory: "/control-plane" + schedule: + interval: "weekly" + cooldown: + default-days: 7 + exclude: + - "rudderstack/*" + groups: + compose-deps: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8993296 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## What this changes + + + +## How I tested it + + + +Fixes # diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml new file mode 100644 index 0000000..a6998fe --- /dev/null +++ b/.github/workflows/builds.yml @@ -0,0 +1,122 @@ +# Publishes devbox's two images to Docker Hub. Follows rudder-server's builds.yml: +# a release publishes the semver and `latest` tags, a push to main publishes the +# branch tag, and a pull request publishes `pr-`. +# +# The chart's `images.tag` defaults to the chart's appVersion, so a release must +# carry a tag equal to `appVersion` in deploy/helm/devbox/Chart.yaml. Bump the two +# together. +name: builds + +on: + release: + # `published`, not `created`: GitHub does not fire `created` for a draft + # release, so a release drafted and then published would never build. + # `published` covers both paths, and fires for prereleases too. + types: [published] + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # The chart's images.tag defaults to the chart's appVersion, so a release whose + # git tag differs from appVersion publishes images a default install cannot + # pull. Nothing else links the two, so fail the release here instead. + # Always runs: a job skipped by `if` skips everything that needs it, which + # would silently stop the push and pull-request builds. The check itself + # no-ops outside a release. + check-version: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: The git tag must equal the chart's appVersion + env: + REF_NAME: ${{ github.ref_name }} + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" != "release" ]; then + echo "not a release; nothing to check" + exit 0 + fi + tag="${REF_NAME#v}" + app=$(sed -n 's/^appVersion:[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' deploy/helm/devbox/Chart.yaml) + echo "git tag: $tag" + echo "Chart.yaml appVersion: $app" + if [ "$tag" != "$app" ]; then + echo "::error::the release tag ($tag) and Chart.yaml appVersion ($app) differ. A default install resolves images.tag from appVersion, so it would pull an image this release does not publish. Bump appVersion and re-tag." + exit 1 + fi + + control-plane: + needs: [check-version] + uses: ./.github/workflows/docker-build-dockerhub.yml + with: + # Two image names per component, as rudder-server does. Branch and pull + # request builds go only to the `develop-` repository, so the repository + # users pull from never accumulates pr- tags. The release name is + # enabled on main and on a release, where its tags are the ones people + # actually pull. + image_names: | + ["rudderlabs/develop-devbox-control-plane", + "rudderlabs/devbox-control-plane,enable=${{ github.ref == 'refs/heads/main' || github.event_name == 'release' }}"] + context: control-plane + dockerfile: control-plane/Dockerfile + # A fork's pull request gets no secrets, so it builds without pushing. + push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + tags: | + type=ref,event=branch + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + type=raw,value=pr-${{ github.event.pull_request.number }},enable=${{ github.event_name == 'pull_request' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + # Disabled while the major version is zero: a floating `0` tag would move + # across breaking 0.x bumps, which semver allows. See + # https://github.com/docker/metadata-action#major-version-zero + type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') && !startsWith(github.ref, 'refs/tags/0.') }} + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + + harness: + needs: [check-version] + uses: ./.github/workflows/docker-build-dockerhub.yml + with: + # Two image names per component, as rudder-server does. Branch and pull + # request builds go only to the `develop-` repository, so the repository + # users pull from never accumulates pr- tags. The release name is + # enabled on main and on a release, where its tags are the ones people + # actually pull. + image_names: | + ["rudderlabs/develop-devbox-harness", + "rudderlabs/devbox-harness,enable=${{ github.ref == 'refs/heads/main' || github.event_name == 'release' }}"] + context: harness + dockerfile: harness/Dockerfile + push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + tags: | + type=ref,event=branch + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} + type=raw,value=pr-${{ github.event.pull_request.number }},enable=${{ github.event_name == 'pull_request' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + # Disabled while the major version is zero: a floating `0` tag would move + # across breaking 0.x bumps, which semver allows. See + # https://github.com/docker/metadata-action#major-version-zero + type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') && !startsWith(github.ref, 'refs/tags/0.') }} + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/chart.yml b/.github/workflows/chart.yml new file mode 100644 index 0000000..1ac2358 --- /dev/null +++ b/.github/workflows/chart.yml @@ -0,0 +1,83 @@ +name: chart + +on: + pull_request: + paths: + - "deploy/**" + - "control-plane/scripts/up/ironProxy.ts" + - "control-plane/infra/local/iron-proxy.template.yaml" + - ".github/workflows/chart.yml" + push: + branches: [main] + paths: + - "deploy/**" + - "control-plane/scripts/up/ironProxy.ts" + - "control-plane/infra/local/iron-proxy.template.yaml" + - ".github/workflows/chart.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: {} + +jobs: + chart: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + strategy: + matrix: + helm: [v3.22.0, v4.3.0] + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: ${{ matrix.helm }} + + - name: Install kubeconform + run: | + curl -fsSLo /tmp/kubeconform.tgz https://github.com/yannh/kubeconform/releases/download/v0.8.0/kubeconform-linux-amd64.tar.gz + echo "9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883 /tmp/kubeconform.tgz" | sha256sum -c - + tar -xzf /tmp/kubeconform.tgz -C /usr/local/bin kubeconform + + # The iron-proxy parity test imports the Compose renderer. + - name: Install control-plane dependencies + working-directory: control-plane + run: bun install --frozen-lockfile --ignore-scripts + + # helm prints `coalesce.go:145: warning: skipping globals because source global + # is not a table` on every command; it comes from the vendored upstream chart's + # `global: null` and is expected. + - name: Lint + run: helm lint deploy/helm/devbox -f deploy/helm/devbox/tests/values/base.yaml + + - name: Validate manifests + shell: bash + run: | + for f in deploy/helm/devbox/tests/values/*.yaml; do + [ "$(basename "$f")" = base.yaml ] && set -- -f "$f" || set -- -f deploy/helm/devbox/tests/values/base.yaml -f "$f" + helm template devbox deploy/helm/devbox -n devbox "$@" \ + | kubeconform -strict -ignore-missing-schemas -kubernetes-version 1.27.0 -summary + done + + - name: Test + run: bun run test:chart + + # The values reference is generated from values.yaml comments. + - name: Check the chart README is up to date + if: matrix.helm == 'v3.22.0' + run: | + docker run --rm -v "$PWD/deploy/helm/devbox:/helm-docs" jnorwood/helm-docs:v1.14.2 + git diff --exit-code deploy/helm/devbox/README.md diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml new file mode 100644 index 0000000..b64f7bc --- /dev/null +++ b/.github/workflows/cli.yml @@ -0,0 +1,56 @@ +name: cli + +on: + pull_request: + paths: + - "cli/**" + - "control-plane/src/types/api.ts" + - ".github/workflows/cli.yml" + push: + branches: + - main + paths: + - "cli/**" + - "control-plane/src/types/api.ts" + - ".github/workflows/cli.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + working-directory: cli + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Keep equal to MIN_BUN_VERSION in cli/interface/runtime.ts (a test checks it). + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun test + + - name: Build binaries + run: bun run build diff --git a/.github/workflows/control-plane.yml b/.github/workflows/control-plane.yml new file mode 100644 index 0000000..edb32d0 --- /dev/null +++ b/.github/workflows/control-plane.yml @@ -0,0 +1,95 @@ +name: control-plane + +on: + pull_request: + paths: + - "control-plane/**" + - ".github/workflows/control-plane.yml" + push: + branches: + - main + paths: + - "control-plane/**" + - ".github/workflows/control-plane.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + working-directory: control-plane + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Biome (lint + format) + run: bun run biome:ci + + - name: Typecheck + run: bun run typecheck + + test: + runs-on: ubuntu-latest + # The whole job normally finishes in ~45s; a DB-pool wedge once idled it + # for 1h+. Fail fast instead of burning runner time. + timeout-minutes: 2 + permissions: + contents: read + + services: + postgres: + image: postgres:18 + env: + POSTGRES_DB: devbox + POSTGRES_USER: devbox + POSTGRES_PASSWORD: devbox + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U devbox -d devbox" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + TEST_DATABASE_URL: postgres://devbox:devbox@localhost:5432/devbox + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run tests + # Via the package script so CI and local runs share one definition -- + # it carries --isolate, which this suite requires. See package.json. + run: bun run test diff --git a/.github/workflows/dependency-scan.yml b/.github/workflows/dependency-scan.yml new file mode 100644 index 0000000..bf1d583 --- /dev/null +++ b/.github/workflows/dependency-scan.yml @@ -0,0 +1,52 @@ +# Scans the bun lockfiles for known vulnerabilities. Dependabot does not read +# bun.lock, so without this job a vulnerable control-plane or CLI dependency +# raises no alert anywhere. The weekly run catches advisories published after +# the lockfile last changed. +name: dependency-scan + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: "17 6 * * 1" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: {} + +jobs: + bun-lockfiles: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + lockfile: + - control-plane/bun.lock + - cli/bun.lock + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Same gate as the image scan in docker-build-dockerhub.yml: a fixable + # Critical or High fails the job. Trivy runs from its digest-pinned + # image because the org action allowlist has no usable trivy-action. + # Exceptions go in .trivyignore.yaml. + - name: Trivy (${{ matrix.lockfile }}) + env: + LOCKFILE: ${{ matrix.lockfile }} + run: | + docker run --rm -v "$PWD:/src:ro" -w /src \ + aquasec/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c \ + fs --scanners vuln --severity CRITICAL,HIGH --ignore-unfixed \ + --ignorefile .trivyignore.yaml --exit-code 1 "$LOCKFILE" diff --git a/.github/workflows/docker-build-dockerhub.yml b/.github/workflows/docker-build-dockerhub.yml new file mode 100644 index 0000000..92d1e23 --- /dev/null +++ b/.github/workflows/docker-build-dockerhub.yml @@ -0,0 +1,225 @@ +# Builds one image for linux/amd64 and linux/arm64 and pushes it to Docker Hub, +# then joins the two into a multi-arch manifest. Adapted from rudder-server's +# workflow of the same name, with two changes devbox needs: +# +# - a `context` input, because devbox builds two images from two directories +# (control-plane/ and harness/) rather than one Dockerfile at the root; +# - GitHub-hosted arm64 runners instead of self-hosted ones. A public +# repository must not run fork pull requests on self-hosted runners. +name: Docker Build (DockerHub) + +on: + workflow_call: + inputs: + image_names: + description: 'JSON array of Docker image names (e.g. ["rudderlabs/devbox-control-plane"])' + required: true + type: string + tags: + description: "docker/metadata-action tag configuration" + required: true + type: string + context: + description: "Build context directory" + required: false + type: string + default: "." + dockerfile: + description: "Path to the Dockerfile, relative to the repository root" + required: false + type: string + default: "Dockerfile" + push: + description: "Push the images. False builds only, for fork pull requests." + required: false + type: boolean + default: true + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + +permissions: + contents: read + +env: + arch_amd64: amd64 + arch_arm64: arm64 + +jobs: + docker-meta: + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + outputs: + tags: ${{ steps.meta.outputs.tags }} + arm64_tags: ${{ steps.arm64_meta.outputs.tags }} + arm64_labels: ${{ steps.arm64_meta.outputs.labels }} + amd64_tags: ${{ steps.amd64_meta.outputs.tags }} + amd64_labels: ${{ steps.amd64_meta.outputs.labels }} + version: ${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} + revision: ${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} + build-date: ${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - name: Process image names + id: process-images + env: + IMAGE_NAMES: ${{ inputs.image_names }} + run: | + FORMATTED_IMAGES=$(echo "$IMAGE_NAMES" | jq -r '.[] | "name=\(.)"') + echo "formatted_images<> "$GITHUB_OUTPUT" + echo "$FORMATTED_IMAGES" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Docker meta + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ steps.process-images.outputs.formatted_images }} + tags: ${{ inputs.tags }} + # The caller decides when `latest` moves, with an explicit tag. Without + # this, metadata-action's default `latest=auto` adds a second `latest` + # on any semver tag, and the manifest step below then matches two + # identical per-arch tags for it. + flavor: latest=false + + - name: Docker arm64 meta + id: arm64_meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ steps.process-images.outputs.formatted_images }} + tags: ${{ inputs.tags }} + flavor: | + latest=false + suffix=-${{ env.arch_arm64 }},onlatest=true + + - name: Docker amd64 meta + id: amd64_meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ steps.process-images.outputs.formatted_images }} + tags: ${{ inputs.tags }} + flavor: | + latest=false + suffix=-${{ env.arch_amd64 }},onlatest=true + + docker-build: + needs: docker-meta + strategy: + fail-fast: false + matrix: + build-config: + # GitHub-hosted arm64; free for public repositories. + - os: ubuntu-24.04-arm + tags: ${{ needs.docker-meta.outputs.arm64_tags }} + labels: ${{ needs.docker-meta.outputs.arm64_labels }} + platform: linux/arm64 + - os: ubuntu-latest + tags: ${{ needs.docker-meta.outputs.amd64_tags }} + labels: ${{ needs.docker-meta.outputs.amd64_labels }} + platform: linux/amd64 + runs-on: ${{ matrix.build-config.os }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Login to DockerHub + if: inputs.push + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + # Vulnerability gate. build-scan-push-action scans only for secrets, so + # build the image once more into a tarball and fail before anything is + # pushed if Trivy finds a fixable Critical or High. Same version and + # inputs as the action's own builds, so the step below is cache hits. + - name: Build for the vulnerability scan + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: ${{ inputs.context }} + file: ./${{ inputs.dockerfile }} + platforms: ${{ matrix.build-config.platform }} + push: false + build-args: | + BUILD_DATE=${{ needs.docker-meta.outputs.build-date }} + VERSION=${{ needs.docker-meta.outputs.version }} + COMMIT_HASH=${{ github.sha }} + REVISION=${{ needs.docker-meta.outputs.revision }} + outputs: type=docker,dest=${{ runner.temp }}/trivy-image.tar + + # Same gate as dependency-scan.yml, and the same digest-pinned Trivy + # image (run directly: the org action allowlist has no usable + # trivy-action). Exceptions go in .trivyignore.yaml. + - name: Trivy image scan + env: + IMAGE_DIR: ${{ runner.temp }} + run: | + docker run --rm \ + -v "$PWD:/src:ro" -v "$IMAGE_DIR:/image:ro" -w /src \ + aquasec/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c \ + image --input /image/trivy-image.tar \ + --scanners vuln --severity CRITICAL,HIGH --ignore-unfixed \ + --ignorefile .trivyignore.yaml --exit-code 1 + + - name: Build, scan and push + uses: rudderlabs/build-scan-push-action@c47115e5eab78162efec1b4961cba4063dfd8d5f # v2.4.0 + with: + context: ${{ inputs.context }} + file: ./${{ inputs.dockerfile }} + platforms: ${{ matrix.build-config.platform }} + push: ${{ inputs.push }} + tags: ${{ matrix.build-config.tags }} + labels: ${{ matrix.build-config.labels }} + build-args: | + BUILD_DATE=${{ needs.docker-meta.outputs.build-date }} + VERSION=${{ needs.docker-meta.outputs.version }} + COMMIT_HASH=${{ github.sha }} + REVISION=${{ needs.docker-meta.outputs.revision }} + + create-manifest: + if: inputs.push + runs-on: ubuntu-latest + needs: [docker-build, docker-meta] + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + + - name: Login to DockerHub + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create the multi-arch manifest + env: + TAGS: ${{ needs.docker-meta.outputs.tags }} + ARM64_TAGS: ${{ needs.docker-meta.outputs.arm64_tags }} + AMD64_TAGS: ${{ needs.docker-meta.outputs.amd64_tags }} + run: | + while read -r tag; do + [ -n "$tag" ] || continue + # -m1: one match per arch even if a tag were ever listed twice, + # so imagetools can never be handed a multi-line argument. + arm_tag=$(echo "$ARM64_TAGS" | grep -F -m1 "$tag-${{ env.arch_arm64 }}") + amd_tag=$(echo "$AMD64_TAGS" | grep -F -m1 "$tag-${{ env.arch_amd64 }}") + echo "$tag <- $arm_tag + $amd_tag" + docker buildx imagetools create -t "$tag" "$arm_tag" "$amd_tag" + done <<< "$TAGS" diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml new file mode 100644 index 0000000..912626f --- /dev/null +++ b/.github/workflows/harness.yml @@ -0,0 +1,84 @@ +name: harness + +on: + pull_request: + paths: + - "harness/**" + - ".github/workflows/harness.yml" + push: + branches: + - main + paths: + - "harness/**" + - ".github/workflows/harness.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +defaults: + run: + working-directory: harness + +jobs: + lint: + name: Lint and format + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: "0.11.6" + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: "harness/.python-version" + + # The locked ruff, so CI and `make lint` check the same rules. + - name: Install dependencies + run: uv sync --frozen + + - name: Run Ruff linter + run: make lint + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: "0.11.6" + enable-cache: true + cache-dependency-glob: "harness/uv.lock" + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version-file: "harness/.python-version" + + - name: Install dependencies + run: uv sync --frozen + + - name: Run tests + run: make test diff --git a/.github/workflows/kubernetes.yml b/.github/workflows/kubernetes.yml new file mode 100644 index 0000000..894d234 --- /dev/null +++ b/.github/workflows/kubernetes.yml @@ -0,0 +1,103 @@ +name: kubernetes + +on: + pull_request: + paths: ["deploy/**", "control-plane/**", "harness/**", "cli/**", ".github/workflows/kubernetes.yml", "package.json", "**/bun.lock"] + push: + branches: [main] + paths: ["deploy/**", "control-plane/**", "harness/**", "cli/**", ".github/workflows/kubernetes.yml", "package.json", "**/bun.lock"] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.sha }} + cancel-in-progress: true + +permissions: {} + +jobs: + kind: + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + + # Both images are built, then loaded into the kind node: one run hit + # "out of disk space" there. Toolchains this job never uses go first. + - name: Free disk space + run: | + df -h / + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/.ghcup /opt/hostedtoolcache/CodeQL + docker image prune -af >/dev/null + df -h / + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.4 + + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: v3.22.0 + + - name: Install kind + run: | + curl -fsSLo /tmp/kind https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64 + echo "50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54 /tmp/kind" | sha256sum -c - + sudo install -m 0755 /tmp/kind /usr/local/bin/kind + + # Pinned like helm and kind, checked against the release's published digest. + - name: Install kubectl + run: | + base=https://dl.k8s.io/release/v1.33.5/bin/linux/amd64 + curl -fsSLo /tmp/kubectl "$base/kubectl" + sha="$(curl -fsSL "$base/kubectl.sha256")" + echo "$sha /tmp/kubectl" | sha256sum -c - + sudo install -m 0755 /tmp/kubectl /usr/local/bin/kubectl + + # This repo as the only configured repo, read with the workflow token. + # Sandboxes get half a CPU: the runner has 2, and the rest of the + # cluster requests about 1.4 of them. + - name: Write devbox.config.json and devbox.kind.json + env: + REPO: ${{ github.repository }} + BRANCH: ${{ github.event.repository.default_branch }} + run: | + cat > devbox.config.json < devbox.kind.json + + - name: Install on kind + env: + GITHUB_TOKEN: ${{ github.token }} + LLM_API_KEY: no-llm-in-ci + run: bun run k8s:kind --skip-push-check --skip-llm-check + + - name: End-to-end checks + env: + GITHUB_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + run: | + bun run k8s:connect --target devbox.kind.json & + # k8s:kind pins the profile's local port from 13000 up; on a fresh runner it is 13000. + for _ in $(seq 1 30); do curl -fsS -o /dev/null http://localhost:13000/health && break; sleep 2; done + curl -fsS -o /dev/null http://localhost:13000/health + bash control-plane/scripts/k8s/e2e.sh "$REPO" + + - name: Diagnostics + if: failure() + run: | + kubectl --context kind-devbox get all,pvc,volumesnapshot -A || true + kubectl --context kind-devbox -n devbox-sandboxes describe pods || true + kubectl --context kind-devbox -n devbox-sandboxes get events --sort-by=.lastTimestamp || true + kubectl --context kind-devbox describe node devbox-control-plane | sed -n '/Allocated resources/,/Events/p' || true + kubectl --context kind-devbox -n devbox logs deploy/devbox-control-plane --tail=200 || true + kubectl --context kind-devbox -n devbox logs deploy/devbox-iron-proxy --tail=200 || true + kubectl --context kind-devbox -n devbox logs deploy/opensandbox-server --tail=200 || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4e0885 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.venv/ +dist/ +.superpowers/ +devbox.config.json +devbox.k8s.json +devbox.kind.json +.env +# GitHub App private keys written by bun run github:setup +*.private-key.pem diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000..ffc19f0 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,51 @@ +# Accepted exceptions to the Trivy gates in dependency-scan.yml and +# docker-build-dockerhub.yml. Both fail on any Critical or High finding that +# has an upstream fix. +# +# An entry is for a finding Trivy calls fixable that no upstream release has +# fixed yet in the component we ship. Scope it with `purls` to the exact +# vulnerable version and, where Trivy reports a file, with `paths` to that +# file; a purl alone also matches any other copy at the same version. Give +# the reason in `statement` and set `expired_at`: after that date the finding +# fails the gates again, on purpose. Then bump the component or renew the +# entry with a new reason. +vulnerabilities: + # kubectl v1.37.1, the latest release on every supported minor, is built with + # Go 1.26.8. The control plane runs it only against the in-cluster API server, + # with the service account token and cluster CA. Expires early: the next + # Kubernetes patch release should rebuild with a fixed Go. + - id: CVE-2026-111943 + purls: ["pkg:golang/stdlib@v1.26.8"] + paths: ["usr/local/bin/kubectl"] + statement: In kubectl v1.37.1 (latest); kubectl only talks to the in-cluster API server. + expired_at: 2026-11-30 + - id: CVE-2026-78667 + purls: ["pkg:golang/stdlib@v1.26.8"] + paths: ["usr/local/bin/kubectl"] + statement: In kubectl v1.37.1 (latest); server-side net/http Range handling, which kubectl does not serve. + expired_at: 2026-11-30 + - id: CVE-2026-78669 + purls: ["pkg:golang/stdlib@v1.26.8", "pkg:golang/golang.org/x/net@v0.57.0"] + paths: ["usr/local/bin/kubectl"] + statement: In kubectl v1.37.1 (latest); kubectl only talks to the in-cluster API server. + expired_at: 2026-11-30 + - id: CVE-2026-97031 + purls: ["pkg:golang/stdlib@v1.26.8"] + paths: ["usr/local/bin/kubectl"] + statement: In kubectl v1.37.1 (latest); kubectl only talks to the in-cluster API server. + expired_at: 2026-11-30 + - id: CVE-2026-56851 + purls: ["pkg:golang/golang.org/x/text@v0.40.0"] + paths: ["usr/local/bin/kubectl"] + statement: In kubectl v1.37.1 (latest); kubectl does not apply the x/text Nickname profile to input. + expired_at: 2026-11-30 + # npm vendors its own undici. No npm release ships a fixed one yet: 11.21.1 + # (latest 11.x) and 12.2.1 (latest) both bundle undici 6.29.0. npm uses it + # only as an HTTP client for registry requests during installs; nothing in + # the harness serves or streams through it. Bump npm in harness/Dockerfile + # once a release bundles undici 6.29.1 or newer, and drop this entry. + - id: CVE-2026-109869 + purls: ["pkg:npm/undici@6.29.0"] + paths: ["usr/local/lib/node_modules/npm/node_modules/undici/package.json"] + statement: In npm's bundled undici (npm 11.21.1, latest 11.x); no npm release bundles a fixed undici yet. + expired_at: 2026-11-30 diff --git a/.truffleignore b/.truffleignore new file mode 100644 index 0000000..3546932 --- /dev/null +++ b/.truffleignore @@ -0,0 +1,32 @@ +# Paths the image scanner must not scan, for rudderlabs/build-scan-push-action. +# Its docker-image-scanner.py reads this file with +# pathspec.PathSpec.from_lines("gitwildmatch", ...), so these are +# **gitignore patterns, not regexes**: a leading `^` would be a literal +# character and would match nothing. +# +# Everything listed is third-party or base-OS content baked into the images, +# where the detectors fire on example URIs in library documentation and on byte +# patterns inside compiled objects (tiktoken, jiter, perl). devbox's own code +# under /app and /opt/harness/src is still scanned. + +# Dependencies +**/node_modules/** +**/.venv/** + +# Package-manager caches the images currently keep +**/.cache/uv/** +**/.cache/node/** + +# Toolchains installed into the harness image +/usr/local/go/** + +# Base image: distro packages, shared libraries, docs, perl, dpkg state +/usr/lib/** +/usr/share/** +/usr/bin/** +/usr/local/bin/** +/usr/local/lib/** +/var/lib/dpkg/** +/bin/** +/sbin/** +/lib/** diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b8f3d60 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Repository conventions + +## Running and operating devbox + +`skills/devbox/SKILL.md` explains how devbox works and how to set it up, use, +connect and troubleshoot it in every deployment mode. Claude Code loads it +from `.claude/skills/devbox`, a symlink. Keep it in step with the commands and +docs it names; `control-plane/scripts/skill.test.ts` checks that every script +and link in it exists. + +## Environment variable namespaces + +- Unprefixed and subsystem-prefixed variables such as `DATABASE_URL`, `GITHUB_*` + and `SANDBOX_*` (including `SANDBOX_IMAGE`) configure the control plane or its + infrastructure. They do not cross into sandboxes automatically. +- `HARNESS_*` is the namespace for operator-configurable settings read by the + harness inside a sandbox. `control-plane/src/forwardedEnv.ts` forwards only + its explicit allowlist and its enumerated stage model/reasoning patterns, + never every variable sharing the prefix. This keeps Kubernetes Service links, + platform-injected variables, and stray control-plane values out of sandboxes. +- Real secrets never use a forwarded name. Forwarded credential-shaped values, + such as `HARNESS_LLM_API_KEY`, are proxy placeholders only; real credentials + remain in the control plane or egress proxy. +- `DEVBOX_CLI_*` belongs only to the CLI and never crosses into a sandbox. +- Runtime-only `DEVBOX_*` names (`DEVBOX_DIR`, event paths, inbox and target ids, + and related path settings) are created or consumed inside the sandbox. They + are deliberately separate from forwarded configuration. +- A new harness setting requires updating its reader, the forwarding contract, + and the contract inventory test. Do not introduce prefix-wide forwarding. + +## Sandbox workspace ownership + +The Kubernetes snapshot builder runs as non-root uid 1000 to satisfy restricted +pod security, while the harness runs as root. `harness/scripts/bootstrap.sh` +therefore configures Git to trust every directory below `/workspace`; removing +that trust breaks restored repositories with dubious-ownership errors. + +Checks that run in a sandbox must exercise the workspace as the harness does. +In particular, Kubernetes end-to-end checks run Git against the restored repo, +so uid or ownership mismatches are caught rather than hidden by file-only tests. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c42a7a8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of conduct + +DevBox follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +Be kind, assume good intent, and keep discussion about the work. Harassment of any kind is not accepted in issues, pull requests, Discussions or any other DevBox space. + +Report unacceptable behavior to [devbox@rudderstack.com](mailto:devbox@rudderstack.com). Reports are read by the maintainers and kept confidential. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ed0dc4d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Contributing to devbox + +Thanks for taking the time and for your help in improving this project! + +## Table of contents + +- [Reporting issues](#reporting-issues) +- [Submitting a pull request](#submitting-a-pull-request) +- [Running the tests and lint](#running-the-tests-and-lint) + +## Reporting issues + +If you encounter a bug or have an improvement suggestion, [**submit an issue**][issue] describing it. +Report security issues as described in [SECURITY.md](SECURITY.md), not as issues. +For questions and ideas, use [Discussions][discussions]. + +Everyone taking part follows the [code of conduct](CODE_OF_CONDUCT.md). + +## Submitting a pull request + +Keep a pull request to one change. Before you open it, run the tests and lint of +every component you changed (below). CI runs equivalent checks for each +component whose files changed. + +We prefer squash or rebase commits so that all changes from a branch are +committed to `main` as a single commit. All pull requests are squashed when +merged, but rebasing prior to merge gives you better control over the commit +message. + +## Running the tests and lint + +The repo has three components. Each has its own tools, and you run its commands +from its own directory. + +### `harness/` (Python) + +Needs [uv](https://docs.astral.sh/uv/). uv installs the Python version in +`harness/.python-version`. + +```bash +cd harness +make setup # once: creates .venv and installs the dependencies +make test +make lint +``` + +### `control-plane/` (Bun) + +Needs Bun 1.4.4 or newer. Some tests need a Postgres 18 database. They read its +URL from `TEST_DATABASE_URL`, and default to +`postgres://devbox:devbox@localhost:5432/devbox`. + +Use a database of its own for the tests, not the one `bun run up` starts. For +example, a throwaway container on port 5433: + +```bash +docker run -d --name devbox-test-pg -p 127.0.0.1:5433:5432 \ + -e POSTGRES_USER=devbox -e POSTGRES_PASSWORD=devbox -e POSTGRES_DB=devbox \ + postgres:18 +``` + +Then: + +```bash +cd control-plane +bun install +bun run biome:ci +bun run typecheck +TEST_DATABASE_URL=postgres://devbox:devbox@localhost:5433/devbox bun test +``` + +`bun run format` fixes the formatting that `biome:ci` reports. + +### `cli/` (Bun) + +```bash +cd cli +bun install +bun run typecheck +bun test +``` + +### All tests at once + +`bun run test` at the repo root runs the tests of all three components. It +needs the setup above: `make setup`, `bun install` in `control-plane/` and +`cli/`, and a test database. `bun run setup` at the repo root checks the Bun +version and does both `bun install` steps. `bun run up` runs it first. + + + +[issue]: https://github.com/rudderlabs/devbox/issues/new/choose +[discussions]: https://github.com/rudderlabs/devbox/discussions diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0edb1e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RudderStack, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a9e47c7..1109bc4 100644 --- a/README.md +++ b/README.md @@ -1 +1,338 @@ -# devbox \ No newline at end of file + + + DevBox: open-source autonomous engineering harness in your cloud. It plans the change, writes the code, runs the tests and reviews its own work, then opens a draft pull request. + + +

+ MIT License +

+ +Hand DevBox a ticket. It plans the change, writes the code, runs the tests, +reviews its own work and opens a draft pull request. You review and merge. +Comment on the pull request, and it pushes the fix to the same branch. + +

+ DevBox demo: a Linear issue is delegated to DevBox, it reviews its own diff, opens draft pull requests in each repo, and pushes a fix after a review comment. Click to watch the full video on YouTube. +
+ Watch the full launch video (2 min) +

+ +DevBox runs on your infrastructure, with your model keys. It is built for a +whole engineering org: one shared harness that picks up Linear tickets, +answers on GitHub pull requests, works across your repos, and remembers what +it learned in them. + +## Why we built it + +We are RudderStack, a customer data infrastructure company. Since April our +engineers have handed tickets to DevBox. Today it merges about **35% of our +internal pull requests**, with about an **80% merge rate**. +[Read the story behind it](https://www.rudderstack.com/blog/open-sourcing-devbox/). + +Coding tools like Claude Code and Codex make one developer faster. We wanted the +other half: a shared teammate the whole org can delegate to, that runs where our +code and secrets already live, and that gets better at our repos over time. +DevBox works alongside those tools. + +## Features + +- **Staged, gated pipeline.** Orientation, planning, plan review, implementation, self-review. No code is written until the plan passes review. +- **Closed feedback loop.** Review comments, @mentions and failed CI runs route back to the running task (with the GitHub App). +- **Versioned memory.** Learnings are distilled into your repo and land as a commit on the pull request, reviewed like code. +- **Credential isolation.** Sandboxes hold placeholder tokens. An egress proxy injects the real ones and scopes writes to your repos. +- **Per-stage model routing.** OpenAI or Anthropic, with model and reasoning effort set per stage. +- **Self-hosted.** Docker Compose or Kubernetes, one isolated sandbox per task. + +## How it works + + + + Tasks come in from the devbox CLI or Linear, and GitHub sends follow-ups on DevBox pull requests. The control plane (API, Postgres, no LLM calls) starts one sandbox per task through OpenSandbox. In the sandbox the harness plans, codes, tests, reviews and opens the PR, holding only dummy tokens. iron-proxy swaps in the real tokens and allows only your repos on GitHub and your LLM endpoint. + + +- The **control plane** (`control-plane/`) stores tasks, starts a sandbox for + each one through [OpenSandbox](https://github.com/alibaba/OpenSandbox) and + relays messages and progress. It makes no LLM calls. +- The **harness** (`harness/`) runs in the sandbox, built on + [DeepAgents](https://github.com/langchain-ai/deepagents), and takes the task + from plan to pull request. +- **iron-proxy** sits between the sandboxes and GitHub and the LLM. It swaps + dummy tokens for real ones and allows writes only to your repos. + +[docs/architecture.md](docs/architecture.md) has the details. + +## Quickstart + +About 15 minutes, most of it the first image build. The steps assume macOS; see the notes below for Linux. + +**You need:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) +with 8 GB of memory for Docker (Compose 2.13 or newer), +[Bun](https://bun.sh) 1.4.4 or newer, `make`, `openssl`, a GitHub token or +`gh` logged in, and an OpenAI or Anthropic API key. + +1. **Get the code.** + + ```bash + git clone https://github.com/rudderlabs/devbox.git && cd devbox + ``` + + > [!TIP] + > **Set it up with Claude Code.** Run `claude` in this directory and ask + > it to help you set up DevBox. The repo ships a DevBox skill + > (`.claude/skills/devbox`, also `/devbox`): Claude checks your tools, + > writes `devbox.config.json` with you, and hands you the + > `bun run quickstart` command to run. Run it yourself, in another terminal + > or with a `!` in front in Claude Code, so you type your keys at its hidden + > prompts and they never go through the chat. Other coding agents can read + > the same skill in `skills/devbox/`. + +2. **Pick your repo and model.** Copy the example config, then set your repo's + URL and your LLM in it. + + ```bash + cp devbox.config.example.json devbox.config.json + ``` + + ```json + { + "repos": [{ "url": "https://github.com/your-org/your-repo.git", "branch": "main" }], + "llm": { "provider": "openai", "baseUrl": "https://api.openai.com/v1", "model": "gpt-5.6-sol" } + } + ``` + + DevBox only ever writes to the repos listed here. + Anthropic and per-stage models: [docs/configuration.md](docs/configuration.md#llm). + + Or skip this step: when there is no `devbox.config.json`, `bun run quickstart` + asks for your repos and model and writes it. + +3. **Start DevBox.** + + ```bash + bun run quickstart + ``` + + It checks your config and the tools you need, and asks whether to run on Docker Compose or a local kind cluster and whether to set up Linear and the GitHub App now (say no for a first try; [Next steps](#next-steps) adds them later). Then it starts DevBox with `bun run up`, which asks for your GitHub token (or takes it from `gh`) and your LLM key, and offers to save them in the macOS Keychain, never in a file. It builds the images (about 10 minutes the first time) and logs the CLI in. + +4. **Give it a task.** + + ```bash + bun run devbox create "Add a /health endpoint to your-org/your-repo" + ``` + + It prints the task's key, for example `DBX-API-1`. + +5. **Watch it work.** + + ```bash + bun run devbox logs -f DBX-API-1 + ``` + + You see it orient, plan, review its plan, implement, test and review its own diff. At the end, a draft pull request opens on your repo, under your GitHub account. + +6. **Ask for changes.** + + ```bash + bun run devbox send DBX-API-1 "Also add a test" + ``` + + It pushes new commits to the same pull request. + +7. **Stop it.** + + ```bash + bun run down + ``` + + Your tasks are kept for the next `bun run up`. `bun run reset` deletes them. + +**Explore the CLI:** `bun run devbox --help` lists every command: `ls`, +`show`, `wait`, `restart` and more. + +> [!WARNING] +> **Linux:** add your user to the `docker` group +> (`sudo usermod -aG docker $USER`, then log out and back in). `bun run up` +> uses the Secret Service (libsecret) instead of the Keychain. + +> [!TIP] +> **On a corporate network or VPN** that blocks public DNS, run +> `bun run up --dns-resolver ` with a DNS server that works there. +> After you change `devbox.config.json`, run `bun run up` again. After you +> change the model, try it on a new task. + +### Task lifecycle + + + + Task lifecycle: task, orient, plan, plan review (a gate: no code until it passes), implement, self-review, draft PR with a knowledge commit. You review; follow-ups from PR comments, @mentions, failed CI or devbox send go back to implement. + + +## Next steps + +### Run tasks from Linear (recommended) + +Delegate an issue to DevBox, or @mention it, and it moves the issue to started, posts its progress in the issue's agent session and opens a draft +pull request. Follow-ups in the session reach the task. Nobody on the team +needs the CLI. + +Linear sends webhooks, so it needs a public HTTPS URL that reaches port 3000 +on your machine. With [ngrok](https://ngrok.com/): + +```bash +ngrok http 3000 # in another terminal +bun run linear:setup --public-url https:// # registers the app in Linear +bun run up # applies it +bun run linear:status # checks the URL reaches DevBox +``` + +`linear:setup` opens Linear's "create app" page, filled in for you. Click +**Create**, then paste the client ID and the two secrets it asks for. + +> [!IMPORTANT] +> **Tunnels on a laptop.** A free tunnel gets a new URL each time it restarts, +> and Linear keeps sending to the old one. Use a URL that doesn't change: a +> free [ngrok](https://ngrok.com/) static domain +> (`ngrok http --url= 3000`) or a named +> [Cloudflare tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/). +> If the URL changed anyway, see +> [When the public URL changes](docs/linear.md#when-the-public-url-changes). +> If delegating does nothing, `bun run linear:status` tells you whether the +> URL reaches DevBox, and [Troubleshooting](docs/linear.md#troubleshooting) +> maps each message to a fix. + +[docs/linear.md](docs/linear.md) covers Kubernetes, private teams and +rotating secrets. + +### Let your team @mention DevBox on pull requests (GitHub App) + +Your token alone is enough for DevBox to open pull requests. A GitHub App adds: +commits from a bot instead of your account, tokens that refresh on their own, +and `@` mentions, reviews and failed CI runs on its pull requests that +reach the task. + +```bash +bun run github:setup --public-url https:// # same URL as Linear +bun run up +bun run github:status +``` + +[docs/github.md](docs/github.md) covers the modes, trust lists and token scope. + +### Teach DevBox your repo + +Put your conventions and build and test commands in `AGENTS.md`, `CLAUDE.md` +or `.agents/knowledge/`. DevBox reads them before every task and adds what it +learns there in each pull request +([docs/extending.md](docs/extending.md#the-repos-the-agent-works-on)). + +### Run on Kubernetes + +A Helm chart runs each task in its own pod, with the same egress proxy. Try it +locally with `bun run k8s:kind`, then follow +[docs/kubernetes/README.md](docs/kubernetes/README.md) for the installer, +GitOps and the Amazon EKS guide. + +### Manage secrets + +`bun run secrets ls` shows what DevBox saved in your keychain, and +`bun run secrets rm ` removes one. Environment variables +(`GITHUB_TOKEN`, `LLM_API_KEY`) win over saved values +([docs/configuration.md](docs/configuration.md#secrets)). + +## Secure by default + + + + The sandbox holds only placeholder tokens. iron-proxy swaps in the real tokens, allows GitHub writes only to your repos and LLM calls only to your endpoint. PR comments are read only from orgs and users you trust. + + +- **No real secrets in the sandbox.** The proxy adds them on the way out + ([where secrets go](docs/configuration.md#where-the-secrets-go)). +- **Writes only to your repos.** Everything else on GitHub is read-only + ([what iron-proxy allows](docs/configuration.md#what-iron-proxy-allows)). +- **Only trusted commenters.** It reads pull request comments only from orgs + and users you list. The list is empty by default + ([trust settings](docs/configuration.md#trust-settings)). +- **No keys in files.** Your GitHub token and model key live in your keychain + or environment, never in the config ([secrets](docs/configuration.md#secrets)). + +To report a vulnerability, see [SECURITY.md](SECURITY.md). + +## Not in v1 + +- GitHub only. No GitLab or Bitbucket. +- Two LLM APIs: OpenAI's Responses API and Anthropic's Messages API. A + self-hosted model needs a server that speaks the Responses API with + reasoning, on port 80 or 443 under a DNS name. +- New tasks come from the CLI and Linear. On GitHub, DevBox only picks up + follow-ups on its own pull requests; it doesn't start work from GitHub + issues. Slack gets only completion updates for Linear tasks. No Jira and no + web UI. +- Stopping a task from Linear isn't supported yet. +- No stable extension interface. [docs/extending.md](docs/extending.md) shows where hooks would go. + +## FAQ + +
+Can DevBox push to main or merge my pull requests? + +It works on its own branch and opens draft pull requests. Merging stays with you, and your branch protection rules still apply. + +
+ +
+Does my code leave my infrastructure? + +Your code stays in your sandboxes and on GitHub. What the model needs to see goes to the LLM endpoint you configure, as with any coding tool. + +
+ +
+Does DevBox send telemetry? + +No. DevBox sends nothing to RudderStack. Tracing with Langfuse is optional, off by default, and goes to the Langfuse instance you set up. + +
+ +
+Can a malicious issue or comment steal my keys? + +The keys are never in the sandbox, so there is nothing to steal there. DevBox reads pull request comments only from people you trust. See [Secure by default](#secure-by-default). + +
+ +
+Why do the pull requests show up under my name? + +With a personal token, DevBox acts as you. Set up the [GitHub App](#let-your-team-mention-devbox-on-pull-requests-github-app) to get a bot identity. + +
+ +
+Do I need Linear? + +No. The CLI does everything. Linear makes it a team tool: anyone can delegate an issue and follow along without the CLI. + +
+ +
+Does it replace Claude Code or Codex? + +No. Those help one developer at the keyboard, and we use them every day. +DevBox is the org's shared harness that picks up tickets in the background. + +
+ +## Docs + +- [docs/configuration.md](docs/configuration.md): `devbox.config.json`, secrets and the keychain, environment variables, trust settings, per-stage models. +- [docs/architecture.md](docs/architecture.md): the components, how the control plane and the harness talk, and the task states. +- [docs/linear.md](docs/linear.md): connecting DevBox to Linear. +- [docs/github.md](docs/github.md): connecting DevBox to GitHub as an App. +- [docs/kubernetes/README.md](docs/kubernetes/README.md): running DevBox on Kubernetes. +- [docs/extending.md](docs/extending.md): where DevBox could be extended. Not a stable interface. +- [CONTRIBUTING.md](CONTRIBUTING.md): running the tests and lint. + +## License + +DevBox is open source and licensed under the [MIT License](LICENSE). \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e3b977b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Reporting + +Thank you in advance for helping us keep devbox secure! + +Please report any security issues or vulnerabilities to [security@rudderstack.com](mailto:security@rudderstack.com), before proceeding to post them publicly as issues on GitHub or any other public channel. These issues might also affect other users, and security vulnerabilities need to be handled quickly and sometimes privately. + +We will triage the issue, contact you for further instructions and make sure to take any necessary measures as needed. + +The security model devbox aims for is described in the [README](README.md#secure-by-default). A way for the agent in a sandbox to read a real credential, or to push to a GitHub repo that is not in `devbox.config.json`, is a security issue. + +## Supported versions + +devbox has no releases yet. We fix security bugs on the `main` branch only. + +| Version | Supported | +| :-------| :---------| +| `main` | ✅ | +| Older commits | ❌ | diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/cli/__tests__/e2e.test.ts b/cli/__tests__/e2e.test.ts new file mode 100644 index 0000000..29b034f --- /dev/null +++ b/cli/__tests__/e2e.test.ts @@ -0,0 +1,305 @@ +// Both layers together against the Hono stub: the wiring the fakes skip. +import { describe, expect, it } from "bun:test"; +import type { ApiEventEnvelope } from "../../control-plane/src/types/api"; +import { ApiClient, type ClientConfig } from "../core/api"; +import { bindCore } from "../core/ops"; +import { testIo } from "../interface/__tests__/fakes"; +import { render as renderTable } from "../interface/commands/ls"; +import { render as renderDetail } from "../interface/commands/show"; +import { runCli } from "../interface/main"; +import { envelope, NOW, task } from "./fixtures"; +import { + apiError, + createStub, + frame, + type Stub, + serveEvents, + serveLinearTask, + serveStream, + sse, + stateFrame, + TOKEN, +} from "./stub"; + +function realCore(stub: Stub, sleeps: number[] = []) { + return (config: ClientConfig) => + bindCore(new ApiClient(config, stub.fetch), { + sleep: async (ms) => { + sleeps.push(ms); + }, + }); +} + +describe("devbox end to end", () => { + it("whoami sends the token from env and prints the name", async () => { + const stub = createStub(); + stub.app.get("/api/me", (c) => c.json({ token: { id: "tok-1", name: "frodo" } })); + const io = testIo(); + expect(await runCli(["whoami"], io, realCore(stub))).toBe(0); + expect(io.stdout).toEqual(["frodo (token tok-1)"]); + expect(stub.requests.map((r) => [r.method, r.path, r.authorization])).toEqual([ + ["GET", "/api/me", `Bearer ${TOKEN}`], + ]); + }); + + // The token never reaches the output. + it("whoami exits 10 with the API's message on 401", async () => { + const stub = createStub(); + stub.app.get("/api/me", () => apiError(401, "unauthorized", "invalid or missing token")); + const io = testIo(); + expect(await runCli(["whoami"], io, realCore(stub))).toBe(10); + expect(io.stderr).toEqual(["devbox: invalid or missing token (401 unauthorized)"]); + expect([...io.stdout, ...io.stderr].join("\n")).not.toContain("SECRET"); + }); + + it("whoami exits 10 with one line when a login page answers instead of the API", async () => { + const stub = createStub(); + stub.app.get( + "/api/me", + () => + new Response("login", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ); + const io = testIo(); + expect(await runCli(["whoami"], io, realCore(stub))).toBe(10); + expect(io.stdout).toEqual([]); + expect(io.stderr).toEqual(["devbox: unexpected response from http://cp.test: not JSON"]); + }); + + it("ls --mine lists every page as one table", async () => { + const stub = createStub(); + const first = task({ key: "DBX-API-8", id: "id-8" }); + const second = task(); + stub.app.get("/api/tasks", (c) => + c.json( + c.req.query("before") + ? { tasks: [second], next_before: null } + : { tasks: [first], next_before: "id-8" }, + ), + ); + const io = testIo(); + expect(await runCli(["ls", "--mine"], io, realCore(stub))).toBe(0); + expect(io.stdout).toEqual([renderTable([first, second], NOW)]); + expect(stub.requests.map((r) => r.path)).toEqual([ + "/api/tasks?created_by=me&limit=100", + "/api/tasks?created_by=me&limit=100&before=id-8", + ]); + }); + + it("show prints the task, and exits 10 with the API's message on 404", async () => { + const stub = createStub(); + stub.app.get("/api/tasks/DBX-API-7", (c) => c.json(task())); + stub.app.get("/api/tasks/NOPE-1", () => + apiError(404, "task_not_found", 'no task matches "NOPE-1"'), + ); + const io = testIo(); + expect(await runCli(["show", "DBX-API-7"], io, realCore(stub))).toBe(0); + expect(io.stdout).toEqual([renderDetail(task(), NOW)]); + + const missing = testIo(); + expect(await runCli(["show", "NOPE-1"], missing, realCore(stub))).toBe(10); + expect(missing.stderr).toEqual(['devbox: no task matches "NOPE-1" (404 task_not_found)']); + }); + + it("create reads stdin, posts, and prints the key", async () => { + const stub = createStub(); + stub.app.post("/api/tasks", (c) => c.json(task(), 201)); + const io = testIo({ stdin: "From stdin." }); + expect( + await runCli(["create", "Fix flaky test", "-", "--flag", "FOO"], io, realCore(stub)), + ).toBe(0); + expect(io.stdout).toEqual(["DBX-API-7"]); + expect(stub.requests.map((r) => r.body)).toEqual([ + { title: "Fix flaky test", description: "From stdin.", experimental_flags: ["FOO"] }, + ]); + }); + + it("create --wait prints the key and the response", async () => { + const created = envelope(1, "task_created", { title: "Fix flaky test" }); + const response = envelope(4, "agent_response", { body: "Done." }); + const completed = envelope(5, "completed", { status: "completed" }); + const stub = createStub(); + stub.app.post("/api/tasks", (c) => c.json(task(), 201)); + serveEvents(stub, "DBX-API-7", [created]); + serveStream(stub, "DBX-API-7", [ + () => sse([frame(created), frame(response), frame(completed), stateFrame("idle")], "hang"), + ]); + const io = testIo(); + expect(await runCli(["create", "Fix flaky test", "--wait"], io, realCore(stub))).toBe(0); + expect(io.stdout).toEqual(["DBX-API-7", "Done."]); + expect(stub.requests.map((r) => [r.method, r.path, r.authorization, r.lastEventId])).toEqual([ + ["POST", "/api/tasks", `Bearer ${TOKEN}`, null], + ["GET", "/api/tasks/DBX-API-7/events?after=0&limit=1000", `Bearer ${TOKEN}`, null], + ["GET", "/api/tasks/DBX-API-7/events/stream", `Bearer ${TOKEN}`, "0"], + // The drain before accepting the result. + ["GET", "/api/tasks/DBX-API-7/events?after=5&limit=1000", `Bearer ${TOKEN}`, null], + ]); + }); + + it("send exits 10 with the API's message for a Linear task", async () => { + const stub = createStub(); + stub.app.post("/api/tasks/ABC-1/messages", () => + apiError(409, "not_api_task", "ABC-1 is a Linear task; steer it from Linear"), + ); + const io = testIo(); + expect(await runCli(["send", "ABC-1", "hi"], io, realCore(stub))).toBe(10); + expect(io.stderr).toEqual([ + "devbox: ABC-1 is a Linear task; steer it from Linear (409 not_api_task)", + ]); + }); + + // Ctrl-C during logs -f exits 130 quietly. + it("logs -f follows across a dropped connection until Ctrl-C", async () => { + const created = envelope(1, "task_created", { title: "Fix flaky test" }); + const sandbox = envelope(2, "sandbox_started", { mode: "fresh" }, null); + const action = envelope(3, "agent_action", { body: "Planning" }); + const stub = createStub(); + serveStream(stub, "DBX-API-7", [ + () => sse([frame(created), stateFrame("queued"), frame(sandbox)], "error"), + () => sse([frame(action)], "hang"), + ]); + const sleeps: number[] = []; + const io = testIo({ stopAfterLines: 3 }); + expect(await runCli(["logs", "DBX-API-7", "-f"], io, realCore(stub, sleeps))).toBe(130); + expect(io.stdout).toEqual([ + "12:00:01 ✚ task created: Fix flaky test", + "12:00:02 ▸ sandbox started (fresh)", + "12:00:03 ⚙ Planning", + ]); + expect(io.stderr).toEqual([]); + expect(stub.requests.map((r) => r.lastEventId)).toEqual(["0", "2"]); + expect(sleeps).toEqual([1000]); + }); + + it("logs -w prints the log and ends with the round", async () => { + const created = envelope(1, "task_created", { title: "Fix flaky test" }); + const response = envelope(2, "agent_response", { body: "Done." }); + const completed = envelope(3, "completed", { status: "completed" }); + const stub = createStub(); + serveEvents(stub, "DBX-API-7", [created]); + serveStream(stub, "DBX-API-7", [ + () => sse([frame(response), frame(completed), stateFrame("idle")], "hang"), + ]); + const io = testIo(); + expect(await runCli(["logs", "DBX-API-7", "-w"], io, realCore(stub))).toBe(0); + expect(io.stdout).toEqual([ + "12:00:01 ✚ task created: Fix flaky test", + "12:00:02 ✉ Done.", + "12:00:03 ✔ completed (completed)", + ]); + }); + + // CP sends the task state after each batch of 500 events, before + // the rest of the history. + it("logs -w --json reads past an earlier round's end to the latest round", async () => { + const log = [ + envelope(1, "task_created", { title: "Fix flaky test" }, "round-a"), + ...Array.from({ length: 498 }, (_, i) => + envelope(i + 2, "agent_thought", { body: `step ${i + 2}` }, "round-a"), + ), + envelope(500, "completed", { status: "completed" }, "round-a"), + envelope(501, "message_queued", { body: "also the docs" }, "round-b"), + envelope(502, "completed", { status: "partial_failure" }, "round-b"), + ]; + const stub = createStub(); + serveEvents(stub, "DBX-API-7", log); + serveStream(stub, "DBX-API-7", [() => sse([stateFrame("idle")], "hang")]); + const io = testIo(); + expect(await runCli(["logs", "DBX-API-7", "-w", "--json"], io, realCore(stub))).toBe(2); + expect(io.stdout.map((line) => JSON.parse(line))).toEqual(log); + expect(io.stderr).toEqual([]); + }); + + // A Linear task's stream sends its state before `logging`. + it("logs -w exits 10 for a Linear task", async () => { + const stub = createStub(); + serveLinearTask(stub, "ABC-1"); + const io = testIo(); + expect(await runCli(["logs", "ABC-1", "-w"], io, realCore(stub))).toBe(10); + expect(io.stdout).toEqual([]); + expect(io.stderr).toEqual(["devbox: ABC-1 has no event log (only API tasks are logged)"]); + }); + + it("logs prints a notice for a Linear task and exits 0", async () => { + const stub = createStub(); + serveLinearTask(stub, "ABC-1"); + const io = testIo(); + expect(await runCli(["logs", "ABC-1"], io, realCore(stub))).toBe(0); + expect(io.stderr).toEqual(["devbox: ABC-1 has no event log (only API tasks are logged)"]); + }); + + it("wait catches up on the log, follows, and prints the final response", async () => { + const created = envelope(1, "task_created", { title: "Fix flaky test" }); + const response = envelope(2, "agent_response", { body: "Done." }); + const completed = envelope(3, "completed", { status: "partial_failure" }); + const stub = createStub(); + serveEvents(stub, "DBX-API-7", [created]); + serveStream(stub, "DBX-API-7", [ + () => + sse([stateFrame("running"), frame(response), frame(completed), stateFrame("idle")], "hang"), + ]); + const io = testIo(); + expect(await runCli(["wait", "DBX-API-7"], io, realCore(stub))).toBe(2); + expect(io.stdout).toEqual(["Done."]); + expect(stub.requests.map((r) => [r.path, r.lastEventId])).toEqual([ + ["/api/tasks/DBX-API-7/events?after=0&limit=1000", null], + ["/api/tasks/DBX-API-7/events?after=1&limit=1000", null], + ["/api/tasks/DBX-API-7/events/stream", "1"], + // The drain before accepting the result. + ["/api/tasks/DBX-API-7/events?after=3&limit=1000", null], + ]); + }); + + // After a reconnect, CP replays the backlog in batches of up to + // 500 events, each followed by the task's state. Scaled down: the + // reconnect's batch is events 2-4, and events 2-7 are written while the + // first connection is open, so the initial catch-up sees only event 1. + function reconnectBacklog(): { stub: Stub; log: ApiEventEnvelope[] } { + const log = [ + envelope(1, "task_created", { title: "Fix flaky test" }, "round-a"), + envelope(2, "agent_action", { body: "Planning" }, "round-a"), + envelope(3, "agent_response", { body: "Done." }, "round-a"), + envelope(4, "completed", { status: "completed" }, "round-a"), + envelope(5, "message_queued", { body: "also the docs" }, "round-b"), + envelope(6, "agent_response", { body: "Docs updated." }, "round-b"), + envelope(7, "completed", { status: "partial_failure" }, "round-b"), + ]; + const served = log.slice(0, 1); + const stub = createStub(); + serveEvents(stub, "DBX-API-7", served); + serveStream(stub, "DBX-API-7", [ + () => { + served.push(...log.slice(1)); + return sse([stateFrame("running")], "close"); + }, + () => sse([...log.slice(1, 4).map(frame), stateFrame("idle")], "hang"), + ]); + return { stub, log }; + } + + it("logs -w --json reads past a reconnect's first batch to the latest round", async () => { + const { stub, log } = reconnectBacklog(); + const io = testIo(); + expect(await runCli(["logs", "DBX-API-7", "-w", "--json"], io, realCore(stub))).toBe(2); + expect(io.stdout.map((line) => JSON.parse(line))).toEqual(log); + expect(io.stderr).toEqual([]); + }); + + it("wait --json reads past a reconnect's first batch to the latest round", async () => { + const { stub, log } = reconnectBacklog(); + const io = testIo(); + expect(await runCli(["wait", "DBX-API-7", "--json"], io, realCore(stub))).toBe(2); + expect(io.stdout.map((line) => JSON.parse(line))).toEqual([log[5]]); + expect(io.stderr).toEqual([]); + }); + + it("wait exits 10 for a Linear task", async () => { + const stub = createStub(); + serveLinearTask(stub, "ABC-1"); + const io = testIo(); + expect(await runCli(["wait", "ABC-1"], io, realCore(stub))).toBe(10); + expect(io.stderr).toEqual(["devbox: ABC-1 has no event log (only API tasks are logged)"]); + }); +}); diff --git a/cli/__tests__/fixtures.ts b/cli/__tests__/fixtures.ts new file mode 100644 index 0000000..a511938 --- /dev/null +++ b/cli/__tests__/fixtures.ts @@ -0,0 +1,55 @@ +// Test fixtures shared by the core, interface and end-to-end tests: API +// resources and event envelopes with fixed ids and times. +import type { ApiEventEnvelope, ApiTask } from "../../control-plane/src/types/api"; + +export const TASK_ID = "0192f000-0000-7000-8000-000000000007"; +export const T0 = "2026-10-12T12:00:00.000Z"; +export const NOW = Date.parse("2026-10-12T12:30:00.000Z"); + +const CP_TYPES = new Set([ + "task_created", + "message_queued", + "task_restarted", + "sandbox_started", + "round_failed", +]); + +// Event `id` is created at T0 + id seconds, so rendered times are 12:00:. +export function envelope( + id: number, + type: string, + data: unknown, + roundId: string | null = "round-1", +): ApiEventEnvelope { + const cp = CP_TYPES.has(type); + return { + id, + task_id: TASK_ID, + origin: cp ? "control_plane" : "daemon", + type, + round_id: roundId, + seq: cp ? null : id, + created_at: new Date(Date.parse(T0) + id * 1000).toISOString(), + data, + }; +} + +export function task(over: Partial = {}): ApiTask { + return { + id: TASK_ID, + key: "DBX-API-7", + source: "api", + title: "Fix flaky test", + description: "The test fails on CI.", + external_url: "https://cp.internal/api/tasks/DBX-API-7", + created_by: { id: "0192f000-0000-7000-8000-0000000000aa", name: "frodo" }, + experimental_flags: [], + state: "queued", + sandbox_live: false, + prs: [], + rounds: [], + created_at: T0, + updated_at: T0, + ...over, + }; +} diff --git a/cli/__tests__/guards.test.ts b/cli/__tests__/guards.test.ts new file mode 100644 index 0000000..10a5104 --- /dev/null +++ b/cli/__tests__/guards.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +// Paths are resolved from this file, so the suite runs from any directory. +const CLI = resolve(import.meta.dir, ".."); +const REPO = resolve(CLI, ".."); +const API_TYPES = join(REPO, "control-plane/src/types/api"); + +const sources = (dir: string): string[] => + readdirSync(dir, { recursive: true, encoding: "utf8" }) + .filter((f) => f.endsWith(".ts") && !f.includes("__tests__")) + .map((f) => join(dir, f)); + +// `[^;]` keeps each match inside one import statement, across line breaks. +const imports = (file: string): string[] => + [...readFileSync(file, "utf8").matchAll(/^import\s[^;]*?from\s+"([^"]+)"/gm)].map( + (m) => m[1] ?? "", + ); + +describe("devbox bundle", () => { + // The CLI may import only types from the control plane: + // control-plane/src/config.ts validates server env on import. Bun's bundler + // marks each bundled module with a `// ` comment, relative to the + // working directory; they are re-rooted at the repo so any cwd works. + it("contains only cli/ modules and commander", async () => { + const result = await Bun.build({ entrypoints: [join(CLI, "devbox.ts")], target: "bun" }); + expect(result.success).toBe(true); + const text = (await result.outputs[0]?.text()) ?? ""; + const modules = [ + ...new Set( + text + .split("\n") + .filter((l) => /^\/\/ \S+\.(ts|js)$/.test(l)) + .map((l) => relative(REPO, resolve(l.slice(3)))), + ), + ]; + expect(modules).toContain("cli/devbox.ts"); + expect( + modules.filter( + (m) => + !(m.startsWith("cli/") && !m.startsWith("cli/node_modules/")) && + !m.startsWith("cli/node_modules/commander/"), + ), + ).toEqual([]); + }); +}); + +describe("layering", () => { + it("core never imports the interface or commander", () => { + const bad = sources(join(CLI, "core")).flatMap((f) => + imports(f) + .filter((i) => i.includes("interface/") || i === "commander") + .map((i) => `${f}: ${i}`), + ); + expect(bad).toEqual([]); + }); + + it("the interface never uses ApiClient", () => { + const bad = sources(join(CLI, "interface")).filter((f) => + /\bApiClient\b/.test(readFileSync(f, "utf8")), + ); + expect(bad).toEqual([]); + }); + + it("only control-plane/src/types/api.ts is imported from the control plane, and only as types", () => { + const bad = [ + ...sources(join(CLI, "core")), + ...sources(join(CLI, "interface")), + join(CLI, "devbox.ts"), + ].flatMap((f) => + [...readFileSync(f, "utf8").matchAll(/^import\s+(type\s+)?[^;]*?from\s+"([^"]+)"/gm)] + // Anything that resolves outside cli/ is the control plane's (or another + // component's); only a type import of the API types is allowed. + .filter((m) => { + const spec = m[2] ?? ""; + if (!spec.startsWith(".")) return false; + const target = resolve(dirname(f), spec); + if (target.startsWith(`${CLI}/`)) return false; + return !m[1] || target !== API_TYPES; + }) + .map((m) => `${relative(REPO, f)}: ${m[0]}`), + ); + expect(bad).toEqual([]); + }); +}); diff --git a/cli/__tests__/stub.ts b/cli/__tests__/stub.ts new file mode 100644 index 0000000..7d43033 --- /dev/null +++ b/cli/__tests__/stub.ts @@ -0,0 +1,116 @@ +// In-process Hono stub of the CP API, for core and end-to-end tests. `fetch` +// calls app.fetch directly: no port, no proxy, and SSE bodies are scripted +// byte for byte. +import { Hono } from "hono"; +import type { ApiEventEnvelope, ApiTaskState } from "../../control-plane/src/types/api"; +import { ApiClient } from "../core/api"; + +export type Recorded = { + method: string; + path: string; + authorization: string | null; + lastEventId: string | null; + body: unknown; +}; +export type Stub = { app: Hono; fetch: typeof fetch; requests: Recorded[] }; + +export function createStub(): Stub { + const app = new Hono(); + const requests: Recorded[] = []; + app.use(async (c, next) => { + const url = new URL(c.req.url); + requests.push({ + method: c.req.method, + path: url.pathname + url.search, + authorization: c.req.header("authorization") ?? null, + lastEventId: c.req.header("last-event-id") ?? null, + body: c.req.method === "POST" ? await c.req.json() : undefined, + }); + await next(); + }); + // Like a real fetch, a request rejects with the signal's reason once it + // aborts, even while the route is still working on its answer. + const fetchFn = (input: string | URL | Request, init?: RequestInit): Promise => { + const signal = init?.signal; + const answer = Promise.resolve(app.fetch(new Request(input as string, init))); + if (!signal) return answer; + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + answer.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); + }); + }; + return { app, requests, fetch: fetchFn as unknown as typeof fetch }; +} + +export const TOKEN = "dbx_tok_SECRET"; + +// An ApiClient on the stub, as the CLI would build it from its config. +// `null` builds a client without a token. +export const stubApi = (stub: Stub, token: string | null = TOKEN): ApiClient => + new ApiClient({ url: "http://cp.test", ...(token ? { token } : {}) }, stub.fetch); + +export const apiError = (status: number, code: string, message: string): Response => + Response.json({ error: { code, message } }, { status }); + +// Same field order as the server's writeSSE: event, data, id. +export const frame = (e: ApiEventEnvelope): string => + `event: ${e.type}\ndata: ${JSON.stringify(e)}\nid: ${e.id}\n\n`; +export const stateFrame = (state: ApiTaskState): string => + `event: state\ndata: ${JSON.stringify({ state, sandbox_live: state === "running" })}\n\n`; +export const loggingFrame = 'event: logging\ndata: {"logging":"unavailable"}\n\n'; + +// One SSE connection: the chunks, then the stream closes ("close", as at the +// server's max duration), fails ("error", a network drop) or stays open +// ("hang") until the client cancels. Each string is one network chunk. +// Chunks are handed out on pull: erroring a stream discards whatever is still +// queued, so a "drop" must come only after the client has read every chunk. +export function sse( + chunks: Array, + end: "close" | "error" | "hang" = "close", +): Response { + const enc = new TextEncoder(); + const queue = chunks.map((c) => (typeof c === "string" ? enc.encode(c) : c)); + const body = new ReadableStream({ + pull(ctrl) { + const next = queue.shift(); + if (next) return ctrl.enqueue(next); + if (end === "close") return ctrl.close(); + if (end === "error") return ctrl.error(new Error("connection reset")); + return new Promise(() => {}); // hang until cancelled + }, + }); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); +} + +// Serves `connections` in order on the stream route. Once they run out, every +// further connection hangs until the client aborts. +export function serveStream(stub: Stub, ref: string, connections: Array<() => Response>): void { + let n = 0; + stub.app.get(`/api/tasks/${ref}/events/stream`, () => { + const next = connections[n++]; + return next ? next() : sse([], "hang"); + }); +} + +// Serves `log` on GET /events with the server's after/limit paging. With +// `delayMs`, each page is answered that much later. +export function serveEvents(stub: Stub, ref: string, log: ApiEventEnvelope[], delayMs = 0): void { + stub.app.get(`/api/tasks/${ref}/events`, async (c) => { + if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); + const after = Number(c.req.query("after") ?? 0); + const limit = Number(c.req.query("limit") ?? 200); + const events = log.filter((e) => e.id > after).slice(0, limit); + return c.json({ events, next_after: events.at(-1)?.id ?? after, logging: "available" }); + }); +} + +// A Linear task: its log pages report `logging: unavailable`, and its stream +// sends one state event, then `logging`, then closes. +export function serveLinearTask(stub: Stub, ref: string): void { + stub.app.get(`/api/tasks/${ref}/events`, (c) => + c.json({ events: [], next_after: 0, logging: "unavailable" }), + ); + serveStream(stub, ref, [() => sse([stateFrame("idle"), loggingFrame])]); +} diff --git a/cli/bun.lock b/cli/bun.lock new file mode 100644 index 0000000..4d4aa54 --- /dev/null +++ b/cli/bun.lock @@ -0,0 +1,74 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "devbox-cli", + "dependencies": { + "commander": "^15.0.0", + }, + "devDependencies": { + "@types/bun": "latest", + "hono": "^4.13.13", + "typescript": "^7", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + + "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], + + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + + "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@8.11.2", "", {}, "sha512-iMVNmWZ0leK/goS6eXMizSzmm9CDWtyphwbaCms3DNLqRxDL+mMoNVcZMTyyVgXP0N+Z8neAMzDoUOUJL8veKg=="], + + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/cli/core/__tests__/account.test.ts b/cli/core/__tests__/account.test.ts new file mode 100644 index 0000000..31d109b --- /dev/null +++ b/cli/core/__tests__/account.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "bun:test"; +import { createStub, stubApi } from "../../__tests__/stub"; +import { whoami } from "../account"; + +describe("whoami", () => { + it("returns /api/me", async () => { + const stub = createStub(); + stub.app.get("/api/me", (c) => c.json({ token: { id: "tok-1", name: "frodo" } })); + expect(await whoami(stubApi(stub))).toEqual({ token: { id: "tok-1", name: "frodo" } }); + }); +}); diff --git a/cli/core/__tests__/api.test.ts b/cli/core/__tests__/api.test.ts new file mode 100644 index 0000000..0e0a2fc --- /dev/null +++ b/cli/core/__tests__/api.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "bun:test"; +import { task } from "../../__tests__/fixtures"; +import { apiError, createStub, sse, stubApi, TOKEN } from "../../__tests__/stub"; +import { ApiClient, ApiFailure, isFatal } from "../api"; + +function failure(p: Promise): Promise { + return p.then( + () => { + throw new Error("expected an ApiFailure"); + }, + (err) => { + expect(err).toBeInstanceOf(ApiFailure); + return err as ApiFailure; + }, + ); +} + +const fields = (err: ApiFailure) => ({ status: err.status, code: err.code, message: err.message }); + +describe("ApiClient", () => { + it("sends the bearer token and JSON bodies", async () => { + const stub = createStub(); + stub.app.post("/api/tasks", (c) => c.json(task(), 201)); + + expect(await stubApi(stub).createTask({ title: "Fix flaky test" })).toEqual(task()); + expect(stub.requests).toEqual([ + { + method: "POST", + path: "/api/tasks", + authorization: `Bearer ${TOKEN}`, + lastEventId: null, + body: { title: "Fix flaky test" }, + }, + ]); + }); + + it("sends no Authorization header without a token", async () => { + const stub = createStub(); + stub.app.get("/api/me", (c) => c.json({ token: null, auth: "disabled" })); + await stubApi(stub, null).me(); + expect(stub.requests[0]?.authorization).toBeNull(); + }); + + it("builds list queries and encodes refs", async () => { + const stub = createStub(); + stub.app.get("/api/tasks", (c) => c.json({ tasks: [], next_before: null })); + stub.app.get("/api/tasks/:ref/events", (c) => + c.json({ events: [], next_after: 5, logging: "available" }), + ); + const api = stubApi(stub); + + await api.listTasks({ + source: "api", + created_by: "me", + state: "idle", + limit: 100, + before: "x", + }); + await api.listTasks({ limit: 100 }); + await api.listEvents("DBX API/7", 5, 1000); + expect(stub.requests.map((r) => r.path)).toEqual([ + "/api/tasks?source=api&created_by=me&state=idle&limit=100&before=x", + "/api/tasks?limit=100", + "/api/tasks/DBX%20API%2F7/events?after=5&limit=1000", + ]); + }); + + it("maps the API error body", async () => { + const stub = createStub(); + stub.app.get("/api/tasks/:ref", () => apiError(404, "task_not_found", 'no task matches "X"')); + expect(fields(await failure(stubApi(stub).getTask("X")))).toEqual({ + status: 404, + code: "task_not_found", + message: 'no task matches "X" (404 task_not_found)', + }); + }); + + it("maps a non-JSON error (e.g. the ALB's 502)", async () => { + const stub = createStub(); + stub.app.get("/api/me", () => new Response("bad gateway", { status: 502 })); + expect(fields(await failure(stubApi(stub).me()))).toEqual({ + status: 502, + code: "http_502", + message: "HTTP 502 from http://cp.test", + }); + }); + + // The token never reaches an error message. + it("reports an unreachable API without the token", async () => { + const down = (async () => { + throw new TypeError("Unable to connect"); + }) as unknown as typeof fetch; + const err = await failure(new ApiClient({ url: "http://cp.test", token: TOKEN }, down).me()); + expect(fields(err)).toEqual({ + status: null, + code: "unreachable", + message: "cannot reach http://cp.test: Unable to connect", + }); + }); + + it("never puts the token in a 401 message", async () => { + const stub = createStub(); + stub.app.get("/api/me", () => apiError(401, "unauthorized", "invalid or missing token")); + const err = await failure(stubApi(stub).me()); + expect(err.message).toBe("invalid or missing token (401 unauthorized)"); + expect(err.message).not.toContain("SECRET"); + }); + + it("opens the stream with Last-Event-ID", async () => { + const stub = createStub(); + stub.app.get("/api/tasks/:ref/events/stream", () => sse([": hi\n\n"])); + + const body = await stubApi(stub).openStream("DBX-API-7", "41", new AbortController().signal); + expect(await new Response(body).text()).toBe(": hi\n\n"); + expect(stub.requests[0]?.lastEventId).toBe("41"); + }); + + // An SSO or ingress login page answers 200 with HTML. + const loginPage = () => + new Response("login", { status: 200, headers: { "content-type": "text/html" } }); + + it("rejects a 2xx answer that is not JSON", async () => { + const stub = createStub(); + stub.app.get("/api/me", loginPage); + expect(fields(await failure(stubApi(stub).me()))).toEqual({ + status: 200, + code: "invalid_response", + message: "unexpected response from http://cp.test: not JSON", + }); + }); + + it("rejects a stream answer that is not an event stream", async () => { + const stub = createStub(); + stub.app.get("/api/tasks/:ref/events/stream", loginPage); + const err = await failure( + stubApi(stub).openStream("DBX-API-7", undefined, new AbortController().signal), + ); + expect(fields(err)).toEqual({ + status: 200, + code: "invalid_response", + message: "unexpected response from http://cp.test: not an event stream", + }); + }); + + it("isFatal: 4xx and invalid responses", () => { + expect(isFatal(new ApiFailure(200, "invalid_response", ""))).toBe(true); + expect(isFatal(new ApiFailure(401, "unauthorized", ""))).toBe(true); + expect(isFatal(new ApiFailure(404, "task_not_found", ""))).toBe(true); + expect(isFatal(new ApiFailure(409, "not_api_task", ""))).toBe(true); + expect(isFatal(new ApiFailure(503, "http_503", ""))).toBe(false); + expect(isFatal(new ApiFailure(null, "unreachable", ""))).toBe(false); + expect(isFatal(new Error("connection reset"))).toBe(false); + }); +}); diff --git a/cli/core/__tests__/events.test.ts b/cli/core/__tests__/events.test.ts new file mode 100644 index 0000000..5f30760 --- /dev/null +++ b/cli/core/__tests__/events.test.ts @@ -0,0 +1,616 @@ +import { describe, expect, it } from "bun:test"; +import type { ApiEventEnvelope } from "../../../control-plane/src/types/api"; +import { envelope } from "../../__tests__/fixtures"; +import { + apiError, + createStub, + frame, + type Stub, + serveEvents, + serveLinearTask, + serveStream, + sse, + stateFrame, + stubApi, +} from "../../__tests__/stub"; +import { ApiFailure } from "../api"; +import { + type FollowItem, + type FollowOptions, + followTask, + NoEventLog, + type RoundResult, + roundStart, + taskHistory, +} from "../events"; +import { MAX_TIMEOUT_MS } from "../limits"; + +const REF = "DBX-API-7"; +const created = envelope(1, "task_created", { title: "Fix flaky test" }); +const sandbox = envelope(2, "sandbox_started", { mode: "fresh" }, null); +const action = envelope(3, "agent_action", { body: "Planning" }); +const response = envelope(4, "agent_response", { body: "Done." }); +const completed = (status?: string, id = 5, round = "round-1") => + envelope(id, "completed", status ? { status } : {}, round); + +const ev = (event: ApiEventEnvelope): FollowItem => ({ kind: "event", event }); +const done = (result: RoundResult): FollowItem => ({ kind: "done", result }); + +// Runs followTask to its end. `stopAfter` aborts the signal once that many +// items arrived, as Ctrl-C would. +async function run(stub: Stub, o: Omit, stopAfter?: number) { + const ctrl = new AbortController(); + const sleeps: number[] = []; + const items: FollowItem[] = []; + const deps = { + sleep: async (ms: number) => { + sleeps.push(ms); + }, + }; + for await (const item of followTask(stubApi(stub), { ...o, signal: ctrl.signal }, deps)) { + items.push(item); + if (items.length === stopAfter) ctrl.abort(); + } + const streams = stub.requests.filter((r) => r.path.endsWith("/events/stream")); + return { items, sleeps, lastEventIds: streams.map((r) => r.lastEventId) }; +} + +async function rejection(p: Promise): Promise { + return p.then( + () => { + throw new Error("expected a rejection"); + }, + (err: unknown) => err, + ); +} + +describe("taskHistory", () => { + it("pages the log after a cursor", async () => { + const stub = createStub(); + serveEvents(stub, REF, [created, sandbox, action]); + const seen: ApiEventEnvelope[] = []; + for await (const e of taskHistory(stubApi(stub), { ref: REF, after: 1 })) seen.push(e); + expect(seen).toEqual([sandbox, action]); + expect(stub.requests.map((r) => r.path)).toEqual([ + `/api/tasks/${REF}/events?after=1&limit=1000`, + `/api/tasks/${REF}/events?after=3&limit=1000`, + ]); + }); + + it("throws NoEventLog for a Linear task", async () => { + const stub = createStub(); + serveLinearTask(stub, "ABC-1"); + const err = await rejection(taskHistory(stubApi(stub), { ref: "ABC-1", after: 0 }).next()); + expect(err).toBeInstanceOf(NoEventLog); + expect((err as Error).message).toBe("ABC-1 has no event log (only API tasks are logged)"); + }); +}); + +describe("followTask from a cursor", () => { + it("follows across a dropped connection until interrupted", async () => { + const stub = createStub(); + serveStream(stub, REF, [ + () => sse([frame(created), stateFrame("queued"), frame(sandbox)], "error"), + () => sse([frame(action)], "hang"), + ]); + const r = await run(stub, { ref: REF, from: { after: 0 } }, 3); + expect(r.items).toEqual([ev(created), ev(sandbox), ev(action)]); + expect(r.lastEventIds).toEqual(["0", "2"]); + expect(r.sleeps).toEqual([1000]); + }); + + it("keeps following past idle", async () => { + const stub = createStub(); + serveStream(stub, REF, [ + () => + sse( + [frame(created), frame(response), frame(completed("completed")), stateFrame("idle")], + "hang", + ), + ]); + const r = await run(stub, { ref: REF, from: { after: 0 } }, 3); + expect(r.items).toEqual([ev(created), ev(response), ev(completed("completed"))]); + }); + + it("throws NoEventLog when the stream reports no logging", async () => { + const stub = createStub(); + serveLinearTask(stub, "ABC-1"); + const err = await rejection(run(stub, { ref: "ABC-1", from: { after: 0 } })); + expect(err).toBeInstanceOf(NoEventLog); + }); + + it("throws ApiFailure on a 404", async () => { + const stub = createStub(); + serveStream(stub, "X-1", [() => apiError(404, "task_not_found", 'no task matches "X-1"')]); + const err = await rejection(run(stub, { ref: "X-1", from: { after: 0 } })); + expect(err).toBeInstanceOf(ApiFailure); + expect((err as ApiFailure).status).toBe(404); + }); +}); + +describe("followTask until done, from the latest round", () => { + function latest(history: ApiEventEnvelope[], live: string[]): Stub { + const stub = createStub(); + serveEvents(stub, REF, history); + serveStream(stub, REF, [() => sse(live, "hang")]); + return stub; + } + + it.each([ + ["completed", "completed"], + ["no_action", "no_action"], + [undefined, null], + ["partial_failure", "partial_failure"], + ] as const)("status %p gives outcome %p", async (status, outcome) => { + const stub = latest( + [created, action], + [stateFrame("running"), frame(response), frame(completed(status)), stateFrame("idle")], + ); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true }); + expect(r.items).toEqual([ + ev(response), + ev(completed(status)), + done({ outcome, failed: false, response }), + ]); + expect(r.lastEventIds).toEqual(["3"]); + }); + + it("reports a failed round with its reason", async () => { + const failed = envelope(5, "round_failed", { reason: "The sandbox could not start." }); + const stub = latest([created], [frame(failed), stateFrame("failed")]); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true }); + expect(r.items).toEqual([ + ev(failed), + done({ outcome: null, failed: true, reason: "The sandbox could not start." }), + ]); + }); + + it("reports a failed round without a reason", async () => { + const failed = envelope(5, "round_failed", {}); + const stub = latest([created], [frame(failed), stateFrame("failed")]); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true }); + expect(r.items).toEqual([ + ev(failed), + done({ outcome: null, failed: true, reason: "the round failed" }), + ]); + }); + + it("reports a completed(failed) round with its agent_error text", async () => { + const error = envelope(4, "agent_error", { body: "Tool crashed" }); + const stub = latest( + [created], + [frame(error), frame(completed("failed")), stateFrame("failed")], + ); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true }); + expect(r.items.at(-1)).toEqual( + done({ outcome: "failed", failed: true, reason: "Tool crashed" }), + ); + }); + + it("returns at once for a task that is already idle", async () => { + const stub = latest([created, action, response, completed("completed")], [stateFrame("idle")]); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true }); + expect(r.items).toEqual([done({ outcome: "completed", failed: false, response })]); + }); + + // CP can mark the round responded before it logs `completed`. + it("keeps following when idle arrives before the completed event", async () => { + const stub = latest( + [created, action, response], + [stateFrame("idle"), frame(completed("partial_failure"))], + ); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true }); + expect(r.items).toEqual([ + ev(completed("partial_failure")), + done({ outcome: "partial_failure", failed: false, response }), + ]); + }); + + it("yields timeout when timeoutMs passes first", async () => { + const stub = latest([created], [stateFrame("running")]); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true, timeoutMs: 50 }); + expect(r.items).toEqual([{ kind: "timeout" }]); + }); + + it("rejects a timeout above MAX_TIMEOUT_MS before any request", async () => { + const stub = createStub(); + const err = await rejection( + run(stub, { ref: REF, from: "latest", untilDone: true, timeoutMs: MAX_TIMEOUT_MS + 1 }), + ); + expect(err).toBeInstanceOf(RangeError); + expect(stub.requests).toEqual([]); + }); + + it("throws NoEventLog for a Linear task", async () => { + const stub = createStub(); + serveLinearTask(stub, "ABC-1"); + const err = await rejection(run(stub, { ref: "ABC-1", from: "latest", untilDone: true })); + expect(err).toBeInstanceOf(NoEventLog); + }); +}); + +describe("followTask from a write's round", () => { + const old = [created, action, response, completed("completed")]; + const queued = envelope(6, "message_queued", { body: "also the docs" }, "round-2"); + const reply = envelope(7, "agent_response", { body: "Docs updated." }, "round-2"); + const done2 = completed(undefined, 8, "round-2"); + + function write(log: ApiEventEnvelope[], live: string[]): Stub { + const stub = createStub(); + serveEvents(stub, REF, log); + serveStream(stub, REF, [() => sse(live, "hang")]); + return stub; + } + + it("starts at the message's round and reports that round", async () => { + const stub = write( + [...old, queued], + [frame(queued), stateFrame("running"), frame(reply), frame(done2), stateFrame("idle")], + ); + const r = await run(stub, { + ref: REF, + from: { round: roundStart.message("round-2") }, + untilDone: true, + }); + expect(r.items).toEqual([ + ev(queued), + ev(reply), + ev(done2), + done({ outcome: null, failed: false, response: reply }), + ]); + expect(r.lastEventIds).toEqual(["5"]); + }); + + // Someone else's restart supersedes the follow-up. + it("reports a round a restart superseded as failed", async () => { + const restarted = envelope( + 7, + "task_restarted", + { title: "T", superseded_round_ids: ["round-2"] }, + "round-3", + ); + const done3 = completed("completed", 9, "round-3"); + const stub = write( + [...old, queued], + [frame(queued), frame(restarted), frame(done3), stateFrame("idle")], + ); + const r = await run(stub, { + ref: REF, + from: { round: roundStart.message("round-2") }, + untilDone: true, + }); + expect(r.items).toEqual([ + ev(queued), + ev(restarted), + done({ outcome: null, failed: true, reason: "superseded by a restart" }), + ]); + }); + + it("ends a waited-on assignment round when the task is restarted", async () => { + const restarted = envelope( + 6, + "task_restarted", + { title: "T", superseded_round_ids: [] }, + "round-2", + ); + const stub = write( + [created], + [ + frame(created), + frame(restarted), + frame(completed("completed", 7, "round-2")), + stateFrame("idle"), + ], + ); + const r = await run(stub, { ref: REF, from: { round: roundStart.created() }, untilDone: true }); + expect(r.items).toEqual([ + ev(created), + ev(restarted), + done({ outcome: null, failed: true, reason: "superseded by a restart" }), + ]); + }); + + it("starts at the last task_restarted for a restart", async () => { + const r1 = envelope(6, "task_restarted", { title: "T", superseded_round_ids: [] }, "round-a"); + const r2 = envelope(7, "task_restarted", { title: "T", superseded_round_ids: [] }, "round-b"); + const answer = envelope(8, "agent_response", { body: "Again." }, "round-b"); + const end = completed("completed", 9, "round-b"); + const stub = write( + [...old, r1, r2], + [frame(r2), frame(answer), frame(end), stateFrame("idle")], + ); + const r = await run(stub, { + ref: REF, + from: { round: roundStart.restarted() }, + untilDone: true, + }); + expect(r.items.at(-1)).toEqual(done({ outcome: "completed", failed: false, response: answer })); + expect(r.lastEventIds).toEqual(["6"]); + }); + + it("starts at task_created for a create", async () => { + const stub = write( + [created], + [frame(created), frame(response), frame(completed("completed")), stateFrame("idle")], + ); + const r = await run(stub, { ref: REF, from: { round: roundStart.created() }, untilDone: true }); + expect(r.items.at(-1)).toEqual(done({ outcome: "completed", failed: false, response })); + expect(r.lastEventIds).toEqual(["0"]); + }); + + it("fails when the write's event is not in the log", async () => { + const stub = write(old, []); + const err = await rejection( + run(stub, { ref: REF, from: { round: roundStart.message("round-9") }, untilDone: true }), + ); + expect(err).toBeInstanceOf(ApiFailure); + expect((err as ApiFailure).code).toBe("event_not_found"); + expect((err as Error).message).toBe(`no message_queued event in the log of ${REF}`); + }); +}); + +describe("followTask without a round to report", () => { + // The task state alone never ends a wait. + it("keeps waiting when the task failed before any round event", async () => { + const stub = createStub(); + serveEvents(stub, REF, []); + serveStream(stub, REF, [() => sse([stateFrame("failed")], "hang")]); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true, timeoutMs: 50 }); + expect(r.items).toEqual([{ kind: "timeout" }]); + }); +}); + +// CP sends the task state after every batch of history, so a +// wait from a cursor must read the whole log before it can end. +describe("followTask until done, from a cursor", () => { + const first = envelope(1, "task_created", { title: "Fix flaky test" }); + const answer = envelope(2, "agent_response", { body: "Done." }); + const end1 = completed("completed", 3, "round-1"); + const queued = envelope(6, "message_queued", { body: "also the docs" }, "round-2"); + const end2 = completed("partial_failure", 7, "round-2"); + const log = [first, answer, end1, queued, end2]; + + function cursor(): Stub { + const stub = createStub(); + serveEvents(stub, REF, log); + serveStream(stub, REF, [() => sse([stateFrame("idle")], "hang")]); + return stub; + } + + it("prints the whole log and ends with the latest round", async () => { + const stub = cursor(); + const r = await run(stub, { ref: REF, from: { after: 0 }, untilDone: true }); + expect(r.items).toEqual([ + ev(first), + ev(answer), + ev(end1), + ev(queued), + ev(end2), + done({ outcome: "partial_failure", failed: false }), + ]); + expect(r.lastEventIds).toEqual(["7"]); + }); + + it("reads the rounds before the cursor without printing them", async () => { + const stub = cursor(); + const r = await run(stub, { ref: REF, from: { after: 3 }, untilDone: true }); + expect(r.items).toEqual([ + ev(queued), + ev(end2), + done({ outcome: "partial_failure", failed: false }), + ]); + expect(r.lastEventIds).toEqual(["7"]); + }); + + it("streams from the cursor when it is past the log's end", async () => { + const stub = cursor(); + const r = await run(stub, { ref: REF, from: { after: 9 }, untilDone: true }); + expect(r.items).toEqual([done({ outcome: "partial_failure", failed: false })]); + expect(r.lastEventIds).toEqual(["9"]); + }); +}); + +// A status-less `completed` with no other round event isn't a work +// round. +describe("followTask past a round that did no work", () => { + it("reports the last work round", async () => { + const stub = createStub(); + serveEvents(stub, REF, [ + created, + response, + completed("partial_failure", 5, "round-1"), + envelope(6, "completed", {}, "round-2"), + ]); + serveStream(stub, REF, [() => sse([stateFrame("idle")], "hang")]); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true }); + expect(r.items).toEqual([done({ outcome: "partial_failure", failed: false, response })]); + }); +}); + +// The timeout and Ctrl-C also cover reading the history. +describe("followTask while the history is slow", () => { + function slow(): Stub { + const stub = createStub(); + serveEvents(stub, REF, [created], 200); + serveStream(stub, REF, [() => sse([stateFrame("running")], "hang")]); + return stub; + } + + async function timed(stub: Stub, o: Omit, abortAfterMs?: number) { + const ctrl = new AbortController(); + if (abortAfterMs !== undefined) setTimeout(() => ctrl.abort(), abortAfterMs); + const items: FollowItem[] = []; + const start = performance.now(); + for await (const item of followTask( + stubApi(stub), + { ...o, signal: ctrl.signal }, + { sleep: async () => {} }, + )) { + items.push(item); + } + return { items, ms: performance.now() - start }; + } + + it("times out while reading the history", async () => { + const r = await timed(slow(), { ref: REF, from: "latest", untilDone: true, timeoutMs: 50 }); + expect(r.items).toEqual([{ kind: "timeout" }]); + expect(r.ms).toBeLessThan(150); + }); + + it("stops quietly when interrupted while reading the history", async () => { + const r = await timed(slow(), { ref: REF, from: "latest", untilDone: true }, 20); + expect(r.items).toEqual([]); + expect(r.ms).toBeLessThan(150); + }); + + it("times out while looking for the write's round", async () => { + const r = await timed(slow(), { + ref: REF, + from: { round: roundStart.created() }, + untilDone: true, + timeoutMs: 50, + }); + expect(r.items).toEqual([{ kind: "timeout" }]); + expect(r.ms).toBeLessThan(150); + }); + + it("stops quietly when interrupted while catching up from a cursor", async () => { + const r = await timed(slow(), { ref: REF, from: { after: 0 }, untilDone: true }, 20); + expect(r.items).toEqual([]); + expect(r.ms).toBeLessThan(150); + }); +}); + +// CP sends the task state after each batch of up to 500 events, +// also on a reconnect, so a reconnect's first batch can end with a terminal +// state while later batches hold a newer round. Scaled down: the batch is +// events 2-4. The log starts as event 1 alone and the rest is appended when +// the first connection opens, as if written while the stream was closed, so +// the initial catch-up sees only event 1. +describe("followTask until done, across a reconnect", () => { + const a1 = envelope(1, "task_created", { title: "Fix flaky test" }, "round-a"); + const a2 = envelope(2, "agent_action", { body: "Planning" }, "round-a"); + const a3 = envelope(3, "agent_response", { body: "Done." }, "round-a"); + const a4 = completed("completed", 4, "round-a"); + const b5 = envelope(5, "message_queued", { body: "also the docs" }, "round-b"); + const b6 = envelope(6, "agent_response", { body: "Docs updated." }, "round-b"); + const b7 = completed("partial_failure", 7, "round-b"); + + // `later` is written while the first connection is open; `second` is what + // the reconnect sends before it hangs. + function reconnect(later: ApiEventEnvelope[], second: string[]): Stub { + const stub = createStub(); + const log = [a1]; + serveEvents(stub, REF, log); + serveStream(stub, REF, [ + () => { + log.push(...later); + return sse([stateFrame("running")], "close"); + }, + () => sse(second, "hang"), + ]); + return stub; + } + + const backlog = () => + reconnect([a2, a3, a4, b5, b6, b7], [frame(a2), frame(a3), frame(a4), stateFrame("idle")]); + const requests = (stub: Stub) => stub.requests.map((r) => [r.path, r.lastEventId]); + + it("reads the rest of the log before ending, from a cursor", async () => { + const stub = backlog(); + const r = await run(stub, { ref: REF, from: { after: 0 }, untilDone: true }); + expect(r.items).toEqual([ + ev(a1), + ev(a2), + ev(a3), + ev(a4), + ev(b5), + ev(b6), + ev(b7), + done({ outcome: "partial_failure", failed: false, response: b6 }), + ]); + expect(r.sleeps).toEqual([1000]); + expect(requests(stub)).toEqual([ + [`/api/tasks/${REF}/events?after=0&limit=1000`, null], + [`/api/tasks/${REF}/events?after=1&limit=1000`, null], + [`/api/tasks/${REF}/events/stream`, "1"], + [`/api/tasks/${REF}/events/stream`, "1"], + [`/api/tasks/${REF}/events?after=4&limit=1000`, null], + [`/api/tasks/${REF}/events?after=7&limit=1000`, null], + ]); + }); + + it("reads the rest of the log before ending, from the latest round", async () => { + const stub = backlog(); + const r = await run(stub, { ref: REF, from: "latest", untilDone: true }); + expect(r.items).toEqual([ + ev(a2), + ev(a3), + ev(a4), + ev(b5), + ev(b6), + ev(b7), + done({ outcome: "partial_failure", failed: false, response: b6 }), + ]); + }); + + // The write's round is fixed, so it still reports round A, after printing + // everything from the round's start. + it("reads the rest of the log before ending, from a write's round", async () => { + const stub = backlog(); + const r = await run(stub, { ref: REF, from: { round: roundStart.created() }, untilDone: true }); + expect(r.items).toEqual([ + ev(a2), + ev(a3), + ev(a4), + ev(b5), + ev(b6), + ev(b7), + done({ outcome: "completed", failed: false, response: a3 }), + ]); + }); + + it("skips the events it read when the stream sends them again", async () => { + const stub = reconnect( + [a2, a3, a4, b5, b6], + [ + frame(a2), + frame(a3), + frame(a4), + stateFrame("idle"), + frame(b5), + frame(b6), + frame(b7), + stateFrame("idle"), + ], + ); + const r = await run(stub, { ref: REF, from: { after: 0 }, untilDone: true }); + expect(r.items).toEqual([ + ev(a1), + ev(a2), + ev(a3), + ev(a4), + ev(b5), + ev(b6), + ev(b7), + done({ outcome: "partial_failure", failed: false, response: b6 }), + ]); + }); + + it("times out while reading the rest of the log", async () => { + const stub = createStub(); + let calls = 0; + stub.app.get(`/api/tasks/${REF}/events`, async (c) => { + calls++; + // The initial catch-up's two pages answer at once; the drain hangs. + if (calls > 2) await new Promise(() => {}); + const events = c.req.query("after") === "0" ? [a1] : []; + return c.json({ events, next_after: events.at(-1)?.id ?? 1, logging: "available" }); + }); + serveStream(stub, REF, [ + () => sse([frame(a2), frame(a3), frame(a4), stateFrame("idle")], "hang"), + ]); + const r = await run(stub, { ref: REF, from: { after: 0 }, untilDone: true, timeoutMs: 50 }); + expect(r.items).toEqual([ev(a1), ev(a2), ev(a3), ev(a4), { kind: "timeout" }]); + }); +}); diff --git a/cli/core/__tests__/ops.test.ts b/cli/core/__tests__/ops.test.ts new file mode 100644 index 0000000..ac9c7ab --- /dev/null +++ b/cli/core/__tests__/ops.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "bun:test"; +import { envelope, task } from "../../__tests__/fixtures"; +import { createStub, serveEvents, stubApi } from "../../__tests__/stub"; +import { bindCore } from "../ops"; + +describe("bindCore", () => { + it("binds each operation to the client", async () => { + const stub = createStub(); + stub.app.get("/api/me", (c) => c.json({ token: null, auth: "disabled" })); + stub.app.get("/api/tasks/DBX-API-7", (c) => c.json(task())); + serveEvents(stub, "DBX-API-7", [envelope(1, "task_created", { title: "T" })]); + const ops = bindCore(stubApi(stub), { sleep: async () => {} }); + expect(await ops.whoami()).toEqual({ token: null, auth: "disabled" }); + expect(await ops.showTask("DBX-API-7")).toEqual(task()); + const history: number[] = []; + for await (const e of ops.taskHistory({ ref: "DBX-API-7", after: 0 })) history.push(e.id); + expect(history).toEqual([1]); + }); +}); diff --git a/cli/core/__tests__/sse.test.ts b/cli/core/__tests__/sse.test.ts new file mode 100644 index 0000000..268b9aa --- /dev/null +++ b/cli/core/__tests__/sse.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "bun:test"; +import { envelope } from "../../__tests__/fixtures"; +import { + apiError, + createStub, + frame, + type Stub, + serveStream, + sse, + stateFrame, + stubApi, +} from "../../__tests__/stub"; +import { ApiFailure, isFatal } from "../api"; +import { Backoff, followStream, type SseMessage, SseParser } from "../sse"; + +describe("SseParser", () => { + it("parses event, multi-line data and id; skips comments", () => { + expect( + new SseParser().push( + ": keepalive\n\nevent: agent_response\ndata: line one\ndata: line two\nid: 7\n\n", + ), + ).toEqual([{ event: "agent_response", data: "line one\nline two", id: "7" }]); + }); + + it("defaults the event name; messages without id have no id", () => { + expect(new SseParser().push('data: {"x":1}\n\n')).toEqual([ + { event: "message", data: '{"x":1}' }, + ]); + }); + + it("handles CRLF and CR line endings, including a CRLF split across chunks", () => { + const p = new SseParser(); + expect(p.push("event: a\r\ndata: 1\r")).toEqual([]); + expect(p.push("\n\r\nevent: b\rdata: 2\r\r\n")).toEqual([ + { event: "a", data: "1" }, + { event: "b", data: "2" }, + ]); + }); + + it("strips only one leading space and accepts fields without a colon", () => { + expect(new SseParser().push("data: two spaces\ndata\n\n")).toEqual([ + { event: "message", data: " two spaces\n" }, + ]); + }); + + it("does not dispatch a block cut off before its blank line", () => { + expect(new SseParser().push("event: x\ndata: 1\nid: 9\n")).toEqual([]); + }); + + it("ignores blocks without data", () => { + expect(new SseParser().push("event: x\nid: 3\n\n")).toEqual([]); + }); +}); + +describe("Backoff", () => { + it("doubles from 1s to a 30s cap and resets", () => { + const b = new Backoff(); + expect([1, 2, 3, 4, 5, 6, 7].map(() => b.delay())).toEqual([ + 1000, 2000, 4000, 8000, 16000, 30000, 30000, + ]); + b.reset(); + expect(b.delay()).toBe(1000); + }); +}); + +// Runs followStream against the stub until `count` messages arrived. +async function collect(stub: Stub, count: number, start?: string) { + const api = stubApi(stub); + const sleeps: number[] = []; + const messages: SseMessage[] = []; + for await (const m of followStream({ + open: (id, signal) => api.openStream("DBX-API-7", id, signal), + lastEventId: start, + signal: new AbortController().signal, + sleep: async (ms) => { + sleeps.push(ms); + }, + isFatal, + })) { + messages.push(m); + if (messages.length === count) break; + } + return { messages, sleeps, lastEventIds: stub.requests.map((r) => r.lastEventId) }; +} + +const e = (id: number) => envelope(id, "agent_action", { body: `step ${id}` }); + +describe("followStream", () => { + it("resumes after the last dispatched id; a state event does not move it", async () => { + const stub = createStub(); + serveStream(stub, "DBX-API-7", [ + () => sse([frame(e(1)), stateFrame("running")], "close"), + // Dropped mid-block: id 3 was never dispatched. + () => sse([frame(e(2)), "event: agent_action\ndata: {}\nid: 3\n"], "error"), + () => sse([frame(e(3))], "hang"), + ]); + + const { messages, lastEventIds } = await collect(stub, 4, "0"); + expect(messages.map((m) => m.id ?? m.event)).toEqual(["1", "state", "2", "3"]); + expect(lastEventIds).toEqual(["0", "1", "2"]); + }); + + it("backs off on failures and resets after an event", async () => { + const stub = createStub(); + serveStream(stub, "DBX-API-7", [ + () => apiError(503, "internal", "down"), + () => new Response("bad gateway", { status: 502 }), + () => sse([frame(e(1))], "close"), + () => sse([], "close"), + () => sse([frame(e(2))], "hang"), + ]); + + const { sleeps } = await collect(stub, 2); + expect(sleeps).toEqual([1000, 2000, 1000, 2000]); + }); + + it.each([ + [401, "unauthorized"], + [404, "task_not_found"], + [409, "not_api_task"], + ])("does not retry %i", async (status, code) => { + const stub = createStub(); + serveStream(stub, "DBX-API-7", [() => apiError(status, code, "no")]); + const err = await collect(stub, 1).catch((x: unknown) => x); + expect(err).toBeInstanceOf(ApiFailure); + expect((err as ApiFailure).status).toBe(status); + expect(stub.requests.length).toBe(1); + }); + + // A multi-byte character can be split across two chunks. + it("decodes UTF-8 split across chunks", async () => { + const bytes = new TextEncoder().encode( + frame(envelope(1, "agent_response", { body: "✉ done" })), + ); + const cut = bytes.indexOf(0xe2) + 1; // inside the 3-byte "✉" + const stub = createStub(); + serveStream(stub, "DBX-API-7", [() => sse([bytes.slice(0, cut), bytes.slice(cut)], "hang")]); + + const { messages } = await collect(stub, 1); + expect(JSON.parse(messages[0]?.data ?? "{}").data).toEqual({ body: "✉ done" }); + }); + + it("ends when the signal aborts, also while a read is pending", async () => { + const stub = createStub(); + serveStream(stub, "DBX-API-7", [() => sse([frame(e(1))], "hang")]); + const api = stubApi(stub); + const ctrl = new AbortController(); + const seen: string[] = []; + for await (const m of followStream({ + open: (id, signal) => api.openStream("DBX-API-7", id, signal), + signal: ctrl.signal, + sleep: async () => {}, + isFatal, + })) { + seen.push(m.id ?? ""); + setTimeout(() => ctrl.abort(), 0); + } + expect(seen).toEqual(["1"]); + }); +}); diff --git a/cli/core/__tests__/tasks.test.ts b/cli/core/__tests__/tasks.test.ts new file mode 100644 index 0000000..607a7a5 --- /dev/null +++ b/cli/core/__tests__/tasks.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "bun:test"; +import { task } from "../../__tests__/fixtures"; +import { apiError, createStub, stubApi } from "../../__tests__/stub"; +import { ApiFailure } from "../api"; +import { roundStart } from "../events"; +import { createTask, listTasks, restartTask, sendMessage, showTask } from "../tasks"; + +describe("listTasks", () => { + const page = (keys: string[], next: string | null) => ({ + tasks: keys.map((key) => task({ key, id: `id-${key}` })), + next_before: next, + }); + + it("follows next_before until the last page", async () => { + const stub = createStub(); + stub.app.get("/api/tasks", (c) => { + const before = c.req.query("before"); + if (!before) return c.json(page(["DBX-API-3"], "id-DBX-API-3")); + if (before === "id-DBX-API-3") return c.json(page(["DBX-API-2"], "id-DBX-API-2")); + return c.json(page(["DBX-API-1"], null)); + }); + const tasks = await listTasks(stubApi(stub), { source: "api", mine: true, state: "idle" }); + expect(tasks.map((t) => t.key)).toEqual(["DBX-API-3", "DBX-API-2", "DBX-API-1"]); + expect(stub.requests.map((r) => r.path)).toEqual([ + "/api/tasks?source=api&created_by=me&state=idle&limit=100", + "/api/tasks?source=api&created_by=me&state=idle&limit=100&before=id-DBX-API-3", + "/api/tasks?source=api&created_by=me&state=idle&limit=100&before=id-DBX-API-2", + ]); + }); + + it("stops at the limit and sizes the last request", async () => { + const stub = createStub(); + stub.app.get("/api/tasks", (c) => { + const limit = Number(c.req.query("limit")); + const keys = Array.from( + { length: limit }, + (_, i) => `K-${c.req.query("before") ?? "a"}-${i}`, + ); + return c.json(page(keys, `next-${limit}`)); + }); + const tasks = await listTasks(stubApi(stub), { mine: false, limit: 150 }); + expect(tasks).toHaveLength(150); + expect(stub.requests.map((r) => r.path)).toEqual([ + "/api/tasks?limit=100", + "/api/tasks?limit=50&before=next-100", + ]); + }); +}); + +describe("showTask", () => { + it("gets the task by ref", async () => { + const stub = createStub(); + stub.app.get("/api/tasks/ABC-1", (c) => c.json(task({ key: "ABC-1", source: "linear" }))); + expect(await showTask(stubApi(stub), "ABC-1")).toEqual( + task({ key: "ABC-1", source: "linear" }), + ); + }); +}); + +describe("createTask", () => { + it("posts only the given fields", async () => { + const stub = createStub(); + stub.app.post("/api/tasks", (c) => c.json(task(), 201)); + const api = stubApi(stub); + + expect(await createTask(api, { title: "Fix flaky test", experimentalFlags: [] })).toEqual({ + task: task(), + from: { round: roundStart.created() }, + }); + await createTask(api, { title: "T", description: "D", experimentalFlags: ["FOO"] }); + expect(stub.requests.map((r) => r.body)).toEqual([ + { title: "Fix flaky test" }, + { title: "T", description: "D", experimental_flags: ["FOO"] }, + ]); + }); +}); + +describe("sendMessage", () => { + it("posts the body", async () => { + const stub = createStub(); + stub.app.post("/api/tasks/DBX-API-7/messages", (c) => c.json({ round_id: "round-2" }, 202)); + expect(await sendMessage(stubApi(stub), { ref: "DBX-API-7", body: "also the docs" })).toEqual({ + message: { round_id: "round-2" }, + from: { round: roundStart.message("round-2") }, + }); + expect(stub.requests[0]?.body).toEqual({ body: "also the docs" }); + }); +}); + +describe("restartTask", () => { + it("restarts with the current definition, overrides applied, by the task's key", async () => { + const stub = createStub(); + stub.app.get("/api/tasks/dbx-api-7", (c) => c.json(task({ experimental_flags: ["OLD"] }))); + stub.app.post("/api/tasks/DBX-API-7/restart", (c) => c.json(task(), 202)); + + expect( + await restartTask(stubApi(stub), { ref: "dbx-api-7", experimentalFlags: ["NEW"] }), + ).toEqual({ + task: task(), + from: { round: roundStart.restarted() }, + }); + expect(stub.requests.map((r) => [r.method, r.path, r.body])).toEqual([ + ["GET", "/api/tasks/dbx-api-7", undefined], + [ + "POST", + "/api/tasks/DBX-API-7/restart", + { + title: "Fix flaky test", + description: "The test fails on CI.", + experimental_flags: ["NEW"], + }, + ], + ]); + }); + + it("replaces title and description and keeps the flags", async () => { + const stub = createStub(); + stub.app.get("/api/tasks/DBX-API-7", (c) => c.json(task({ experimental_flags: ["KEEP"] }))); + stub.app.post("/api/tasks/DBX-API-7/restart", (c) => c.json(task(), 202)); + await restartTask(stubApi(stub), { ref: "DBX-API-7", title: "New", description: "New text" }); + expect(stub.requests[1]?.body).toEqual({ + title: "New", + description: "New text", + experimental_flags: ["KEEP"], + }); + }); + + it("passes the API's 409 for a Linear task through", async () => { + const stub = createStub(); + stub.app.get("/api/tasks/ABC-1", (c) => c.json(task({ key: "ABC-1", source: "linear" }))); + stub.app.post("/api/tasks/ABC-1/restart", () => + apiError(409, "not_api_task", "ABC-1 is a Linear task"), + ); + const err = await restartTask(stubApi(stub), { ref: "ABC-1" }).catch((x: unknown) => x); + expect(err).toBeInstanceOf(ApiFailure); + expect((err as ApiFailure).code).toBe("not_api_task"); + }); +}); diff --git a/cli/core/account.ts b/cli/core/account.ts new file mode 100644 index 0000000..9824848 --- /dev/null +++ b/cli/core/account.ts @@ -0,0 +1,7 @@ +// Account operations: who the API says the token is. +import type { MeResponse } from "../../control-plane/src/types/api"; +import type { ApiClient } from "./api"; + +export function whoami(api: ApiClient): Promise { + return api.me(); +} diff --git a/cli/core/api.ts b/cli/core/api.ts new file mode 100644 index 0000000..bbb6512 --- /dev/null +++ b/cli/core/api.ts @@ -0,0 +1,190 @@ +// HTTP transport for the CP API: one method per endpoint and nothing else; +// the operations in tasks.ts, account.ts and events.ts build on it. Errors +// become ApiFailure, with `status` null when the API can't be reached. The token +// goes only into the Authorization header, never into a message. +import type { + ApiErrorBody, + ApiTask, + ApiTaskSource, + ApiTaskState, + CreateTaskRequest, + ListEventsResponse, + ListTasksResponse, + MeResponse, + RestartTaskRequest, + SendMessageRequest, + SendMessageResponse, +} from "../../control-plane/src/types/api"; + +export class ApiFailure extends Error { + constructor( + readonly status: number | null, + readonly code: string, + message: string, + ) { + super(message); + } +} + +// 4xx answers, including 401, 404 and 409, won't change on retry. Nor will an +// answer that isn't the API's (e.g. an SSO login page). +export const isFatal = (err: unknown): boolean => + err instanceof ApiFailure && + (err.code === "invalid_response" || + (err.status !== null && err.status >= 400 && err.status < 500)); + +export type ClientConfig = { url: string; token?: string }; + +export type ListTasksQuery = { + source?: ApiTaskSource; + created_by?: string; + state?: ApiTaskState; + limit: number; + before?: string; +}; + +const taskPath = (ref: string) => `/api/tasks/${encodeURIComponent(ref)}`; + +export class ApiClient { + constructor( + private readonly config: ClientConfig, + private readonly fetchFn: typeof fetch, + ) {} + + me(): Promise { + return this.json("GET", "/api/me"); + } + + createTask(body: CreateTaskRequest): Promise { + return this.json("POST", "/api/tasks", body); + } + + listTasks(q: ListTasksQuery): Promise { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(q)) if (v !== undefined) params.set(k, String(v)); + return this.json("GET", `/api/tasks?${params}`); + } + + getTask(ref: string): Promise { + return this.json("GET", taskPath(ref)); + } + + sendMessage(ref: string, body: SendMessageRequest): Promise { + return this.json("POST", `${taskPath(ref)}/messages`, body); + } + + restartTask(ref: string, body: RestartTaskRequest): Promise { + return this.json("POST", `${taskPath(ref)}/restart`, body); + } + + // `signal` also aborts reading the body. + listEvents( + ref: string, + after: number, + limit: number, + signal?: AbortSignal, + ): Promise { + return this.json( + "GET", + `${taskPath(ref)}/events?after=${after}&limit=${limit}`, + undefined, + signal, + ); + } + + async openStream( + ref: string, + lastEventId: string | undefined, + signal: AbortSignal, + ): Promise> { + const headers: Record = { accept: "text/event-stream" }; + if (lastEventId !== undefined) headers["last-event-id"] = lastEventId; + const res = await this.send( + "GET", + `${taskPath(ref)}/events/stream`, + undefined, + headers, + signal, + ); + if (!res.headers.get("content-type")?.startsWith("text/event-stream")) { + throw this.invalid(res, "not an event stream"); + } + if (!res.body) throw new ApiFailure(null, "unreachable", "the event stream has no body"); + return res.body; + } + + private async json( + method: string, + path: string, + body?: unknown, + signal?: AbortSignal, + ): Promise { + const res = await this.send(method, path, body, {}, signal); + try { + return (await res.json()) as T; + } catch (err) { + if (signal?.aborted) throw err; + throw this.invalid(res, "not JSON"); + } + } + + // A 2xx answer that isn't the API's. Only the URL goes into the message. + private invalid(res: Response, what: string): ApiFailure { + return new ApiFailure( + res.status, + "invalid_response", + `unexpected response from ${this.config.url}: ${what}`, + ); + } + + private async send( + method: string, + path: string, + body?: unknown, + headers: Record = {}, + signal?: AbortSignal, + ): Promise { + const all = { ...headers }; + if (this.config.token) all.authorization = `Bearer ${this.config.token}`; + if (body !== undefined) all["content-type"] = "application/json"; + let res: Response; + try { + res = await this.fetchFn(`${this.config.url}${path}`, { + method, + headers: all, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + } catch (err) { + if (signal?.aborted) throw err; + throw new ApiFailure( + null, + "unreachable", + `cannot reach ${this.config.url}: ${(err as Error).message}`, + ); + } + if (!res.ok) throw await this.failure(res); + return res; + } + + private async failure(res: Response): Promise { + const text = await res.text(); + try { + const { error } = JSON.parse(text) as Partial; + if (error?.code) { + return new ApiFailure( + res.status, + error.code, + `${error.message} (${res.status} ${error.code})`, + ); + } + } catch { + // Not an API error body (e.g. the ALB's HTML page). + } + return new ApiFailure( + res.status, + `http_${res.status}`, + `HTTP ${res.status} from ${this.config.url}`, + ); + } +} diff --git a/cli/core/events.ts b/cli/core/events.ts new file mode 100644 index 0000000..94b4eeb --- /dev/null +++ b/cli/core/events.ts @@ -0,0 +1,294 @@ +// The task event log: reading the history and following the live stream. +import type { ApiEventEnvelope, ApiRoundOutcome } from "../../control-plane/src/types/api"; +import { type ApiClient, ApiFailure, isFatal } from "./api"; +import { MAX_TIMEOUT_MS } from "./limits"; +import { followStream, type Sleep } from "./sse"; + +export class NoEventLog extends Error { + constructor(ref: string) { + super(`${ref} has no event log (only API tasks are logged)`); + } +} + +// Where a write's round starts: the CP event the write committed. Only core reads its fields. +export type RoundStart = { + readonly type: "task_created" | "message_queued" | "task_restarted"; + readonly roundId?: string; + readonly pick: "first" | "last"; +}; +export const roundStart = { + created: (): RoundStart => ({ type: "task_created", pick: "first" }), + message: (roundId: string): RoundStart => ({ type: "message_queued", roundId, pick: "first" }), + restarted: (): RoundStart => ({ type: "task_restarted", pick: "last" }), +}; +export type From = { round: RoundStart } | { after: number } | "latest"; +export type HistoryOptions = { ref: string; after: number; signal?: AbortSignal }; +export type FollowOptions = { + ref: string; + from: From; + untilDone?: boolean; + timeoutMs?: number; + signal: AbortSignal; +}; +export type RoundResult = { + outcome: ApiRoundOutcome | null; + failed: boolean; + response?: ApiEventEnvelope; + reason?: string; +}; +export type FollowItem = + | { kind: "event"; event: ApiEventEnvelope } + | { kind: "done"; result: RoundResult } + | { kind: "timeout" }; +export type CoreDeps = { sleep: Sleep }; + +const PAGE_SIZE = 1000; + +export async function* taskHistory( + api: ApiClient, + o: HistoryOptions, +): AsyncGenerator { + let cursor = o.after; + for (;;) { + const page = await api.listEvents(o.ref, cursor, PAGE_SIZE, o.signal); + if (page.logging === "unavailable") throw new NoEventLog(o.ref); + if (page.events.length === 0) return; + yield* page.events; + cursor = page.next_after; + } +} + +const ROUND_EVENTS = new Set([ + "task_created", + "message_queued", + "task_restarted", + "agent_thought", + "agent_action", + "agent_response", + "agent_error", + "pr_opened", + "completed", + "round_failed", +]); + +// `superseded`: a restart ended the round, which is final whatever the task's state. +type RoundEnd = { + outcome: ApiRoundOutcome | null; + failed: boolean; + reason?: string; + superseded?: boolean; +}; + +const SUPERSEDED: RoundEnd = { + outcome: null, + failed: true, + reason: "superseded by a restart", + superseded: true, +}; + +// Follows the rounds in an event stream to tell when the latest one is over. +class RoundTracker { + private latest: string | null = null; + // The round of the last task_created or task_restarted. A restart gives the + // assignment a new round id and doesn't list the old one as superseded. + private assignment: string | null = null; + + // `fixed` pins the round to report; without it, the latest one is reported. + constructor(private readonly fixed: string | null = null) {} + + private readonly responses = new Map(); + private readonly errors = new Map(); + private readonly ended = new Map(); + // Rounds that have had a round event other than `completed`. + private readonly worked = new Set(); + + observe(e: ApiEventEnvelope): void { + const data = (e.data ?? {}) as Record; + if (e.type === "task_restarted" && Array.isArray(data.superseded_round_ids)) { + for (const id of data.superseded_round_ids) this.ended.set(String(id), SUPERSEDED); + } + if (e.round_id === null || !ROUND_EVENTS.has(e.type)) return; + const round = e.round_id; + // A status-less `completed` for a round with no other round event did no + // work. Such a `completed` neither moves the latest round nor ends one. + if (e.type === "completed" && round !== this.fixed && !this.worked.has(round)) return; + if (e.type !== "completed") this.worked.add(round); + if (e.type === "task_created" || e.type === "task_restarted") { + const previous = this.assignment; + if ( + e.type === "task_restarted" && + previous !== null && + previous !== round && + !this.ended.has(previous) + ) { + this.ended.set(previous, SUPERSEDED); + } + this.assignment = round; + } + this.latest = round; + if (e.type === "agent_response") { + this.responses.set(round, e); + } else if (e.type === "agent_error") { + this.errors.set(round, typeof data.body === "string" ? data.body : ""); + } else if (e.type === "round_failed") { + this.ended.set(round, { + outcome: null, + failed: true, + reason: + typeof data.reason === "string" && data.reason !== "" ? data.reason : "the round failed", + }); + } else if (e.type === "completed") { + const outcome = (data.status as ApiRoundOutcome | undefined) ?? null; + this.ended.set(round, { + outcome, + failed: outcome === "failed", + reason: this.errors.get(round) ?? "the round failed", + }); + } + } + + // The target round's result once it is final, else undefined. + result(state: string | undefined): RoundResult | undefined { + const settled = state === "idle" || state === "failed"; + const target = this.fixed ?? this.latest; + // No round seen yet: the task's state alone doesn't say how a round ended. + if (target === null) return undefined; + const end = this.ended.get(target); + // A superseded round is over at once. Other ends wait for the task to + // settle: CP marks the round responded before it logs `completed`, so the + // task can be idle while the round's end is still on its way. + if (!end || (!end.superseded && !settled)) return undefined; + const result: RoundResult = { + outcome: end.outcome, + failed: end.failed, + response: this.responses.get(target), + }; + if (end.failed) result.reason = end.reason; + return result; + } +} + +// The event the write committed, with a non-null round id. +async function findRoundStart( + api: ApiClient, + ref: string, + start: RoundStart, + signal: AbortSignal, +): Promise { + let match: ApiEventEnvelope | undefined; + for await (const e of taskHistory(api, { ref, after: 0, signal })) { + if (e.type !== start.type) continue; + if (start.roundId !== undefined && e.round_id !== start.roundId) continue; + match = e; + if (start.pick === "first") break; + } + if (!match || match.round_id === null) { + throw new ApiFailure(null, "event_not_found", `no ${start.type} event in the log of ${ref}`); + } + return { ...match, round_id: match.round_id }; +} + +// Reads the log after `after` into `tracker` and returns the last event's id +// (`after` for no events). Events after `printAfter` are yielded; without it, +// none are. +async function* readLog( + api: ApiClient, + ref: string, + after: number, + tracker: RoundTracker, + signal: AbortSignal, + printAfter?: number, +): AsyncGenerator { + let last = after; + for await (const event of taskHistory(api, { ref, after, signal })) { + tracker.observe(event); + last = event.id; + if (printAfter !== undefined && event.id > printAfter) yield { kind: "event", event }; + } + return last; +} + +export async function* followTask( + api: ApiClient, + o: FollowOptions, + deps: CoreDeps, +): AsyncGenerator { + if (o.timeoutMs !== undefined && !(o.timeoutMs >= 1 && o.timeoutMs <= MAX_TIMEOUT_MS)) { + throw new RangeError(`timeoutMs must be between 1 and ${MAX_TIMEOUT_MS}`); + } + // The deadline and Ctrl-C cover reading the history too. + const timeout = new AbortController(); + const timer = + o.timeoutMs === undefined ? undefined : setTimeout(() => timeout.abort(), o.timeoutMs); + const signal = AbortSignal.any([o.signal, timeout.signal]); + const timedOut = () => timeout.signal.aborted && !o.signal.aborted; + try { + let tracker = new RoundTracker(); + let cursor: number; + try { + if (o.from === "latest") { + cursor = yield* readLog(api, o.ref, 0, tracker, signal); + } else if ("round" in o.from) { + const match = await findRoundStart(api, o.ref, o.from.round, signal); + tracker = new RoundTracker(match.round_id); + cursor = match.id - 1; + } else if (o.untilDone) { + // CP sends the task's state after every batch of history, so a state + // seen mid-replay can belong to an older round: read the whole log + // first, including the rounds before the cursor, which can hold the + // outcome. + const after = o.from.after; + cursor = Math.max(yield* readLog(api, o.ref, 0, tracker, signal, after), after); + } else { + cursor = o.from.after; + } + } catch (err) { + // fetch's AbortError: the deadline passed or Ctrl-C. + if (!signal.aborted) throw err; + if (timedOut()) yield { kind: "timeout" }; + return; + } + // The highest event id read so far. The stream resends events a drain + // below already read; those are skipped. + let seen = cursor; + let state: string | undefined; + for await (const message of followStream({ + open: (lastEventId, connSignal) => api.openStream(o.ref, lastEventId, connSignal), + lastEventId: String(cursor), + signal, + sleep: deps.sleep, + isFatal, + })) { + if (message.event === "logging") throw new NoEventLog(o.ref); + if (message.event === "state") { + state = (JSON.parse(message.data) as { state: string }).state; + } else { + const event = JSON.parse(message.data) as ApiEventEnvelope; + if (event.id <= seen) continue; + seen = event.id; + tracker.observe(event); + yield { kind: "event", event }; + } + if (!o.untilDone || !tracker.result(state)) continue; + // CP sends the task's state after every batch of up to 500 events, also + // when a reconnect replays what was logged while the stream was down, so + // the state can arrive before a newer round's events. Read the rest of + // the log before accepting the result. + try { + seen = yield* readLog(api, o.ref, seen, tracker, signal, seen); + } catch (err) { + if (!signal.aborted) throw err; + if (timedOut()) yield { kind: "timeout" }; + return; + } + const result = tracker.result(state); + if (result) { + yield { kind: "done", result }; + return; + } + } + if (timedOut()) yield { kind: "timeout" }; + } finally { + clearTimeout(timer); + } +} diff --git a/cli/core/limits.ts b/cli/core/limits.ts new file mode 100644 index 0000000..00bc7d2 --- /dev/null +++ b/cli/core/limits.ts @@ -0,0 +1,5 @@ +// Limits shared by core operations and the interface's validation. + +// Timers can't wait longer than 2^31-1 ms (about 24.8 days): larger delays +// fire at once. followTask rejects a longer timeout; the CLI rejects it first. +export const MAX_TIMEOUT_MS = 2 ** 31 - 1; diff --git a/cli/core/ops.ts b/cli/core/ops.ts new file mode 100644 index 0000000..e191abf --- /dev/null +++ b/cli/core/ops.ts @@ -0,0 +1,50 @@ +// The core's operations bound to an API client; the interface layer calls only these. +import type { ApiEventEnvelope, ApiTask, MeResponse } from "../../control-plane/src/types/api"; +import { whoami } from "./account"; +import type { ApiClient } from "./api"; +import { + type CoreDeps, + type FollowItem, + type FollowOptions, + followTask, + type HistoryOptions, + taskHistory, +} from "./events"; +import { + type CreatedTask, + type CreateTaskOptions, + createTask, + type ListTasksOptions, + listTasks, + type RestartedTask, + type RestartTaskOptions, + restartTask, + type SendMessageOptions, + type SentMessage, + sendMessage, + showTask, +} from "./tasks"; + +export type CoreOps = { + whoami(): Promise; + listTasks(o: ListTasksOptions): Promise; + showTask(ref: string): Promise; + createTask(o: CreateTaskOptions): Promise; + sendMessage(o: SendMessageOptions): Promise; + restartTask(o: RestartTaskOptions): Promise; + taskHistory(o: HistoryOptions): AsyncGenerator; + followTask(o: FollowOptions): AsyncGenerator; +}; + +export function bindCore(api: ApiClient, deps: CoreDeps): CoreOps { + return { + whoami: () => whoami(api), + listTasks: (o) => listTasks(api, o), + showTask: (ref) => showTask(api, ref), + createTask: (o) => createTask(api, o), + sendMessage: (o) => sendMessage(api, o), + restartTask: (o) => restartTask(api, o), + taskHistory: (o) => taskHistory(api, o), + followTask: (o) => followTask(api, o, deps), + }; +} diff --git a/cli/core/sse.ts b/cli/core/sse.ts new file mode 100644 index 0000000..042c9d6 --- /dev/null +++ b/cli/core/sse.ts @@ -0,0 +1,128 @@ +// Server-Sent Events for /api/tasks/:ref/events/stream: an incremental parser, +// and a follower that reconnects with Last-Event-ID. + +export type SseMessage = { event: string; data: string; id?: string }; + +// text/event-stream parser (WHATWG HTML, "Server-sent events"): lines end in +// LF, CRLF or CR; ":" lines are comments; data lines join with "\n"; a blank +// line dispatches. A block cut off by a dropped connection is never +// dispatched, so its id never becomes the resume point. +export class SseParser { + private buffer = ""; + private data: string[] = []; + private event = ""; + private id: string | undefined; + + push(chunk: string): SseMessage[] { + this.buffer += chunk; + const messages: SseMessage[] = []; + for (;;) { + const end = /\r\n|\r|\n/.exec(this.buffer); + // A trailing CR may be the first half of a CRLF split across chunks. + if (!end || (end[0] === "\r" && end.index === this.buffer.length - 1)) break; + const line = this.buffer.slice(0, end.index); + this.buffer = this.buffer.slice(end.index + end[0].length); + const message = this.line(line); + if (message) messages.push(message); + } + return messages; + } + + private line(line: string): SseMessage | undefined { + if (line === "") return this.dispatch(); + if (line.startsWith(":")) return undefined; + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + const raw = colon === -1 ? "" : line.slice(colon + 1); + const value = raw.startsWith(" ") ? raw.slice(1) : raw; + if (field === "data") this.data.push(value); + else if (field === "event") this.event = value; + else if (field === "id" && !value.includes("\0")) this.id = value; + return undefined; + } + + private dispatch(): SseMessage | undefined { + const message: SseMessage | undefined = + this.data.length === 0 + ? undefined + : { + event: this.event || "message", + data: this.data.join("\n"), + ...(this.id === undefined ? {} : { id: this.id }), + }; + this.data = []; + this.event = ""; + this.id = undefined; + return message; + } +} + +export const BACKOFF_START_MS = 1000; +export const BACKOFF_CAP_MS = 30_000; + +// 1s doubling to a 30s cap; reset() after any event. +export class Backoff { + private next = BACKOFF_START_MS; + + delay(): number { + const d = this.next; + this.next = Math.min(d * 2, BACKOFF_CAP_MS); + return d; + } + + reset(): void { + this.next = BACKOFF_START_MS; + } +} + +export type Sleep = (ms: number, signal: AbortSignal) => Promise; + +export type FollowStreamOptions = { + open: ( + lastEventId: string | undefined, + signal: AbortSignal, + ) => Promise>; + lastEventId?: string; + signal: AbortSignal; + sleep: Sleep; + isFatal: (err: unknown) => boolean; +}; + +// Yields messages across reconnects until `signal` aborts or a fatal error is +// thrown. The server ends streams at its max duration and on shutdown, and +// networks drop them; every reconnect resumes after the last dispatched id. +export async function* followStream(o: FollowStreamOptions): AsyncGenerator { + const backoff = new Backoff(); + let lastEventId = o.lastEventId; + while (!o.signal.aborted) { + const conn = new AbortController(); + const abort = () => conn.abort(); + o.signal.addEventListener("abort", abort, { once: true }); + try { + const reader = (await o.open(lastEventId, conn.signal)).getReader(); + // End a pending read on abort, even when the transport ignores the signal. + conn.signal.addEventListener("abort", () => void reader.cancel().catch(() => {}), { + once: true, + }); + const parser = new SseParser(); + const decoder = new TextDecoder(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + for (const message of parser.push(decoder.decode(value, { stream: true }))) { + if (message.id !== undefined) lastEventId = message.id; + backoff.reset(); + yield message; + } + } + } catch (err) { + if (o.signal.aborted) return; + if (o.isFatal(err)) throw err; + } finally { + o.signal.removeEventListener("abort", abort); + conn.abort(); + } + if (o.signal.aborted) return; + await o.sleep(backoff.delay(), o.signal); + } +} diff --git a/cli/core/tasks.ts b/cli/core/tasks.ts new file mode 100644 index 0000000..0a436f6 --- /dev/null +++ b/cli/core/tasks.ts @@ -0,0 +1,86 @@ +// Task operations. Every command's logic lives in core, even when it is one +// API call today, so new behaviour has one obvious home. +import type { + ApiTask, + ApiTaskSource, + ApiTaskState, + CreateTaskRequest, + SendMessageResponse, +} from "../../control-plane/src/types/api"; +import type { ApiClient } from "./api"; +import { type From, roundStart } from "./events"; + +export type ListTasksOptions = { + source?: ApiTaskSource; + mine: boolean; + state?: ApiTaskState; + limit?: number; +}; + +const PAGE_SIZE = 100; + +// Pages through the list until every task (or `o.limit` of them) is collected. +export async function listTasks(api: ApiClient, o: ListTasksOptions): Promise { + const tasks: ApiTask[] = []; + let before: string | undefined; + while (o.limit === undefined || tasks.length < o.limit) { + const page = await api.listTasks({ + source: o.source, + created_by: o.mine ? "me" : undefined, + state: o.state, + limit: o.limit === undefined ? PAGE_SIZE : Math.min(PAGE_SIZE, o.limit - tasks.length), + before, + }); + tasks.push(...page.tasks); + if (page.next_before === null) break; + before = page.next_before; + } + return tasks; +} + +export function showTask(api: ApiClient, ref: string): Promise { + return api.getTask(ref); +} + +export type CreateTaskOptions = { + title: string; + description?: string; + experimentalFlags: string[]; +}; +export type SendMessageOptions = { ref: string; body: string }; +export type RestartTaskOptions = { + ref: string; + title?: string; + description?: string; + experimentalFlags?: string[]; +}; +export type CreatedTask = { task: ApiTask; from: From }; +export type SentMessage = { message: SendMessageResponse; from: From }; +export type RestartedTask = { task: ApiTask; from: From }; + +export async function createTask(api: ApiClient, o: CreateTaskOptions): Promise { + const body: CreateTaskRequest = { title: o.title }; + if (o.description !== undefined) body.description = o.description; + if (o.experimentalFlags.length > 0) body.experimental_flags = o.experimentalFlags; + return { task: await api.createTask(body), from: { round: roundStart.created() } }; +} + +export async function sendMessage(api: ApiClient, o: SendMessageOptions): Promise { + const message = await api.sendMessage(o.ref, { body: o.body }); + return { message, from: { round: roundStart.message(message.round_id) } }; +} + +// Restart takes the whole definition, so the current one is fetched and the +// overrides laid over it. The restart goes by the fetched key, which is the +// canonical form of whatever ref the caller typed. +export async function restartTask(api: ApiClient, o: RestartTaskOptions): Promise { + const current = await api.getTask(o.ref); + const task = await api.restartTask(current.key, { + title: o.title ?? current.title, + description: o.description ?? current.description, + experimental_flags: o.experimentalFlags ?? current.experimental_flags, + }); + // The restart has committed before anything reads the log, so the last + // task_restarted is this restart's (or a later one's: the last restart wins). + return { task, from: { round: roundStart.restarted() } }; +} diff --git a/cli/devbox.ts b/cli/devbox.ts new file mode 100644 index 0000000..4b10c0a --- /dev/null +++ b/cli/devbox.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env bun +// devbox: a CLI over the control-plane HTTP API. Wires the real core into the +// interface. Config comes from flags, env, then a profile in the +// config file `bun run up` and `bun run k8s:install` write; the CLI writes only +// the config file, through `devbox profile`. +import { ApiClient } from "./core/api"; +import { bindCore } from "./core/ops"; +import { realConfigFs } from "./interface/configFile"; +import { EXIT } from "./interface/exit"; +import { runCli } from "./interface/main"; +import { bunVersionWarning } from "./interface/runtime"; + +const interrupt = new AbortController(); +process.on("SIGINT", () => { + if (interrupt.signal.aborted) process.exit(EXIT.interrupted); + interrupt.abort(); + // Safety net if something ignores the signal (e.g. a pending stdin read). + setTimeout(() => process.exit(EXIT.interrupted), 1000).unref(); +}); + +const sleep = (ms: number, signal: AbortSignal) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +const warning = bunVersionWarning(Bun.version); +if (warning) process.stderr.write(`${warning}\n`); + +process.exitCode = await runCli( + process.argv.slice(2), + { + out: (line) => process.stdout.write(`${line}\n`), + err: (line) => process.stderr.write(`${line}\n`), + readStdin: () => Bun.stdin.text(), + readFile: (path) => Bun.file(path).text(), + env: process.env, + configFs: realConfigFs, + isTTY: process.stdout.isTTY === true, + now: () => Date.now(), + interrupt: interrupt.signal, + }, + (config) => bindCore(new ApiClient(config, fetch), { sleep }), +); diff --git a/cli/interface/__tests__/config.test.ts b/cli/interface/__tests__/config.test.ts new file mode 100644 index 0000000..e205827 --- /dev/null +++ b/cli/interface/__tests__/config.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "bun:test"; +import { UsageError } from "../command"; +import { resolveConfig } from "../config"; +import type { ProfileFile } from "../profiles"; + +const PATH = "/home/me/.config/devbox/config.json"; +const FILE: ProfileFile = { + current: "local", + profiles: { + local: { url: "http://localhost:3000/", token: "dbx_local" }, + "k8s-eks": { url: "http://localhost:13000", token: "dbx_eks" }, + }, +}; +const file = (f: ProfileFile | undefined) => ({ path: PATH, load: () => f }); +const noRead = { + path: PATH, + load: (): ProfileFile | undefined => { + throw new Error("must not read the file"); + }, +}; + +describe("resolveConfig", () => { + it("uses --url and --token without reading the file", () => { + expect(resolveConfig({ url: "http://cp/", token: "t" }, {}, noRead)).toEqual({ + client: { url: "http://cp", token: "t" }, + }); + }); + + it("uses DEVBOX_CLI_URL and DEVBOX_CLI_TOKEN without reading the file", () => { + expect( + resolveConfig({}, { DEVBOX_CLI_URL: "http://cp", DEVBOX_CLI_TOKEN: "t" }, noRead), + ).toEqual({ client: { url: "http://cp", token: "t" } }); + }); + + it("uses the current profile", () => { + expect(resolveConfig({}, {}, file(FILE))).toEqual({ + client: { url: "http://localhost:3000", token: "dbx_local" }, + tokenFrom: { path: PATH, profile: "local" }, + }); + }); + + it("--profile beats DEVBOX_CLI_PROFILE, which beats current", () => { + expect(resolveConfig({}, { DEVBOX_CLI_PROFILE: "k8s-eks" }, file(FILE)).client.url).toBe( + "http://localhost:13000", + ); + expect( + resolveConfig({ profile: "local" }, { DEVBOX_CLI_PROFILE: "k8s-eks" }, file(FILE)).client.url, + ).toBe("http://localhost:3000"); + }); + + it("--token overrides the profile's token", () => { + expect(resolveConfig({ token: "t" }, {}, file(FILE))).toEqual({ + client: { url: "http://localhost:3000", token: "t" }, + }); + }); + + it("names the profiles when the requested one is missing", () => { + expect(() => resolveConfig({ profile: "gke" }, {}, file(FILE))).toThrow( + new UsageError(`no profile "gke" in ${PATH} (profiles: k8s-eks, local)`), + ); + }); + + it("treats prototype names as missing profiles", () => { + const missing = new UsageError(`no profile "toString" in ${PATH} (profiles: k8s-eks, local)`); + expect(() => resolveConfig({ profile: "toString" }, {}, file(FILE))).toThrow(missing); + expect(() => resolveConfig({}, { DEVBOX_CLI_PROFILE: "toString" }, file(FILE))).toThrow( + missing, + ); + }); + + it("asks for `devbox profile use` when none is current", () => { + expect(() => resolveConfig({}, {}, file({ ...FILE, current: undefined }))).toThrow( + new UsageError( + `no current profile in ${PATH}: run \`devbox profile use \` (profiles: k8s-eks, local)`, + ), + ); + }); + + it("says how to log in when there is no file or no profile", () => { + const message = `no API URL and no profile in ${PATH}: run \`bun run up\` or \`bun run k8s:install\`, or pass --url/--token`; + expect(() => resolveConfig({}, {}, file(undefined))).toThrow(new UsageError(message)); + expect(() => resolveConfig({}, {}, file({ current: undefined, profiles: {} }))).toThrow( + new UsageError(message), + ); + }); +}); diff --git a/cli/interface/__tests__/configFile.test.ts b/cli/interface/__tests__/configFile.test.ts new file mode 100644 index 0000000..0d902fe --- /dev/null +++ b/cli/interface/__tests__/configFile.test.ts @@ -0,0 +1,113 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { UsageError } from "../command"; +import { + type ConfigFs, + configFilePath, + loadConfigFile, + realConfigFs, + writeConfigFile, +} from "../configFile"; +import { memFs } from "./fakes"; + +const HINT = "run `bun run up` or `bun run k8s:install`, or pass --url/--token"; + +const P = "/home/me/.config/devbox/config.json"; +const FILE = { + current: "local", + profiles: { local: { url: "http://localhost:3000", token: "dbx_f" } }, +}; +const PROFILES = JSON.stringify(FILE); + +describe("configFilePath", () => { + it("uses XDG_CONFIG_HOME when set, else ~/.config", () => { + const fs = memFs({}); + expect(configFilePath({ XDG_CONFIG_HOME: "/xdg" }, fs)).toBe("/xdg/devbox/config.json"); + expect(configFilePath({ XDG_CONFIG_HOME: "" }, fs)).toBe(P); + expect(configFilePath({}, fs)).toBe(P); + }); +}); + +describe("loadConfigFile", () => { + const load = (fs: ConfigFs) => { + const warnings: string[] = []; + const config = loadConfigFile(P, fs, (w) => warnings.push(w)); + return { config, warnings }; + }; + + it("reads profiles and the current one", () => { + const fs = memFs({ [P]: { text: PROFILES } }); + expect(load(fs)).toEqual({ config: FILE, warnings: [] }); + }); + + it("returns undefined for a missing file", () => { + expect(load(memFs({}))).toEqual({ config: undefined, warnings: [] }); + }); + + it("gives a usage error naming the path when the file is unreadable", () => { + expect(() => load(memFs({ [P]: { error: "EACCES" } }))).toThrow( + new UsageError(`cannot read ${P} (EACCES): ${HINT}`), + ); + }); + + it("gives a usage error naming the path for invalid JSON", () => { + expect(() => load(memFs({ [P]: { text: "{url:" } }))).toThrow( + new UsageError(`${P} is not valid JSON: ${HINT}`), + ); + }); + + it("gives a usage error naming the path for the wrong shape, the old format included", () => { + for (const text of ["[]", "null", '{"url":"http://u","token":"dbx"}']) { + expect(() => load(memFs({ [P]: { text } }))).toThrow( + new UsageError( + `${P} must hold {"current": "...", "profiles": {"": {"url": "...", "token": "..."}}}: ${HINT}`, + ), + ); + } + }); + + it("warns about a mode wider than 0600 and still uses the file", () => { + const fs = memFs({ [P]: { text: PROFILES, mode: 0o644 } }); + expect(load(fs)).toEqual({ + config: FILE, + warnings: [ + `devbox: warning: ${P} has mode 0644, so other users may read its token; run: chmod 600 ${P}`, + ], + }); + }); +}); + +describe("writeConfigFile", () => { + it("serializes the profiles through the fs", () => { + const fs = memFs({}); + writeConfigFile(P, FILE, fs); + expect(fs.files).toEqual({ [P]: { text: `${JSON.stringify(FILE, null, 2)}\n`, mode: 0o600 } }); + }); +}); + +describe("realConfigFs", () => { + const dir = mkdtempSync(join(tmpdir(), "devbox-configfs-")); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + it("reads text and mode, and throws ENOENT for a missing file", () => { + const path = join(dir, "config.json"); + writeFileSync(path, "{}"); + chmodSync(path, 0o640); + expect(realConfigFs.read(path)).toBe("{}"); + expect(realConfigFs.mode(path)).toBe(0o640); + expect(() => realConfigFs.read(join(dir, "nope"))).toThrow( + expect.objectContaining({ code: "ENOENT" }), + ); + }); + + it("writes a file of mode 0600 in a 0700 directory, replacing the old one", () => { + const path = join(dir, "sub", "config.json"); + realConfigFs.write(path, "one"); + realConfigFs.write(path, "two"); + expect(readFileSync(path, "utf8")).toBe("two"); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(statSync(join(dir, "sub")).mode & 0o777).toBe(0o700); + }); +}); diff --git a/cli/interface/__tests__/duration.test.ts b/cli/interface/__tests__/duration.test.ts new file mode 100644 index 0000000..8773b87 --- /dev/null +++ b/cli/interface/__tests__/duration.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "bun:test"; +import { parseDuration } from "../duration"; + +describe("parseDuration", () => { + it.each([ + ["45s", 45_000], + ["30m", 1_800_000], + ["2h", 7_200_000], + ["1h30m", 5_400_000], + ["1h0m5s", 3_605_000], + ])("%s", (text, ms) => { + expect(parseDuration(text)).toBe(ms); + }); + + it.each(["", "600", "1.5h", "0s", "5x", "m", "30m1h", " 5m"])("rejects %p", (text) => { + expect(parseDuration(text)).toBeNull(); + }); +}); diff --git a/cli/interface/__tests__/exit.test.ts b/cli/interface/__tests__/exit.test.ts new file mode 100644 index 0000000..656fa3a --- /dev/null +++ b/cli/interface/__tests__/exit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test"; +import { EXIT, exitCodeFor } from "../exit"; + +describe("EXIT", () => { + it("defines each command exit code", () => { + expect(EXIT).toEqual({ ok: 0, failed: 1, partial: 2, timeout: 3, error: 10, interrupted: 130 }); + }); +}); + +describe("exitCodeFor", () => { + it.each([ + [{ outcome: "completed", failed: false }, EXIT.ok], + [{ outcome: "no_action", failed: false }, EXIT.ok], + [{ outcome: null, failed: false }, EXIT.ok], + [{ outcome: "partial_failure", failed: false }, EXIT.partial], + [{ outcome: "failed", failed: true, reason: "x" }, EXIT.failed], + [{ outcome: null, failed: true, reason: "x" }, EXIT.failed], + ] as const)("%p → %i", (result, code) => { + expect(exitCodeFor(result)).toBe(code); + }); +}); diff --git a/cli/interface/__tests__/fakes.ts b/cli/interface/__tests__/fakes.ts new file mode 100644 index 0000000..81700e9 --- /dev/null +++ b/cli/interface/__tests__/fakes.ts @@ -0,0 +1,115 @@ +// Fakes for interface tests: a CoreOps that records calls, async-iterable +// helpers, and an Io that captures output with a frozen clock. +import type { ApiEventEnvelope } from "../../../control-plane/src/types/api"; +import { NOW } from "../../__tests__/fixtures"; +import type { FollowItem, From, RoundResult } from "../../core/events"; +import type { CoreOps } from "../../core/ops"; +import type { ConfigFs } from "../configFile"; +import type { Io } from "../io"; + +export const conn = { connection: {}, json: false }; +export const asJson = { connection: {}, json: true }; + +export const FROM: From = { after: 41 }; // any From: commands pass it through untouched +export const ev = (event: ApiEventEnvelope): FollowItem => ({ kind: "event", event }); +export const done = (result: RoundResult): FollowItem => ({ kind: "done", result }); + +export type Call = { op: string; arg: unknown }; + +// A CoreOps whose operations come from `impl`. Every call is recorded with its +// argument; calling an operation `impl` doesn't provide throws. +export function fakeOps(impl: Partial): { ops: CoreOps; calls: Call[] } { + const calls: Call[] = []; + const ops = new Proxy({} as CoreOps, { + get: (_target, op) => (arg?: unknown) => { + calls.push({ op: String(op), arg }); + const fn = (impl as Record unknown) | undefined>)[String(op)]; + if (!fn) throw new Error(`unexpected call: ${String(op)}`); + return fn(arg); + }, + }); + return { ops, calls }; +} + +export async function* items(...values: T[]): AsyncGenerator { + yield* values; +} + +// Yields `values`, then stays open until `signal` aborts, like a live stream. +export async function* thenHang(signal: AbortSignal, ...values: T[]): AsyncGenerator { + yield* values; + await new Promise((resolve) => { + // The signal may already be aborted (testIo aborts inside io.out), and an + // abort listener added afterwards never fires. + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + +export async function* failing(err: Error): AsyncGenerator { + yield* []; + throw err; +} + +export type TestIo = Io & { stdout: string[]; stderr: string[]; abort: () => void }; + +// `stopAfterLines` fires the Ctrl-C signal once stdout holds that many lines. +export function testIo( + opts: { + stdin?: string; + files?: Record; + stopAfterLines?: number; + env?: Record; + isTTY?: boolean; + configFiles?: MemFiles; + } = {}, +): TestIo { + const stdout: string[] = []; + const stderr: string[] = []; + const interrupt = new AbortController(); + return { + out: (line) => { + stdout.push(line); + if (stdout.length === opts.stopAfterLines) interrupt.abort(); + }, + err: (line) => { + stderr.push(line); + }, + readStdin: async () => opts.stdin ?? "", + readFile: async (path) => { + const text = opts.files?.[path]; + if (text === undefined) throw new Error(`ENOENT: no such file '${path}'`); + return text; + }, + env: opts.env ?? { DEVBOX_CLI_URL: "http://cp.test", DEVBOX_CLI_TOKEN: "dbx_tok_SECRET" }, + isTTY: opts.isTTY ?? false, + configFs: memFs(opts.configFiles ?? {}), + now: () => NOW, + interrupt: interrupt.signal, + timeZone: "UTC", + stdout, + stderr, + abort: () => interrupt.abort(), + }; +} + +// An in-memory ConfigFs: a file's text and mode, or the error reading it throws. +type MemFiles = Record; + +export function memFs(files: MemFiles): ConfigFs & { files: MemFiles } { + const get = (path: string) => { + const f = files[path]; + if (!f) throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" }); + if ("error" in f) throw Object.assign(new Error(`${f.error}: ${path}`), { code: f.error }); + return f; + }; + return { + home: () => "/home/me", + read: (path) => get(path).text, + mode: (path) => get(path).mode ?? 0o600, + write: (path, text) => { + files[path] = { text, mode: 0o600 }; + }, + files, + }; +} diff --git a/cli/interface/__tests__/format.test.ts b/cli/interface/__tests__/format.test.ts new file mode 100644 index 0000000..8ad80fe --- /dev/null +++ b/cli/interface/__tests__/format.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { NOW } from "../../__tests__/fixtures"; +import { age, oneLine, paint } from "../format"; + +describe("age", () => { + it.each([ + ["2026-10-12T12:29:55.000Z", "5s"], + ["2026-10-12T12:25:00.000Z", "5m"], + ["2026-10-12T09:30:00.000Z", "3h"], + ["2026-10-10T12:30:00.000Z", "2d"], + ["2026-10-12T12:31:00.000Z", "0s"], + ])("%s", (iso, expected) => { + expect(age(iso, NOW)).toBe(expected); + }); +}); + +describe("paint", () => { + it("wraps text only when colour is on, and never empty text", () => { + expect(paint("x", "red", true)).toBe("\x1b[31mx\x1b[0m"); + expect(paint("x", "red", false)).toBe("x"); + expect(paint("x", null, true)).toBe("x"); + expect(paint("", "red", true)).toBe(""); + }); +}); + +describe("oneLine", () => { + it("flattens whitespace and truncates with an ellipsis", () => { + expect(oneLine(" a\n b\tc ", 10)).toBe("a b c"); + expect(oneLine("abcdefghij", 5)).toBe("abcd…"); + }); +}); diff --git a/cli/interface/__tests__/logLine.test.ts b/cli/interface/__tests__/logLine.test.ts new file mode 100644 index 0000000..58709a8 --- /dev/null +++ b/cli/interface/__tests__/logLine.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "bun:test"; +import { envelope } from "../../__tests__/fixtures"; +import { renderEvent } from "../logLine"; + +const plain = { color: false, timeZone: "UTC" }; + +describe("renderEvent", () => { + it("renders a whole fixed sequence", () => { + const events = [ + envelope(1, "task_created", { + title: "Fix flaky test", + description: null, + experimental_flags: [], + }), + envelope(2, "sandbox_started", { mode: "fresh" }, null), + envelope(3, "agent_action", { body: "Planning" }), + envelope(4, "agent_thought", { body: "Reading the test" }), + envelope(5, "pr_opened", { + repo: "rudderlabs/rudder-server", + pr_url: "https://github.com/rudderlabs/rudder-server/pull/1234", + branch: "devbox/DBX-API-7-fix", + }), + envelope(6, "agent_response", { body: "Fixed it.\n\nSee the PR." }), + envelope(7, "completed", { status: "partial_failure" }), + envelope(8, "message_queued", { body: "also update the docs" }, "round-2"), + envelope(9, "round_failed", { reason: "The sandbox could not start." }, "round-2"), + envelope(10, "task_restarted", { title: "Fix flaky test v2" }, "round-3"), + envelope(11, "agent_error", { body: "Tool crashed" }, "round-3"), + envelope(12, "completed", {}, "round-3"), + envelope(13, "something_new", {}), + ]; + expect(events.map((e) => renderEvent(e, plain)).join("\n")).toBe( + [ + "12:00:01 ✚ task created: Fix flaky test", + "12:00:02 ▸ sandbox started (fresh)", + "12:00:03 ⚙ Planning", + "12:00:04 … Reading the test", + "12:00:05 ⇪ PR opened rudderlabs/rudder-server#1234", + "12:00:06 ✉ Fixed it.", + "", + " See the PR.", + "12:00:07 ✔ completed (partial_failure)", + "12:00:08 → also update the docs", + "12:00:09 ✘ round failed: The sandbox could not start.", + "12:00:10 ↻ task restarted: Fix flaky test v2", + "12:00:11 ✘ Tool crashed", + "12:00:12 ✔ completed", + "12:00:13 · something_new", + ].join("\n"), + ); + }); + + it("colours marker and text when asked", () => { + expect( + renderEvent(envelope(4, "agent_thought", { body: "hmm" }), { color: true, timeZone: "UTC" }), + ).toBe("12:00:04 \x1b[2m… hmm\x1b[0m"); + }); + + it("falls back to the PR URL when it has no number", () => { + expect( + renderEvent( + envelope(5, "pr_opened", { repo: "r/x", pr_url: "https://example.com/x" }), + plain, + ), + ).toBe("12:00:05 ⇪ PR opened https://example.com/x"); + }); +}); diff --git a/cli/interface/__tests__/main.test.ts b/cli/interface/__tests__/main.test.ts new file mode 100644 index 0000000..ff9c31b --- /dev/null +++ b/cli/interface/__tests__/main.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "bun:test"; +import { ApiFailure, type ClientConfig } from "../../core/api"; +import { NoEventLog } from "../../core/events"; +import type { CoreOps } from "../../core/ops"; +import { runCli } from "../main"; +import { parse } from "../program"; +import { failing, fakeOps, testIo } from "./fakes"; + +const CONFIG = "/home/me/.config/devbox/config.json"; + +const profileText = (token: string) => + JSON.stringify({ current: "local", profiles: { local: { url: "http://file", token } } }); +const FILE_TEXT = profileText("dbx_file"); +const OLD_TEXT = profileText("dbx_old"); + +const noOps = (): CoreOps => { + throw new Error("makeOps must not be called"); +}; + +const withOps = + (impl: Partial, configs: ClientConfig[] = []) => + (config: ClientConfig) => { + configs.push(config); + return fakeOps(impl).ops; + }; + +describe("runCli", () => { + it("prints help without building the core", async () => { + const io = testIo(); + expect(await runCli(["--help"], io, noOps)).toBe(0); + const help = parse(["--help"]); + expect(io.stdout).toEqual([help.kind === "help" ? help.text : ""]); + }); + + it("runs a local command without building the core", async () => { + const io = testIo({ env: {} }); + expect(await runCli(["profile", "ls"], io, noOps)).toBe(0); + expect(io.stdout).toEqual(["no profiles: run `bun run up` or `bun run k8s:install`"]); + }); + + it("maps a usage error to exit 10 with the command's help hint", async () => { + const io = testIo(); + expect(await runCli(["whoami", "extra"], io, noOps)).toBe(10); + expect(io.stderr).toEqual([ + "devbox: too many arguments for 'whoami'. Expected 0 arguments but got 1: extra.", + "Run 'devbox whoami --help' for usage.", + ]); + }); + + it("uses the root help hint when there is no command", async () => { + const io = testIo(); + expect(await runCli(["nope"], io, noOps)).toBe(10); + expect(io.stderr).toEqual(["devbox: unknown command 'nope'", "Run 'devbox --help' for usage."]); + }); + + it("builds the core from flags over env", async () => { + const configs: ClientConfig[] = []; + const code = await runCli( + ["whoami", "--url", "http://flag/"], + testIo(), + withOps({ whoami: async () => ({ token: null, auth: "disabled" }) }, configs), + ); + expect(code).toBe(0); + expect(configs).toEqual([{ url: "http://flag", token: "dbx_tok_SECRET" }]); + }); + + it("exits 10 without a URL or config file, saying what to run", async () => { + const io = testIo({ env: {} }); + expect(await runCli(["whoami"], io, noOps)).toBe(10); + expect(io.stderr).toEqual([ + `devbox: no API URL and no profile in ${CONFIG}: run \`bun run up\` or \`bun run k8s:install\`, or pass --url/--token`, + "Run 'devbox --help' for usage.", + ]); + }); + + it("reads the config file when no flag or env gives a URL", async () => { + const configs: ClientConfig[] = []; + const io = testIo({ + env: {}, + configFiles: { [CONFIG]: { text: FILE_TEXT } }, + }); + const ops = withOps({ whoami: async () => ({ token: null, auth: "disabled" }) }, configs); + expect(await runCli(["whoami"], io, ops)).toBe(0); + expect(configs).toEqual([{ url: "http://file", token: "dbx_file" }]); + expect(io.stderr).toEqual([]); + }); + + it("warns about a too-open config file and still uses it", async () => { + const configs: ClientConfig[] = []; + const io = testIo({ + env: {}, + configFiles: { [CONFIG]: { text: FILE_TEXT, mode: 0o644 } }, + }); + const ops = withOps({ whoami: async () => ({ token: null, auth: "disabled" }) }, configs); + expect(await runCli(["whoami"], io, ops)).toBe(0); + expect(configs).toEqual([{ url: "http://file", token: "dbx_file" }]); + expect(io.stderr).toEqual([ + `devbox: warning: ${CONFIG} has mode 0644, so other users may read its token; run: chmod 600 ${CONFIG}`, + ]); + }); + + it("exits 10 without a stack for an unreadable or broken config file", async () => { + for (const [file, message] of [ + [{ error: "EACCES" }, `cannot read ${CONFIG} (EACCES)`], + [{ text: "{" }, `${CONFIG} is not valid JSON`], + ] as const) { + const io = testIo({ env: {}, configFiles: { [CONFIG]: file } }); + expect(await runCli(["whoami"], io, noOps)).toBe(10); + expect(io.stderr).toEqual([ + `devbox: ${message}: run \`bun run up\` or \`bun run k8s:install\`, or pass --url/--token`, + "Run 'devbox --help' for usage.", + ]); + } + }); + + it("says how to log in again when the profile's token is rejected", async () => { + const io = testIo({ + env: {}, + configFiles: { [CONFIG]: { text: OLD_TEXT } }, + }); + const failure = new ApiFailure( + 401, + "unauthorized", + "invalid or missing token (401 unauthorized)", + ); + const ops = withOps({ + whoami: async () => { + throw failure; + }, + }); + expect(await runCli(["whoami"], io, ops)).toBe(10); + expect(io.stderr).toEqual([ + "devbox: invalid or missing token (401 unauthorized)", + `The token of profile "local" in ${CONFIG} is no longer valid: log in again with \`bun run up\` or \`bun run k8s:install\`.`, + ]); + }); + + it("prints an ApiFailure's message only", async () => { + const io = testIo(); + const failure = new ApiFailure( + 401, + "unauthorized", + "invalid or missing token (401 unauthorized)", + ); + const code = await runCli( + ["whoami"], + io, + withOps({ + whoami: async () => { + throw failure; + }, + }), + ); + expect(code).toBe(10); + expect(io.stderr).toEqual(["devbox: invalid or missing token (401 unauthorized)"]); + }); + + it("prints an InputError's message only", async () => { + const io = testIo(); + expect(await runCli(["create", "T", "-i", "missing.md"], io, withOps({}))).toBe(10); + expect(io.stderr).toEqual([ + "devbox: cannot read missing.md: ENOENT: no such file 'missing.md'", + ]); + }); + + it("adds the stack for unexpected errors", async () => { + const io = testIo(); + const code = await runCli( + ["whoami"], + io, + withOps({ + whoami: async () => { + throw new Error("boom"); + }, + }), + ); + expect(code).toBe(10); + expect(io.stderr[0]).toBe("devbox: boom"); + expect(io.stderr).toHaveLength(2); + expect(io.stderr[1]).toContain("Error: boom"); + }); + + it("prints a NoEventLog's message only", async () => { + const io = testIo(); + const code = await runCli( + ["wait", "ABC-1"], + io, + withOps({ followTask: () => failing(new NoEventLog("ABC-1")) }), + ); + expect(code).toBe(10); + expect(io.stderr).toEqual(["devbox: ABC-1 has no event log (only API tasks are logged)"]); + }); +}); diff --git a/cli/interface/__tests__/parsing.ts b/cli/interface/__tests__/parsing.ts new file mode 100644 index 0000000..b558999 --- /dev/null +++ b/cli/interface/__tests__/parsing.ts @@ -0,0 +1,29 @@ +// Helpers for parsing tests: a parsed command's name and args, a help text, +// or the usage error. +import { UsageError } from "../command"; +import { parse } from "../program"; + +export const base = { connection: {}, json: false }; +export const still = { follow: false, wait: false }; + +export function parsed(argv: string[]): { name: string; args: unknown } { + const result = parse(argv); + if (result.kind !== "command") throw new Error(`expected a command for: ${argv.join(" ")}`); + return { name: result.name, args: result.args }; +} + +export function helpText(argv: string[]): string { + const result = parse(argv); + if (result.kind !== "help") throw new Error(`expected help for: ${argv.join(" ")}`); + return result.text; +} + +export function usage(argv: string[]): { message: string; command?: string } { + try { + parse(argv); + } catch (err) { + if (err instanceof UsageError) return { message: err.message, command: err.command }; + throw err; + } + throw new Error(`expected a UsageError for: ${argv.join(" ")}`); +} diff --git a/cli/interface/__tests__/profiles.test.ts b/cli/interface/__tests__/profiles.test.ts new file mode 100644 index 0000000..5159ded --- /dev/null +++ b/cli/interface/__tests__/profiles.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "bun:test"; +import { + EMPTY_PROFILE_FILE, + ProfileFileError, + parseProfileFile, + serializeProfileFile, + withoutProfile, + withProfile, +} from "../profiles"; + +const FILE = { + current: "local", + profiles: { + local: { url: "http://localhost:3000", token: "dbx_a" }, + "k8s-kind-devbox": { url: "http://localhost:13000", token: "dbx_b" }, + }, +}; + +describe("parseProfileFile", () => { + it("reads profiles and the current one", () => { + expect(parseProfileFile(JSON.stringify(FILE))).toEqual(FILE); + }); + + it("allows no current profile", () => { + expect(parseProfileFile('{"profiles":{}}')).toEqual(EMPTY_PROFILE_FILE); + }); + + it.each([ + ["{", "is not valid JSON"], + [ + '{"url":"http://x","token":"t"}', + 'must hold {"current": "...", "profiles": {"": {"url": "...", "token": "..."}}}', + ], + [ + '{"profiles":{"Bad_Name":{"url":"http://x","token":"t"}}}', + 'has a bad profile name "Bad_Name"', + ], + [ + '{"profiles":{"a":{"url":"","token":"t"}}}', + 'profile "a" must hold {"url": "...", "token": "..."}', + ], + ['{"current":"constructor","profiles":{}}', '"current" names no profile: "constructor"'], + [ + '{"current":"b","profiles":{"a":{"url":"http://x","token":"t"}}}', + '"current" names no profile: "b"', + ], + ])("rejects %s", (text, message) => { + expect(() => parseProfileFile(text)).toThrow(new ProfileFileError(message)); + }); +}); + +describe("editing", () => { + it("serializes what it parses", () => { + expect(parseProfileFile(serializeProfileFile(FILE))).toEqual(FILE); + expect(serializeProfileFile(EMPTY_PROFILE_FILE)).toBe('{\n "profiles": {}\n}\n'); + }); + + it("adds or replaces a profile, optionally making it current", () => { + const p = { url: "http://localhost:13001", token: "dbx_c" }; + expect(withProfile(FILE, "k8s-eks", p, false)).toEqual({ + current: "local", + profiles: { ...FILE.profiles, "k8s-eks": p }, + }); + expect(withProfile(FILE, "local", p, true)).toEqual({ + current: "local", + profiles: { ...FILE.profiles, local: p }, + }); + }); + + it("refuses a bad name", () => { + expect(() => withProfile(FILE, "My Cluster", { url: "http://x", token: "" }, false)).toThrow( + new ProfileFileError('bad profile name "My Cluster": use lower-case letters, digits and "-"'), + ); + }); + + it("removes a profile and clears current when it was current", () => { + expect(withoutProfile(FILE, "local")).toEqual({ + current: undefined, + profiles: { "k8s-kind-devbox": FILE.profiles["k8s-kind-devbox"] }, + }); + expect(withoutProfile(FILE, "absent")).toEqual(FILE); + }); +}); diff --git a/cli/interface/__tests__/program.test.ts b/cli/interface/__tests__/program.test.ts new file mode 100644 index 0000000..dffe8ff --- /dev/null +++ b/cli/interface/__tests__/program.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { parse } from "../program"; +import { helpText, parsed, usage } from "./parsing"; + +const ROOT_HELP = `Usage: devbox [options] [command] + +A thin CLI over the harness control-plane API. + +Options: + -h, --help display help for command + +Commands: + whoami [options] Show the token the API sees + create [options] [input] Create a task and print its key + ls [options] List tasks, newest first + show [options] <ref> Show a task's state, sandbox, PRs and rounds + send [options] <ref> [message] Send a follow-up message + restart [options] <ref> Restart a task from a clean slate + logs [options] <ref> Print a task's event log + wait [options] <ref> Wait for a task to finish and print its + final response + profile List and switch the control planes the CLI + talks to + help [command] display help for command + +Environment: + DEVBOX_CLI_URL API base URL, unless --url is given + DEVBOX_CLI_TOKEN API token, unless --token is given + DEVBOX_CLI_PROFILE profile to use, unless --profile is given + +Config file: + Without --url or DEVBOX_CLI_URL, the URL and token come from a profile in + $XDG_CONFIG_HOME/devbox/config.json (default ~/.config/devbox/config.json). + \`bun run up\` writes the profile "local", \`bun run k8s:install\` one per + cluster; \`devbox profile ls|use|add\` list and edit them. --token or + DEVBOX_CLI_TOKEN overrides the profile's token. + +Exit codes of wait and --wait: + 0 done, 1 failed, 2 partial failure, 3 timeout, 10 usage or API error, + 130 interrupted`; + +describe("parse", () => { + it("prints the root help for --help, help, and no arguments", () => { + expect(helpText(["--help"])).toBe(ROOT_HELP); + expect(helpText(["help"])).toBe(ROOT_HELP); + expect(helpText([])).toBe(ROOT_HELP); + }); + + it("parses profile commands as local commands", () => { + expect(parse(["profile", "use", "x"])).toMatchObject({ kind: "local", name: "profile use" }); + }); + + it("takes --profile on control plane commands", () => { + expect(parsed(["ls", "--profile", "k8s-eks"]).args).toMatchObject({ + connection: { profile: "k8s-eks" }, + }); + }); + + it("prints a command's help for help <command> as for <command> --help", () => { + expect(helpText(["help", "whoami"])).toBe(helpText(["whoami", "--help"])); + }); + + it.each([ + [["nope"], "unknown command 'nope'"], + [["whoam"], "unknown command 'whoam'\n(Did you mean whoami?)"], + [["crate", "x"], "unknown command 'crate'\n(Did you mean create?)"], + [["constructor", "x"], "unknown command 'constructor'"], + ])("rejects %p", (argv, message) => { + expect(usage(argv)).toEqual({ message, command: undefined }); + }); +}); diff --git a/cli/interface/__tests__/runtime.test.ts b/cli/interface/__tests__/runtime.test.ts new file mode 100644 index 0000000..1cf0a15 --- /dev/null +++ b/cli/interface/__tests__/runtime.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { bunVersionWarning, MIN_BUN_VERSION } from "../runtime"; + +describe("bunVersionWarning", () => { + it("warns below the minimum, with the versions", () => { + expect(bunVersionWarning("1.2.20")).toBe( + "devbox: Bun 1.2.20 is older than 1.4.4, the version devbox is built and tested with; upgrade with `bun upgrade`.", + ); + }); + + it.each(["1.3.14", "1.4.3"])("warns on %s, which lacks the 1.4.4 fixes", (version) => { + expect(bunVersionWarning(version)).toContain(`Bun ${version} is older than 1.4.4`); + }); + + it.each(["1.4.4", "1.4.5", "1.5.0"])("is silent on %s", (version) => { + expect(bunVersionWarning(version)).toBeUndefined(); + }); + + it("matches the Bun version the CLI's CI pins", () => { + const pin = /bun-version:\s*([\d.]+)/.exec( + readFileSync(join(import.meta.dir, "../../../.github/workflows/cli.yml"), "utf8"), + )?.[1]; + expect(MIN_BUN_VERSION).toBe(pin ?? ""); + }); +}); diff --git a/cli/interface/command.ts b/cli/interface/command.ts new file mode 100644 index 0000000..dca8464 --- /dev/null +++ b/cli/interface/command.ts @@ -0,0 +1,38 @@ +// The shapes every devbox command shares. Each command lives in its own +// module under commands/: it registers itself with commander and hands back +// its parsed arguments bound to its run(); parsing never runs anything. +import type { Command } from "commander"; +import type { CoreOps } from "../core/ops"; +import type { Io } from "./io"; + +export { UsageError } from "./usageError"; + +// Where a command's free text comes from; input.ts reads it. +export type TextSource = + | { kind: "text"; text: string } + | { kind: "file"; path: string } + | { kind: "stdin" }; + +export type Connection = { url?: string; token?: string; profile?: string }; +export type FollowFlags = { follow: boolean; wait: boolean; timeoutMs?: number }; +export type CommonArgs = { connection: Connection; json: boolean }; + +// A parsed command: its name, the arguments commander collected, and its run() +// bound to them. +export type ParsedCommand = { + kind: "command"; + name: string; + args: CommonArgs & Record<string, unknown>; + run: (ops: CoreOps, io: Io) => Promise<number>; +}; +export type HelpRequest = { kind: "help"; text: string }; + +// A command that runs without a control plane (devbox profile …): main runs +// it before resolving a URL or token. +export type LocalCommand = { kind: "local"; name: string; run: (io: Io) => Promise<number> }; + +// One devbox command. register() adds it to the program; the command's action +// hands `parsed` the result. +export type CommandModule = { + register(program: Command, parsed: (command: ParsedCommand | LocalCommand) => void): void; +}; diff --git a/cli/interface/commands/__tests__/create.test.ts b/cli/interface/commands/__tests__/create.test.ts new file mode 100644 index 0000000..2aec8c9 --- /dev/null +++ b/cli/interface/commands/__tests__/create.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "bun:test"; +import { envelope, task } from "../../../__tests__/fixtures"; +import { + asJson, + conn, + done, + ev, + FROM, + fakeOps, + items, + testIo, + thenHang, +} from "../../__tests__/fakes"; +import { base, helpText, parsed, still, usage } from "../../__tests__/parsing"; +import { InputError } from "../../input"; +import { run } from "../create"; + +const CREATE_HELP = `Usage: devbox create [options] <title> [input] + +Create a task and print its key + +Arguments: + title the task title + input - reads the description from stdin + +Options: + -d, --description <text> the task description + -i, --input <path> read the description from a file + --flag <name> an experimental flag; repeat for more + -f, --follow then print the task's log as it happens + -w, --wait then wait until the task is idle or failed and print + the response + -t, --timeout <duration> with --wait: give up after (e.g. 45s, 1h30m) + --url <url> API base URL (default: $DEVBOX_CLI_URL or config) + --token <token> API token (default: $DEVBOX_CLI_TOKEN or config) + --profile <name> profile from the config file (default: + $DEVBOX_CLI_PROFILE or the current one) + --json print JSON instead of text + -h, --help display help for command`; + +describe("create: parsing", () => { + it("has its own help", () => { + expect(helpText(["create", "--help"])).toBe(CREATE_HELP); + expect(helpText(["help", "create"])).toBe(CREATE_HELP); + }); + + it.each([ + [["create", "Fix it"], { ...base, title: "Fix it", experimentalFlags: [], afterWrite: still }], + [ + ["create", "Fix it", "-d", "details", "--flag", "A", "--flag", "B"], + { + ...base, + title: "Fix it", + description: { kind: "text", text: "details" }, + experimentalFlags: ["A", "B"], + afterWrite: still, + }, + ], + [ + ["create", "Fix it", "-"], + { + ...base, + title: "Fix it", + description: { kind: "stdin" }, + experimentalFlags: [], + afterWrite: still, + }, + ], + [ + ["create", "Fix it", "-i", "plan.md", "--json"], + { + ...base, + json: true, + title: "Fix it", + description: { kind: "file", path: "plan.md" }, + experimentalFlags: [], + afterWrite: still, + }, + ], + [ + ["create", "T", "-fwt", "45s"], + { + ...base, + title: "T", + experimentalFlags: [], + afterWrite: { follow: true, wait: true, timeoutMs: 45_000 }, + }, + ], + [ + ["create", "Fix it", "--follow", "--wait", "--timeout", "1h30m"], + { + ...base, + title: "Fix it", + experimentalFlags: [], + afterWrite: { follow: true, wait: true, timeoutMs: 5_400_000 }, + }, + ], + ])("%p", (argv, args) => { + expect(parsed(argv)).toEqual({ name: "create", args }); + }); + + it.each([ + [["create"], "missing required argument 'title'"], + [["create", "t", "extra"], 'unexpected argument "extra"'], + [["create", "t", "-d", "x", "-i", "y"], "give the text only once: inline, -i FILE or -"], + [["create", "t", "-d", "x", "-"], "give the text only once: inline, -i FILE or -"], + [["create", "t", "--timeout", "5m"], "--timeout needs --wait"], + [ + ["create", "t", "--wait", "--timeout", "600"], + "option '-t, --timeout <duration>' argument '600' is invalid. Use e.g. 45s, 30m, 2h or 1h30m.", + ], + [ + ["create", "t", "--wait", "--timeout", "597h"], + "option '-t, --timeout <duration>' argument '597h' is invalid. The longest timeout is 596h.", + ], + ])("rejects %p", (argv, message) => { + expect(usage(argv)).toEqual({ message, command: "create" }); + }); +}); + +describe("create: run", () => { + it("reads the description file, creates, and prints the key", async () => { + const { ops, calls } = fakeOps({ createTask: async () => ({ task: task(), from: FROM }) }); + const io = testIo({ files: { "plan.md": "Do it.\n" } }); + const code = await run( + { + ...conn, + title: "Fix flaky test", + description: { kind: "file", path: "plan.md" }, + experimentalFlags: ["FOO"], + afterWrite: still, + }, + ops, + io, + ); + expect(code).toBe(0); + expect(calls).toEqual([ + { + op: "createTask", + arg: { title: "Fix flaky test", description: "Do it.\n", experimentalFlags: ["FOO"] }, + }, + ]); + expect(io.stdout).toEqual(["DBX-API-7"]); + }); + + it("reads stdin for - and prints the task as JSON", async () => { + const { ops, calls } = fakeOps({ createTask: async () => ({ task: task(), from: FROM }) }); + const io = testIo({ stdin: "from stdin" }); + await run( + { + ...asJson, + title: "T", + description: { kind: "stdin" }, + experimentalFlags: [], + afterWrite: still, + }, + ops, + io, + ); + expect(calls[0]?.arg).toEqual({ title: "T", description: "from stdin", experimentalFlags: [] }); + expect(JSON.parse(io.stdout.join("\n"))).toEqual(task()); + }); + + it("throws InputError for an unreadable file, before calling core", async () => { + const { ops, calls } = fakeOps({}); + const err = await run( + { + ...conn, + title: "T", + description: { kind: "file", path: "missing.md" }, + experimentalFlags: [], + afterWrite: still, + }, + ops, + testIo(), + ).catch((x: unknown) => x); + expect(err).toBeInstanceOf(InputError); + expect((err as Error).message).toBe( + "cannot read missing.md: ENOENT: no such file 'missing.md'", + ); + expect(calls).toEqual([]); + }); + + it("--wait follows the new round and prints its response", async () => { + const reply = envelope(4, "agent_response", { body: "Done.\nAll green." }); + const { ops, calls } = fakeOps({ + createTask: async () => ({ task: task(), from: FROM }), + followTask: () => + items(ev(reply), done({ outcome: "completed", failed: false, response: reply })), + }); + const io = testIo(); + const code = await run( + { + ...conn, + title: "T", + experimentalFlags: [], + afterWrite: { follow: false, wait: true, timeoutMs: 60_000 }, + }, + ops, + io, + ); + expect(code).toBe(0); + expect(calls[1]).toEqual({ + op: "followTask", + arg: { + ref: "DBX-API-7", + from: FROM, + untilDone: true, + timeoutMs: 60_000, + signal: io.interrupt, + }, + }); + expect(io.stdout).toEqual(["DBX-API-7", "Done.\nAll green."]); + }); + + // With --json, every line of a followed write is one JSON value. + it("--json --wait prints the task on one line", async () => { + const reply = envelope(4, "agent_response", { body: "Done." }); + const { ops } = fakeOps({ + createTask: async () => ({ task: task(), from: FROM }), + followTask: () => + items(ev(reply), done({ outcome: "completed", failed: false, response: reply })), + }); + const io = testIo(); + const code = await run( + { ...asJson, title: "T", experimentalFlags: [], afterWrite: { follow: false, wait: true } }, + ops, + io, + ); + expect(code).toBe(0); + expect(io.stdout).toEqual([JSON.stringify(task()), JSON.stringify(reply)]); + }); + + // Ctrl-C exits 130 quietly. + it("--follow prints the log until Ctrl-C", async () => { + const created = envelope(1, "task_created", { title: "Fix flaky test" }); + const sandbox = envelope(2, "sandbox_started", { mode: "fresh" }, null); + const { ops, calls } = fakeOps({ + createTask: async () => ({ task: task(), from: FROM }), + followTask: (o) => thenHang(o.signal, ev(created), ev(sandbox)), + }); + const io = testIo({ stopAfterLines: 3 }); + const code = await run( + { ...conn, title: "T", experimentalFlags: [], afterWrite: { follow: true, wait: false } }, + ops, + io, + ); + expect(code).toBe(130); + expect(io.stdout).toEqual([ + "DBX-API-7", + "12:00:01 ✚ task created: Fix flaky test", + "12:00:02 ▸ sandbox started (fresh)", + ]); + expect(io.stderr).toEqual([]); + expect(calls[1]).toEqual({ + op: "followTask", + arg: { + ref: "DBX-API-7", + from: FROM, + untilDone: false, + timeoutMs: undefined, + signal: io.interrupt, + }, + }); + }); +}); diff --git a/cli/interface/commands/__tests__/logs.test.ts b/cli/interface/commands/__tests__/logs.test.ts new file mode 100644 index 0000000..88219d6 --- /dev/null +++ b/cli/interface/commands/__tests__/logs.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "bun:test"; +import { envelope } from "../../../__tests__/fixtures"; +import { type FollowItem, NoEventLog } from "../../../core/events"; +import { + asJson, + conn, + done, + failing, + fakeOps, + items, + testIo, + thenHang, +} from "../../__tests__/fakes"; +import { base, helpText, parsed, usage } from "../../__tests__/parsing"; +import { run } from "../logs"; + +describe("logs: parsing", () => { + it.each([ + [ + ["logs", "DBX-API-1", "-f", "--after", "42"], + { ...base, ref: "DBX-API-1", follow: true, wait: false, after: 42 }, + ], + [["logs", "DBX-API-1"], { ...base, ref: "DBX-API-1", follow: false, wait: false, after: 0 }], + [["logs", "X", "-f"], { ...base, ref: "X", follow: true, wait: false, after: 0 }], + [["logs", "X", "-w"], { ...base, ref: "X", follow: true, wait: true, after: 0 }], + [ + ["logs", "X", "-wt", "1m"], + { ...base, ref: "X", follow: true, wait: true, timeoutMs: 60_000, after: 0 }, + ], + ])("%p", (argv, args) => { + expect(parsed(argv)).toEqual({ name: "logs", args }); + }); + + it.each([ + [ + ["logs", "DBX-API-1", "--after", "-1"], + "option '--after <id>' argument '-1' is invalid. Expected an integer >= 0.", + ], + [["logs", "DBX-API-1", "--after"], "option '--after <id>' argument missing"], + [ + ["logs", "DBX-API-1", "--after", "99999999999999999999"], + "option '--after <id>' argument '99999999999999999999' is invalid. Expected an integer >= 0.", + ], + [["logs", "X", "-t", "1m"], "--timeout needs --wait"], + ])("rejects %p", (argv, message) => { + expect(usage(argv)).toEqual({ message, command: "logs" }); + }); +}); + +const HELP = `Usage: devbox logs [options] <ref> + +Print a task's event log + +Arguments: + ref task key or id + +Options: + -f, --follow keep following the log live + -w, --wait follow until the task's latest round is done + (implies --follow) + -t, --timeout <duration> with --wait: give up after (e.g. 45s, 1h30m) + --after <id> start after this event id + --url <url> API base URL (default: $DEVBOX_CLI_URL or config) + --token <token> API token (default: $DEVBOX_CLI_TOKEN or config) + --profile <name> profile from the config file (default: + $DEVBOX_CLI_PROFILE or the current one) + --json print JSON instead of text + -h, --help display help for command`; + +describe("logs: help", () => { + it("has its own help", () => { + expect(helpText(["logs", "--help"]).trimEnd()).toBe(HELP); + }); +}); + +describe("logs: run", () => { + const created = envelope(1, "task_created", { title: "Fix flaky test" }); + const sandbox = envelope(2, "sandbox_started", { mode: "fresh" }, null); + const history = { ref: "DBX-API-7", follow: false, wait: false, after: 0 }; + const ev = (event: typeof created): FollowItem => ({ kind: "event", event }); + + it("prints the history from --after", async () => { + const { ops, calls } = fakeOps({ taskHistory: () => items(created, sandbox) }); + const io = testIo(); + expect(await run({ ...conn, ...history }, ops, io)).toBe(0); + expect(calls).toEqual([{ op: "taskHistory", arg: { ref: "DBX-API-7", after: 0 } }]); + expect(io.stdout).toEqual([ + "12:00:01 ✚ task created: Fix flaky test", + "12:00:02 ▸ sandbox started (fresh)", + ]); + }); + + it("prints NDJSON with --json, and colour only on a TTY without NO_COLOR", async () => { + const { ops } = fakeOps({ taskHistory: () => items(created) }); + const json = testIo(); + await run({ ...asJson, ...history }, ops, json); + expect(json.stdout.map((l) => JSON.parse(l))).toEqual([created]); + + const tty = testIo({ isTTY: true, env: {} }); + await run({ ...conn, ...history }, ops, tty); + expect(tty.stdout).toEqual(["12:00:01 \x1b[1m✚ task created: Fix flaky test\x1b[0m"]); + + const noColor = testIo({ isTTY: true, env: { NO_COLOR: "1" } }); + await run({ ...conn, ...history }, ops, noColor); + expect(noColor.stdout).toEqual(["12:00:01 ✚ task created: Fix flaky test"]); + }); + + it("prints a notice for a Linear task and exits 0", async () => { + const { ops } = fakeOps({ taskHistory: () => failing(new NoEventLog("ABC-1")) }); + const io = testIo(); + expect(await run({ ...conn, ...history, ref: "ABC-1" }, ops, io)).toBe(0); + expect(io.stderr).toEqual(["devbox: ABC-1 has no event log (only API tasks are logged)"]); + }); + + // Ctrl-C exits 130 quietly. + it("-f follows from --after until Ctrl-C", async () => { + const { ops, calls } = fakeOps({ followTask: (o) => thenHang(o.signal, ev(created)) }); + const io = testIo({ stopAfterLines: 1 }); + expect( + await run({ ...conn, ref: "DBX-API-7", follow: true, wait: false, after: 5 }, ops, io), + ).toBe(130); + expect(calls).toEqual([ + { + op: "followTask", + arg: { + ref: "DBX-API-7", + from: { after: 5 }, + untilDone: false, + timeoutMs: undefined, + signal: io.interrupt, + }, + }, + ]); + expect(io.stdout).toEqual(["12:00:01 ✚ task created: Fix flaky test"]); + expect(io.stderr).toEqual([]); + }); + + it("-f prints a notice for a Linear task and exits 0", async () => { + const { ops } = fakeOps({ followTask: () => failing(new NoEventLog("ABC-1")) }); + const io = testIo(); + expect(await run({ ...conn, ref: "ABC-1", follow: true, wait: false, after: 0 }, ops, io)).toBe( + 0, + ); + expect(io.stderr).toEqual(["devbox: ABC-1 has no event log (only API tasks are logged)"]); + }); + + const waiting = { ...conn, ref: "DBX-API-7", follow: true, wait: true, after: 0 }; + + it("-fw prints the log, ends with the round and exits with its outcome", async () => { + const { ops, calls } = fakeOps({ + followTask: () => items(ev(created), done({ outcome: "partial_failure", failed: false })), + }); + const io = testIo(); + expect(await run(waiting, ops, io)).toBe(2); + expect(calls).toEqual([ + { + op: "followTask", + arg: { + ref: "DBX-API-7", + from: { after: 0 }, + untilDone: true, + timeoutMs: undefined, + signal: io.interrupt, + }, + }, + ]); + expect(io.stdout).toEqual(["12:00:01 ✚ task created: Fix flaky test"]); + }); + + it("-w prints the reason a round failed", async () => { + const { ops } = fakeOps({ + followTask: () => items(done({ outcome: "failed", failed: true, reason: "x" })), + }); + const io = testIo(); + expect(await run(waiting, ops, io)).toBe(1); + expect(io.stderr).toEqual(["devbox: DBX-API-7 failed: x"]); + }); + + it("-w propagates NoEventLog for a Linear task", async () => { + const { ops } = fakeOps({ followTask: () => failing(new NoEventLog("ABC-1")) }); + await expect(run({ ...waiting, ref: "ABC-1" }, ops, testIo())).rejects.toBeInstanceOf( + NoEventLog, + ); + }); +}); diff --git a/cli/interface/commands/__tests__/ls.test.ts b/cli/interface/commands/__tests__/ls.test.ts new file mode 100644 index 0000000..c80ffef --- /dev/null +++ b/cli/interface/commands/__tests__/ls.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "bun:test"; +import { NOW, task } from "../../../__tests__/fixtures"; +import { asJson, conn, fakeOps, testIo } from "../../__tests__/fakes"; +import { base, parsed, usage } from "../../__tests__/parsing"; +import { render, run } from "../ls"; + +describe("ls: parsing", () => { + it.each([ + [["ls"], { ...base, mine: false }], + [ + ["ls", "--source", "linear", "--mine", "--state", "idle", "--limit", "250"], + { ...base, source: "linear", mine: true, state: "idle", limit: 250 }, + ], + ])("%p", (argv, args) => { + expect(parsed(argv)).toEqual({ name: "ls", args }); + }); + + it.each([ + [ + ["ls", "--source", "github"], + "option '--source <source>' argument 'github' is invalid. Allowed choices are api, linear.", + ], + [ + ["ls", "--state", "done"], + "option '--state <state>' argument 'done' is invalid. Allowed choices are queued, running, idle, failed.", + ], + [ + ["ls", "--limit", "0"], + "option '--limit <n>' argument '0' is invalid. Expected an integer >= 1.", + ], + [ + ["ls", "--limit", "ten"], + "option '--limit <n>' argument 'ten' is invalid. Expected an integer >= 1.", + ], + [ + ["ls", "--limit", "99999999999999999999"], + "option '--limit <n>' argument '99999999999999999999' is invalid. Expected an integer >= 1.", + ], + ])("rejects %p", (argv, message) => { + expect(usage(argv)).toEqual({ message, command: "ls" }); + }); +}); + +describe("ls: render", () => { + it("renders aligned columns", () => { + const tasks = [ + task({ state: "running", created_at: "2026-10-12T12:25:00.000Z" }), + task({ + key: "ABC-1234", + source: "linear", + state: "idle", + title: "Port the docs", + description: null, + created_by: null, + created_at: "2026-10-10T12:30:00.000Z", + }), + ]; + expect(render(tasks, NOW)).toBe( + [ + "KEY STATE TITLE DESCRIPTION CREATOR AGE", + "DBX-API-7 running Fix flaky test The test fails on CI. frodo 5m", + "ABC-1234 idle Port the docs - - 2d", + ].join("\n"), + ); + }); + + it("truncates long titles and descriptions to 40 characters and flattens newlines", () => { + const [, row = ""] = render( + [task({ title: `${"a".repeat(70)}\nmore`, description: `${"b".repeat(70)}\nmore` })], + NOW, + ).split("\n"); + expect(row).toContain(`${"a".repeat(39)}…`); + expect(row).toContain(`${"b".repeat(39)}…`); + expect(row).not.toContain("more"); + }); + + it("colours the header, key and state when asked", () => { + const tasks = [task({ state: "running", created_at: "2026-10-12T12:25:00.000Z" })]; + expect(render(tasks, NOW, { color: true })).toBe( + [ + "\x1b[2mKEY STATE TITLE DESCRIPTION CREATOR AGE\x1b[0m", + "\x1b[1mDBX-API-7\x1b[0m \x1b[36mrunning\x1b[0m Fix flaky test The test fails on CI. frodo 5m", + ].join("\n"), + ); + }); +}); + +describe("ls: run", () => { + it("passes the filters and prints the table", async () => { + const tasks = [task({ created_at: "2026-10-12T12:25:00.000Z" })]; + const { ops, calls } = fakeOps({ listTasks: async () => tasks }); + const io = testIo(); + expect( + await run({ ...conn, source: "api", mine: true, state: "idle", limit: 5 }, ops, io), + ).toBe(0); + expect(calls).toEqual([ + { op: "listTasks", arg: { source: "api", mine: true, state: "idle", limit: 5 } }, + ]); + expect(io.stdout).toEqual([render(tasks, NOW)]); + }); + + it("says so when there are no tasks, and prints an array with --json", async () => { + const { ops } = fakeOps({ listTasks: async () => [] }); + const io = testIo(); + await run({ ...conn, mine: false }, ops, io); + expect(io.stdout).toEqual(["No tasks."]); + + const json = testIo(); + await run({ ...asJson, mine: false }, ops, json); + expect(JSON.parse(json.stdout.join("\n"))).toEqual([]); + }); + + it("colours output only on a TTY without NO_COLOR", async () => { + const tasks = [task({ created_at: "2026-10-12T12:25:00.000Z" })]; + const { ops } = fakeOps({ listTasks: async () => tasks }); + const tty = testIo({ isTTY: true, env: {} }); + await run({ ...conn, mine: false }, ops, tty); + expect(tty.stdout).toEqual([render(tasks, NOW, { color: true })]); + + const noColor = testIo({ isTTY: true, env: { NO_COLOR: "1" } }); + await run({ ...conn, mine: false }, ops, noColor); + expect(noColor.stdout).toEqual([render(tasks, NOW)]); + }); +}); diff --git a/cli/interface/commands/__tests__/profile.test.ts b/cli/interface/commands/__tests__/profile.test.ts new file mode 100644 index 0000000..2d53267 --- /dev/null +++ b/cli/interface/commands/__tests__/profile.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { testIo } from "../../__tests__/fakes"; +import { UsageError } from "../../command"; +import { runAdd, runLs, runUse } from "../profile"; + +const P = "/home/me/.config/devbox/config.json"; +const TEXT = JSON.stringify({ + current: "local", + profiles: { + local: { url: "http://localhost:3000", token: "dbx_a" }, + "k8s-kind-devbox": { url: "http://localhost:13000", token: "dbx_b" }, + }, +}); +const io = () => testIo({ env: {}, configFiles: { [P]: { text: TEXT } } }); +const saved = (t: ReturnType<typeof io>) => JSON.parse(t.configFs.read(P)); + +describe("devbox profile", () => { + it("ls marks the current profile and never prints tokens", async () => { + const t = io(); + expect(await runLs(false, t)).toBe(0); + expect(t.stdout).toEqual([ + " k8s-kind-devbox http://localhost:13000", + "* local http://localhost:3000", + ]); + }); + + it("ls --json", async () => { + const t = io(); + await runLs(true, t); + expect(JSON.parse(t.stdout.join("\n"))).toEqual({ + current: "local", + profiles: [ + { name: "k8s-kind-devbox", url: "http://localhost:13000" }, + { name: "local", url: "http://localhost:3000" }, + ], + }); + }); + + it("ls without a file says how to log in", async () => { + const t = testIo({ env: {} }); + await runLs(false, t); + expect(t.stdout).toEqual(["no profiles: run `bun run up` or `bun run k8s:install`"]); + }); + + it("use switches the current profile", async () => { + const t = io(); + expect(await runUse("k8s-kind-devbox", t)).toBe(0); + expect(saved(t).current).toBe("k8s-kind-devbox"); + expect(t.stdout).toEqual(["Using profile k8s-kind-devbox"]); + }); + + it("use refuses an unknown profile", async () => { + await expect(runUse("gke", io())).rejects.toThrow( + new UsageError(`no profile "gke" in ${P}`, "profile"), + ); + }); + + it("use refuses a prototype name", async () => { + await expect(runUse("constructor", io())).rejects.toThrow( + new UsageError(`no profile "constructor" in ${P}`, "profile"), + ); + }); + + it("add saves a profile without switching unless --use", async () => { + const t = io(); + await runAdd("k8s-gke", { url: "https://devbox.example.com/", token: "dbx_c", use: false }, t); + expect(saved(t)).toEqual({ + current: "local", + profiles: { + ...JSON.parse(TEXT).profiles, + "k8s-gke": { url: "https://devbox.example.com", token: "dbx_c" }, + }, + }); + expect(t.stdout).toEqual(["Saved profile k8s-gke"]); + }); + + it("add refuses a bad name or URL", async () => { + await expect(runAdd("GKE", { url: "http://x", token: "t", use: false }, io())).rejects.toThrow( + new UsageError('bad profile name "GKE": use lower-case letters, digits and "-"', "profile"), + ); + await expect(runAdd("gke", { url: "nope", token: "t", use: false }, io())).rejects.toThrow( + new UsageError("--url must be an http(s) URL, got nope", "profile"), + ); + }); + + it("add refuses a URL without http or https", async () => { + await expect(runAdd("k8s", { url: "localhost:3000", token: "dbx_c", use: false }, io())).rejects.toThrow( + new UsageError("--url must be an http(s) URL, got localhost:3000", "profile"), + ); + }); +}); diff --git a/cli/interface/commands/__tests__/restart.test.ts b/cli/interface/commands/__tests__/restart.test.ts new file mode 100644 index 0000000..c34292a --- /dev/null +++ b/cli/interface/commands/__tests__/restart.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "bun:test"; +import { task } from "../../../__tests__/fixtures"; +import { asJson, conn, done, FROM, fakeOps, items, testIo } from "../../__tests__/fakes"; +import { base, helpText, parsed, still, usage } from "../../__tests__/parsing"; +import { run } from "../restart"; + +describe("restart: parsing", () => { + it.each([ + [["restart", "DBX-API-1"], { ...base, ref: "DBX-API-1", afterWrite: still }], + [ + ["restart", "DBX-API-1", "--title", "T", "-d", "D", "--flag", "X"], + { + ...base, + ref: "DBX-API-1", + title: "T", + description: { kind: "text", text: "D" }, + experimentalFlags: ["X"], + afterWrite: still, + }, + ], + ])("%p", (argv, args) => { + expect(parsed(argv)).toEqual({ name: "restart", args }); + }); + + it("rejects - (no stdin for restart)", () => { + expect(usage(["restart", "DBX-API-1", "-"])).toEqual({ + message: "too many arguments for 'restart'. Expected 1 argument but got 2: DBX-API-1, -.", + command: "restart", + }); + }); + + it("describes --timeout", () => { + expect(helpText(["restart", "--help"]).split("\n")).toContain( + " -t, --timeout <duration> with --wait: give up after (e.g. 45s, 1h30m)", + ); + }); + + it("says --flag replaces the list", () => { + expect(helpText(["restart", "--help"]).split("\n")).toContain( + " --flag <name> an experimental flag; repeatable; replaces the list", + ); + }); +}); + +describe("restart: run", () => { + it("passes overrides through and prints the key", async () => { + const { ops, calls } = fakeOps({ restartTask: async () => ({ task: task(), from: FROM }) }); + const io = testIo(); + expect( + await run( + { + ...conn, + ref: "dbx-api-7", + description: { kind: "text", text: "New" }, + afterWrite: still, + }, + ops, + io, + ), + ).toBe(0); + expect(calls).toEqual([ + { + op: "restartTask", + arg: { + ref: "dbx-api-7", + title: undefined, + description: "New", + experimentalFlags: undefined, + }, + }, + ]); + expect(io.stdout).toEqual(["DBX-API-7"]); + }); + + it("--wait follows the restarted task by its key", async () => { + const { ops, calls } = fakeOps({ + restartTask: async () => ({ task: task(), from: FROM }), + followTask: () => items(done({ outcome: "completed", failed: false })), + }); + const io = testIo(); + const code = await run( + { ...conn, ref: "dbx-api-7", afterWrite: { follow: false, wait: true } }, + ops, + io, + ); + expect(code).toBe(0); + expect(calls[1]).toEqual({ + op: "followTask", + arg: { + ref: "DBX-API-7", + from: FROM, + untilDone: true, + timeoutMs: undefined, + signal: io.interrupt, + }, + }); + expect(io.stdout).toEqual(["DBX-API-7"]); + }); + + // With --json, every line of a followed write is one JSON value. + it("--json --wait prints the task on one line", async () => { + const { ops } = fakeOps({ + restartTask: async () => ({ task: task(), from: FROM }), + followTask: () => items(done({ outcome: "completed", failed: false })), + }); + const io = testIo(); + const code = await run( + { ...asJson, ref: "dbx-api-7", afterWrite: { follow: false, wait: true } }, + ops, + io, + ); + expect(code).toBe(0); + expect(io.stdout).toEqual([JSON.stringify(task())]); + }); +}); diff --git a/cli/interface/commands/__tests__/send.test.ts b/cli/interface/commands/__tests__/send.test.ts new file mode 100644 index 0000000..a068381 --- /dev/null +++ b/cli/interface/commands/__tests__/send.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "bun:test"; +import { envelope } from "../../../__tests__/fixtures"; +import { asJson, conn, done, ev, FROM, fakeOps, items, testIo } from "../../__tests__/fakes"; +import { base, parsed, still, usage } from "../../__tests__/parsing"; +import { run } from "../send"; + +describe("send: parsing", () => { + it.each([ + [ + ["send", "DBX-API-1", "hello"], + { ...base, ref: "DBX-API-1", message: { kind: "text", text: "hello" }, afterWrite: still }, + ], + [ + ["send", "DBX-API-1", "-"], + { ...base, ref: "DBX-API-1", message: { kind: "stdin" }, afterWrite: still }, + ], + [ + ["send", "DBX-API-1", "-i", "m.md"], + { ...base, ref: "DBX-API-1", message: { kind: "file", path: "m.md" }, afterWrite: still }, + ], + [ + ["send", "DBX-API-1", "hi", "--follow"], + { + ...base, + ref: "DBX-API-1", + message: { kind: "text", text: "hi" }, + afterWrite: { follow: true, wait: false }, + }, + ], + ])("%p", (argv, args) => { + expect(parsed(argv)).toEqual({ name: "send", args }); + }); + + it.each([ + [["send", "DBX-API-1"], "send needs a MESSAGE, -i FILE or -"], + [["send", "DBX-API-1", "msg", "-i", "x"], "give the text only once: inline, -i FILE or -"], + ])("rejects %p", (argv, message) => { + expect(usage(argv)).toEqual({ message, command: "send" }); + }); +}); + +describe("send: run", () => { + it("sends the text and prints the round id", async () => { + const { ops, calls } = fakeOps({ + sendMessage: async () => ({ message: { round_id: "round-2" }, from: FROM }), + }); + const io = testIo(); + expect( + await run( + { + ...conn, + ref: "DBX-API-7", + message: { kind: "text", text: "also the docs" }, + afterWrite: still, + }, + ops, + io, + ), + ).toBe(0); + expect(calls).toEqual([ + { op: "sendMessage", arg: { ref: "DBX-API-7", body: "also the docs" } }, + ]); + expect(io.stdout).toEqual(["round-2"]); + }); + + it("reads the message from stdin and prints the response as JSON", async () => { + const { ops, calls } = fakeOps({ + sendMessage: async () => ({ message: { round_id: "round-2" }, from: FROM }), + }); + const io = testIo({ stdin: "piped" }); + expect( + await run( + { ...asJson, ref: "DBX-API-7", message: { kind: "stdin" }, afterWrite: still }, + ops, + io, + ), + ).toBe(0); + expect(calls).toEqual([{ op: "sendMessage", arg: { ref: "DBX-API-7", body: "piped" } }]); + expect(JSON.parse(io.stdout.join("\n"))).toEqual({ round_id: "round-2" }); + }); + + it("--follow --wait prints the log once and exits with the outcome", async () => { + const queued = envelope(6, "message_queued", { body: "also the docs" }, "round-2"); + const answer = envelope(7, "agent_response", { body: "Docs updated." }, "round-2"); + const end = envelope(8, "completed", { status: "partial_failure" }, "round-2"); + const { ops, calls } = fakeOps({ + sendMessage: async () => ({ message: { round_id: "round-2" }, from: FROM }), + followTask: () => + items( + ev(queued), + ev(answer), + ev(end), + done({ outcome: "partial_failure", failed: false, response: answer }), + ), + }); + const io = testIo(); + const code = await run( + { + ...conn, + ref: "dbx-api-7", + message: { kind: "text", text: "also the docs" }, + afterWrite: { follow: true, wait: true }, + }, + ops, + io, + ); + expect(code).toBe(2); + expect(calls).toEqual([ + { op: "sendMessage", arg: { ref: "dbx-api-7", body: "also the docs" } }, + { + op: "followTask", + arg: { + ref: "dbx-api-7", + from: FROM, + untilDone: true, + timeoutMs: undefined, + signal: io.interrupt, + }, + }, + ]); + expect(io.stdout).toEqual([ + "round-2", + "12:00:06 → also the docs", + "12:00:07 ✉ Docs updated.", + "12:00:08 ✔ completed (partial_failure)", + ]); + }); + + // With --json, every line of a followed write is one JSON value. + it("--json --follow --wait prints one JSON value per line", async () => { + const queued = envelope(6, "message_queued", { body: "also the docs" }, "round-2"); + const { ops } = fakeOps({ + sendMessage: async () => ({ message: { round_id: "round-2" }, from: FROM }), + followTask: () => items(ev(queued), done({ outcome: "completed", failed: false })), + }); + const io = testIo(); + const code = await run( + { + ...asJson, + ref: "dbx-api-7", + message: { kind: "text", text: "also the docs" }, + afterWrite: { follow: true, wait: true }, + }, + ops, + io, + ); + expect(code).toBe(0); + const lines = io.stdout.join("\n").split("\n"); + expect(lines.map((line) => JSON.parse(line))).toEqual([{ round_id: "round-2" }, queued]); + }); + + it("--follow --wait prints the reason a round failed", async () => { + const queued = envelope(6, "message_queued", { body: "also the docs" }, "round-2"); + const { ops } = fakeOps({ + sendMessage: async () => ({ message: { round_id: "round-2" }, from: FROM }), + followTask: () => + items(ev(queued), done({ outcome: null, failed: true, reason: "superseded by a restart" })), + }); + const io = testIo(); + const code = await run( + { + ...conn, + ref: "dbx-api-7", + message: { kind: "text", text: "also the docs" }, + afterWrite: { follow: true, wait: true }, + }, + ops, + io, + ); + expect(code).toBe(1); + expect(io.stdout).toEqual(["round-2", "12:00:06 → also the docs"]); + expect(io.stderr).toEqual(["devbox: dbx-api-7 failed: superseded by a restart"]); + }); +}); diff --git a/cli/interface/commands/__tests__/show.test.ts b/cli/interface/commands/__tests__/show.test.ts new file mode 100644 index 0000000..881218f --- /dev/null +++ b/cli/interface/commands/__tests__/show.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "bun:test"; +import { NOW, task } from "../../../__tests__/fixtures"; +import { asJson, conn, fakeOps, testIo } from "../../__tests__/fakes"; +import { base, parsed, usage } from "../../__tests__/parsing"; +import { render, run } from "../show"; + +describe("show: parsing", () => { + it("takes a ref", () => { + expect(parsed(["show", "ABC-1"])).toEqual({ name: "show", args: { ...base, ref: "ABC-1" } }); + }); + + it("needs a ref", () => { + expect(usage(["show"])).toEqual({ + message: "missing required argument 'ref'", + command: "show", + }); + }); +}); + +describe("show: render", () => { + const t = task({ + state: "running", + sandbox_live: true, + experimental_flags: ["FOO", "BAR"], + prs: [ + { + repo: "rudderlabs/rudder-server", + url: "https://github.com/rudderlabs/rudder-server/pull/1234", + branch: "devbox/DBX-API-7-fix", + }, + ], + rounds: [ + { + id: "round-2", + source: "api", + kind: "comment", + status: "delivered", + outcome: null, + failure_reason: null, + created_at: "2026-10-12T12:20:00.000Z", + }, + { + id: "round-1", + source: "api", + kind: "assignment", + status: "failed", + outcome: null, + failure_reason: "The sandbox could not start.", + created_at: "2026-10-12T12:00:00.000Z", + }, + ], + }); + + it("renders the detail view", () => { + expect(render(t, NOW)).toBe( + [ + "DBX-API-7 running Fix flaky test", + " id 0192f000-0000-7000-8000-000000000007", + " source api", + " creator frodo", + " flags FOO, BAR", + " sandbox live", + " url https://cp.internal/api/tasks/DBX-API-7", + " created 30m ago", + " prs https://github.com/rudderlabs/rudder-server/pull/1234 (devbox/DBX-API-7-fix)", + " rounds comment · delivered · 10m ago", + " assignment · failed · 30m ago · The sandbox could not start.", + ].join("\n"), + ); + }); + + it("colours key, state, labels and links when asked", () => { + expect(render(t, NOW, { color: true })).toBe( + [ + "\x1b[1mDBX-API-7\x1b[0m \x1b[36mrunning\x1b[0m Fix flaky test", + " \x1b[2mid \x1b[0m0192f000-0000-7000-8000-000000000007", + " \x1b[2msource \x1b[0mapi", + " \x1b[2mcreator \x1b[0mfrodo", + " \x1b[2mflags \x1b[0mFOO, BAR", + " \x1b[2msandbox \x1b[0mlive", + " \x1b[2murl \x1b[0m\x1b[36mhttps://cp.internal/api/tasks/DBX-API-7\x1b[0m", + " \x1b[2mcreated \x1b[0m30m ago", + " \x1b[2mprs \x1b[0m\x1b[36mhttps://github.com/rudderlabs/rudder-server/pull/1234\x1b[0m (devbox/DBX-API-7-fix)", + " \x1b[2mrounds \x1b[0mcomment · delivered · 10m ago", + " assignment · failed · 30m ago · The sandbox could not start.", + ].join("\n"), + ); + }); + + it("renders empty fields as -", () => { + expect(render(task({ created_by: null }), NOW)).toBe( + [ + "DBX-API-7 queued Fix flaky test", + " id 0192f000-0000-7000-8000-000000000007", + " source api", + " creator -", + " flags -", + " sandbox none", + " url https://cp.internal/api/tasks/DBX-API-7", + " created 30m ago", + " prs -", + " rounds -", + ].join("\n"), + ); + }); +}); + +describe("show: run", () => { + it("prints the detail view, or JSON", async () => { + const { ops, calls } = fakeOps({ showTask: async () => task() }); + const io = testIo(); + expect(await run({ ...conn, ref: "DBX-API-7" }, ops, io)).toBe(0); + expect(calls).toEqual([{ op: "showTask", arg: "DBX-API-7" }]); + expect(io.stdout).toEqual([render(task(), NOW)]); + + const json = testIo(); + await run({ ...asJson, ref: "DBX-API-7" }, ops, json); + expect(JSON.parse(json.stdout.join("\n"))).toEqual(task()); + }); +}); diff --git a/cli/interface/commands/__tests__/wait.test.ts b/cli/interface/commands/__tests__/wait.test.ts new file mode 100644 index 0000000..e6315ce --- /dev/null +++ b/cli/interface/commands/__tests__/wait.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "bun:test"; +import { envelope } from "../../../__tests__/fixtures"; +import { type FollowItem, NoEventLog } from "../../../core/events"; +import { conn, done, ev, failing, fakeOps, items, testIo, thenHang } from "../../__tests__/fakes"; +import { base, helpText, parsed, usage } from "../../__tests__/parsing"; +import { run } from "../wait"; + +describe("wait: parsing", () => { + it.each([ + [["wait", "DBX-API-1", "-t", "45s"], { ...base, ref: "DBX-API-1", timeoutMs: 45_000 }], + [["wait", "DBX-API-1", "--timeout", "45s"], { ...base, ref: "DBX-API-1", timeoutMs: 45_000 }], + [["wait", "DBX-API-1"], { ...base, ref: "DBX-API-1" }], + [ + ["wait", "DBX-API-1", "--timeout", "596h"], + { ...base, ref: "DBX-API-1", timeoutMs: 2_145_600_000 }, + ], + ])("%p", (argv, args) => { + expect(parsed(argv)).toEqual({ name: "wait", args }); + }); + + it.each([ + [ + ["wait", "DBX-API-1", "--timeout", "soon"], + "option '-t, --timeout <duration>' argument 'soon' is invalid. Use e.g. 45s, 30m, 2h or 1h30m.", + ], + [ + ["wait", "DBX-API-1", "--timeout", "600"], + "option '-t, --timeout <duration>' argument '600' is invalid. Use e.g. 45s, 30m, 2h or 1h30m.", + ], + [ + ["wait", "DBX-API-1", "--timeout", "597h"], + "option '-t, --timeout <duration>' argument '597h' is invalid. The longest timeout is 596h.", + ], + ])("rejects %p", (argv, message) => { + expect(usage(argv)).toEqual({ message, command: "wait" }); + }); + + it("describes --timeout", () => { + expect(helpText(["wait", "--help"]).split("\n")).toContain( + " -t, --timeout <duration> give up after this long (e.g. 45s, 1h30m)", + ); + }); +}); + +describe("wait: run", () => { + const reply = envelope(4, "agent_response", { body: "Done.\nAll green." }); + const waitWith = ( + followTask: () => AsyncGenerator<FollowItem>, + json = false, + timeoutMs?: number, + ) => { + const { ops, calls } = fakeOps({ followTask }); + const io = testIo(); + const code = run({ connection: {}, json, ref: "DBX-API-7", timeoutMs }, ops, io); + return { code, io, calls }; + }; + + it("follows from the latest round and prints the response", async () => { + const { code, io, calls } = waitWith( + () => items(ev(reply), done({ outcome: "completed", failed: false, response: reply })), + false, + 45_000, + ); + expect(await code).toBe(0); + expect(io.stdout).toEqual(["Done.\nAll green."]); + expect(calls[0]?.arg).toEqual({ + ref: "DBX-API-7", + from: "latest", + untilDone: true, + timeoutMs: 45_000, + signal: io.interrupt, + }); + }); + + it("exits 2 on partial failure", async () => { + expect( + await waitWith(() => items(done({ outcome: "partial_failure", failed: false }))).code, + ).toBe(2); + }); + + it("exits 1 on failure with the reason on stderr", async () => { + const { code, io } = waitWith(() => + items(done({ outcome: null, failed: true, reason: "The sandbox could not start." })), + ); + expect(await code).toBe(1); + expect(io.stdout).toEqual([]); + expect(io.stderr).toEqual(["devbox: DBX-API-7 failed: The sandbox could not start."]); + }); + + it("exits 3 on timeout", async () => { + const { code, io } = waitWith(() => items<FollowItem>({ kind: "timeout" })); + expect(await code).toBe(3); + expect(io.stderr).toEqual(["devbox: timed out waiting for DBX-API-7"]); + }); + + it("exits 130 when interrupted", async () => { + const { ops } = fakeOps({ followTask: (o) => thenHang<FollowItem>(o.signal) }); + const io = testIo(); + setTimeout(() => io.abort(), 0); + expect(await run({ ...conn, ref: "DBX-API-7" }, ops, io)).toBe(130); + }); + + it("prints the response envelope with --json", async () => { + const { code, io } = waitWith( + () => items(done({ outcome: "completed", failed: false, response: reply })), + true, + ); + expect(await code).toBe(0); + expect(io.stdout.map((l) => JSON.parse(l))).toEqual([reply]); + }); + + it("prints nothing for a response without a body", async () => { + const bare = envelope(4, "agent_response", {}); + const { code, io } = waitWith(() => + items(done({ outcome: "completed", failed: false, response: bare })), + ); + expect(await code).toBe(0); + expect(io.stdout).toEqual([]); + }); + + it("prints a body-less response envelope with --json", async () => { + const bare = envelope(4, "agent_response", {}); + const { code, io } = waitWith( + () => items(done({ outcome: "completed", failed: false, response: bare })), + true, + ); + expect(await code).toBe(0); + expect(io.stdout.map((l) => JSON.parse(l))).toEqual([bare]); + }); + + it("lets NoEventLog propagate", async () => { + const { code } = waitWith(() => failing(new NoEventLog("ABC-1"))); + expect(await code.catch((x: unknown) => x)).toBeInstanceOf(NoEventLog); + }); +}); diff --git a/cli/interface/commands/__tests__/whoami.test.ts b/cli/interface/commands/__tests__/whoami.test.ts new file mode 100644 index 0000000..406ba7e --- /dev/null +++ b/cli/interface/commands/__tests__/whoami.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { asJson, conn, fakeOps, testIo } from "../../__tests__/fakes"; +import { base, helpText, parsed, usage } from "../../__tests__/parsing"; +import { render, run } from "../whoami"; + +const WHOAMI_HELP = `Usage: devbox whoami [options] + +Show the token the API sees + +Options: + --url <url> API base URL (default: $DEVBOX_CLI_URL or config) + --token <token> API token (default: $DEVBOX_CLI_TOKEN or config) + --profile <name> profile from the config file (default: $DEVBOX_CLI_PROFILE + or the current one) + --json print JSON instead of text + -h, --help display help for command`; + +describe("whoami: parsing", () => { + it("has its own help", () => { + expect(helpText(["whoami", "--help"])).toBe(WHOAMI_HELP); + }); + + it.each([ + [["whoami"], base], + [ + ["whoami", "--url", "http://cp", "--token", "dbx_a_b", "--json"], + { connection: { url: "http://cp", token: "dbx_a_b" }, json: true }, + ], + ])("%p", (argv, args) => { + expect(parsed(argv)).toEqual({ name: "whoami", args }); + }); + + it.each([ + [["whoami", "--verbose"], "unknown option '--verbose'"], + [ + ["whoami", "extra"], + "too many arguments for 'whoami'. Expected 0 arguments but got 1: extra.", + ], + ])("rejects %p", (argv, message) => { + expect(usage(argv)).toEqual({ message, command: "whoami" }); + }); +}); + +describe("whoami: render", () => { + it("names the token, or says auth is off", () => { + expect(render({ token: { id: "tok-1", name: "frodo" } })).toBe("frodo (token tok-1)"); + expect(render({ token: null, auth: "disabled" })).toBe("auth is disabled on this server"); + }); +}); + +describe("whoami: run", () => { + it("prints the token name, or JSON", async () => { + const me = { token: { id: "tok-1", name: "frodo" } }; + const { ops, calls } = fakeOps({ whoami: async () => me }); + const io = testIo(); + expect(await run(conn, ops, io)).toBe(0); + expect(io.stdout).toEqual(["frodo (token tok-1)"]); + expect(calls).toEqual([{ op: "whoami", arg: undefined }]); + + const json = testIo(); + await run(asJson, ops, json); + expect(JSON.parse(json.stdout.join("\n"))).toEqual(me); + }); +}); diff --git a/cli/interface/commands/create.ts b/cli/interface/commands/create.ts new file mode 100644 index 0000000..7500dd8 --- /dev/null +++ b/cli/interface/commands/create.ts @@ -0,0 +1,71 @@ +// devbox create: create a task and print its key. +import type { CoreOps } from "../../core/ops"; +import { + type CommandModule, + type CommonArgs, + type FollowFlags, + type TextSource, + UsageError, +} from "../command"; +import { followAfterWrite, writtenJson } from "../follow"; +import { readOptional } from "../input"; +import type { Io } from "../io"; +import { + common, + commonArgs, + definition, + followFlags, + following, + type Opts, + oneSource, +} from "../options"; + +export type CreateArgs = CommonArgs & { + title: string; + description?: TextSource; + experimentalFlags: string[]; + afterWrite: FollowFlags; +}; + +export async function run(args: CreateArgs, ops: CoreOps, io: Io): Promise<number> { + const { task, from } = await ops.createTask({ + title: args.title, + description: await readOptional(args.description, io), + experimentalFlags: args.experimentalFlags, + }); + io.out(args.json ? writtenJson(task, args.afterWrite) : task.key); + return followAfterWrite(ops, io, args, task.key, from); +} + +export const create: CommandModule = { + register(program, parsed) { + common( + following( + definition( + program + .command("create") + .description("Create a task and print its key") + .argument("<title>", "the task title") + .argument("[input]", "- reads the description from stdin"), + "an experimental flag; repeat for more", + ), + ), + ).action((title: string, input: string | undefined, o: Opts) => { + if (input !== undefined && input !== "-") { + throw new UsageError(`unexpected argument "${input}"`, "create"); + } + const args: CreateArgs = { + ...commonArgs(o), + title, + description: oneSource("create", [ + o.description === undefined ? undefined : { kind: "text", text: o.description }, + o.input === undefined ? undefined : { kind: "file", path: o.input }, + input === "-" ? { kind: "stdin" } : undefined, + ]), + experimentalFlags: o.flag ?? [], + afterWrite: followFlags(o, "create"), + }; + parsed({ kind: "command", name: "create", args, run: (ops, io) => run(args, ops, io) }); + }); + }, +}; diff --git a/cli/interface/commands/index.ts b/cli/interface/commands/index.ts new file mode 100644 index 0000000..41f16e7 --- /dev/null +++ b/cli/interface/commands/index.ts @@ -0,0 +1,23 @@ +// Every devbox command, in the order the root help lists them. +import type { CommandModule } from "../command"; +import { create } from "./create"; +import { logs } from "./logs"; +import { ls } from "./ls"; +import { profile } from "./profile"; +import { restart } from "./restart"; +import { send } from "./send"; +import { show } from "./show"; +import { wait } from "./wait"; +import { whoami } from "./whoami"; + +export const COMMANDS: CommandModule[] = [ + whoami, + create, + ls, + show, + send, + restart, + logs, + wait, + profile, +]; diff --git a/cli/interface/commands/logs.ts b/cli/interface/commands/logs.ts new file mode 100644 index 0000000..264f9dc --- /dev/null +++ b/cli/interface/commands/logs.ts @@ -0,0 +1,66 @@ +// devbox logs: print a task's event log, follow it live with -f, or follow it +// until the latest round is done with -w. +import { NoEventLog } from "../../core/events"; +import type { CoreOps } from "../../core/ops"; +import { type CommandModule, type CommonArgs, UsageError } from "../command"; +import { EXIT } from "../exit"; +import { follow, printEvent } from "../follow"; +import type { Io } from "../io"; +import { common, commonArgs, integer, type Opts, timeout } from "../options"; + +export type LogsArgs = CommonArgs & { + ref: string; + follow: boolean; + wait: boolean; + timeoutMs?: number; + after: number; +}; + +export async function run(args: LogsArgs, ops: CoreOps, io: Io): Promise<number> { + if (args.follow) { + return follow(ops, io, { + ref: args.ref, + from: { after: args.after }, + json: args.json, + untilDone: args.wait, + timeoutMs: args.timeoutMs, + }); + } + try { + for await (const event of ops.taskHistory({ ref: args.ref, after: args.after })) { + printEvent(io, event, args.json); + } + } catch (err) { + if (!(err instanceof NoEventLog)) throw err; + io.err(`devbox: ${err.message}`); + } + return EXIT.ok; +} + +export const logs: CommandModule = { + register(program, parsed) { + common( + program + .command("logs") + .description("Print a task's event log") + .argument("<ref>", "task key or id") + .option("-f, --follow", "keep following the log live") + .option("-w, --wait", "follow until the task's latest round is done (implies --follow)") + .option("-t, --timeout <duration>", "with --wait: give up after (e.g. 45s, 1h30m)", timeout) + .option("--after <id>", "start after this event id", integer(0)), + ).action((ref: string, o: Opts) => { + if (o.timeout !== undefined && o.wait !== true) { + throw new UsageError("--timeout needs --wait", "logs"); + } + const args: LogsArgs = { + ...commonArgs(o), + ref, + follow: o.follow === true || o.wait === true, + wait: o.wait === true, + timeoutMs: o.timeout, + after: o.after ?? 0, + }; + parsed({ kind: "command", name: "logs", args, run: (ops, io) => run(args, ops, io) }); + }); + }, +}; diff --git a/cli/interface/commands/ls.ts b/cli/interface/commands/ls.ts new file mode 100644 index 0000000..e9a71f5 --- /dev/null +++ b/cli/interface/commands/ls.ts @@ -0,0 +1,99 @@ +// devbox ls: list tasks, newest first. +import { Option } from "commander"; +import type { ApiTask, ApiTaskSource, ApiTaskState } from "../../../control-plane/src/types/api"; +import type { CoreOps } from "../../core/ops"; +import type { CommandModule, CommonArgs } from "../command"; +import { age, oneLine, paint, STATE_TONE, type Tone } from "../format"; +import { colorEnabled, type Io } from "../io"; +import { common, commonArgs, integer, type Opts } from "../options"; + +export type LsArgs = CommonArgs & { + source?: ApiTaskSource; + mine: boolean; + state?: ApiTaskState; + limit?: number; +}; + +const HEADER = ["KEY", "STATE", "TITLE", "DESCRIPTION", "CREATOR", "AGE"]; + +export function render( + tasks: ApiTask[], + now: number, + o: { color: boolean } = { color: false }, +): string { + const rows = [ + HEADER, + ...tasks.map((t) => [ + t.key, + t.state, + oneLine(t.title, 40), + t.description === null ? "-" : oneLine(t.description, 40), + t.created_by?.name ?? "-", + age(t.created_at, now), + ]), + ]; + const widths = HEADER.map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length))); + const line = (r: string[], tone: (i: number) => Tone | null): string => + r + .map((c, i) => { + const cell = i === r.length - 1 ? c : c.padEnd(widths[i] ?? 0); + return paint(cell, tone(i), o.color); + }) + .join(" "); + const [head = HEADER, ...body] = rows; + return [ + paint( + line(head, () => null), + "dim", + o.color, + ), + ...body.map((r) => + line(r, (i) => (i === 0 ? "bold" : i === 1 ? STATE_TONE[r[1] as ApiTaskState] : null)), + ), + ].join("\n"); +} + +export async function run(args: LsArgs, ops: CoreOps, io: Io): Promise<number> { + const tasks = await ops.listTasks({ + source: args.source, + mine: args.mine, + state: args.state, + limit: args.limit, + }); + if (args.json) io.out(JSON.stringify(tasks, null, 2)); + else + io.out(tasks.length === 0 ? "No tasks." : render(tasks, io.now(), { color: colorEnabled(io) })); + return 0; +} + +export const ls: CommandModule = { + register(program, parsed) { + common( + program + .command("ls") + .description("List tasks, newest first") + .addOption( + new Option("--source <source>", "only tasks from this source").choices(["api", "linear"]), + ) + .option("--mine", "only tasks created with your token") + .addOption( + new Option("--state <state>", "only tasks in this state").choices([ + "queued", + "running", + "idle", + "failed", + ]), + ) + .option("--limit <n>", "list at most n tasks (default: all)", integer(1)), + ).action((o: Opts) => { + const args: LsArgs = { + ...commonArgs(o), + source: o.source, + mine: o.mine === true, + state: o.state, + limit: o.limit, + }; + parsed({ kind: "command", name: "ls", args, run: (ops, io) => run(args, ops, io) }); + }); + }, +}; diff --git a/cli/interface/commands/profile.ts b/cli/interface/commands/profile.ts new file mode 100644 index 0000000..3ac565e --- /dev/null +++ b/cli/interface/commands/profile.ts @@ -0,0 +1,113 @@ +// devbox profile ls|use|add: list and edit the profiles in the CLI config +// file. These run without a control plane. +import type { CommandModule } from "../command"; +import { UsageError } from "../command"; +import { configFilePath, loadConfigFile, writeConfigFile } from "../configFile"; +import type { Io } from "../io"; +import { EMPTY_PROFILE_FILE, ProfileFileError, profileOf, withProfile } from "../profiles"; + +function load(io: Io) { + const path = configFilePath(io.env, io.configFs); + return { path, file: loadConfigFile(path, io.configFs, io.err) ?? EMPTY_PROFILE_FILE }; +} + +export async function runLs(json: boolean, io: Io): Promise<number> { + const { file } = load(io); + const names = Object.keys(file.profiles).sort(); + if (json) { + io.out( + JSON.stringify( + { + current: file.current ?? null, + profiles: names.map((name) => ({ name, url: file.profiles[name]?.url })), + }, + null, + 2, + ), + ); + return 0; + } + if (names.length === 0) { + io.out("no profiles: run `bun run up` or `bun run k8s:install`"); + return 0; + } + const width = Math.max(...names.map((n) => n.length)); + for (const name of names) { + io.out( + `${name === file.current ? "*" : " "} ${name.padEnd(width)} ${file.profiles[name]?.url}`, + ); + } + return 0; +} + +export async function runUse(name: string, io: Io): Promise<number> { + const { path, file } = load(io); + if (!profileOf(file, name)) throw new UsageError(`no profile "${name}" in ${path}`, "profile"); + writeConfigFile(path, { ...file, current: name }, io.configFs); + io.out(`Using profile ${name}`); + return 0; +} + +export async function runAdd( + name: string, + opts: { url: string; token: string; use: boolean }, + io: Io, +): Promise<number> { + const url = URL.canParse(opts.url) ? new URL(opts.url) : undefined; + if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) { + throw new UsageError(`--url must be an http(s) URL, got ${opts.url}`, "profile"); + } + const { path, file } = load(io); + let next: ReturnType<typeof withProfile>; + try { + next = withProfile( + file, + name, + { url: opts.url.replace(/\/+$/, ""), token: opts.token }, + opts.use, + ); + } catch (err) { + if (err instanceof ProfileFileError) throw new UsageError(err.message, "profile"); + throw err; + } + writeConfigFile(path, next, io.configFs); + io.out(`Saved profile ${name}${opts.use ? " and made it current" : ""}`); + return 0; +} + +export const profile: CommandModule = { + register(program, parsed) { + const group = program + .command("profile") + .description("List and switch the control planes the CLI talks to"); + group + .command("ls") + .description("List profiles; * marks the current one") + .allowExcessArguments(false) + .option("--json", "print JSON instead of text") + .action((o: { json?: boolean }) => + parsed({ kind: "local", name: "profile ls", run: (io) => runLs(o.json === true, io) }), + ); + group + .command("use <name>") + .description("Make a profile current") + .allowExcessArguments(false) + .action((name: string) => + parsed({ kind: "local", name: "profile use", run: (io) => runUse(name, io) }), + ); + group + .command("add <name>") + .description("Add or replace a profile (for installs made without k8s:install)") + .allowExcessArguments(false) + .requiredOption("--url <url>", "control plane URL") + .requiredOption("--token <token>", "API token") + .option("--use", "also make it current") + .action((name: string, o: { url: string; token: string; use?: boolean }) => + parsed({ + kind: "local", + name: "profile add", + run: (io) => runAdd(name, { url: o.url, token: o.token, use: o.use === true }, io), + }), + ); + }, +}; diff --git a/cli/interface/commands/restart.ts b/cli/interface/commands/restart.ts new file mode 100644 index 0000000..d89ab1f --- /dev/null +++ b/cli/interface/commands/restart.ts @@ -0,0 +1,64 @@ +// devbox restart: restart a task from a clean slate. +import type { CoreOps } from "../../core/ops"; +import type { CommandModule, CommonArgs, FollowFlags, TextSource } from "../command"; +import { followAfterWrite, writtenJson } from "../follow"; +import { readOptional } from "../input"; +import type { Io } from "../io"; +import { + common, + commonArgs, + definition, + followFlags, + following, + type Opts, + oneSource, +} from "../options"; + +export type RestartArgs = CommonArgs & { + ref: string; + title?: string; + description?: TextSource; + experimentalFlags?: string[]; + afterWrite: FollowFlags; +}; + +export async function run(args: RestartArgs, ops: CoreOps, io: Io): Promise<number> { + const { task, from } = await ops.restartTask({ + ref: args.ref, + title: args.title, + description: await readOptional(args.description, io), + experimentalFlags: args.experimentalFlags, + }); + io.out(args.json ? writtenJson(task, args.afterWrite) : task.key); + return followAfterWrite(ops, io, args, task.key, from); +} + +export const restart: CommandModule = { + register(program, parsed) { + common( + following( + definition( + program + .command("restart") + .description("Restart a task from a clean slate") + .argument("<ref>", "task key or id") + .option("--title <title>", "a new title"), + "an experimental flag; repeatable; replaces the list", + ), + ), + ).action((ref: string, o: Opts) => { + const args: RestartArgs = { + ...commonArgs(o), + ref, + title: o.title, + description: oneSource("restart", [ + o.description === undefined ? undefined : { kind: "text", text: o.description }, + o.input === undefined ? undefined : { kind: "file", path: o.input }, + ]), + experimentalFlags: o.flag, + afterWrite: followFlags(o, "restart"), + }; + parsed({ kind: "command", name: "restart", args, run: (ops, io) => run(args, ops, io) }); + }); + }, +}; diff --git a/cli/interface/commands/send.ts b/cli/interface/commands/send.ts new file mode 100644 index 0000000..2ec344b --- /dev/null +++ b/cli/interface/commands/send.ts @@ -0,0 +1,54 @@ +// devbox send: send a follow-up message. +import type { SendMessageResponse } from "../../../control-plane/src/types/api"; +import type { CoreOps } from "../../core/ops"; +import { + type CommandModule, + type CommonArgs, + type FollowFlags, + type TextSource, + UsageError, +} from "../command"; +import { followAfterWrite, writtenJson } from "../follow"; +import { readText } from "../input"; +import type { Io } from "../io"; +import { common, commonArgs, followFlags, following, type Opts, oneSource } from "../options"; + +export type SendArgs = CommonArgs & { + ref: string; + message: TextSource; + afterWrite: FollowFlags; +}; + +export const render = (message: SendMessageResponse): string => message.round_id; + +export async function run(args: SendArgs, ops: CoreOps, io: Io): Promise<number> { + const { message, from } = await ops.sendMessage({ + ref: args.ref, + body: await readText(args.message, io), + }); + io.out(args.json ? writtenJson(message, args.afterWrite) : render(message)); + return followAfterWrite(ops, io, args, args.ref, from); +} + +export const send: CommandModule = { + register(program, parsed) { + common( + following( + program + .command("send") + .description("Send a follow-up message") + .argument("<ref>", "task key or id") + .argument("[message]", "the message, or - to read it from stdin") + .option("-i, --input <path>", "read the message from a file"), + ), + ).action((ref: string, text: string | undefined, o: Opts) => { + const message = oneSource("send", [ + text === undefined ? undefined : text === "-" ? { kind: "stdin" } : { kind: "text", text }, + o.input === undefined ? undefined : { kind: "file", path: o.input }, + ]); + if (!message) throw new UsageError("send needs a MESSAGE, -i FILE or -", "send"); + const args: SendArgs = { ...commonArgs(o), ref, message, afterWrite: followFlags(o, "send") }; + parsed({ kind: "command", name: "send", args, run: (ops, io) => run(args, ops, io) }); + }); + }, +}; diff --git a/cli/interface/commands/show.ts b/cli/interface/commands/show.ts new file mode 100644 index 0000000..d9efe16 --- /dev/null +++ b/cli/interface/commands/show.ts @@ -0,0 +1,76 @@ +// devbox show: a task's state, sandbox, PRs and rounds. +import type { ApiRound, ApiTask } from "../../../control-plane/src/types/api"; +import type { CoreOps } from "../../core/ops"; +import type { CommandModule, CommonArgs } from "../command"; +import { age, paint, STATE_TONE } from "../format"; +import { colorEnabled, type Io } from "../io"; +import { common, commonArgs, type Opts } from "../options"; + +export type ShowArgs = CommonArgs & { ref: string }; + +type Color = { color: boolean }; + +// A labelled value; further values continue on indented lines. +const field = (label: string, values: string[], color: boolean): string[] => { + const [first = "-", ...rest] = values.length > 0 ? values : ["-"]; + return [ + ` ${paint(label.padEnd(9), "dim", color)}${first}`, + ...rest.map((v) => `${" ".repeat(11)}${v}`), + ]; +}; + +const round = (r: ApiRound, now: number): string => + [r.kind, r.status, r.outcome, `${age(r.created_at, now)} ago`, r.failure_reason] + .filter((x): x is string => typeof x === "string" && x !== "") + .join(" · "); + +export function render(t: ApiTask, now: number, o: Color = { color: false }): string { + const { color } = o; + const link = (url: string): string => paint(url, "cyan", color); + return [ + `${paint(t.key, "bold", color)} ${paint(t.state, STATE_TONE[t.state], color)} ${t.title}`, + ...field("id", [t.id], color), + ...field("source", [t.source], color), + ...field("creator", t.created_by ? [t.created_by.name] : [], color), + ...field( + "flags", + t.experimental_flags.length > 0 ? [t.experimental_flags.join(", ")] : [], + color, + ), + ...field("sandbox", [t.sandbox_live ? "live" : "none"], color), + ...field("url", [link(t.external_url)], color), + ...field("created", [`${age(t.created_at, now)} ago`], color), + ...field( + "prs", + t.prs.map((p) => `${link(p.url)} (${p.branch})`), + color, + ), + ...field( + "rounds", + t.rounds.slice(0, 5).map((r) => round(r, now)), + color, + ), + ].join("\n"); +} + +export async function run(args: ShowArgs, ops: CoreOps, io: Io): Promise<number> { + const task = await ops.showTask(args.ref); + io.out( + args.json ? JSON.stringify(task, null, 2) : render(task, io.now(), { color: colorEnabled(io) }), + ); + return 0; +} + +export const show: CommandModule = { + register(program, parsed) { + common( + program + .command("show") + .description("Show a task's state, sandbox, PRs and rounds") + .argument("<ref>", "task key or id"), + ).action((ref: string, o: Opts) => { + const args: ShowArgs = { ...commonArgs(o), ref }; + parsed({ kind: "command", name: "show", args, run: (ops, io) => run(args, ops, io) }); + }); + }, +}; diff --git a/cli/interface/commands/wait.ts b/cli/interface/commands/wait.ts new file mode 100644 index 0000000..957fbdb --- /dev/null +++ b/cli/interface/commands/wait.ts @@ -0,0 +1,34 @@ +// devbox wait: block until a task is done and print its final response. +import type { CoreOps } from "../../core/ops"; +import type { CommandModule, CommonArgs } from "../command"; +import { follow } from "../follow"; +import type { Io } from "../io"; +import { common, commonArgs, type Opts, timeout } from "../options"; + +export type WaitArgs = CommonArgs & { ref: string; timeoutMs?: number }; + +export function run(args: WaitArgs, ops: CoreOps, io: Io): Promise<number> { + return follow(ops, io, { + ref: args.ref, + from: "latest", + quiet: true, + untilDone: true, + timeoutMs: args.timeoutMs, + json: args.json, + }); +} + +export const wait: CommandModule = { + register(program, parsed) { + common( + program + .command("wait") + .description("Wait for a task to finish and print its final response") + .argument("<ref>", "task key or id") + .option("-t, --timeout <duration>", "give up after this long (e.g. 45s, 1h30m)", timeout), + ).action((ref: string, o: Opts) => { + const args: WaitArgs = { ...commonArgs(o), ref, timeoutMs: o.timeout }; + parsed({ kind: "command", name: "wait", args, run: (ops, io) => run(args, ops, io) }); + }); + }, +}; diff --git a/cli/interface/commands/whoami.ts b/cli/interface/commands/whoami.ts new file mode 100644 index 0000000..93ded0b --- /dev/null +++ b/cli/interface/commands/whoami.ts @@ -0,0 +1,29 @@ +// devbox whoami: show the token the API sees. +import type { MeResponse } from "../../../control-plane/src/types/api"; +import type { CoreOps } from "../../core/ops"; +import type { CommandModule, CommonArgs } from "../command"; +import type { Io } from "../io"; +import { common, commonArgs, type Opts } from "../options"; + +export type WhoamiArgs = CommonArgs; + +export function render(me: MeResponse): string { + return me.token ? `${me.token.name} (token ${me.token.id})` : "auth is disabled on this server"; +} + +export async function run(args: WhoamiArgs, ops: CoreOps, io: Io): Promise<number> { + const me = await ops.whoami(); + io.out(args.json ? JSON.stringify(me, null, 2) : render(me)); + return 0; +} + +export const whoami: CommandModule = { + register(program, parsed) { + common(program.command("whoami").description("Show the token the API sees")).action( + (o: Opts) => { + const args: WhoamiArgs = commonArgs(o); + parsed({ kind: "command", name: "whoami", args, run: (ops, io) => run(args, ops, io) }); + }, + ); + }, +}; diff --git a/cli/interface/config.ts b/cli/interface/config.ts new file mode 100644 index 0000000..7bdfaa5 --- /dev/null +++ b/cli/interface/config.ts @@ -0,0 +1,50 @@ +// Resolves the API URL and token: flags, then env, then a profile from the +// CLI config file that `bun run up` and `bun run k8s:install` write +// (configFile.ts, profiles.ts). +import type { ClientConfig } from "../core/api"; +import { type Connection, UsageError } from "./command"; +import { type ProfileFile, profileOf } from "./profiles"; + +export type ConfigFile = { path: string; load: () => ProfileFile | undefined }; + +// `tokenFrom` names the file and profile when the token came from it, so a +// rejected token can be explained. +export type ResolvedConfig = { + client: ClientConfig; + tokenFrom?: { path: string; profile: string }; +}; + +export function resolveConfig( + connection: Connection, + env: Record<string, string | undefined>, + file: ConfigFile, +): ResolvedConfig { + const token = connection.token || env.DEVBOX_CLI_TOKEN; + const url = connection.url || env.DEVBOX_CLI_URL; + // A URL from a flag or env never reads the file: its token belongs to its URL. + if (url) return { client: { url: trim(url), ...(token ? { token } : {}) } }; + const saved = file.load(); + const names = Object.keys(saved?.profiles ?? {}).sort(); + if (!saved || names.length === 0) { + throw new UsageError( + `no API URL and no profile in ${file.path}: run \`bun run up\` or \`bun run k8s:install\`, or pass --url/--token`, + ); + } + const name = connection.profile || env.DEVBOX_CLI_PROFILE || saved.current; + if (!name) { + throw new UsageError( + `no current profile in ${file.path}: run \`devbox profile use <name>\` (profiles: ${names.join(", ")})`, + ); + } + const profile = profileOf(saved, name); + if (!profile) { + throw new UsageError(`no profile "${name}" in ${file.path} (profiles: ${names.join(", ")})`); + } + if (token) return { client: { url: trim(profile.url), token } }; + return { + client: { url: trim(profile.url), ...(profile.token ? { token: profile.token } : {}) }, + ...(profile.token ? { tokenFrom: { path: file.path, profile: name } } : {}), + }; +} + +const trim = (url: string) => url.replace(/\/+$/, ""); diff --git a/cli/interface/configFile.ts b/cli/interface/configFile.ts new file mode 100644 index 0000000..1aaa02f --- /dev/null +++ b/cli/interface/configFile.ts @@ -0,0 +1,78 @@ +// The CLI config file is $XDG_CONFIG_HOME/devbox/config.json, or +// ~/.config/devbox/config.json when XDG_CONFIG_HOME is unset. It holds named +// profiles (profiles.ts). +// `bun run up`, `bun run k8s:install` and `devbox profile` write it. +import { readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { UsageError } from "./usageError"; +import { writePrivateFile } from "./privateFile"; +import { + type ProfileFile, + ProfileFileError, + parseProfileFile, + serializeProfileFile, +} from "./profiles"; + +// File access, injected so tests never touch the real home directory. +export type ConfigFs = { + home: () => string; + // Throw an error with a `code` (e.g. ENOENT, EACCES) on failure. + read: (path: string) => string; + mode: (path: string) => number; + write: (path: string, text: string) => void; +}; + +export const realConfigFs: ConfigFs = { + home: homedir, + read: (path) => readFileSync(path, "utf8"), + mode: (path) => statSync(path).mode & 0o777, + write: writePrivateFile, +}; + +const HINT = "run `bun run up` or `bun run k8s:install`, or pass --url/--token"; + +export function configFilePath(env: Record<string, string | undefined>, fs: ConfigFs): string { + return join(env.XDG_CONFIG_HOME || join(fs.home(), ".config"), "devbox", "config.json"); +} + +// The file's contents, or undefined when it doesn't exist. Anything else wrong +// with it is a usage error naming the path and what to run. +export function loadConfigFile( + path: string, + fs: ConfigFs, + warn: (line: string) => void, +): ProfileFile | undefined { + let text: string; + try { + text = fs.read(path); + } catch (err) { + const code = (err as { code?: unknown }).code; + if (code === "ENOENT") return undefined; + throw new UsageError( + `cannot read ${path} (${typeof code === "string" ? code : "error"}): ${HINT}`, + ); + } + let file: ProfileFile; + try { + file = parseProfileFile(text); + } catch (err) { + if (err instanceof ProfileFileError) throw new UsageError(`${path} ${err.message}: ${HINT}`); + throw err; + } + try { + const mode = fs.mode(path); + if (mode & 0o077) { + warn( + `devbox: warning: ${path} has mode 0${mode.toString(8)}, so other users may read its token; run: chmod 600 ${path}`, + ); + } + } catch { + // Read a moment ago; a failed stat only skips the warning. + } + return file; +} + +export function writeConfigFile(path: string, file: ProfileFile, fs: ConfigFs): void { + fs.write(path, serializeProfileFile(file)); +} diff --git a/cli/interface/duration.ts b/cli/interface/duration.ts new file mode 100644 index 0000000..fe9ecb9 --- /dev/null +++ b/cli/interface/duration.ts @@ -0,0 +1,11 @@ +// --timeout values: Go-style h/m/s parts, e.g. 45s, 30m, 2h, 1h30m. A bare +// number is rejected because seconds-or-minutes would be a guess. +const DURATION_RE = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/; + +export function parseDuration(text: string): number | null { + const m = DURATION_RE.exec(text); + if (text === "" || !m) return null; + const [, h = "0", min = "0", s = "0"] = m; + const ms = ((Number(h) * 60 + Number(min)) * 60 + Number(s)) * 1000; + return ms > 0 ? ms : null; +} diff --git a/cli/interface/exit.ts b/cli/interface/exit.ts new file mode 100644 index 0000000..9078ca8 --- /dev/null +++ b/cli/interface/exit.ts @@ -0,0 +1,16 @@ +import type { RoundResult } from "../core/events"; + +// `interrupted` follows the shell exit-code convention for SIGINT. +export const EXIT = { + ok: 0, + failed: 1, + partial: 2, + timeout: 3, + error: 10, + interrupted: 130, +} as const; + +export function exitCodeFor(result: RoundResult): number { + if (result.failed) return EXIT.failed; + return result.outcome === "partial_failure" ? EXIT.partial : EXIT.ok; +} diff --git a/cli/interface/follow.ts b/cli/interface/follow.ts new file mode 100644 index 0000000..913f069 --- /dev/null +++ b/cli/interface/follow.ts @@ -0,0 +1,80 @@ +// The follow loop shared by `logs -f`/`-w`, `wait`, `--follow` and `--wait`: stream a +// task's events, print them, stop on Ctrl-C. +import type { ApiEventEnvelope } from "../../control-plane/src/types/api"; +import { type From, NoEventLog } from "../core/events"; +import type { CoreOps } from "../core/ops"; +import type { FollowFlags } from "./command"; +import { EXIT, exitCodeFor } from "./exit"; +import { colorEnabled, type Io } from "./io"; +import { renderEvent } from "./logLine"; + +export type FollowPlan = { + ref: string; + from: From; + json: boolean; + quiet?: boolean; + untilDone?: boolean; + timeoutMs?: number; +}; + +export function printEvent(io: Io, event: ApiEventEnvelope, json: boolean): void { + io.out( + json + ? JSON.stringify(event) + : renderEvent(event, { color: colorEnabled(io), timeZone: io.timeZone }), + ); +} + +export async function follow(ops: CoreOps, io: Io, plan: FollowPlan): Promise<number> { + try { + for await (const item of ops.followTask({ + ref: plan.ref, + from: plan.from, + untilDone: plan.untilDone, + timeoutMs: plan.timeoutMs, + signal: io.interrupt, + })) { + if (item.kind === "event") { + if (!plan.quiet) printEvent(io, item.event, plan.json); + } else if (item.kind === "timeout") { + io.err(`devbox: timed out waiting for ${plan.ref}`); + return EXIT.timeout; + } else { + const { result } = item; + if (plan.quiet) { + const body = (result.response?.data as { body?: unknown } | undefined)?.body; + if (result.response && plan.json) io.out(JSON.stringify(result.response)); + else if (typeof body === "string") io.out(body); + } + if (result.failed) io.err(`devbox: ${plan.ref} failed: ${result.reason}`); + return exitCodeFor(result); + } + } + } catch (err) { + if (err instanceof NoEventLog && !plan.untilDone) { + io.err(`devbox: ${err.message}`); + return EXIT.ok; + } + throw err; + } + return EXIT.interrupted; +} + +// A write's resource as --json prints it: on one line when the write is +// followed, so every line of the output is one JSON value. +export const writtenJson = (value: unknown, afterWrite: FollowFlags): string => + afterWrite.follow || afterWrite.wait ? JSON.stringify(value) : JSON.stringify(value, null, 2); + +// Follows the round a write created, as its flags ask: --follow prints the log, +// --wait ends with the round and prints its response. +export async function followAfterWrite( + ops: CoreOps, + io: Io, + args: { afterWrite: FollowFlags; json: boolean }, + ref: string, + from: From, +): Promise<number> { + const { follow: live, wait, timeoutMs } = args.afterWrite; + if (!live && !wait) return EXIT.ok; + return follow(ops, io, { ref, from, json: args.json, quiet: !live, untilDone: wait, timeoutMs }); +} diff --git a/cli/interface/format.ts b/cli/interface/format.ts new file mode 100644 index 0000000..178eefb --- /dev/null +++ b/cli/interface/format.ts @@ -0,0 +1,45 @@ +// Formatting shared by several commands (ls, show, log lines): ages, PR +// references, one-line truncation and ANSI colour. +import type { ApiTaskState } from "../../control-plane/src/types/api"; + +export function prRef(repo: string, url: string): string { + const n = /\/pull\/(\d+)/.exec(url)?.[1]; + return n && repo ? `${repo}#${n}` : url; +} + +export function age(iso: string, now: number): string { + const s = Math.max(0, Math.floor((now - Date.parse(iso)) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + return `${Math.floor(s / 86400)}d`; +} + +export type Tone = "dim" | "bold" | "red" | "green" | "yellow" | "cyan"; + +const ANSI: Record<Tone, string> = { + dim: "\x1b[2m", + bold: "\x1b[1m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", +}; + +// Wraps text in an ANSI colour when `color` is on. Empty text stays empty. +export const paint = (text: string, tone: Tone | null, color: boolean): string => + color && tone && text !== "" ? `${ANSI[tone]}${text}\x1b[0m` : text; + +export const STATE_TONE: Record<ApiTaskState, Tone> = { + queued: "yellow", + running: "cyan", + idle: "green", + failed: "red", +}; + +// One line of at most `max` characters: whitespace runs collapse to a space, +// and longer text ends in "…". +export const oneLine = (text: string, max: number): string => { + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +}; diff --git a/cli/interface/input.ts b/cli/interface/input.ts new file mode 100644 index 0000000..50f9a42 --- /dev/null +++ b/cli/interface/input.ts @@ -0,0 +1,29 @@ +// The interface reads input (inline text, files, stdin) so core only ever gets text. +import type { TextSource } from "./command"; +import type { Io } from "./io"; + +// A problem with the input itself; reported by message only. +export class InputError extends Error {} + +export async function readText(source: TextSource, io: Io): Promise<string> { + switch (source.kind) { + case "text": + return source.text; + case "stdin": + return io.readStdin(); + case "file": + try { + return await io.readFile(source.path); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new InputError(`cannot read ${source.path}: ${reason}`); + } + } +} + +export async function readOptional( + source: TextSource | undefined, + io: Io, +): Promise<string | undefined> { + return source === undefined ? undefined : readText(source, io); +} diff --git a/cli/interface/io.ts b/cli/interface/io.ts new file mode 100644 index 0000000..61c379f --- /dev/null +++ b/cli/interface/io.ts @@ -0,0 +1,21 @@ +// What the CLI touches outside itself, injected so tests run in-process with +// captured output and a frozen clock. +import type { ConfigFs } from "./configFile"; + +export type Io = { + out: (line: string) => void; + err: (line: string) => void; + readStdin: () => Promise<string>; + readFile: (path: string) => Promise<string>; + env: Record<string, string | undefined>; + // The CLI config file written by `bun run up`. + configFs: ConfigFs; + isTTY: boolean; + now: () => number; + // Aborted on Ctrl-C. + interrupt: AbortSignal; + // Undefined means the local time zone; tests pin "UTC". + timeZone?: string; +}; + +export const colorEnabled = (io: Io): boolean => io.isTTY && !io.env.NO_COLOR; diff --git a/cli/interface/logLine.ts b/cli/interface/logLine.ts new file mode 100644 index 0000000..0e23942 --- /dev/null +++ b/cli/interface/logLine.ts @@ -0,0 +1,64 @@ +// Log lines use `HH:MM:SS <marker> <text>` and are shared by `logs` and the +// follow loop behind `logs -f`, `wait`, `--follow` and `--wait`. +import type { ApiEventEnvelope } from "../../control-plane/src/types/api"; +import { paint, prRef, type Tone } from "./format"; + +export type RenderOptions = { color: boolean; timeZone?: string }; + +// Width of "HH:MM:SS ✉ ": continuation lines of a multi-line body line up under the text. +const INDENT = " ".repeat(12); + +function clock(iso: string, timeZone?: string): string { + return new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZone, + }).format(new Date(iso)); +} + +function describeEvent(e: ApiEventEnvelope): { marker: string; text: string; tone: Tone | null } { + const d = (e.data ?? {}) as Record<string, unknown>; + const s = (k: string) => (typeof d[k] === "string" ? (d[k] as string) : ""); + switch (e.type) { + case "task_created": + return { marker: "✚", text: `task created: ${s("title")}`, tone: "bold" }; + case "task_restarted": + return { marker: "↻", text: `task restarted: ${s("title")}`, tone: "bold" }; + case "message_queued": + return { marker: "→", text: s("body"), tone: null }; + case "sandbox_started": + return { marker: "▸", text: `sandbox started (${s("mode")})`, tone: null }; + case "round_failed": + return { marker: "✘", text: `round failed: ${s("reason")}`, tone: "red" }; + case "agent_thought": + return { marker: "…", text: s("body"), tone: "dim" }; + case "agent_action": + return { marker: "⚙", text: s("body"), tone: null }; + case "agent_response": + return { marker: "✉", text: s("body"), tone: null }; + case "agent_error": + return { marker: "✘", text: s("body"), tone: "red" }; + case "pr_opened": + return { marker: "⇪", text: `PR opened ${prRef(s("repo"), s("pr_url"))}`, tone: "cyan" }; + case "completed": { + const status = s("status"); + if (status === "failed") return { marker: "✘", text: "completed (failed)", tone: "red" }; + return { + marker: "✔", + text: status ? `completed (${status})` : "completed", + tone: status === "partial_failure" ? "yellow" : "green", + }; + } + default: + return { marker: "·", text: e.type, tone: "dim" }; + } +} + +export function renderEvent(e: ApiEventEnvelope, o: RenderOptions): string { + const { marker, text, tone } = describeEvent(e); + const [first = "", ...rest] = text.split("\n"); + const head = `${clock(e.created_at, o.timeZone)} ${paint(first ? `${marker} ${first}` : marker, tone, o.color)}`; + return [head, ...rest.map((l) => (l === "" ? "" : INDENT + paint(l, tone, o.color)))].join("\n"); +} diff --git a/cli/interface/main.ts b/cli/interface/main.ts new file mode 100644 index 0000000..0cb1bf1 --- /dev/null +++ b/cli/interface/main.ts @@ -0,0 +1,50 @@ +// runCli: parse argv, build the core, run the command, map errors to exit codes. +import { ApiFailure, type ClientConfig } from "../core/api"; +import { NoEventLog } from "../core/events"; +import type { CoreOps } from "../core/ops"; +import { UsageError } from "./command"; +import { type ResolvedConfig, resolveConfig } from "./config"; +import { configFilePath, loadConfigFile } from "./configFile"; +import { EXIT } from "./exit"; +import { InputError } from "./input"; +import type { Io } from "./io"; +import { parse } from "./program"; + +export type OpsFactory = (config: ClientConfig) => CoreOps; + +export async function runCli(argv: string[], io: Io, makeOps: OpsFactory): Promise<number> { + let resolved: ResolvedConfig | undefined; + try { + const parsed = parse(argv); + if (parsed.kind === "help") { + io.out(parsed.text); + return EXIT.ok; + } + if (parsed.kind === "local") return await parsed.run(io); + const path = configFilePath(io.env, io.configFs); + resolved = resolveConfig(parsed.args.connection, io.env, { + path, + load: () => loadConfigFile(path, io.configFs, io.err), + }); + return await parsed.run(makeOps(resolved.client), io); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + io.err(`devbox: ${message}`); + if (err instanceof ApiFailure && err.status === 401 && resolved?.tokenFrom) { + io.err( + `The token of profile "${resolved.tokenFrom.profile}" in ${resolved.tokenFrom.path} is no longer valid: log in again with \`bun run up\` or \`bun run k8s:install\`.`, + ); + } else if (err instanceof UsageError) { + io.err( + err.command + ? `Run 'devbox ${err.command} --help' for usage.` + : "Run 'devbox --help' for usage.", + ); + } else if ( + !(err instanceof ApiFailure || err instanceof InputError || err instanceof NoEventLog) + ) { + io.err(err instanceof Error && err.stack ? err.stack : String(err)); + } + return EXIT.error; + } +} diff --git a/cli/interface/options.ts b/cli/interface/options.ts new file mode 100644 index 0000000..a02268b --- /dev/null +++ b/cli/interface/options.ts @@ -0,0 +1,103 @@ +// Option definitions and value parsers shared by the command modules. +import { type Command, InvalidArgumentError } from "commander"; +import type { ApiTaskSource, ApiTaskState } from "../../control-plane/src/types/api"; +import { MAX_TIMEOUT_MS } from "../core/limits"; +import { type CommonArgs, type FollowFlags, type TextSource, UsageError } from "./command"; +import { parseDuration } from "./duration"; + +// Option values as commander hands them to an action. +export type Opts = { + url?: string; + token?: string; + profile?: string; + json?: boolean; + source?: ApiTaskSource; + mine?: boolean; + state?: ApiTaskState; + limit?: number; + description?: string; + input?: string; + flag?: string[]; + title?: string; + follow?: boolean; + wait?: boolean; + after?: number; + timeout?: number; +}; + +// Options every command takes, listed last in each command's help. +export function common(c: Command): Command { + return c + .allowExcessArguments(false) + .option("--url <url>", "API base URL (default: $DEVBOX_CLI_URL or config)") + .option("--token <token>", "API token (default: $DEVBOX_CLI_TOKEN or config)") + .option( + "--profile <name>", + "profile from the config file (default: $DEVBOX_CLI_PROFILE or the current one)", + ) + .option("--json", "print JSON instead of text"); +} + +export const commonArgs = (o: Opts): CommonArgs => ({ + connection: { url: o.url, token: o.token, profile: o.profile }, + json: o.json === true, +}); + +export const integer = + (min: number) => + (value: string): number => { + const n = Number(value); + if (!/^\d+$/.test(value) || !Number.isSafeInteger(n) || n < min) { + throw new InvalidArgumentError(`Expected an integer >= ${min}.`); + } + return n; + }; + +export function timeout(value: string): number { + const ms = parseDuration(value); + if (ms === null) throw new InvalidArgumentError("Use e.g. 45s, 30m, 2h or 1h30m."); + if (ms > MAX_TIMEOUT_MS) { + throw new InvalidArgumentError( + `The longest timeout is ${Math.floor(MAX_TIMEOUT_MS / 3_600_000)}h.`, + ); + } + return ms; +} + +// The description options shared by create and restart. +export function definition(c: Command, flagHelp: string): Command { + return c + .option("-d, --description <text>", "the task description") + .option("-i, --input <path>", "read the description from a file") + .option("--flag <name>", flagHelp, (value: string, previous: string[] = []) => [ + ...previous, + value, + ]); +} + +// The flags that follow a write's round, listed after the command's own options. +export function following(c: Command): Command { + return c + .option("-f, --follow", "then print the task's log as it happens") + .option("-w, --wait", "then wait until the task is idle or failed and print the response") + .option("-t, --timeout <duration>", "with --wait: give up after (e.g. 45s, 1h30m)", timeout); +} + +export function followFlags(o: Opts, command: string): FollowFlags { + if (o.timeout !== undefined && o.wait !== true) { + throw new UsageError("--timeout needs --wait", command); + } + return { follow: o.follow === true, wait: o.wait === true, timeoutMs: o.timeout }; +} + +// The one text source a command was given, or undefined for none. +export function oneSource( + command: string, + sources: Array<TextSource | undefined>, +): TextSource | undefined { + const given = sources.filter((s): s is TextSource => s !== undefined); + if (given.length > 1) { + throw new UsageError("give the text only once: inline, -i FILE or -", command); + } + return given[0]; +} diff --git a/cli/interface/privateFile.ts b/cli/interface/privateFile.ts new file mode 100644 index 0000000..6e35e46 --- /dev/null +++ b/cli/interface/privateFile.ts @@ -0,0 +1,16 @@ +// Writes a file only its owner can read. Shared by the CLI and the control +// plane's scripts, so it imports nothing but node:fs and node:path. +import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +// Written beside the target and renamed over it, so the file never exists +// with a wider mode or half-written. Mode 0600 in a 0700 directory. +export function writePrivateFile(path: string, text: string): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, text, { mode: 0o600 }); + chmodSync(tmp, 0o600); + renameSync(tmp, path); +} diff --git a/cli/interface/profiles.ts b/cli/interface/profiles.ts new file mode 100644 index 0000000..28a74b1 --- /dev/null +++ b/cli/interface/profiles.ts @@ -0,0 +1,75 @@ +// The CLI config file contains named profiles, each with a control plane URL +// and API token, plus the current profile. `bun run up` and +// `bun run k8s:install` write it (control-plane/scripts/up/cliLogin.ts); +// the CLI reads it, and `devbox profile use|add` edit it. Pure: no file +// access, and no imports, because the control plane's scripts import it too. + +export type Profile = { url: string; token: string }; +export type ProfileFile = { current: string | undefined; profiles: Record<string, Profile> }; + +export const EMPTY_PROFILE_FILE: ProfileFile = { current: undefined, profiles: {} }; + +// What a kube context sanitizes to (control-plane/scripts/k8s/profileName.ts). +export const PROFILE_NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + +export class ProfileFileError extends Error {} + +// Own properties only: "constructor" or "toString" name no profile. +export const profileOf = (file: ProfileFile, name: string): Profile | undefined => + Object.hasOwn(file.profiles, name) ? file.profiles[name] : undefined; + +const isObject = (v: unknown): v is Record<string, unknown> => + typeof v === "object" && v !== null && !Array.isArray(v); + +export function parseProfileFile(text: string): ProfileFile { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new ProfileFileError("is not valid JSON"); + } + if (!isObject(raw) || !isObject(raw.profiles)) { + throw new ProfileFileError( + 'must hold {"current": "...", "profiles": {"<name>": {"url": "...", "token": "..."}}}', + ); + } + const profiles: Record<string, Profile> = {}; + for (const [name, p] of Object.entries(raw.profiles)) { + if (!PROFILE_NAME.test(name)) throw new ProfileFileError(`has a bad profile name "${name}"`); + if (!isObject(p) || typeof p.url !== "string" || !p.url || typeof p.token !== "string") { + throw new ProfileFileError(`profile "${name}" must hold {"url": "...", "token": "..."}`); + } + profiles[name] = { url: p.url, token: p.token }; + } + const current = raw.current; + if (current !== undefined && (typeof current !== "string" || !Object.hasOwn(profiles, current))) { + throw new ProfileFileError(`"current" names no profile: ${JSON.stringify(current)}`); + } + return { current, profiles }; +} + +export function serializeProfileFile(file: ProfileFile): string { + return `${JSON.stringify({ current: file.current, profiles: file.profiles }, null, 2)}\n`; +} + +export function withProfile( + file: ProfileFile, + name: string, + profile: Profile, + makeCurrent: boolean, +): ProfileFile { + if (!PROFILE_NAME.test(name)) { + throw new ProfileFileError( + `bad profile name "${name}": use lower-case letters, digits and "-"`, + ); + } + return { + current: makeCurrent ? name : file.current, + profiles: { ...file.profiles, [name]: profile }, + }; +} + +export function withoutProfile(file: ProfileFile, name: string): ProfileFile { + const { [name]: _removed, ...profiles } = file.profiles; + return { current: file.current === name ? undefined : file.current, profiles }; +} diff --git a/cli/interface/program.ts b/cli/interface/program.ts new file mode 100644 index 0000000..7d9c115 --- /dev/null +++ b/cli/interface/program.ts @@ -0,0 +1,65 @@ +// Command-line parsing for devbox builds the commander program from the command +// modules and turns argv into a parsed command or a help request. Parsing runs +// nothing and reads no files, stdin or env. +import { Command, CommanderError } from "commander"; +import { type HelpRequest, type LocalCommand, type ParsedCommand, UsageError } from "./command"; +import { COMMANDS } from "./commands"; + +const FOOTER = ` +Environment: + DEVBOX_CLI_URL API base URL, unless --url is given + DEVBOX_CLI_TOKEN API token, unless --token is given + DEVBOX_CLI_PROFILE profile to use, unless --profile is given + +Config file: + Without --url or DEVBOX_CLI_URL, the URL and token come from a profile in + $XDG_CONFIG_HOME/devbox/config.json (default ~/.config/devbox/config.json). + \`bun run up\` writes the profile "local", \`bun run k8s:install\` one per + cluster; \`devbox profile ls|use|add\` list and edit them. --token or + DEVBOX_CLI_TOKEN overrides the profile's token. + +Exit codes of wait and --wait: + 0 done, 1 failed, 2 partial failure, 3 timeout, 10 usage or API error, + 130 interrupted`; + +export function parse(argv: string[]): ParsedCommand | LocalCommand | HelpRequest { + let result: ParsedCommand | LocalCommand | undefined; + let command: string | undefined; + let output = ""; + const program = new Command("devbox") + .description("A thin CLI over the harness control-plane API.") + .exitOverride() + .configureOutput({ + writeOut: (text) => { + output += text; + }, + writeErr: (text) => { + output += text; + }, + outputError: () => {}, + }) + .configureHelp({ helpWidth: 80 }) + .showSuggestionAfterError(true) + .addHelpText("after", FOOTER); + for (const module of COMMANDS) { + module.register(program, (parsed) => { + result = parsed; + }); + } + program.hook("preSubcommand", (_program, sub) => { + command = sub.name(); + }); + try { + program.parse(argv, { from: "user" }); + } catch (err) { + if (err instanceof UsageError) throw err; + if (!(err instanceof CommanderError)) throw err; + // --help, `devbox help [command]`, and no command at all. + if (err.code === "commander.helpDisplayed" || err.code === "commander.help") { + return { kind: "help", text: output.trimEnd() }; + } + throw new UsageError(err.message.replace(/^error: /, ""), command); + } + if (!result) throw new UsageError("no command given"); + return result; +} diff --git a/cli/interface/runtime.ts b/cli/interface/runtime.ts new file mode 100644 index 0000000..a60c9dd --- /dev/null +++ b/cli/interface/runtime.ts @@ -0,0 +1,11 @@ +// The oldest Bun devbox supports: the version of the CP Docker image, which +// CI pins as `bun-version` in .github/workflows/cli.yml (a test keeps them equal). +export const MIN_BUN_VERSION = "1.4.4"; + +// A warning for a Bun older than MIN_BUN_VERSION, or undefined. Compiled +// binaries embed the Bun that built them, so they only warn if built with an older one. +export function bunVersionWarning(version: string): string | undefined { + return Bun.semver.order(version, MIN_BUN_VERSION) < 0 + ? `devbox: Bun ${version} is older than ${MIN_BUN_VERSION}, the version devbox is built and tested with; upgrade with \`bun upgrade\`.` + : undefined; +} diff --git a/cli/interface/usageError.ts b/cli/interface/usageError.ts new file mode 100644 index 0000000..c53089d --- /dev/null +++ b/cli/interface/usageError.ts @@ -0,0 +1,11 @@ +// A bad command line or config. Its own module, without commander, so the +// control plane's scripts can import configFile.ts. +export class UsageError extends Error { + // `command` names the subcommand, for the "--help" hint. + constructor( + message: string, + readonly command?: string, + ) { + super(message); + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..b42ccce --- /dev/null +++ b/cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "devbox-cli", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "start": "bun run devbox.ts", + "typecheck": "bunx tsc --noEmit", + "test": "bun test", + "build": "bun build --compile --target=bun-darwin-arm64 devbox.ts --outfile dist/devbox-darwin-arm64 && bun build --compile --target=bun-darwin-x64 devbox.ts --outfile dist/devbox-darwin-x64 && bun build --compile --target=bun-linux-x64 devbox.ts --outfile dist/devbox-linux-x64" + }, + "dependencies": { + "commander": "^15.0.0" + }, + "devDependencies": { + "@types/bun": "latest", + "hono": "^4.13.13", + "typescript": "^7" + } +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..7abfc7a --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + // Resolve bun:test and Bun globals from cli/node_modules, including for the + // control plane's API types file outside this directory. + "types": ["bun"] + }, + // The CLI imports only types from the control plane: this one file. + "include": ["**/*.ts", "../control-plane/src/types/api.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/control-plane/.dockerignore b/control-plane/.dockerignore new file mode 100644 index 0000000..581a9bb --- /dev/null +++ b/control-plane/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.env +*.log +.DS_Store + +# Tests and their fixtures are not part of the runtime image. +**/*.test.ts +**/__tests__/ +**/__pycache__/ diff --git a/control-plane/.env.example b/control-plane/.env.example new file mode 100644 index 0000000..89f18cc --- /dev/null +++ b/control-plane/.env.example @@ -0,0 +1,72 @@ +DATABASE_URL=postgres://devbox:devbox@localhost:5432/devbox +PORT=3000 +AUTO_MIGRATE=true + +# Linear (optional). LINEAR_ENABLED=true turns it on, and then all three +# settings are required (startup fails otherwise). Unset, empty or false: off, +# and the settings are ignored. Any other value is a startup error. +LINEAR_ENABLED=false +# LINEAR_CLIENT_ID=<linear-client-id> +# LINEAR_CLIENT_SECRET=<linear-client-secret> +# LINEAR_WEBHOOK_SECRET=<linear-webhook-secret> + +# Slack task-completion notifications (optional). SLACK_ENABLED=true turns them +# on, and then the bot token (xoxb-…) and channel id are required. +SLACK_ENABLED=false +# SLACK_BOT_TOKEN=<slack-bot-token> +# SLACK_CHANNEL_ID=<slack-channel-id> + +# The CP's own GitHub token. The Docker Compose stack (`bun run up`) does not +# read this file: it passes GITHUB_TOKEN from the shell, `gh auth token` or a +# prompt, and also uses it for sandboxes through iron-proxy. +GITHUB_TOKEN=<github-token> + +# GitHub webhooks (optional). GITHUB_WEBHOOKS_ENABLED=true enables +# /webhook/github, and then GITHUB_WEBHOOK_SECRET, GITHUB_BOT_LOGIN, +# GITHUB_BOT_USER_ID and at least one of GITHUB_TRUSTED_ORGS / +# GITHUB_TRUSTED_USERS are required (startup fails otherwise). +GITHUB_WEBHOOKS_ENABLED=false +# GITHUB_WEBHOOK_SECRET=<github-webhook-secret> +# Login of the GitHub App / bot account. @<login> mentions trigger it. +# GITHUB_BOT_LOGIN=<bot-login> +# Numeric GitHub user id of the bot account (sender.id of the bot's webhooks). +# Used by the workflow_run trust gate. +# GITHUB_BOT_USER_ID=<github-bot-user-id> + +# Whose comments the agent trusts, no defaults (fail closed). Comma- or +# space-separated. ORGS: every member of these orgs. USERS: these GitHub logins, +# resolved to numeric ids through the GitHub API. Both are forwarded to the +# sandbox for PR-discussion trust. +# GITHUB_TRUSTED_ORGS=<org> +# GITHUB_TRUSTED_USERS=<login> + +SANDBOX_RESOURCE_CPU=2 +SANDBOX_RESOURCE_MEMORY=24Gi +# Only the HARNESS_* settings explicitly listed in src/forwardedEnv.ts are +# forwarded to sandboxes. Never put a real secret under a forwarded name. +# The LLM endpoint. The control plane does not check it at startup, but the +# harness needs it: without it, a task fails at its first model call. With +# `bun run up`, these come from devbox.config.json's `llm` and iron-proxy +# intercepts the host. +# openai (default, the Responses API) or anthropic (the Messages API). +# HARNESS_LLM_PROVIDER=openai +# openai: the base URL with /v1. anthropic: the API root without /v1. +HARNESS_LLM_BASE_URL=https://<llm-host>/v1 +# HARNESS_MODEL=gpt-5.6-sol +# HARNESS_LLM_API_KEY is forwarded to sandboxes, so it only ever holds the +# dummy the egress proxy swaps for the real key (docker-compose.yml sets it; +# the real key is LLM_API_KEY, read by iron-proxy). +HARNESS_POLL_INTERVAL_SECONDS=0.5 +# Numeric GitHub user ids of bot accounts (e.g. review bots) trusted as +# PR-discussion authors alongside the trusted orgs and users above. +# Comma/space-separated. Optional: unset trusts no bot ids. +# HARNESS_TRUSTED_BOT_IDS=<bot-user-id> + +# /api (devbox HTTP interface). Off by default. +API_ENABLED=false +# API_AUTH_ENABLED=true +# API_PUBLIC_BASE_URL=http://localhost:3000 +# API_ALLOWED_HOSTS= +# API_TASK_KEY_PREFIX=DBX-API- +# API_SSE_POLL_MS=1000 +# API_SSE_MAX_DURATION_MS=600000 diff --git a/control-plane/.gitignore b/control-plane/.gitignore new file mode 100644 index 0000000..ae36bde --- /dev/null +++ b/control-plane/.gitignore @@ -0,0 +1,54 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +__pycache__/ +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# VS Code (local editor/debugger config) +.vscode + +# Claude Code (local agent config) +.claude + +# Local dev-test sandbox connection state +.dev-test.state +.dev-test.state.lock + +# Per-run evaluation inputs, source archives, and sandbox launch records +.eval-runs/ + +# Finder (MacOS) folder config +.DS_Store + +# Local stack (`bun run up`): iron-proxy CA, OpenSandbox API key, and the +# iron-proxy config generated from devbox.config.json +infra/local/certs/ +infra/local/opensandbox.key +infra/local/iron-proxy.yaml diff --git a/control-plane/Dockerfile b/control-plane/Dockerfile new file mode 100644 index 0000000..0a4f43d --- /dev/null +++ b/control-plane/Dockerfile @@ -0,0 +1,52 @@ +FROM oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d AS deps +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production + +FROM oven/bun:1.4.4-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4 AS runtime +WORKDIR /app +ENV NODE_ENV=production + +# kubectl is required at runtime: src/clients/k8s.ts shells out to it for +# VolumeSnapshot/PVC operations because Bun's node:https mTLS path is broken +# (oven-sh/bun#21643). In-cluster auth uses the SA token mounted by k8s, so +# kubectl works without extra config. Pinned to track recent Kubernetes minors. +# +# SHA256s are pinned in the Dockerfile (not fetched at build time) so a +# compromised dl.k8s.io mirror can't serve a matching binary+sha pair. When +# bumping KUBECTL_VERSION, update both digests from the upstream +# kubectl.sha256 files for each arch. +ARG KUBECTL_VERSION=v1.37.1 +ARG KUBECTL_SHA256_AMD64=65691ff77eb6fa44c908b77a1082c9f092c3b9733b5cefabec0d1104890e21a8 +ARG KUBECTL_SHA256_ARM64=ff749f4b78d9c4f1ec87307df9b50119ed819e2094aa9810cb9acffc3286c8c7 +# git and openssl: the chart's snapshot builder and bootstrap Job run in this image. +# The upgrade picks up Debian security fixes newer than the pinned base digest. +RUN set -eux \ + && apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends curl ca-certificates git openssl \ + && apt-mark manual ca-certificates git openssl \ + && arch="$(dpkg --print-architecture)" \ + && case "${arch}" in \ + amd64) expected="${KUBECTL_SHA256_AMD64}" ;; \ + arm64) expected="${KUBECTL_SHA256_ARM64}" ;; \ + *) echo "unsupported arch: ${arch}" >&2; exit 1 ;; \ + esac \ + && curl -fsSLo /usr/local/bin/kubectl \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${arch}/kubectl" \ + && echo "${expected} /usr/local/bin/kubectl" | sha256sum -c - \ + && chmod +x /usr/local/bin/kubectl \ + && kubectl version --client \ + && apt-get purge -y curl \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=deps /app/node_modules ./node_modules +COPY --chown=bun:bun package.json bun.lock ./ +COPY --chown=bun:bun src ./src +COPY --chown=bun:bun scripts ./scripts +COPY --chown=bun:bun drizzle ./drizzle + +USER bun +EXPOSE 3000 +CMD ["bun", "run", "start"] diff --git a/control-plane/Dockerfile.dev b/control-plane/Dockerfile.dev new file mode 100644 index 0000000..e348ae0 --- /dev/null +++ b/control-plane/Dockerfile.dev @@ -0,0 +1,26 @@ +FROM oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d + +WORKDIR /app + +# kubectl required at runtime — see runtime Dockerfile rationale. docker-cli +# serves SANDBOX_RUNTIME=docker (local compose stack, host socket mounted). +ARG KUBECTL_VERSION=v1.37.1 +RUN set -eux \ + && apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl docker-cli \ + && arch="$(dpkg --print-architecture)" \ + && curl -fsSLo /usr/local/bin/kubectl \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${arch}/kubectl" \ + && expected="$(curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${arch}/kubectl.sha256")" \ + && echo "${expected} /usr/local/bin/kubectl" | sha256sum -c - \ + && chmod +x /usr/local/bin/kubectl \ + && kubectl version --client=true \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile + +COPY . . + +EXPOSE 3000 +CMD ["bun", "run", "dev"] diff --git a/control-plane/README.md b/control-plane/README.md new file mode 100644 index 0000000..a367b2d --- /dev/null +++ b/control-plane/README.md @@ -0,0 +1,100 @@ +# control-plane + +The control plane is the part of devbox that stores tasks, starts a sandbox for +each task through OpenSandbox, delivers work to the harness in the sandbox, and +reads its progress back. The CLI talks to its HTTP API. + +This file is for working on the control plane. For using devbox, see the +[README](../README.md). The settings are in +[docs/configuration.md](../docs/configuration.md), and how the pieces fit is in +[docs/architecture.md](../docs/architecture.md). + +Stack: Bun, [Hono](https://hono.dev), Drizzle with Postgres 18, Biome. + +## Build and test + +Needs Bun 1.4.4 or newer. Run from this directory: + +```bash +bun install +bun run biome:ci # lint and format check (bun run format fixes formatting) +bun run typecheck +TEST_DATABASE_URL=postgres://devbox:devbox@localhost:5433/devbox bun test +``` + +Some tests need a Postgres 18 database at `TEST_DATABASE_URL`. +[CONTRIBUTING.md](../CONTRIBUTING.md) shows how to start one. + +## Running it + +Run the whole local stack with `bun run up` from the repo root, not with +`docker compose up`. `up` passes the secrets and the LLM settings to Compose; +without them, iron-proxy and the control plane start with empty secrets. +`bun run down` stops the stack and `bun run reset` also removes its volumes. + +`docker-compose.yml` mounts this directory into the control-plane container and +runs it with `bun run dev`, which restarts the server when a file changes. + +The control plane's own settings are environment variables without a prefix, +read in `src/config.ts`, `src/apiConfig.ts` and `src/integrationsConfig.ts`. +`.env.example` lists them. Compose sets them explicitly in `docker-compose.yml` +and reads no `.env` file. `bun run dev` outside Compose reads `.env` from this +directory. + +## Scripts + +| Command | What it does | +|---|---| +| `bun run dev` | The server, restarted on file changes. | +| `bun run start` | The server. | +| `bun run db:generate` | A new migration in `drizzle/` from changes to `src/db/schema.ts`. | +| `bun run db:migrate` | Applies the migrations. With `AUTO_MIGRATE=true`, the server does this when it starts. | +| `bun run api-token create <name>` | Creates an API token and prints it once. Also `list` and `revoke <name>`. | + +In the local stack, run `api-token` inside the container: + +```bash +docker compose -f control-plane/docker-compose.yml exec control-plane bun run api-token list +``` + +## Layout + +| Path | What it is | +|---|---| +| `src/index.ts`, `src/app.ts` | Startup and wiring. | +| `src/config.ts` | Settings from the environment, checked at startup. | +| `src/forwardedEnv.ts` | Which variables reach the sandbox. | +| `src/routes/`, `src/controllers/` | HTTP routes: `/health`, `/api/*`. | +| `src/services/taskProcessor.service.ts` | The loop: claims tasks, starts and reaps sandboxes, reads events. | +| `src/services/sandboxManager.service.ts` | Sandboxes through OpenSandbox, and the files in them. | +| `src/services/inbox.service.ts` | Delivers inbox rows to the sandbox. | +| `src/services/eventDispatcher.ts` | Handles the harness's events. | +| `src/services/apiTask.service.ts` | The API's tasks. | +| `src/repositories/` | Database access. `apiTask.repository.ts` computes the task states. | +| `src/types/inboxMessage.ts`, `src/types/harnessEvent.ts` | The files exchanged with the harness. | +| `src/types/api.ts` | The API's types, shared with the CLI. | +| `src/db/schema.ts`, `drizzle/` | The schema and its migrations. | +| `scripts/up.ts`, `scripts/up/` | `bun run up`. | +| `scripts/local-down.sh` | `bun run down`. | +| `scripts/build-repos-volume.sh` | Clones the configured repos into the `devbox-repos` volume. | +| `scripts/gen-iron-proxy-ca.sh` | Creates iron-proxy's local CA. | +| `infra/local/iron-proxy.template.yaml` | The template of iron-proxy's config. | +| `infra/opensandbox.toml` | The OpenSandbox server's config. | + +## Conventions + +- Layers: routes call controllers, controllers call services, services call + repositories and clients. Only repositories use the database. +- Never log a token or secret. The logger (`src/logger.ts`) redacts keys that + match `token`, `secret`, `password` or `authorization`, but don't rely on it. +- In tests, `mock.module` replaces a whole module: mock its full export surface, + mock the module the code imports (a barrel, if it imports one), and never the + implementation module behind it. +- Run the suite through `bun run test`, not a bare `bun test`. The script adds + `--isolate`, which gives every test file a fresh module registry. `mock.module` + is process-global and otherwise leaks across files in readdir order + (oven-sh/bun#12823) -- which is environment-dependent, so a suite that passes + locally can fail in CI. Importing the implementation module instead of the + mocked barrel is not sufficient on its own; it was tried and still failed in + CI. Isolation is what contains it. +- No test asserts the order of keys in JSON. diff --git a/control-plane/biome.json b/control-plane/biome.json new file mode 100644 index 0000000..32a3cd0 --- /dev/null +++ b/control-plane/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["**", "!drizzle", "!node_modules", "!bun.lock"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "useImportType": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always" + } + } +} diff --git a/control-plane/bun.lock b/control-plane/bun.lock new file mode 100644 index 0000000..f6b68f6 --- /dev/null +++ b/control-plane/bun.lock @@ -0,0 +1,318 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "devbox-control-plane", + "dependencies": { + "@alibaba-group/opensandbox": "^0.1.6", + "@linear/sdk": "^81.0.0", + "cls-rtracer": "^2.6.4", + "drizzle-orm": "^0.45.4", + "hono": "^4.13.13", + "lodash": "^4.18.1", + "safe-stable-stringify": "^2.5.0", + "winston": "^3.19.0", + "yaml": "2.9.1", + }, + "devDependencies": { + "@biomejs/biome": "2.5.15", + "@types/bun": "latest", + "drizzle-kit": "^0.31.11", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "overrides": { + "undici": "^7.30.1", + }, + "packages": { + "@alibaba-group/opensandbox": ["@alibaba-group/opensandbox@0.1.6", "", { "dependencies": { "openapi-fetch": "^0.14.1", "undici": "^7.18.2" } }, "sha512-mZ2Q2qXNC0dgctoPIlcotnlPSJ1ODMG4DKQ3AA2lTO4ZoC/vWU3CzSL5pNEU7hakfMOotQiZVxunNItVGY4W8w=="], + + "@biomejs/biome": ["@biomejs/biome@2.5.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.15", "@biomejs/cli-darwin-x64": "2.5.15", "@biomejs/cli-linux-arm64": "2.5.15", "@biomejs/cli-linux-arm64-musl": "2.5.15", "@biomejs/cli-linux-x64": "2.5.15", "@biomejs/cli-linux-x64-musl": "2.5.15", "@biomejs/cli-win32-arm64": "2.5.15", "@biomejs/cli-win32-x64": "2.5.15" }, "bin": { "biome": "bin/biome" } }, "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.15", "", { "os": "linux", "cpu": "x64" }, "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.15", "", { "os": "win32", "cpu": "x64" }, "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g=="], + + "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], + + "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + + "@linear/sdk": ["@linear/sdk@81.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0" } }, "sha512-9WYd4eRbFTFNLlWU625/aKLzSu5QfOZ7cYuoxkGZbCB44/8aEOQyCzjOifeSWvYgSMCoO0jF4+XnVtZjC5bf8g=="], + + "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], + + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "cls-rtracer": ["cls-rtracer@2.6.4", "", { "dependencies": { "uuid": "^9.0.1" } }, "sha512-ZvMGg96OWmujBjYdH56TCr3PvimGDmVDTt8jJJsyLbvuvk/0dRlaJT2ibotn/mIYD0lZT5/xMrweeroUNvAhnQ=="], + + "color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="], + + "color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="], + + "color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="], + + "color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="], + + "drizzle-kit": ["drizzle-kit@0.31.11", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg=="], + + "drizzle-orm": ["drizzle-orm@0.45.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ=="], + + "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], + + "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + + "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], + + "hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], + + "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="], + + "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], + + "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + + "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], + + "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + } +} diff --git a/control-plane/docker-compose.yml b/control-plane/docker-compose.yml new file mode 100644 index 0000000..6fdf7f9 --- /dev/null +++ b/control-plane/docker-compose.yml @@ -0,0 +1,237 @@ +# Project name: containers are named devbox-<service>-1. +name: devbox + +# The control plane's image; the GitHub App token minter runs the same one. +x-control-plane-build: &control-plane-build + context: . + dockerfile: Dockerfile.dev + +services: + postgres: + image: postgres:18 + restart: unless-stopped + environment: + POSTGRES_DB: devbox + POSTGRES_USER: devbox + POSTGRES_PASSWORD: devbox + # Loopback only. POSTGRES_PORT moves it off a local Postgres's 5432. + ports: + - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U devbox -d devbox" ] + interval: 5s + timeout: 5s + retries: 5 + networks: [ default ] + + # OpenSandbox lifecycle API — the control plane's sandboxManager talks to this + # on port 8080. Docker-socket mount lets it spawn container sandboxes on this + # host. Pinned to a known-good server version. + opensandbox: + image: ${OPENSANDBOX_IMAGE:-opensandbox/server:v0.2.3} + restart: unless-stopped + # --config points at the mounted toml so the server uses docker runtime + # (default is k8s, which fails without a kubeconfig). + command: [ "--config", "/etc/opensandbox.toml" ] + environment: + # Required: sandboxes share the `devbox` network with this server and + # could otherwise drive its API (e.g. create sandboxes mounting any host + # volume). `bun run up` sets it; unset, the server refuses to start + # rather than run without a key. + OPENSANDBOX_SERVER_API_KEY: ${OPENSANDBOX_API_KEY:-} + ports: + - "127.0.0.1:8080:8080" + volumes: + # Lets OpenSandbox spawn sibling containers on the host Docker daemon + - /var/run/docker.sock:/var/run/docker.sock + - ./infra/opensandbox.toml:/etc/opensandbox.toml:ro + # default: reached by the CP. devbox: reaches sandboxes by container IP. + networks: [ default, devbox ] + + # Egress proxy for sandboxes: terminates TLS for the aliased hosts with the + # local CA (scripts/gen-iron-proxy-ca.sh) and swaps the sandboxes' dummy + # tokens for the real GitHub token and LLM_API_KEY. Its config, + # infra/local/iron-proxy.yaml, is generated by `bun run up` from + # infra/local/iron-proxy.template.yaml and devbox.config.json. Each alias + # must match a host in that config. + iron-proxy: + image: ironsh/iron-proxy:0.45.0 + restart: unless-stopped + entrypoint: [ "iron-proxy" ] + command: [ "-config", "/etc/iron-proxy/config.yaml" ] + cap_add: [ NET_BIND_SERVICE ] + # The real secrets, set only in the environment of the `docker compose` + # process that `bun run up` starts, never in a file. The defaults (empty) + # only let `down` and `reset` run without them. With the GitHub App, + # GITHUB_TOKEN is empty and the token is read from the token directory. + environment: + GITHUB_TOKEN: ${GITHUB_TOKEN:-} + LLM_API_KEY: ${LLM_API_KEY:-} + volumes: + - ./infra/local/iron-proxy.yaml:/etc/iron-proxy/config.yaml:ro + - ./infra/local/certs:/certs:ro + # The GitHub App's token: the minter's volume, or (external tokens) the + # directory of integrations.githubApp.tokenFile, set by `bun run up`. A + # directory, not the file, so a token replaced by rename is seen. + - ${GITHUB_TOKEN_DIR:-devbox_github_token}:/var/run/devbox/github:ro + networks: + devbox: + ipv4_address: 172.30.0.53 + aliases: + - github.com + - api.github.com + - codeload.github.com + - raw.githubusercontent.com + # Host of llm.baseUrl, set by `bun run up`. + - ${IRON_PROXY_LLM_HOST:-llm.invalid} + + control-plane: + # Compose's own default name for this build, named so github-token + # runs the same image. + image: devbox-control-plane + build: *control-plane-build + restart: unless-stopped + # Explicit, with no env_file. Only allowlisted HARNESS_* settings cross + # into sandboxes, and a forwarded name must never hold a real secret. + environment: + OPENSANDBOX_API_KEY: ${OPENSANDBOX_API_KEY:-} + DATABASE_URL: postgres://devbox:devbox@postgres:5432/devbox + AUTO_MIGRATE: "true" + PORT: "3000" + OPENSANDBOX_DOMAIN: opensandbox:8080 + # The control plane's own GitHub calls (not forwarded to sandboxes): + # GITHUB_TOKEN with a personal token; with the GitHub App, the token + # file in the mounted token directory, re-read as it is replaced. + GITHUB_TOKEN: ${GITHUB_TOKEN:-} + GITHUB_TOKEN_FILE: ${GITHUB_TOKEN_FILE:-} + API_ENABLED: "true" + API_PUBLIC_BASE_URL: http://localhost:3000 + # Per-task workspace volumes copied from the base volume built by + # scripts/build-repos-volume.sh, instead of k8s VolumeSnapshots. + SANDBOX_RUNTIME: docker + REPOS_BASE_VOLUME: devbox-repos + # Built by `make -C harness build IRON_PROXY_CA=...`. + SANDBOX_IMAGE: devbox-dev:local + # Per-sandbox limits. Five busy 4Gi sandboxes can exceed Docker Desktop's + # default 8 GB; resize its VM or lower these in devbox.config.json. + SANDBOX_RESOURCE_CPU: ${SANDBOX_RESOURCE_CPU:-2} + SANDBOX_RESOURCE_MEMORY: ${SANDBOX_RESOURCE_MEMORY:-4Gi} + MAX_CONCURRENT_SANDBOXES: ${MAX_CONCURRENT_SANDBOXES:-5} + # From devbox.config.json's `llm`, set by `bun run up`. Users configure + # the JSON file, not this tracked list. Empty optional values are unset to + # the harness and keep shell-exported HARNESS_* values from leaking in. + HARNESS_LLM_PROVIDER: ${HARNESS_LLM_PROVIDER:-openai} + HARNESS_LLM_BASE_URL: ${HARNESS_LLM_BASE_URL:-} + HARNESS_MODEL: ${HARNESS_MODEL:-} + HARNESS_REASONING_EFFORT: ${HARNESS_REASONING_EFFORT:-} + HARNESS_ORIENTATION_MODEL: ${HARNESS_ORIENTATION_MODEL:-} + HARNESS_ORIENTATION_REASONING_EFFORT: ${HARNESS_ORIENTATION_REASONING_EFFORT:-} + HARNESS_PLANNING_MODEL: ${HARNESS_PLANNING_MODEL:-} + HARNESS_PLANNING_REASONING_EFFORT: ${HARNESS_PLANNING_REASONING_EFFORT:-} + HARNESS_PLAN_REVIEW_MODEL: ${HARNESS_PLAN_REVIEW_MODEL:-} + HARNESS_PLAN_REVIEW_REASONING_EFFORT: ${HARNESS_PLAN_REVIEW_REASONING_EFFORT:-} + HARNESS_IMPLEMENTATION_MODEL: ${HARNESS_IMPLEMENTATION_MODEL:-} + HARNESS_IMPLEMENTATION_REASONING_EFFORT: ${HARNESS_IMPLEMENTATION_REASONING_EFFORT:-} + HARNESS_DISTILLATION_MODEL: ${HARNESS_DISTILLATION_MODEL:-} + HARNESS_DISTILLATION_REASONING_EFFORT: ${HARNESS_DISTILLATION_REASONING_EFFORT:-} + HARNESS_REVIEW_MODEL: ${HARNESS_REVIEW_MODEL:-} + HARNESS_REVIEW_REASONING_EFFORT: ${HARNESS_REVIEW_REASONING_EFFORT:-} + HARNESS_FOLLOWUP_MODEL: ${HARNESS_FOLLOWUP_MODEL:-} + HARNESS_FOLLOWUP_REASONING_EFFORT: ${HARNESS_FOLLOWUP_REASONING_EFFORT:-} + # A dummy: iron-proxy swaps it for LLM_API_KEY on the LLM host. + HARNESS_LLM_API_KEY: proxy-llm-dummy-token + # Linear, on only when devbox.config.json has `integrations.linear` + # with `enabled: true`: `bun run up` then sets all four, and otherwise + # passes them empty. A bare `docker compose up` leaves Linear off. + LINEAR_ENABLED: ${LINEAR_ENABLED:-} + LINEAR_CLIENT_ID: ${LINEAR_CLIENT_ID:-} + LINEAR_CLIENT_SECRET: ${LINEAR_CLIENT_SECRET:-} + LINEAR_WEBHOOK_SECRET: ${LINEAR_WEBHOOK_SECRET:-} + # GitHub webhooks, on only with `integrations.githubApp` enabled: + # `bun run up` then sets them from the App, and otherwise passes them + # empty. The trust lists come from devbox.config.json's `github` block + # in every mode. + GITHUB_WEBHOOKS_ENABLED: ${GITHUB_WEBHOOKS_ENABLED:-} + GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-} + GITHUB_BOT_LOGIN: ${GITHUB_BOT_LOGIN:-} + GITHUB_BOT_USER_ID: ${GITHUB_BOT_USER_ID:-} + GITHUB_CI_RESULT_ENABLED: ${GITHUB_CI_RESULT_ENABLED:-} + GITHUB_TRUSTED_ORGS: ${GITHUB_TRUSTED_ORGS:-} + GITHUB_TRUSTED_USERS: ${GITHUB_TRUSTED_USERS:-} + ports: + - "3000:3000" + volumes: + - .:/app + # Named (not anonymous) so `bun run reset` removes it: `bun run down` + # would orphan an anonymous volume, and a later `down -v` can't find it. + - node_modules:/app/node_modules + # Docker runtime: the CP creates/copies/removes per-task volumes and + # inspects sandbox containers through the host daemon. + - /var/run/docker.sock:/var/run/docker.sock + # The GitHub App's token directory, as for iron-proxy. + - ${GITHUB_TOKEN_DIR:-devbox_github_token}:/var/run/devbox/github:ro + # Not on `devbox`: its iron-proxy aliases would send the CP's own + # api.github.com calls (real GITHUB_TOKEN) into the proxy and fail TLS. + # Sandboxes are reached through the opensandbox server (useServerProxy). + networks: [ default ] + depends_on: + postgres: + condition: service_healthy + opensandbox: + condition: service_started + iron-proxy: + condition: service_started + + # The GitHub App token minter (devbox-minted tokens only): the only holder of + # the App's private key. It writes a fresh installation token to the shared + # volume about every 30 minutes, readable by the control plane and + # iron-proxy. `bun run up` starts it with `--profile github-app`, after a + # one-off `--once` run so the first token is there before they start. + github-token: + profiles: [ github-app ] + image: devbox-control-plane + build: *control-plane-build + restart: unless-stopped + command: [ "bun", "run", "github-token" ] + environment: + GITHUB_APP_ID: ${GITHUB_APP_ID:-} + GITHUB_APP_INSTALLATION_ID: ${GITHUB_APP_INSTALLATION_ID:-} + # Repo names the tokens are narrowed to; empty for the whole installation. + GITHUB_APP_REPOSITORIES: ${GITHUB_APP_REPOSITORIES:-} + GITHUB_APP_PRIVATE_KEY_FILE: /run/secrets/github_app_private_key + GITHUB_TOKEN_SINK: file:/var/run/devbox/github/token + secrets: [ github_app_private_key ] + volumes: + - .:/app + - node_modules:/app/node_modules + - devbox_github_token:/var/run/devbox/github + # Calls only api.github.com. + networks: [ default ] + +networks: + # Sandbox network: sandboxes, iron-proxy (with its host aliases) and the + # opensandbox server only. Fixed name (no compose project prefix): + # infra/opensandbox.toml puts sandboxes on it by this name. + devbox: + name: devbox + ipam: + config: + - subnet: 172.30.0.0/24 + +volumes: + postgres_data: + name: devbox_postgres_data + node_modules: + name: devbox_node_modules + # The minted GitHub token and its expiry (files readable by every uid). + devbox_github_token: + name: devbox_github_token + +secrets: + # The GitHub App's private key, from the environment of the `docker compose` + # process `bun run up` starts (whichever form it was found in), so the + # container never depends on a host path. + github_app_private_key: + environment: GITHUB_APP_PRIVATE_KEY diff --git a/control-plane/drizzle.config.ts b/control-plane/drizzle.config.ts new file mode 100644 index 0000000..9dd98c7 --- /dev/null +++ b/control-plane/drizzle.config.ts @@ -0,0 +1,10 @@ +import type { Config } from "drizzle-kit"; + +export default { + schema: "./src/db/schema.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL ?? "postgres://devbox:devbox@localhost:5432/devbox", + }, +} satisfies Config; diff --git a/control-plane/drizzle/0000_init.sql b/control-plane/drizzle/0000_init.sql new file mode 100644 index 0000000..e09bceb --- /dev/null +++ b/control-plane/drizzle/0000_init.sql @@ -0,0 +1,70 @@ +CREATE TYPE "public"."task_source" AS ENUM('linear', 'api');--> statement-breakpoint +CREATE SEQUENCE "public"."api_task_key_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1;--> statement-breakpoint +CREATE TABLE "api_tokens" ( + "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL, + "name" text NOT NULL, + "secret_hash" text NOT NULL, + "github_login" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + CONSTRAINT "api_tokens_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "inbox" ( + "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL, + "task_id" uuid NOT NULL, + "external_id" text NOT NULL, + "source" text NOT NULL, + "kind" text NOT NULL, + "payload" jsonb NOT NULL, + "reply_targets" jsonb DEFAULT '[]'::jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "failure_reason" text, + "outcome" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "task_events" ( + "id" bigserial PRIMARY KEY NOT NULL, + "task_id" uuid NOT NULL, + "origin" text NOT NULL, + "type" text NOT NULL, + "inbox_id" uuid, + "seq" bigint, + "data" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tasks" ( + "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL, + "source" "task_source" NOT NULL, + "external_id" text NOT NULL, + "key" text NOT NULL, + "created_by" uuid, + "title" text NOT NULL, + "external_url" text NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "owner_id" text, + "heartbeat_at" timestamp with time zone, + "sandbox_id" text, + "pvc_name" text, + "pr_urls" jsonb DEFAULT '[]'::jsonb NOT NULL, + "last_observed_event_seq" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "inbox" ADD CONSTRAINT "inbox_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "task_events" ADD CONSTRAINT "task_events_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_created_by_api_tokens_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."api_tokens"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "inbox_source_external_uniq" ON "inbox" USING btree ("source","external_id");--> statement-breakpoint +CREATE INDEX "inbox_pending_idx" ON "inbox" USING btree ("task_id") WHERE "inbox"."status" = 'pending';--> statement-breakpoint +CREATE INDEX "inbox_task_created_idx" ON "inbox" USING btree ("task_id","created_at");--> statement-breakpoint +CREATE INDEX "task_events_task_id_id_idx" ON "task_events" USING btree ("task_id","id");--> statement-breakpoint +CREATE UNIQUE INDEX "tasks_source_external_id_uniq" ON "tasks" USING btree ("source","external_id");--> statement-breakpoint +CREATE UNIQUE INDEX "tasks_key_uniq" ON "tasks" USING btree ("key");--> statement-breakpoint +CREATE INDEX "tasks_lease_idx" ON "tasks" USING btree ("heartbeat_at") WHERE "tasks"."owner_id" IS NOT NULL;--> statement-breakpoint +CREATE INDEX "tasks_pr_urls_gin_idx" ON "tasks" USING gin ("pr_urls" jsonb_path_ops);--> statement-breakpoint +CREATE INDEX "tasks_owner_sandbox_idx" ON "tasks" USING btree ("owner_id") WHERE "tasks"."sandbox_id" IS NOT NULL; \ No newline at end of file diff --git a/control-plane/drizzle/meta/0000_snapshot.json b/control-plane/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..ba40f3b --- /dev/null +++ b/control-plane/drizzle/meta/0000_snapshot.json @@ -0,0 +1,580 @@ +{ + "id": "08e0c675-47da-4dc6-bb2e-1329f1a39eb5", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_name_unique": { + "name": "api_tokens_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inbox": { + "name": "inbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "reply_targets": { + "name": "reply_targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_source_external_uniq": { + "name": "inbox_source_external_uniq", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_pending_idx": { + "name": "inbox_pending_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"inbox\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_created_idx": { + "name": "inbox_task_created_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inbox_task_id_tasks_id_fk": { + "name": "inbox_task_id_tasks_id_fk", + "tableFrom": "inbox", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_events": { + "name": "task_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inbox_id": { + "name": "inbox_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_events_task_id_id_idx": { + "name": "task_events_task_id_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_events_task_id_tasks_id_fk": { + "name": "task_events_task_id_tasks_id_fk", + "tableFrom": "task_events", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "source": { + "name": "source", + "type": "task_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pvc_name": { + "name": "pvc_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_urls": { + "name": "pr_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_observed_event_seq": { + "name": "last_observed_event_seq", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_source_external_id_uniq": { + "name": "tasks_source_external_id_uniq", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_key_uniq": { + "name": "tasks_key_uniq", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_lease_idx": { + "name": "tasks_lease_idx", + "columns": [ + { + "expression": "heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"tasks\".\"owner_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_pr_urls_gin_idx": { + "name": "tasks_pr_urls_gin_idx", + "columns": [ + { + "expression": "pr_urls", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "jsonb_path_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "tasks_owner_sandbox_idx": { + "name": "tasks_owner_sandbox_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"tasks\".\"sandbox_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_created_by_api_tokens_id_fk": { + "name": "tasks_created_by_api_tokens_id_fk", + "tableFrom": "tasks", + "tableTo": "api_tokens", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.task_source": { + "name": "task_source", + "schema": "public", + "values": [ + "linear", + "api" + ] + } + }, + "schemas": {}, + "sequences": { + "public.api_task_key_seq": { + "name": "api_task_key_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/control-plane/drizzle/meta/_journal.json b/control-plane/drizzle/meta/_journal.json new file mode 100644 index 0000000..a637ac5 --- /dev/null +++ b/control-plane/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1792183649849, + "tag": "0000_init", + "breakpoints": true + } + ] +} diff --git a/control-plane/infra/local/iron-proxy.template.yaml b/control-plane/infra/local/iron-proxy.template.yaml new file mode 100644 index 0000000..e039e35 --- /dev/null +++ b/control-plane/infra/local/iron-proxy.template.yaml @@ -0,0 +1,82 @@ +# Template for the local iron-proxy config. `bun run up` +# (scripts/up/ironProxy.ts) renders it into infra/local/iron-proxy.yaml +# (gitignored) from devbox.config.json, adding: +# - dns.upstream_resolver from `bun run up --dns-resolver` (default 1.1.1.1:53); +# - full access to each configured repo on github.com, api.github.com and +# raw.githubusercontent.com; +# - GET/HEAD api.github.com /orgs/<org>/members per trusted org and /users/* +# (the harness's trust lookups), and *.blob.core.windows.net +# /actions-results/* (CI log downloads); +# - POST to the one LLM endpoint the harness calls: `<baseUrl>/responses` +# for openai, `<baseUrl>/v1/messages` for anthropic; +# - the hosts each secret is swapped on. +# +# Routing: docker-compose.yml gives this container a network alias for each +# host below, so Docker's embedded DNS sends sandbox traffic for them here. +# Sandboxes only ever hold dummy tokens; iron-proxy swaps in the real ones +# from its environment (GITHUB_TOKEN, LLM_API_KEY). +# +# Path patterns: a trailing "/*" matches the path itself and everything under +# it. A "*" anywhere else matches within one path segment only. A pattern +# without "*" matches that exact path. +dns: + listen: ":53" + proxy_ip: "172.30.0.53" + # The proxy's own upstream lookups must skip Docker's DNS, which would + # resolve the aliased hosts back to this container. Set from --dns-resolver. + upstream_resolver: "" + +proxy: + http_listen: ":80" + https_listen: ":443" + max_request_body_bytes: 10485760 + +tls: + ca_cert: "/certs/ca.crt" + ca_key: "/certs/ca.key" + cert_cache_size: 500 + leaf_cert_expiry_hours: 24 + +transforms: + - name: allowlist + config: + # Read-only GitHub access. The rendered file appends the repo and LLM rules. + rules: + - host: "codeload.github.com" + methods: ["GET", "HEAD"] + paths: ["/*"] + - host: "github.com" + methods: ["GET", "HEAD"] + paths: ["/*"] + # Clones and fetches of any repo: git-upload-pack is the read side of + # smart-HTTP git (pushes use git-receive-pack, allowed per repo below). + # A `*` inside a path matches one segment: /<owner>/<repo>.git needs two. + - host: "github.com" + methods: ["POST"] + paths: + - "/*/*/git-upload-pack" + - name: secrets + config: + secrets: + - source: + type: env + var: GITHUB_TOKEN + proxy_value: "proxy-github-dummy-token" + match_headers: ["Authorization"] + rules: + - host: "github.com" + - host: "api.github.com" + - host: "raw.githubusercontent.com" + - source: + type: env + # Never HARNESS_LLM_API_KEY: that allowlisted sandbox name holds + # the dummy below, while this unforwarded name holds the real key. + var: LLM_API_KEY + proxy_value: "proxy-llm-dummy-token" + # api-key: Azure OpenAI's header. x-api-key: Anthropic's. + match_headers: ["Authorization", "api-key", "x-api-key"] + # The rendered file sets the LLM host here. + rules: [] + +log: + level: "info" diff --git a/control-plane/infra/opensandbox.toml b/control-plane/infra/opensandbox.toml new file mode 100644 index 0000000..01bee52 --- /dev/null +++ b/control-plane/infra/opensandbox.toml @@ -0,0 +1,41 @@ +# Docker-mode OpenSandbox server config for the local stack. +# Mounted into the opensandbox container via docker-compose. The execd and +# egress versions match server v0.2.3 (its example configs). + +[server] +host = "0.0.0.0" +port = 8080 +max_sandbox_timeout_seconds = 86400 + +[log] +level = "INFO" + +[runtime] +type = "docker" +execd_image = "opensandbox/execd:v1.0.22" + +[storage] +allowed_host_paths = [] +volume_default_size = "1Gi" + +[docker] +# User-defined network from docker-compose.yml: sandboxes share it with +# iron-proxy (whose aliases route GitHub and the LLM host through it) and the server +# reaches them by container IP. Rules out networkPolicy, which the CP never sends. +network_mode = "devbox" +drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] +no_new_privileges = true +apparmor_profile = "" +pids_limit = 4096 +seccomp_profile = "" + +[ingress] +mode = "direct" + +[egress] +image = "opensandbox/egress:v1.1.7" +mode = "dns" + +[renew_intent] +enabled = false +min_interval_seconds = 60 diff --git a/control-plane/package.json b/control-plane/package.json new file mode 100644 index 0000000..65561eb --- /dev/null +++ b/control-plane/package.json @@ -0,0 +1,46 @@ +{ + "name": "devbox-control-plane", + "version": "0.19.1", + "module": "src/index.ts", + "type": "module", + "private": true, + "license": "MIT", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "db:generate": "bunx drizzle-kit generate", + "db:migrate": "bun run src/db/migrate.ts", + "api-token": "bun run scripts/api-token.ts", + "github-token": "bun run src/githubTokenMinter.ts", + "dev-test": "bun run scripts/dev-test.ts", + "local-down": "bash scripts/local-down.sh", + "typecheck": "bunx tsc --noEmit", + "lint": "bunx @biomejs/biome lint .", + "format": "bunx @biomejs/biome format --write .", + "biome": "bunx @biomejs/biome check .", + "biome:ci": "bunx @biomejs/biome ci .", + "test": "bun test --isolate" + }, + "devDependencies": { + "@biomejs/biome": "2.5.15", + "@types/bun": "latest", + "drizzle-kit": "^0.31.11" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@alibaba-group/opensandbox": "^0.1.6", + "@linear/sdk": "^81.0.0", + "cls-rtracer": "^2.6.4", + "drizzle-orm": "^0.45.4", + "hono": "^4.13.13", + "lodash": "^4.18.1", + "safe-stable-stringify": "^2.5.0", + "winston": "^3.19.0", + "yaml": "2.9.1" + }, + "overrides": { + "undici": "^7.30.1" + } +} diff --git a/control-plane/scripts/api-token.test.ts b/control-plane/scripts/api-token.test.ts new file mode 100644 index 0000000..cf5df77 --- /dev/null +++ b/control-plane/scripts/api-token.test.ts @@ -0,0 +1,90 @@ +import { beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { sql } from "drizzle-orm"; + +const TEST_DATABASE_URL = + process.env.TEST_DATABASE_URL ?? "postgres://devbox:devbox@localhost:5432/devbox"; +mock.module("../src/config", () => ({ config: { databaseUrl: TEST_DATABASE_URL } })); + +const { db } = await import("../src/db"); +const { ApiTokenRepository } = await import("../src/repositories/apiToken.repository"); +const { ApiAuthService } = await import("../src/services/apiAuth.service"); +const { runApiTokenCommand } = await import("./api-token"); +const { migrate } = await import("drizzle-orm/bun-sql/migrator"); + +const repo = new ApiTokenRepository(); +const run = async (argv: string[]) => { + const out: string[] = []; + const err: string[] = []; + const code = await runApiTokenCommand(argv, { + repo, + out: (s) => out.push(s), + err: (s) => err.push(s), + }); + return { code, out: out.join(""), err: err.join("") }; +}; + +describe("api-token", () => { + beforeAll(async () => { + await migrate(db, { migrationsFolder: "./drizzle" }); + }); + beforeEach(async () => { + await db.execute( + sql`TRUNCATE TABLE task_events, inbox, tasks, api_tokens RESTART IDENTITY CASCADE`, + ); + }); + + it("create prints the token once; it authenticates; revoke makes it fail", async () => { + const created = await run(["create", "frodo", "--github-login", "frodo-gh"]); + expect(created.code).toBe(0); + const token = created.out.match(/^(dbx_\S+)$/m)?.[1] ?? ""; + const [row] = await db.execute<{ id: string; github_login: string }>( + sql`SELECT id, github_login FROM api_tokens WHERE name = 'frodo'`, + ); + expect(created.out).toBe( + `Created API token "frodo" (id ${(row as { id: string }).id}).\nStore it now. It is not shown again.\n\n${token}\n`, + ); + expect((row as { github_login: string }).github_login).toBe("frodo-gh"); + + const auth = new ApiAuthService(repo); + expect(await auth.authenticate(`Bearer ${token}`)).toEqual({ + tokenId: (row as { id: string }).id, + name: "frodo", + }); + + expect(await run(["revoke", "frodo"])).toEqual({ + code: 0, + out: 'Revoked API token "frodo".\n', + err: "", + }); + expect(await auth.authenticate(`Bearer ${token}`)).toBeNull(); + expect(await run(["revoke", "frodo"])).toEqual({ + code: 1, + out: "", + err: 'No active API token named "frodo".\n', + }); + }); + + it("list shows name, id, created, last used and revoked", async () => { + await run(["create", "ci-golden-tasks"]); + await db.execute( + sql`UPDATE api_tokens SET created_at = '2026-10-07T12:00:00Z', last_used_at = NULL`, + ); + const [row] = await db.execute<{ id: string }>(sql`SELECT id FROM api_tokens`); + expect(await run(["list"])).toEqual({ + code: 0, + out: `NAME\tID\tCREATED\tLAST USED\tREVOKED\nci-golden-tasks\t${(row as { id: string }).id}\t2026-10-07T12:00:00.000Z\t-\t-\n`, + err: "", + }); + }); + + it("rejects a duplicate name and bad usage", async () => { + await run(["create", "frodo"]); + expect(await run(["create", "frodo"])).toEqual({ + code: 1, + out: "", + err: 'An API token named "frodo" already exists.\n', + }); + expect((await run([])).code).toBe(1); + expect((await run(["create"])).code).toBe(1); + }); +}); diff --git a/control-plane/scripts/api-token.ts b/control-plane/scripts/api-token.ts new file mode 100644 index 0000000..29f3a82 --- /dev/null +++ b/control-plane/scripts/api-token.ts @@ -0,0 +1,80 @@ +import { parseArgs } from "node:util"; +import { setup as setupLogger } from "../src/logger"; +import { + type ApiTokenRepository, + apiTokenRepository, +} from "../src/repositories/apiToken.repository"; +import { + formatApiToken, + generateApiTokenSecret, + hashApiTokenSecret, +} from "../src/services/apiAuth.service"; + +// bun run api-token create <name> [--github-login <login>] | list | revoke <name> +// Output goes to stdout directly: the logger redacts /token/i, and `create` +// must print the token (once). +const USAGE = + "Usage: bun run api-token create <name> [--github-login <login>] | list | revoke <name>\n"; + +export async function runApiTokenCommand( + argv: string[], + deps: { repo: ApiTokenRepository; out: (s: string) => void; err: (s: string) => void }, +): Promise<number> { + const [cmd, ...rest] = argv; + if (cmd === "create") { + const { positionals, values } = parseArgs({ + args: rest, + options: { "github-login": { type: "string" } }, + allowPositionals: true, + }); + const name = positionals[0]; + if (!name || positionals.length > 1) return usage(deps); + if (await deps.repo.findByName(name)) { + deps.err(`An API token named "${name}" already exists.\n`); + return 1; + } + const secret = generateApiTokenSecret(); + const { id } = await deps.repo.create({ + name, + secretHash: await hashApiTokenSecret(secret), + githubLogin: values["github-login"] ?? null, + }); + deps.out( + `Created API token "${name}" (id ${id}).\nStore it now. It is not shown again.\n\n${formatApiToken(id, secret)}\n`, + ); + return 0; + } + if (cmd === "list" && rest.length === 0) { + const iso = (d: Date | null) => (d ? d.toISOString() : "-"); + const lines = ["NAME\tID\tCREATED\tLAST USED\tREVOKED"]; + for (const t of await deps.repo.list()) { + lines.push([t.name, t.id, iso(t.createdAt), iso(t.lastUsedAt), iso(t.revokedAt)].join("\t")); + } + deps.out(`${lines.join("\n")}\n`); + return 0; + } + if (cmd === "revoke" && rest.length === 1 && rest[0]) { + if (!(await deps.repo.revokeByName(rest[0]))) { + deps.err(`No active API token named "${rest[0]}".\n`); + return 1; + } + deps.out(`Revoked API token "${rest[0]}".\n`); + return 0; + } + return usage(deps); +} + +function usage(deps: { err: (s: string) => void }): number { + deps.err(USAGE); + return 1; +} + +if (import.meta.main) { + setupLogger("api-token", {}, [/token/i, /secret/i, /password/i, /authorization/i]); + const code = await runApiTokenCommand(process.argv.slice(2), { + repo: apiTokenRepository, + out: (s) => process.stdout.write(s), + err: (s) => process.stderr.write(s), + }); + process.exit(code); +} diff --git a/control-plane/scripts/build-repos-volume.sh b/control-plane/scripts/build-repos-volume.sh new file mode 100755 index 0000000..534739a --- /dev/null +++ b/control-plane/scripts/build-repos-volume.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Builds/refreshes the local base repos volume (default `devbox-repos`) from +# the `repos` of devbox.config.json. `bun run up` runs it; usage: +# build-repos-volume.sh [path/to/devbox.config.json] <<< "$GITHUB_TOKEN" +# The control plane (SANDBOX_RUNTIME=docker) copies this volume into a fresh +# per-task volume; sandboxes never mount it. +# +# Clone shape: --depth 1 --single-branch, flat /<repo-name> layout, and +# incremental: existing clones are fetched and hard reset instead of wiped and +# re-cloned. Repos removed from the config are not deleted from the volume. +# +# Token handling: the GitHub token is the first line of stdin; an empty line +# means anonymous clones (public repos only). It reaches the container over +# stdin (never argv, env or `docker inspect`), is applied via GIT_CONFIG_* env +# (never argv or .git/config), and the run fails if any .git/config ends up +# holding it. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VOLUME="${REPOS_BASE_VOLUME:-devbox-repos}" +CONFIG_FILE="${1:-$ROOT/../devbox.config.json}" +GIT_IMAGE="alpine/git:v2.49.1" + +TOKEN="" +IFS= read -r TOKEN || true +TOKEN="${TOKEN%$'\r'}" + +# "<url> <branch>" per line; branch defaults to main. +REPOS="$(bun -e ' + const { repos } = await Bun.file(process.argv[1]).json(); + for (const r of repos) console.log(r.url, r.branch ?? "main"); +' "$CONFIG_FILE")" + +printf '%s\n' "$TOKEN" | docker run -i --rm \ + -e REPOS="$REPOS" \ + -v "$VOLUME:/repos" \ + --entrypoint sh \ + "$GIT_IMAGE" -c ' +set -eu +token="" +read -r token || true +if [ -n "$token" ]; then + basic="$(printf "x-access-token:%s" "$token" | base64 | tr -d "\n")" + export GIT_CONFIG_COUNT=1 + export GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" + export GIT_CONFIG_VALUE_0="Authorization: Basic $basic" +else + echo "no GitHub token: cloning anonymously" +fi +unset token +export GIT_TERMINAL_PROMPT=0 + +echo "$REPOS" | while read -r url branch; do + [ -n "$url" ] || continue + # `bun run up` validated the config; still keep a typo from turning into a + # git option (url starting with "-") or `rm -rf /repos/..`. + case "$url" in + https://github.com/*/*.git) ;; + *) echo "unsupported repo url: $url (expected https://github.com/<org>/<repo>.git)" >&2; exit 1 ;; + esac + name="$(basename "$url" .git)" + case "$name" in ""|.|..|-*) echo "bad repo name in $url" >&2; exit 1 ;; esac + case "$branch" in ""|-*|*..*) echo "bad branch for $url: $branch" >&2; exit 1 ;; esac + dir="/repos/$name" + if [ -d "$dir/.git" ]; then + echo "fetch $url ($branch)" + git -C "$dir" fetch --depth 1 origin "+refs/heads/$branch:refs/remotes/origin/$branch" + git -C "$dir" checkout -q -f -B "$branch" "origin/$branch" + git -C "$dir" clean -q -ffdx + else + echo "clone $url ($branch)" + rm -rf "$dir" + git clone -q --depth 1 --single-branch --branch "$branch" -- "$url" "$dir" + fi +done + +if grep -lsE "x-access-token|github_pat_|gh[opsu]_" /repos/*/.git/config; then + echo "token found in the git config files above; aborting" >&2 + exit 1 +fi +' +echo "Volume $VOLUME is up to date with $CONFIG_FILE" diff --git a/control-plane/scripts/dev-test.ts b/control-plane/scripts/dev-test.ts new file mode 100644 index 0000000..c0831e5 --- /dev/null +++ b/control-plane/scripts/dev-test.ts @@ -0,0 +1,752 @@ +import { link, mkdir, mkdtemp, readFile, rename, rm, stat, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { Sandbox, SandboxApiException } from "@alibaba-group/opensandbox"; +import { + createPvcFromSnapshot, + deletePvc, + getLatestVolumeSnapshotName, + isK8sNotFoundError, +} from "../src/clients/k8s"; +import type { InboxMessage, PrScope, TaskIdentity } from "../src/types/inboxMessage"; +import { + acquireLockFile, + bootstrapSandbox, + connectSandbox, + createSourceArchive, + envPositiveInteger, + errorMessage, + HARNESS_DIR, + installHarnessSource, + isNodeError, + loadSandboxConfig, + printExecution, + requireSuccessfulExecution, + runCheckedProcess, + runRemoteChecked, + type SandboxConfig, + sandboxHarnessTarget, + shellQuote, + startDaemon, + stopDaemon, + syncHarnessDependencies, + validateHarnessWorktree, + WORKSPACE, +} from "./harness-sandbox"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const STATE_PATH = join(REPO_ROOT, ".dev-test.state"); +const STATE_LOCK_PATH = `${STATE_PATH}.lock`; +const DAEMON_LOG = `${HARNESS_DIR}/dev-test-daemon.log`; +const DAEMON_PID = `${HARNESS_DIR}/dev-test-daemon.pid`; +const KUBECTL = process.env.KUBECTL_BIN ?? "kubectl"; + +const USAGE = `Usage: bun run dev-test <command> [options] + +Lifecycle: + up --worktree <path> Create a snapshot-backed sandbox + push Replace harness source and restart the daemon + logs [--events <n>] [--daemon <n>] Tail events and daemon output (default: 50/100) + exec -- <remote command...> Run a command inside the sandbox + archive [--out <path>] Capture a harness-compatible .tgz archive + down Delete the sandbox, PVC, and local state + +Typed inbox seeding: + seed assignment --title <text> [--description <text>] [--task-key <key>] + seed comment --body <text> [--task-key <key>] [--pr-repo <repo> --pr-url <url>] + seed review [--body <text>] --pr-repo <repo> --pr-url <url> + [--inline <path:line:body> ...] [--task-key <key>] + seed ci_result --pr-repo <repo> --pr-url <url> [--conclusion <value>] + [--workflow-name <name>] [--workflow-path <path>] + [--run-url <url>] [--head-sha <sha>] [--head-branch <branch>] + +Environment: OPENSANDBOX_DOMAIN, OPENSANDBOX_API_KEY, SANDBOX_IMAGE, +PVC_NAMESPACE, PVC_STORAGE_CLASS, PVC_STORAGE_SIZE, SNAPSHOT_NAME_PREFIX, +SANDBOX_RESOURCE_CPU, SANDBOX_RESOURCE_MEMORY, and allowlisted HARNESS_* settings.`; + +class UsageError extends Error {} + +interface DevTestState { + sandboxId: string; + pvcName: string; + worktree: string; +} + +interface DevTestConfig extends SandboxConfig { + timeoutSeconds: number; +} + +type OptionSpec = Record<string, { repeatable?: boolean }>; +type ParsedOptions = Record<string, string[]>; + +function parseOptions(args: string[], specs: OptionSpec): ParsedOptions { + const parsed: ParsedOptions = {}; + for (let index = 0; index < args.length; index += 1) { + const name = args[index]; + if (!name?.startsWith("--") || name === "--") { + throw new UsageError(`Unexpected argument: ${name ?? ""}`); + } + const spec = specs[name]; + if (!spec) throw new UsageError(`Unknown option: ${name}`); + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new UsageError(`Option ${name} requires a value`); + } + const values = parsed[name] ?? []; + if (!spec.repeatable && values.length > 0) { + throw new UsageError(`Option ${name} may only be specified once`); + } + values.push(value); + parsed[name] = values; + index += 1; + } + return parsed; +} + +function option(options: ParsedOptions, name: string): string | undefined { + return options[name]?.[0]; +} + +function requiredOption(options: ParsedOptions, name: string): string { + const value = option(options, name); + if (!value) throw new UsageError(`Missing required option: ${name}`); + return value; +} + +function assertNoArgs(args: string[]): void { + if (args.length > 0) throw new UsageError(`Unexpected argument: ${args[0]}`); +} + +function positiveInteger(value: string, optionName: string): number { + if (!/^\d+$/.test(value)) throw new UsageError(`${optionName} must be a positive integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new UsageError(`${optionName} must be a positive integer`); + } + return parsed; +} + +function loadConfig(): DevTestConfig { + return { + ...loadSandboxConfig(), + timeoutSeconds: envPositiveInteger("DEV_TEST_SANDBOX_TIMEOUT_SECONDS", 24 * 60 * 60), + }; +} + +function validateState(value: unknown): DevTestState { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${STATE_PATH} must contain a JSON object`); + } + const record = value as Record<string, unknown>; + const expectedKeys = ["sandboxId", "pvcName", "worktree"] as const; + const unexpectedKeys = Object.keys(record).filter( + (key) => !expectedKeys.includes(key as (typeof expectedKeys)[number]), + ); + if (unexpectedKeys.length > 0) { + throw new Error(`${STATE_PATH} has unexpected fields: ${unexpectedKeys.join(", ")}`); + } + for (const key of expectedKeys) { + if (typeof record[key] !== "string" || record[key].trim() === "") { + throw new Error(`${STATE_PATH} has an invalid ${key}`); + } + } + if ( + (record.pvcName as string).length > 63 || + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(record.pvcName as string) + ) { + throw new Error(`${STATE_PATH} has an invalid pvcName`); + } + if (!isAbsolute(record.worktree as string)) { + throw new Error(`${STATE_PATH} has a non-absolute worktree`); + } + return { + sandboxId: record.sandboxId as string, + pvcName: record.pvcName as string, + worktree: record.worktree as string, + }; +} + +async function readState(): Promise<DevTestState> { + let raw: string; + try { + raw = await readFile(STATE_PATH, "utf8"); + } catch (error) { + if (isNodeError(error, "ENOENT")) { + throw new Error(`No dev-test state found at ${STATE_PATH}; run 'bun run dev-test up' first`); + } + throw error; + } + try { + return validateState(JSON.parse(raw)); + } catch (error) { + if (error instanceof SyntaxError) throw new Error(`${STATE_PATH} contains invalid JSON`); + throw error; + } +} + +async function stateExists(): Promise<boolean> { + try { + await stat(STATE_PATH); + return true; + } catch (error) { + if (isNodeError(error, "ENOENT")) return false; + throw error; + } +} + +async function writeState(state: DevTestState): Promise<void> { + const tempPath = `${STATE_PATH}.${crypto.randomUUID()}.tmp`; + try { + await Bun.write(tempPath, `${JSON.stringify(state, null, 2)}\n`); + await link(tempPath, STATE_PATH).catch((error) => { + if (isNodeError(error, "EEXIST")) { + throw new Error(`Refusing to replace existing state at ${STATE_PATH}`); + } + throw error; + }); + } finally { + await unlink(tempPath).catch((error) => { + if (!isNodeError(error, "ENOENT")) throw error; + }); + } +} + +async function removeState(): Promise<void> { + await unlink(STATE_PATH).catch((error) => { + if (!isNodeError(error, "ENOENT")) throw error; + }); +} + +function timestampForName(): string { + return new Date() + .toISOString() + .replace(/[-:TZ.]/g, "") + .slice(0, 14); +} + +function pvcName(): string { + return `devbox-dev-test-${timestampForName()}-${crypto.randomUUID().slice(0, 6)}`.toLowerCase(); +} + +async function commandUp(args: string[]): Promise<void> { + const options = parseOptions(args, { "--worktree": {} }); + const worktree = await validateHarnessWorktree(requiredOption(options, "--worktree")); + if (await stateExists()) { + throw new Error( + `Refusing to replace existing state at ${STATE_PATH}; run 'bun run dev-test down' first`, + ); + } + + const releaseUpLock = await acquireLockFile( + STATE_LOCK_PATH, + `Another dev-test up is already running (${STATE_LOCK_PATH} exists)`, + ); + try { + if (await stateExists()) { + throw new Error( + `Refusing to replace existing state at ${STATE_PATH}; run 'bun run dev-test down' first`, + ); + } + await createDevSandbox(worktree); + } finally { + await releaseUpLock(); + } +} + +async function createDevSandbox(worktree: string): Promise<void> { + const config = loadConfig(); + const snapshotName = await getLatestVolumeSnapshotName(config.namespace, config.snapshotPrefix); + if (!snapshotName) { + throw new Error( + `No ready VolumeSnapshot found in namespace ${config.namespace}` + + (config.snapshotPrefix ? ` with prefix ${config.snapshotPrefix}` : ""), + ); + } + + const newPvcName = pvcName(); + const tempDirectory = await mkdtemp(join(tmpdir(), "devbox-dev-test-")); + const archivePath = join(tempDirectory, "source.tar.gz"); + let pvcCreated = false; + let sandbox: Sandbox | undefined; + try { + await createSourceArchive(worktree, archivePath); + if (!config.storageClass) { + throw new Error("PVC_STORAGE_CLASS is required: the StorageClass for the test PVC"); + } + await createPvcFromSnapshot({ + name: newPvcName, + namespace: config.namespace, + snapshotName, + storageClassName: config.storageClass, + storageSize: config.storageSize, + }); + pvcCreated = true; + console.log(`Created PVC ${config.namespace}/${newPvcName} from ${snapshotName}`); + + sandbox = await Sandbox.create({ + connectionConfig: config.connectionConfig, + image: config.image, + entrypoint: ["tail", "-f", "/dev/null"], + resource: { cpu: config.resourceCpu, memory: config.resourceMemory }, + timeoutSeconds: config.timeoutSeconds, + volumes: [ + { + name: "workspace", + mountPath: WORKSPACE, + readOnly: false, + pvc: { claimName: newPvcName }, + }, + ], + readyTimeoutSeconds: config.readyTimeoutSeconds, + env: config.daemonEnv, + metadata: { + source: "dev-test", + task_id: `dev-test-${Date.now()}`, + external_id: `dev-test-${Date.now()}`, + }, + skipHealthCheck: true, + }); + console.log(`Created sandbox ${sandbox.id}; waiting for command API readiness`); + await sandbox.waitUntilReady({ + readyTimeoutSeconds: config.readyTimeoutSeconds, + pollingIntervalMillis: 1_000, + }); + + const target = sandboxHarnessTarget(sandbox, config.namespace); + await installHarnessSource(target, archivePath); + await syncHarnessDependencies(target); + await bootstrapSandbox(sandbox, `${HARNESS_DIR}/dev-test-bootstrap.log`); + await startDaemon(sandbox, DAEMON_LOG, DAEMON_PID); + await writeState({ sandboxId: sandbox.id, pvcName: newPvcName, worktree }); + console.log(`Dev sandbox ready; state written to ${STATE_PATH}`); + } catch (error) { + const cleanupErrors: string[] = []; + if (sandbox) { + await sandbox.kill().catch((cleanupError) => { + if (!isSandboxNotFoundError(cleanupError)) cleanupErrors.push(errorMessage(cleanupError)); + }); + } + if (pvcCreated) { + await deletePvc({ name: newPvcName, namespace: config.namespace }).catch((cleanupError) => { + if (!isK8sNotFoundError(cleanupError)) cleanupErrors.push(errorMessage(cleanupError)); + }); + } + if (cleanupErrors.length > 0) { + console.error(`Rollback also encountered: ${cleanupErrors.join("; ")}`); + } + throw error; + } finally { + await sandbox?.close().catch(() => {}); + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +async function commandPush(args: string[]): Promise<void> { + assertNoArgs(args); + const state = await readState(); + const config = loadConfig(); + const worktree = await validateHarnessWorktree(state.worktree); + const tempDirectory = await mkdtemp(join(tmpdir(), "devbox-dev-test-")); + const archivePath = join(tempDirectory, "source.tar.gz"); + let sandbox: Sandbox | undefined; + let stopped = false; + let restarted = false; + try { + await createSourceArchive(worktree, archivePath); + sandbox = await connectSandbox(state.sandboxId, config); + await stopDaemon(sandbox, DAEMON_PID); + stopped = true; + const target = sandboxHarnessTarget(sandbox, config.namespace); + await installHarnessSource(target, archivePath); + await syncHarnessDependencies(target); + await startDaemon(sandbox, DAEMON_LOG, DAEMON_PID); + restarted = true; + console.log(`Pushed ${state.worktree} and restarted the daemon`); + } finally { + if (sandbox && stopped && !restarted) { + await startDaemon(sandbox, DAEMON_LOG, DAEMON_PID).catch((error) => { + console.error(`Could not restore the daemon after push failure: ${errorMessage(error)}`); + }); + } + await sandbox?.close().catch(() => {}); + await rm(tempDirectory, { recursive: true, force: true }); + } +} + +async function commandLogs(args: string[]): Promise<void> { + const options = parseOptions(args, { "--events": {}, "--daemon": {} }); + const events = positiveInteger(option(options, "--events") ?? "50", "--events"); + const daemon = positiveInteger(option(options, "--daemon") ?? "100", "--daemon"); + const state = await readState(); + const config = loadConfig(); + const sandbox = await connectSandbox(state.sandboxId, config); + try { + const execution = await sandbox.commands.run( + [ + `echo '== events.jsonl (last ${events}) =='`, + `[ ! -f ${HARNESS_DIR}/events.jsonl ] || tail -n ${events} ${HARNESS_DIR}/events.jsonl`, + `echo '== dev-test-daemon.log (last ${daemon}) =='`, + `[ ! -f ${DAEMON_LOG} ] || tail -n ${daemon} ${DAEMON_LOG}`, + ].join("\n"), + { timeoutSeconds: 30 }, + ); + printExecution(execution); + requireSuccessfulExecution(execution, "Reading sandbox logs"); + } finally { + await sandbox.close().catch(() => {}); + } +} + +async function commandExec(args: string[]): Promise<void> { + const commandArgs = args[0] === "--" ? args.slice(1) : args; + if (commandArgs.length === 0) throw new UsageError("exec requires a remote command"); + const remoteCommand = commandArgs.map(shellQuote).join(" "); + const state = await readState(); + const config = loadConfig(); + const sandbox = await connectSandbox(state.sandboxId, config); + try { + const execution = await sandbox.commands.run(remoteCommand); + printExecution(execution); + const exitCode = execution.exitCode; + if (exitCode === null || exitCode === undefined) { + throw new Error("Remote command completed without an exit code"); + } + process.exitCode = exitCode; + } finally { + await sandbox.close().catch(() => {}); + } +} + +async function commandArchive(args: string[]): Promise<void> { + const options = parseOptions(args, { "--out": {} }); + const state = await readState(); + const config = loadConfig(); + const outputPath = resolve( + option(options, "--out") ?? + join(REPO_ROOT, `${basename(state.worktree)}-${timestampForName()}.archive.tgz`), + ); + const partialPath = `${outputPath}.${crypto.randomUUID()}.partial`; + const remotePath = `/tmp/dev-test-archive-${crypto.randomUUID()}.tar.gz`; + const podName = `${state.sandboxId}-0`; + const sandbox = await connectSandbox(state.sandboxId, config); + try { + await mkdir(dirname(outputPath), { recursive: true }); + await runRemoteChecked( + sandbox, + `harness archive --out ${shellQuote(remotePath)} --override`, + "Creating harness archive", + 120, + ); + await runCheckedProcess( + [KUBECTL, "cp", `${config.namespace}/${podName}:${remotePath}`, partialPath, "-c", "sandbox"], + `Copying archive from assumed OpenSandbox pod ${config.namespace}/${podName}`, + ); + await rename(partialPath, outputPath); + console.log(`Archive written to ${outputPath}`); + } finally { + await rm(partialPath, { force: true }); + await sandbox.commands.run(`rm -f ${shellQuote(remotePath)}`).catch(() => {}); + await sandbox.close().catch(() => {}); + } +} + +function buildPrScope(options: ParsedOptions, required: boolean): PrScope | null { + const repo = option(options, "--pr-repo"); + const url = option(options, "--pr-url"); + if ((repo && !url) || (!repo && url)) { + throw new UsageError("--pr-repo and --pr-url must be supplied together"); + } + if (!repo || !url) { + if (required) throw new UsageError("--pr-repo and --pr-url are required"); + return null; + } + return { repo, pr_url: url }; +} + +const SEED_COMMON = { + "--task-key": {}, + "--target": {}, + "--external-id": {}, +} satisfies OptionSpec; + +function seedIdentity(options: ParsedOptions): { + id: string; + targetId: string; + externalId: string; + taskKey: string; + createdAt: string; +} { + return { + id: crypto.randomUUID(), + targetId: option(options, "--target") ?? crypto.randomUUID(), + externalId: option(options, "--external-id") ?? `dev-test-${Date.now()}`, + taskKey: option(options, "--task-key") ?? "TEST-1", + createdAt: new Date().toISOString(), + }; +} + +function seedTaskIdentity(identity: { taskKey: string }): TaskIdentity { + return { + task_id: crypto.randomUUID(), + task_key: identity.taskKey, + }; +} + +function buildSeedMessage(kind: string, args: string[]): InboxMessage { + if (kind === "assignment") { + const options = parseOptions(args, { + ...SEED_COMMON, + "--title": {}, + "--description": {}, + "--external-url": {}, + }); + const identity = seedIdentity(options); + return { + id: identity.id, + source: "linear", + kind: "assignment", + external_id: identity.externalId, + payload: { + title: requiredOption(options, "--title"), + description: option(options, "--description") ?? null, + ...seedTaskIdentity(identity), + external_url: + option(options, "--external-url") ?? + `https://linear.app/dev-test/issue/${encodeURIComponent(identity.taskKey)}`, + target_id: identity.targetId, + }, + }; + } + + if (kind === "comment") { + const options = parseOptions(args, { + ...SEED_COMMON, + "--body": {}, + "--pr-repo": {}, + "--pr-url": {}, + }); + const identity = seedIdentity(options); + const pr = buildPrScope(options, false); + const common = { + id: identity.id, + kind: "comment" as const, + external_id: identity.externalId, + payload: { + target_id: identity.targetId, + body: requiredOption(options, "--body"), + ...seedTaskIdentity(identity), + pr, + created_at: identity.createdAt, + }, + }; + return pr ? { ...common, source: "github" } : { ...common, source: "linear" }; + } + + if (kind === "review") { + const options = parseOptions(args, { + ...SEED_COMMON, + "--body": {}, + "--pr-repo": {}, + "--pr-url": {}, + "--inline": { repeatable: true }, + }); + const identity = seedIdentity(options); + const pr = buildPrScope(options, true); + if (!pr) throw new UsageError("Review PR scope is required"); + return { + id: identity.id, + source: "github", + kind: "review", + external_id: identity.externalId, + payload: { + review_body: { target_id: identity.targetId, body: option(options, "--body") ?? "" }, + inline_comments: (options["--inline"] ?? []).map(parseInlineComment), + ...seedTaskIdentity(identity), + pr, + created_at: identity.createdAt, + }, + }; + } + + if (kind === "ci_result") { + const options = parseOptions(args, { + ...SEED_COMMON, + "--pr-repo": {}, + "--pr-url": {}, + "--workflow-name": {}, + "--workflow-path": {}, + "--conclusion": {}, + "--run-url": {}, + "--head-sha": {}, + "--head-branch": {}, + }); + const identity = seedIdentity(options); + const pr = buildPrScope(options, true); + if (!pr) throw new UsageError("CI result PR scope is required"); + const conclusion = option(options, "--conclusion") ?? "failure"; + const validConclusions = new Set([ + "action_required", + "cancelled", + "failure", + "neutral", + "skipped", + "stale", + "success", + "timed_out", + ]); + if (!validConclusions.has(conclusion)) { + throw new UsageError(`Invalid --conclusion: ${conclusion}`); + } + return { + id: identity.id, + source: "github", + kind: "ci_result", + external_id: identity.externalId, + payload: { + target_id: identity.targetId, + workflow_name: option(options, "--workflow-name") ?? "CI", + workflow_path: option(options, "--workflow-path") ?? ".github/workflows/ci.yml", + conclusion, + run_url: option(options, "--run-url") ?? "https://github.com/dev-test/actions/runs/1", + head_sha: option(options, "--head-sha") ?? "dev-test", + head_branch: option(options, "--head-branch") ?? "dev-test", + ...seedTaskIdentity(identity), + pr, + }, + }; + } + + throw new UsageError(`Unknown seed kind: ${kind}`); +} + +function parseInlineComment(value: string): { + target_id: string; + path: string; + line: number; + body: string; +} { + const firstColon = value.indexOf(":"); + const secondColon = firstColon === -1 ? -1 : value.indexOf(":", firstColon + 1); + if (firstColon <= 0 || secondColon === -1) { + throw new UsageError(`Invalid --inline value '${value}'; expected path:line:body`); + } + const path = value.slice(0, firstColon); + const rawLine = value.slice(firstColon + 1, secondColon); + const body = value.slice(secondColon + 1); + if (!body) throw new UsageError(`Invalid --inline value '${value}'; body must not be empty`); + const line = positiveInteger(rawLine, "inline line"); + return { target_id: crypto.randomUUID(), path, line, body }; +} + +async function commandSeed(args: string[]): Promise<void> { + const kind = args[0]; + if (!kind) throw new UsageError("seed requires assignment, comment, review, or ci_result"); + const message = buildSeedMessage(kind, args.slice(1)); + const state = await readState(); + const config = loadConfig(); + const sandbox = await connectSandbox(state.sandboxId, config); + const inboxPath = `${HARNESS_DIR}/inbox/${message.id}.json`; + try { + await runRemoteChecked(sandbox, `mkdir -p ${HARNESS_DIR}/inbox`, "Creating inbox directory"); + await sandbox.files.writeFiles([ + { path: inboxPath, data: `${JSON.stringify(message, null, 2)}\n` }, + ]); + console.log(`Seeded ${message.source}/${message.kind} at ${inboxPath}`); + } finally { + await sandbox.close().catch(() => {}); + } +} + +function isSandboxNotFoundError(error: unknown): boolean { + return error instanceof SandboxApiException && error.statusCode === 404; +} + +async function commandDown(args: string[]): Promise<void> { + assertNoArgs(args); + const state = await readState(); + const config = loadConfig(); + let sandboxRemoved = false; + let pvcRemoved = false; + let sandbox: Sandbox | undefined; + + try { + try { + sandbox = await connectSandbox(state.sandboxId, config); + await sandbox.kill(); + sandboxRemoved = true; + console.log(`Deleted sandbox ${state.sandboxId}`); + } catch (error) { + if (isSandboxNotFoundError(error)) { + sandboxRemoved = true; + console.log(`Sandbox ${state.sandboxId} is already absent`); + } else { + throw error; + } + } finally { + await sandbox?.close().catch(() => {}); + } + + try { + await deletePvc({ name: state.pvcName, namespace: config.namespace }); + pvcRemoved = true; + console.log(`Deleted PVC ${config.namespace}/${state.pvcName}`); + } catch (error) { + if (isK8sNotFoundError(error)) { + pvcRemoved = true; + console.log(`PVC ${config.namespace}/${state.pvcName} is already absent`); + } else { + throw error; + } + } + } finally { + if (sandboxRemoved && pvcRemoved) await removeState(); + } + console.log(`Removed ${STATE_PATH}`); +} + +async function main(): Promise<void> { + const [command, ...args] = process.argv.slice(2); + if (command === undefined) { + console.error(USAGE); + process.exitCode = 1; + return; + } + if (command === "--help" || command === "-h" || command === "help") { + console.log(USAGE); + return; + } + + switch (command) { + case "up": + await commandUp(args); + return; + case "push": + await commandPush(args); + return; + case "logs": + await commandLogs(args); + return; + case "exec": + await commandExec(args); + return; + case "archive": + await commandArchive(args); + return; + case "seed": + await commandSeed(args); + return; + case "down": + await commandDown(args); + return; + default: + throw new UsageError(`Unknown command: ${command}`); + } +} + +try { + await main(); +} catch (error) { + console.error(`dev-test: ${errorMessage(error)}`); + if (error instanceof UsageError) console.error(`\n${USAGE}`); + process.exitCode = 1; +} diff --git a/control-plane/scripts/gen-iron-proxy-ca.sh b/control-plane/scripts/gen-iron-proxy-ca.sh new file mode 100755 index 0000000..2c5266d --- /dev/null +++ b/control-plane/scripts/gen-iron-proxy-ca.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Generates the local iron-proxy CA into infra/local/certs (gitignored). +# iron-proxy signs per-host leaf certs with it; the harness image trusts it via +# `make build IRON_PROXY_CA=.../ca.crt`. No-op when the CA already exists, so +# re-running never invalidates an image built against the current CA. +set -euo pipefail + +CERTS_DIR="$(cd "$(dirname "$0")/.." && pwd)/infra/local/certs" +if [ -f "$CERTS_DIR/ca.crt" ] && [ -f "$CERTS_DIR/ca.key" ]; then + echo "iron-proxy CA already exists at $CERTS_DIR" + exit 0 +fi + +mkdir -p "$CERTS_DIR" +( + umask 077 + openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \ + -subj "/CN=harness-local iron-proxy CA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -keyout "$CERTS_DIR/ca.key" -out "$CERTS_DIR/ca.crt" 2>/dev/null +) +# The cert is public (baked into the harness image); the key stays owner-only. +chmod 644 "$CERTS_DIR/ca.crt" +echo "Generated iron-proxy CA at $CERTS_DIR (rebuild the harness image with IRON_PROXY_CA=$CERTS_DIR/ca.crt)" diff --git a/control-plane/scripts/github.ts b/control-plane/scripts/github.ts new file mode 100644 index 0000000..8944beb --- /dev/null +++ b/control-plane/scripts/github.ts @@ -0,0 +1,230 @@ +// The GitHub App: +// bun run github:setup [--target <file>] [--public-url <url>] [--org <org>] +// [--name <app name>] [--external-tokens] +// [--app-id <id> [--private-key-file <path>]] +// [--reregister] [--no-keychain] [--print-registration-link] +// bun run github:status [--target <file>] [--no-keychain] +// bun run github:disable [--target <file>] [--no-keychain] [--forget [--yes]] +// Without --target it acts on the Compose deployment (devbox.config.json), +// with it on the k8s deployment that target file describes. The App's key and +// webhook secret are never printed to the log. +import { randomBytes } from "node:crypto"; +import { parseArgs } from "node:util"; +import { GithubAppError } from "../src/githubToken/mint"; +import { AppApiError } from "./github/appApi"; +import { loadDeployment } from "./github/deployment"; +import { disable } from "./github/disable"; +import { CallbackError, startCallbackListener } from "./github/listener"; +import { SetupError, setup } from "./github/setup"; +import { status } from "./github/status"; +import { K8sPreflightError } from "./k8s/preflight"; +import { KubeError } from "./k8s/run"; +import { capture } from "./lib/capture"; +import { DestructiveRefused } from "./lib/destructive"; +import { keychainFor } from "./lib/keychain"; +import { errorMessage, isKnownError } from "./lib/knownError"; +import { openUrl } from "./lib/openUrl"; +import type { ResolverDeps } from "./lib/secrets"; +import { confirm, promptHidden, promptLine, watchEnter } from "./up/prompt"; + +const COMMANDS = ["setup", "status", "disable"]; + +const USAGE = `Usage: + bun run github:setup [--target <file>] [--public-url <url>] [--org <org>] + [--name <app name>] [--external-tokens] + [--app-id <id> [--private-key-file <path>]] + [--reregister] [--no-keychain] [--print-registration-link] + +Registers a GitHub App for devbox (your browser opens GitHub's pre-filled +create page), saves its private key and webhook secret, waits for you to +install it, and writes its settings, the public URL and a default trust list +to the deployment's config. It applies nothing: it names the command that +does (bun run up, k8s:install with --target, or k8s:kind on kind). + + --target <file> the k8s deployment's target file (default: Compose) + --public-url <url> the public HTTPS URL GitHub sends webhooks to + --org <org> register the App under this organization + (default: your account) + --name <app name> the App's name on GitHub (default: devbox) + --external-tokens your own tooling mints the App's tokens, not devbox + --app-id <id> adopt an existing App instead of registering one + --private-key-file <path> that App's private key (default: GITHUB_APP_PRIVATE_KEY_FILE, + GITHUB_APP_PRIVATE_KEY, then the keychain) + --reregister register a new App even if one is configured + --no-keychain never read or write the system keychain + --print-registration-link + for a machine without a browser: print a link that + creates the App, then adopt it with --app-id + + bun run github:status [--target <file>] [--no-keychain] + bun run github:disable [--target <file>] [--no-keychain] [--forget [--yes]] + +status shows the configuration, where the key and webhook secret are found, +what the control plane reports and whether the webhook URL reaches devbox. +disable switches the App off (enabled: false), keeping its settings and saved +secrets so that github:setup turns it back on; GitHub then uses a personal +token. --forget removes integrations.githubApp and the saved App key and +webhook secret instead, after you confirm (--yes confirms without a terminal); +the App on GitHub is left for you to delete. + + --forget disable: delete the configuration and saved secrets + --yes disable --forget: confirm without asking +`; + +class UsageError extends Error {} + +const log = (line: string) => process.stdout.write(`${line}\n`); + +const SETUP_FLAGS = { + target: { type: "string" }, + "public-url": { type: "string" }, + org: { type: "string" }, + name: { type: "string" }, + "external-tokens": { type: "boolean" }, + "app-id": { type: "string" }, + "private-key-file": { type: "string" }, + reregister: { type: "boolean" }, + "no-keychain": { type: "boolean" }, + "print-registration-link": { type: "boolean" }, + help: { type: "boolean", short: "h" }, +} as const; + +const OTHER_FLAGS = { + target: { type: "string" }, + "no-keychain": { type: "boolean" }, + forget: { type: "boolean" }, + yes: { type: "boolean" }, + help: { type: "boolean", short: "h" }, +} as const; + +function resolverFor(d: ReturnType<typeof loadDeployment>, noKeychain: boolean): ResolverDeps { + return { + env: process.env, + keychain: keychainFor(noKeychain), + deployment: d.deployment, + ...(d.deployment.kind === "k8s" ? { targetPath: d.configPath } : {}), + isTTY: process.stdin.isTTY === true, + prompt: promptHidden, + confirm, + run: (cmd) => capture(cmd), + log, + }; +} + +function appIdFlag(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + if (!/^[1-9]\d*$/.test(raw)) throw new UsageError(`--app-id: a positive number, got "${raw}"`); + return Number(raw); +} + +async function main(command: string | undefined, argv: string[]): Promise<number> { + if (command === "--help" || command === "-h" || command === "help") { + process.stdout.write(USAGE); + return 0; + } + if (command === "status" || command === "disable") { + const { values } = parseArgs({ args: argv, options: OTHER_FLAGS }); + if (values.help) { + process.stdout.write(USAGE); + return 0; + } + if (command === "status" && (values.forget || values.yes)) { + throw new UsageError("--forget and --yes belong to github:disable"); + } + if (values.yes && !values.forget) throw new UsageError("--yes goes with --forget"); + const noKeychain = values["no-keychain"] === true; + const d = loadDeployment({ target: values.target, noKeychain }); + const resolver = resolverFor(d, noKeychain); + if (command === "status") { + await status(d, { resolver, fetch, now: () => new Date(), log }); + } else { + await disable( + d, + { forget: values.forget === true, yes: values.yes === true }, + { resolver, log }, + ); + } + return 0; + } + if (command !== "setup") { + throw new UsageError(`unknown command "${command ?? ""}": use one of ${COMMANDS.join(", ")}`); + } + const { values } = parseArgs({ args: argv, options: SETUP_FLAGS }); + if (values.help) { + process.stdout.write(USAGE); + return 0; + } + const appId = appIdFlag(values["app-id"]); + if (values["private-key-file"] !== undefined && appId === undefined) { + throw new UsageError("--private-key-file goes with --app-id"); + } + if (values["print-registration-link"] && appId !== undefined) { + throw new UsageError( + "--print-registration-link registers a new App; --app-id adopts one: use one of them", + ); + } + if (values.reregister && appId !== undefined) { + throw new UsageError("--reregister registers a new App; --app-id adopts one: use one of them"); + } + const noKeychain = values["no-keychain"] === true; + const d = loadDeployment({ target: values.target, noKeychain }); + const resolver = resolverFor(d, noKeychain); + await setup( + d, + { + publicUrl: values["public-url"], + ...(values.org !== undefined ? { org: values.org } : {}), + name: values.name ?? "devbox", + externalTokens: values["external-tokens"] === true, + ...(appId !== undefined ? { appId } : {}), + ...(values["private-key-file"] !== undefined + ? { privateKeyFile: values["private-key-file"] } + : {}), + reregister: values.reregister === true, + printRegistrationLink: values["print-registration-link"] === true, + }, + { + resolver, + promptLine, + openUrl, + fetch, + now: () => new Date(), + sleep: (ms) => Bun.sleep(ms), + enter: () => watchEnter(), + listen: startCallbackListener, + random: (bytes) => randomBytes(bytes).toString("hex"), + cwd: process.cwd(), + // The terminal, not the log: shown once, for the user to copy. + show: (secret) => process.stderr.write(`${secret}\n`), + log, + }, + ); + return 0; +} + +if (import.meta.main) { + const [command, ...argv] = process.argv.slice(2); + try { + process.exit(await main(command, argv)); + } catch (err) { + process.stderr.write(`\nbun run github:${command ?? ""}: ${errorMessage(err)}\n`); + if ( + !isKnownError( + err, + UsageError, + SetupError, + CallbackError, + AppApiError, + GithubAppError, + KubeError, + K8sPreflightError, + DestructiveRefused, + ) && + err instanceof Error && + err.stack + ) { + process.stderr.write(`${err.stack}\n`); + } + process.exit(1); + } +} diff --git a/control-plane/scripts/github/appApi.test.ts b/control-plane/scripts/github/appApi.test.ts new file mode 100644 index 0000000..ed827f1 --- /dev/null +++ b/control-plane/scripts/github/appApi.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { GithubAppError } from "../../src/githubToken/mint"; +import { AppApiError, appApi } from "./appApi"; + +const { privateKey: PEM } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); +const BASE = "https://api.example.com"; +const KEY = { appId: 123, pem: PEM }; +const NOW = new Date("2026-10-19T12:00:00Z"); + +type Call = { url: string; method: string; auth: string | null; body: unknown }; + +function fake(answers: Response[], now: () => Date = () => NOW) { + const calls: Call[] = []; + const api = appApi({ + apiBase: BASE, + now, + fetch: async (url, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(url), + method: init?.method ?? "GET", + auth: headers.get("authorization"), + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + }); + const next = answers.shift(); + if (!next) throw new Error("no answer left"); + return next; + }, + }); + return { api, calls }; +} + +const claims = (auth: string | null): unknown => { + const [, payload] = (auth ?? "").replace(/^Bearer /, "").split("."); + return JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")); +}; + +const CONVERSION = { + id: 123, + slug: "example-devbox", + pem: "-----BEGIN RSA PRIVATE KEY-----\nsecret-key\n-----END RSA PRIVATE KEY-----\n", + webhook_secret: "whsec-1", + html_url: "https://github.com/apps/example-devbox", + owner: { login: "example-org", type: "Organization", id: 9 }, + client_secret: "not-used", +}; + +describe("convertManifest", () => { + it("posts the code without credentials and returns the App", async () => { + const { api, calls } = fake([Response.json(CONVERSION, { status: 201 })]); + expect(await api.convertManifest("code-1")).toEqual({ + id: 123, + slug: "example-devbox", + pem: CONVERSION.pem, + webhookSecret: "whsec-1", + htmlUrl: "https://github.com/apps/example-devbox", + owner: { login: "example-org", type: "Organization" }, + }); + expect(calls).toEqual([ + { + url: `${BASE}/app-manifests/code-1/conversions`, + method: "POST", + auth: null, + body: undefined, + }, + ]); + }); + + it("a null webhook secret stays null", async () => { + const { api } = fake([Response.json({ ...CONVERSION, webhook_secret: null }, { status: 201 })]); + expect((await api.convertManifest("c")).webhookSecret).toBeNull(); + }); + + it("a refusal names the status only, never the body", async () => { + const { api } = fake([Response.json({ message: "Not Found", pem: "leak" }, { status: 404 })]); + const err = await api.convertManifest("c").catch((e: unknown) => e); + expect(err).toEqual( + new AppApiError( + "GitHub didn't create the App from its manifest (HTTP 404): run bun run github:setup again", + ), + ); + }); + + it("an answer without the key: the missing field is named, the body is not", async () => { + const { api } = fake([Response.json({ ...CONVERSION, pem: undefined }, { status: 201 })]); + const err = await api.convertManifest("c").catch((e: unknown) => e); + expect(err).toEqual( + new AppApiError( + "GitHub's answer for the new App has no usable pem: run bun run github:setup again", + ), + ); + expect(String((err as Error).message)).not.toContain("whsec-1"); + }); + + it("an answer that is not JSON: no part of it in the message", async () => { + const { api } = fake([new Response(`{"pem":"${"x".repeat(10)}`, { status: 201 })]); + const err = await api.convertManifest("c").catch((e: unknown) => e); + expect(err).toEqual( + new AppApiError( + "GitHub's answer for the new App is not JSON: run bun run github:setup again", + ), + ); + }); +}); + +describe("JWT calls", () => { + it("listInstallations: signs every call afresh", async () => { + let t = NOW.getTime(); + const page = [ + { id: 7, account: { login: "example-org", type: "Organization" }, app_id: 123 }, + { id: 8, account: { login: "example-user", type: "User" } }, + ]; + const { api, calls } = fake([Response.json(page), Response.json([])], () => new Date(t)); + expect(await api.listInstallations(KEY)).toEqual([ + { id: 7, account: { login: "example-org", type: "Organization" } }, + { id: 8, account: { login: "example-user", type: "User" } }, + ]); + t += 20 * 60_000; + expect(await api.listInstallations(KEY)).toEqual([]); + expect(calls.map((c) => [c.method, c.url])).toEqual([ + ["GET", `${BASE}/app/installations?per_page=100&page=1`], + ["GET", `${BASE}/app/installations?per_page=100&page=1`], + ]); + const seconds = NOW.getTime() / 1000; + expect(claims(calls[0]?.auth ?? null)).toEqual({ + iat: seconds - 60, + exp: seconds + 540, + iss: "123", + }); + expect(claims(calls[1]?.auth ?? null)).toEqual({ + iat: seconds + 1200 - 60, + exp: seconds + 1200 + 540, + iss: "123", + }); + }); + + it("listInstallations: follows the pages until one is short", async () => { + const full = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + account: { login: `user-${i + 1}`, type: "User" }, + })); + const { api, calls } = fake([ + Response.json(full), + Response.json([{ id: 101, account: { login: "example-org", type: "Organization" } }]), + ]); + const all = await api.listInstallations(KEY); + expect(all).toEqual([ + ...full, + { id: 101, account: { login: "example-org", type: "Organization" } }, + ]); + expect(calls.map((c) => c.url)).toEqual([ + `${BASE}/app/installations?per_page=100&page=1`, + `${BASE}/app/installations?per_page=100&page=2`, + ]); + }); + + it("listInstallations: a refusal is GitHub's error", async () => { + const { api } = fake([Response.json({ message: "Bad credentials" }, { status: 401 })]); + const err = await api.listInstallations(KEY).catch((e: unknown) => e); + expect(err).toBeInstanceOf(GithubAppError); + expect((err as GithubAppError).status).toBe(401); + }); + + it("updateHookConfig sends only the given fields", async () => { + const { api, calls } = fake([Response.json({}), Response.json({})]); + await api.updateHookConfig(KEY, { secret: "s" }); + await api.updateHookConfig(KEY, { + url: "https://hooks.example.com/webhook/github", + secret: "t", + }); + expect(calls.map((c) => [c.method, c.url, c.body])).toEqual([ + ["PATCH", `${BASE}/app/hook/config`, { secret: "s" }], + [ + "PATCH", + `${BASE}/app/hook/config`, + { url: "https://hooks.example.com/webhook/github", secret: "t" }, + ], + ]); + expect(calls.every((c) => c.auth?.startsWith("Bearer ey"))).toBe(true); + }); +}); + +describe("getBotUserId", () => { + it("looks up <slug>[bot] without credentials", async () => { + const { api, calls } = fake([Response.json({ id: 1234567, login: "example-devbox[bot]" })]); + expect(await api.getBotUserId("example-devbox")).toBe(1234567); + expect(calls).toEqual([ + { url: `${BASE}/users/example-devbox%5Bbot%5D`, method: "GET", auth: null, body: undefined }, + ]); + }); + + it("an answer without an id", async () => { + const { api } = fake([Response.json({ login: "x" })]); + expect(await api.getBotUserId("example-devbox").catch((e: unknown) => e)).toEqual( + new AppApiError("GitHub has no user id for example-devbox[bot]"), + ); + }); +}); diff --git a/control-plane/scripts/github/appApi.ts b/control-plane/scripts/github/appApi.ts new file mode 100644 index 0000000..c12cfdf --- /dev/null +++ b/control-plane/scripts/github/appApi.ts @@ -0,0 +1,142 @@ +// The GitHub calls `github:setup` makes for the App itself. The manifest +// conversion and the bot lookup are unauthenticated; the rest are signed +// with the App's JWT, signed afresh for every call, since the installation +// wait can outlast one JWT. The conversion's answer holds the App's private +// key and webhook secret, so its errors never quote the answer. +import { appJwt } from "../../src/githubToken/jwt"; +import { type Fetch, GITHUB_TIMEOUT_MS, githubAppFetch } from "../../src/githubToken/mint"; + +export class AppApiError extends Error {} + +export type AppAccount = { login: string; type: string }; + +export type ConvertedApp = { + id: number; + slug: string; + pem: string; + webhookSecret: string | null; + htmlUrl: string; + owner: AppAccount; +}; + +export type Installation = { id: number; account: AppAccount }; + +// The App's id and private key, for signing. +export type AppKey = { appId: number; pem: string }; + +export type AppApiDeps = { fetch: Fetch; apiBase: string; now: () => Date }; + +const AGAIN = "run bun run github:setup again"; +const PER_PAGE = 100; + +const isObject = (v: unknown): v is Record<string, unknown> => + typeof v === "object" && v !== null && !Array.isArray(v); + +const account = (v: unknown): AppAccount | undefined => + isObject(v) && typeof v.login === "string" && typeof v.type === "string" + ? { login: v.login, type: v.type } + : undefined; + +export type AppApi = ReturnType<typeof appApi>; + +export function appApi(deps: AppApiDeps) { + const http = { fetch: deps.fetch, apiBase: deps.apiBase }; + const signed = (key: AppKey) => () => appJwt(key.appId, key.pem, deps.now()); + + return { + // Trades the one-time code from GitHub's redirect for the new App's + // credentials. + async convertManifest(code: string): Promise<ConvertedApp> { + const res = await deps.fetch( + `${deps.apiBase}/app-manifests/${encodeURIComponent(code)}/conversions`, + { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + }, + ); + const text = await res.text(); + if (!res.ok) { + throw new AppApiError( + `GitHub didn't create the App from its manifest (HTTP ${res.status}): ${AGAIN}`, + ); + } + let body: unknown; + try { + body = JSON.parse(text); + } catch { + throw new AppApiError(`GitHub's answer for the new App is not JSON: ${AGAIN}`); + } + const missing = (field: string) => + new AppApiError(`GitHub's answer for the new App has no usable ${field}: ${AGAIN}`); + if (!isObject(body)) throw missing("App"); + const { id, slug, pem, webhook_secret: secret, html_url: htmlUrl } = body; + if (typeof id !== "number") throw missing("id"); + if (typeof slug !== "string" || !slug) throw missing("slug"); + if (typeof pem !== "string" || !pem) throw missing("pem"); + if (typeof htmlUrl !== "string") throw missing("html_url"); + const owner = account(body.owner); + if (!owner) throw missing("owner"); + return { + id, + slug, + pem, + webhookSecret: typeof secret === "string" && secret ? secret : null, + htmlUrl, + owner, + }; + }, + + // Every installation, a page of 100 at a time. + async listInstallations(key: AppKey): Promise<Installation[]> { + const all: Installation[] = []; + for (let page = 1; ; page++) { + const out = await githubAppFetch( + `/app/installations?per_page=${PER_PAGE}&page=${page}`, + { method: "GET", jwt: signed(key) }, + http, + ); + const items = Array.isArray(out) ? out : []; + for (const i of items) { + const acc = isObject(i) ? account(i.account) : undefined; + if (isObject(i) && typeof i.id === "number" && acc) all.push({ id: i.id, account: acc }); + } + if (items.length < PER_PAGE) return all; + } + }, + + // The App's bot user: its id goes into the commit e-mail. A JWT can't + // call /users, so this one is unauthenticated. + async getBotUserId(slug: string): Promise<number> { + const login = `${slug}[bot]`; + const out = await githubAppFetch( + `/users/${encodeURIComponent(login)}`, + { method: "GET" }, + http, + ); + if (!isObject(out) || typeof out.id !== "number") { + throw new AppApiError(`GitHub has no user id for ${login}`); + } + return out.id; + }, + + // Sets the App's webhook URL and/or secret; nothing else is sent. + async updateHookConfig(key: AppKey, config: { url?: string; secret?: string }): Promise<void> { + await githubAppFetch( + "/app/hook/config", + { + method: "PATCH", + jwt: signed(key), + body: { + ...(config.url !== undefined ? { url: config.url } : {}), + ...(config.secret !== undefined ? { secret: config.secret } : {}), + }, + }, + http, + ); + }, + }; +} diff --git a/control-plane/scripts/github/deployment.test.ts b/control-plane/scripts/github/deployment.test.ts new file mode 100644 index 0000000..4394d5d --- /dev/null +++ b/control-plane/scripts/github/deployment.test.ts @@ -0,0 +1,154 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CliConfigError } from "../up/cliLogin"; +import { loadDeployment } from "./deployment"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-github-deployment-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +let savedXdg: string | undefined; +beforeEach(() => { + savedXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = mkdtempSync(join(dir, "config-")); +}); +afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; +}); + +const PUBLIC = "https://hooks.example.com"; +const APP = { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 7, + botUserId: 1234567, + tokens: "devbox" as const, +}; +const REPO = { + url: "https://github.com/example-org/example-repo.git", + branch: "main", + owner: "example-org", + name: "example-repo", +}; + +let n = 0; +function file(content: unknown): string { + const path = join(dir, `f${n++}.json`); + writeFileSync(path, JSON.stringify(content)); + return path; +} + +const SETUP = { + repos: [{ url: REPO.url }], + llm: { baseUrl: "https://llm.example.com/v1", model: "m1" }, +}; + +describe("loadDeployment", () => { + it("Compose: devbox.config.json holds the App, the trust lists and the repos", async () => { + const configPath = file({ + ...SETUP, + publicUrl: PUBLIC, + github: { trustedOrgs: ["example-org"] }, + integrations: { githubApp: APP, linear: { enabled: true, clientId: "c" } }, + }); + const { api, ...d } = loadDeployment({ noKeychain: true, configPath }); + expect(d).toEqual({ + deployment: { kind: "compose" }, + configPath, + githubApp: { ...APP, tokenScope: "repos", ciResults: true }, + linear: true, + repos: [REPO], + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + publicUrl: PUBLIC, + applyCommand: "bun run up", + statusCommand: "bun run github:status", + }); + await expect(api("/api/integrations/github")).rejects.toThrow( + new CliConfigError('no CLI profile "local": run `bun run up` first'), + ); + }); + + it("Compose without an App or Linear", () => { + const configPath = file(SETUP); + const { api: _, ...d } = loadDeployment({ noKeychain: true, configPath }); + expect(d).toEqual({ + deployment: { kind: "compose" }, + configPath, + githubApp: undefined, + linear: false, + repos: [REPO], + trust: { trustedOrgs: [], trustedUsers: [] }, + applyCommand: "bun run up", + statusCommand: "bun run github:status", + }); + }); + + it("k8s: the target file holds the App and the trust lists; the user's own Secrets", async () => { + const configPath = file(SETUP); + const path = file({ + context: "prod", + profile: "k8s-prod", + publicUrl: PUBLIC, + values: { + github: { trustedUsers: ["example-user"] }, + secrets: { + github: { existingSecret: "my-token" }, + githubApp: { existingSecret: "my-app-key" }, + integrations: { existingSecret: "my-integrations" }, + }, + }, + integrations: { githubApp: { enabled: false, appId: 123, slug: "example-devbox" } }, + }); + const { api, ...d } = loadDeployment({ target: path, noKeychain: true, configPath }); + expect(d).toEqual({ + deployment: { kind: "k8s", context: "prod", namespace: "devbox" }, + configPath: path, + githubApp: { + enabled: false, + appId: 123, + slug: "example-devbox", + tokenScope: "repos", + ciResults: true, + }, + linear: false, + repos: [REPO], + trust: { trustedOrgs: [], trustedUsers: ["example-user"] }, + publicUrl: PUBLIC, + fromApiIngress: false, + applyCommand: `bun run k8s:install --target ${path}`, + statusCommand: `bun run github:status --target ${path}`, + byoGithubSecret: "my-token", + byoKeySecret: "my-app-key", + byoIntegrationsSecret: "my-integrations", + }); + await expect(api("/api/integrations/github")).rejects.toThrow( + new CliConfigError( + `no CLI profile "k8s-prod": run \`bun run k8s:install --target ${path}\` first`, + ), + ); + }); + + it("k8s: an Ingress gives the URL", () => { + const configPath = file(SETUP); + const path = file({ + context: "kind-devbox", + values: { ingress: { webhooks: { enabled: true, host: "hooks.example.com", tls: [{}] } } }, + }); + const { api: _, ...d } = loadDeployment({ target: path, noKeychain: true, configPath }); + expect(d).toEqual({ + deployment: { kind: "k8s", context: "kind-devbox", namespace: "devbox" }, + configPath: path, + githubApp: undefined, + linear: false, + repos: [REPO], + trust: { trustedOrgs: [], trustedUsers: [] }, + ingressUrl: PUBLIC, + fromApiIngress: false, + applyCommand: "bun run k8s:kind", + statusCommand: `bun run github:status --target ${path}`, + }); + }); +}); diff --git a/control-plane/scripts/github/deployment.ts b/control-plane/scripts/github/deployment.ts new file mode 100644 index 0000000..bbcb393 --- /dev/null +++ b/control-plane/scripts/github/deployment.ts @@ -0,0 +1,103 @@ +// The deployment `github:*` acts on: Compose without --target, the k8s +// deployment a target file describes with it. Each knows where its +// `integrations.githubApp` and trust lists live, the repos the App must +// cover, the commands that apply and check it, the user's own Secrets, and +// how to call its API through its CLI profile. Nothing here applies anything. +import { join, resolve } from "node:path"; +import { installCommand } from "../k8s/kind"; +import { NAMESPACE } from "../k8s/run"; +import { existingSecret } from "../k8s/secrets"; +import { loadTarget } from "../k8s/target"; +import { enabledLinear, type GithubAppIntegration } from "../lib/integrations"; +import type { Deployment } from "../lib/keychain"; +import { ingressPublicUrl, ingressSource } from "../lib/publicUrl"; +import { composeApi, k8sApi } from "../linear/deployment"; +import { loadConfig, type RepoConfig } from "../up/config"; + +export type TrustLists = { trustedOrgs: string[]; trustedUsers: string[] }; + +export type GithubDeployment = { + deployment: Deployment; + // The file holding integrations.githubApp: devbox.config.json or the target file. + configPath: string; + // The App's block as configured, on or off. + githubApp: GithubAppIntegration | undefined; + // Whether Linear is on: its webhook URL follows the shared public URL. + linear: boolean; + // The repos the App's installation must cover (devbox.config.json). + repos: RepoConfig[]; + // Who may drive the agent from GitHub: Compose's top-level `github`, or + // the target's `values.github` on k8s. Empty lists when unset. + trust: TrustLists; + // The deployment's top-level publicUrl, shared with Linear. + publicUrl?: string; + // k8s: the URL derived from the target's Ingress values, and whether it is + // the api Ingress's (which may be VPN-only). + ingressUrl?: string; + fromApiIngress?: boolean; + applyCommand: string; + statusCommand: string; + // k8s: the user's own Secrets for the App's key and for the integrations' + // secrets (secrets.githubApp / secrets.integrations existingSecret). + byoGithubSecret?: string; + byoKeySecret?: string; + byoIntegrationsSecret?: string; + // GET through the deployment's CLI profile; the body is read in full. + api: (path: string) => Promise<Response>; +}; + +const DEFAULT_CONFIG_PATH = join(import.meta.dir, "..", "..", "..", "devbox.config.json"); + +const strings = (v: unknown): string[] => + Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; + +export function loadDeployment(opts: { + target?: string; + noKeychain: boolean; + // devbox.config.json; another file in tests. + configPath?: string; +}): GithubDeployment { + const configPath = opts.configPath ?? DEFAULT_CONFIG_PATH; + if (opts.target === undefined) { + const cfg = loadConfig(configPath); + return { + deployment: { kind: "compose" }, + configPath, + githubApp: cfg.integrations.githubApp, + linear: enabledLinear(cfg.integrations) !== undefined, + repos: cfg.repos, + trust: { trustedOrgs: cfg.github.trustedOrgs, trustedUsers: cfg.github.trustedUsers }, + ...(cfg.publicUrl !== undefined ? { publicUrl: cfg.publicUrl } : {}), + applyCommand: "bun run up", + statusCommand: "bun run github:status", + api: composeApi, + }; + } + + const cfg = loadConfig(configPath, { integrations: false }); + const path = resolve(opts.target); + const target = loadTarget(path); + const github = (target.values.github ?? {}) as Record<string, unknown>; + const byoGithub = existingSecret(target.values, "github"); + const byoKey = existingSecret(target.values, "githubApp"); + const byoIntegrations = existingSecret(target.values, "integrations"); + const ingressUrl = ingressPublicUrl(target); + const apply = installCommand(target.context, path); + return { + deployment: { kind: "k8s", context: target.context, namespace: NAMESPACE }, + configPath: path, + githubApp: target.integrations?.githubApp, + linear: enabledLinear(target.integrations ?? {}) !== undefined, + repos: cfg.repos, + trust: { trustedOrgs: strings(github.trustedOrgs), trustedUsers: strings(github.trustedUsers) }, + ...(target.publicUrl !== undefined ? { publicUrl: target.publicUrl } : {}), + ...(ingressUrl !== undefined ? { ingressUrl } : {}), + fromApiIngress: ingressSource(target) === "api", + applyCommand: apply, + statusCommand: `bun run github:status --target ${path}`, + ...(byoGithub !== undefined ? { byoGithubSecret: byoGithub } : {}), + ...(byoKey !== undefined ? { byoKeySecret: byoKey } : {}), + ...(byoIntegrations !== undefined ? { byoIntegrationsSecret: byoIntegrations } : {}), + api: k8sApi(target, apply), + }; +} diff --git a/control-plane/scripts/github/disable.test.ts b/control-plane/scripts/github/disable.test.ts new file mode 100644 index 0000000..d226fb2 --- /dev/null +++ b/control-plane/scripts/github/disable.test.ts @@ -0,0 +1,265 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DestructiveRefused } from "../lib/destructive"; +import { type Deployment, MemoryKeychain } from "../lib/keychain"; +import type { ResolverDeps } from "../lib/secrets"; +import type { GithubDeployment } from "./deployment"; +import { disable } from "./disable"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-github-disable-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const COMPOSE: Deployment = { kind: "compose" }; +const APP = { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 7, + botUserId: 1234567, + tokens: "devbox" as const, + tokenScope: "repos" as const, + ciResults: true, +}; +const OFF = { ...APP, enabled: false }; +const SHARED = { publicUrl: "https://hooks.example.com", github: { trustedOrgs: ["example-org"] } }; +let n = 0; + +async function run(o: { + app?: typeof APP & { org?: string; pending?: { appId: number; slug: string; org?: string } }; + forget?: boolean; + yes?: boolean; + isTTY?: boolean; + answer?: boolean; + saved?: Record<string, string>; + noKeychain?: boolean; +}) { + const path = join(dir, `c${n++}.json`); + const original = JSON.stringify({ + port: 1, + ...SHARED, + ...(o.app + ? { integrations: { githubApp: o.app, linear: { enabled: true, clientId: "c" } } } + : {}), + }); + writeFileSync(path, original); + const keychain = new MemoryKeychain({ + "compose/GITHUB_TOKEN": "c", + ...(o.saved ?? { + "compose/GITHUB_APP_PRIVATE_KEY": "pem", + "compose/GITHUB_WEBHOOK_SECRET": "b", + }), + }); + const log: string[] = []; + const asked: string[] = []; + const d: GithubDeployment = { + deployment: COMPOSE, + configPath: path, + githubApp: o.app, + linear: false, + repos: [], + trust: { trustedOrgs: [], trustedUsers: [] }, + applyCommand: "bun run up", + statusCommand: "bun run github:status", + api: async () => Response.json({}), + }; + const resolver = { + env: {}, + keychain: o.noKeychain ? undefined : keychain, + deployment: COMPOSE, + isTTY: o.isTTY ?? true, + confirm: async (q: string) => { + asked.push(q); + return o.answer ?? false; + }, + } as unknown as ResolverDeps; + let error: unknown; + try { + await disable( + d, + { forget: o.forget === true, yes: o.yes === true }, + { resolver, log: (l) => log.push(l) }, + ); + } catch (err) { + error = err; + } + return { + log, + asked, + error, + path, + keychain, + original: JSON.parse(original), + file: JSON.parse(readFileSync(path, "utf8")), + }; +} + +const ALL_ENTRIES = { + "compose/GITHUB_TOKEN": "c", + "compose/GITHUB_APP_PRIVATE_KEY": "pem", + "compose/GITHUB_WEBHOOK_SECRET": "b", +}; +const LINEAR = { enabled: true, clientId: "c" }; + +describe("github disable", () => { + it("switches off, keeps the block's keys, the shared keys and the keychain", async () => { + const r = await run({ app: APP }); + expect(r.file).toEqual({ + port: 1, + ...SHARED, + integrations: { githubApp: OFF, linear: LINEAR }, + }); + expect(r.keychain.entries()).toEqual(ALL_ENTRIES); + expect(r.asked).toEqual([]); + expect(r.log).toEqual([ + `Disabled the GitHub App in ${r.path}.`, + "GitHub falls back to a personal token: the next apply needs GITHUB_TOKEN.", + "Next: bun run up", + ]); + }); + + it("a new App pending: kept, and named with how to resume or remove it", async () => { + const pending = { appId: 124, slug: "new-devbox" }; + const r = await run({ app: { ...APP, pending } }); + expect(r.file).toEqual({ + port: 1, + ...SHARED, + integrations: { githubApp: { ...OFF, pending }, linear: LINEAR }, + }); + expect(r.log).toEqual([ + `Disabled the GitHub App in ${r.path}.`, + "GitHub falls back to a personal token: the next apply needs GITHUB_TOKEN.", + "The new App new-devbox is still waiting for its install: bun run github:setup resumes it, bun run github:disable --forget removes it", + "Next: bun run up", + ]); + + const again = await run({ app: { ...OFF, pending } as typeof APP }); + expect(again.file).toEqual(again.original); + expect(again.log).toEqual([ + "The GitHub App is already disabled for compose.", + "The new App new-devbox is still waiting for its install: bun run github:setup resumes it, bun run github:disable --forget removes it", + ]); + }); + + it("already disabled", async () => { + const r = await run({ app: OFF as typeof APP }); + expect(r.file).toEqual(r.original); + expect(r.log).toEqual(["The GitHub App is already disabled for compose."]); + }); + + it("not configured", async () => { + for (const forget of [false, true]) { + const r = await run({ forget }); + expect(r.file).toEqual(r.original); + expect(r.keychain.entries()).toEqual(ALL_ENTRIES); + expect(r.log).toEqual(["The GitHub App is not configured for compose. Nothing to disable."]); + } + }); + + it("--forget lists the block and both entries, removes them on yes, keeps shared keys", async () => { + const r = await run({ app: APP, forget: true, answer: true }); + expect(r.asked).toEqual(["Remove these? [y/N] "]); + expect(r.file).toEqual({ port: 1, ...SHARED, integrations: { linear: LINEAR } }); + expect(r.keychain.entries()).toEqual({ "compose/GITHUB_TOKEN": "c" }); + expect(r.log).toEqual([ + "This deletes:", + ` - integrations.githubApp in ${r.path}`, + " - GITHUB_APP_PRIVATE_KEY for compose in the system keychain", + " - GITHUB_WEBHOOK_SECRET for compose in the system keychain", + "Next: bun run up", + "Delete the App on GitHub: https://github.com/settings/apps/example-devbox/advanced", + ]); + }); + + it("--forget with a new App pending: lists and removes it and its entries too", async () => { + const r = await run({ + app: { ...APP, pending: { appId: 124, slug: "new-devbox", org: "example-org" } }, + forget: true, + answer: true, + saved: { + "compose/GITHUB_APP_PRIVATE_KEY": "pem", + "compose/GITHUB_WEBHOOK_SECRET": "b", + "compose/GITHUB_APP_PRIVATE_KEY_PENDING": "pem2", + "compose/GITHUB_WEBHOOK_SECRET_PENDING": "b2", + }, + }); + expect(r.file).toEqual({ port: 1, ...SHARED, integrations: { linear: LINEAR } }); + expect(r.keychain.entries()).toEqual({ "compose/GITHUB_TOKEN": "c" }); + expect(r.log).toEqual([ + "This deletes:", + ` - integrations.githubApp in ${r.path}`, + ` - the pending App new-devbox (integrations.githubApp.pending) in ${r.path}`, + " - GITHUB_APP_PRIVATE_KEY for compose in the system keychain", + " - GITHUB_WEBHOOK_SECRET for compose in the system keychain", + " - GITHUB_APP_PRIVATE_KEY_PENDING for compose in the system keychain", + " - GITHUB_WEBHOOK_SECRET_PENDING for compose in the system keychain", + "Next: bun run up", + "Delete the App on GitHub: https://github.com/settings/apps/example-devbox/advanced", + "Delete the pending App on GitHub: https://github.com/organizations/example-org/settings/apps/new-devbox/advanced", + ]); + }); + + it("--forget on an organization's App names the organization's page", async () => { + const r = await run({ app: { ...APP, org: "example-org" }, forget: true, yes: true }); + expect(r.file).toEqual({ port: 1, ...SHARED, integrations: { linear: LINEAR } }); + expect(r.log).toEqual([ + "Next: bun run up", + "Delete the App on GitHub: https://github.com/organizations/example-org/settings/apps/example-devbox/advanced", + ]); + }); + + it("--forget on a disabled App works too", async () => { + const r = await run({ app: OFF as typeof APP, forget: true, yes: true }); + expect(r.file).toEqual({ port: 1, ...SHARED, integrations: { linear: LINEAR } }); + }); + + it("--forget on no removes nothing", async () => { + const r = await run({ app: APP, forget: true, answer: false }); + expect(r.file).toEqual(r.original); + expect(r.keychain.entries()).toEqual(ALL_ENTRIES); + expect(r.log).toEqual([ + "This deletes:", + ` - integrations.githubApp in ${r.path}`, + " - GITHUB_APP_PRIVATE_KEY for compose in the system keychain", + " - GITHUB_WEBHOOK_SECRET for compose in the system keychain", + "Nothing was removed.", + ]); + }); + + it("--forget without a terminal refuses; --forget --yes removes", async () => { + const refused = await run({ app: APP, forget: true, isTTY: false }); + expect(refused.error).toBeInstanceOf(DestructiveRefused); + expect((refused.error as Error).message).toBe( + "github:disable --forget deletes data: pass --yes to confirm without a terminal", + ); + expect(refused.file).toEqual(refused.original); + expect(refused.keychain.entries()).toEqual(ALL_ENTRIES); + + const yes = await run({ app: APP, forget: true, yes: true, isTTY: false }); + expect(yes.error).toBeUndefined(); + expect(yes.asked).toEqual([]); + expect(yes.keychain.entries()).toEqual({ "compose/GITHUB_TOKEN": "c" }); + }); + + it("--forget lists only saved entries", async () => { + const some = await run({ + app: APP, + forget: true, + answer: true, + saved: { "compose/GITHUB_WEBHOOK_SECRET": "b" }, + }); + expect(some.log.slice(0, 3)).toEqual([ + "This deletes:", + ` - integrations.githubApp in ${some.path}`, + " - GITHUB_WEBHOOK_SECRET for compose in the system keychain", + ]); + const none = await run({ app: APP, forget: true, answer: true, noKeychain: true }); + expect(none.log.slice(0, 3)).toEqual([ + "This deletes:", + ` - integrations.githubApp in ${none.path}`, + "Next: bun run up", + ]); + expect(none.keychain.entries()).toEqual(ALL_ENTRIES); + }); +}); diff --git a/control-plane/scripts/github/disable.ts b/control-plane/scripts/github/disable.ts new file mode 100644 index 0000000..9e3f260 --- /dev/null +++ b/control-plane/scripts/github/disable.ts @@ -0,0 +1,102 @@ +// `bun run github:disable [--forget]`. Plain, it switches the GitHub App off +// and keeps everything needed to switch it back on, a new App waiting for +// its install included (the next github:setup installs that one). --forget removes the +// block (with a new App waiting for its install, if any) and the saved App +// keys and webhook secrets, after the one destructive +// confirmation; the key is only ever deleted here, at the user's request. +// Neither applies anything: both name the command that does. +import { writeConfigKeys } from "../lib/configFile"; +import { confirmDestructive } from "../lib/destructive"; +import { appSettingsUrl } from "../lib/githubApp"; +import { deploymentScope, entryName } from "../lib/keychain"; +import type { ResolverDeps } from "../lib/secrets"; +import type { GithubDeployment } from "./deployment"; + +export type DisableDeps = { resolver: ResolverDeps; log: (line: string) => void }; +export type DisableOpts = { forget: boolean; yes: boolean }; + +// The App's secrets, and those of a new App waiting for its install. +const APP_SECRETS = [ + "GITHUB_APP_PRIVATE_KEY", + "GITHUB_WEBHOOK_SECRET", + "GITHUB_APP_PRIVATE_KEY_PENDING", + "GITHUB_WEBHOOK_SECRET_PENDING", +] as const; + +export async function disable( + d: GithubDeployment, + opts: DisableOpts, + deps: DisableDeps, +): Promise<void> { + const { log, resolver } = deps; + const scope = deploymentScope(d.deployment); + const app = d.githubApp; + if (!app) { + log(`The GitHub App is not configured for ${scope}. Nothing to disable.`); + return; + } + + if (!opts.forget) { + // A new App waiting for its install stays: setup resumes it. + const flag = d.deployment.kind === "k8s" ? ` --target ${d.configPath}` : ""; + const pending = app.pending + ? `The new App ${app.pending.slug} is still waiting for its install: bun run github:setup${flag} resumes it, bun run github:disable${flag} --forget removes it` + : undefined; + if (!app.enabled) { + log(`The GitHub App is already disabled for ${scope}.`); + if (pending) log(pending); + return; + } + writeConfigKeys(d.configPath, { "integrations.githubApp": { ...app, enabled: false } }); + log(`Disabled the GitHub App in ${d.configPath}.`); + log(`GitHub falls back to a personal token: the next apply needs GITHUB_TOKEN.`); + if (pending) log(pending); + log(`Next: ${d.applyCommand}`); + return; + } + + const k = resolver.keychain && !resolver.keychain.disabled ? resolver.keychain : undefined; + const saved: (typeof APP_SECRETS)[number][] = []; + for (const name of APP_SECRETS) { + if (k && (await k.get(entryName(d.deployment, name))) !== undefined) saved.push(name); + } + const proceed = await confirmDestructive( + { + command: "github:disable --forget", + items: [ + `integrations.githubApp in ${d.configPath}`, + ...(app.pending + ? [ + `the pending App ${app.pending.slug} (integrations.githubApp.pending) in ${d.configPath}`, + ] + : []), + ...saved.map((name) => `${name} for ${scope} in the system keychain`), + ], + question: "Remove these? [y/N] ", + }, + { yes: opts.yes, isTTY: resolver.isTTY, confirm: resolver.confirm, log }, + ); + if (!proceed) { + log("Nothing was removed."); + return; + } + + // Keychain first: if a delete fails, the block is still there to list on a retry. + for (const name of saved) await k?.delete(entryName(d.deployment, name)); + writeConfigKeys(d.configPath, { "integrations.githubApp": undefined }); + log(`Next: ${d.applyCommand}`); + if (app.slug) { + const settings = appSettingsUrl( + app.slug, + app.org ? { login: app.org, type: "Organization" } : undefined, + ); + log(`Delete the App on GitHub: ${settings}/advanced`); + } + if (app.pending) { + const settings = appSettingsUrl( + app.pending.slug, + app.pending.org ? { login: app.pending.org, type: "Organization" } : undefined, + ); + log(`Delete the pending App on GitHub: ${settings}/advanced`); + } +} diff --git a/control-plane/scripts/github/listener.test.ts b/control-plane/scripts/github/listener.test.ts new file mode 100644 index 0000000..2022d1e --- /dev/null +++ b/control-plane/scripts/github/listener.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "bun:test"; +import { type Bind, CallbackError, startCallbackListener } from "./listener"; + +const page = (port: number) => `form for ${port}`; +const WRONG_STATE = "The answer from GitHub doesn't match this run: run bun run github:setup again"; +const TIMEOUT = "No answer from GitHub after 15 minutes: run bun run github:setup again"; + +// Whether a new TCP connection to the port is refused. fetch could reuse a +// kept-alive connection to the stopped server, so it can't tell. +const refused = async (port: number): Promise<boolean> => { + try { + const socket = await Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { data() {} }, + }); + socket.end(); + return false; + } catch { + return true; + } +}; + +describe("startCallbackListener", () => { + it("serves the form at / on 127.0.0.1", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page }); + try { + const res = await fetch(`http://127.0.0.1:${l.port}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(await res.text()).toBe(`form for ${l.port}`); + } finally { + l.close(); + } + }); + + it("the right state: resolves the code, answers a close-this-tab page and stops", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page }); + const res = await fetch(`http://127.0.0.1:${l.port}/callback?code=c&state=s1`); + expect(await l.code).toBe("c"); + expect(res.status).toBe(200); + expect(await res.text()).toBe( + "<!doctype html>\n<p>devbox got the App from GitHub. You can close this tab and go back to the terminal.</p>\n", + ); + expect(await refused(l.port)).toBe(true); + }); + + it("a wrong state: rejects, uses no code, and stops", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page }); + const settled = l.code.then( + () => "resolved", + (err: unknown) => err, + ); + const res = await fetch(`http://127.0.0.1:${l.port}/callback?code=c&state=other`); + expect(res.status).toBe(400); + expect(await res.text()).toBe( + "<!doctype html>\n<p>This answer doesn't match the running bun run github:setup. Run it again.</p>\n", + ); + expect(await settled).toEqual(new CallbackError(WRONG_STATE)); + expect(await refused(l.port)).toBe(true); + }); + + it("a code without a state is a wrong answer", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page }); + const settled = l.code.catch((err: unknown) => err); + await fetch(`http://127.0.0.1:${l.port}/callback?code=c`); + expect(await settled).toEqual(new CallbackError(WRONG_STATE)); + }); + + it("a request without a code is turned away and the wait goes on", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page }); + try { + for (const query of ["", "?state=s1", "?state=other", "?error=access_denied"]) { + const res = await fetch(`http://127.0.0.1:${l.port}/callback${query}`); + expect(res.status).toBe(400); + expect(await res.text()).toBe( + "<!doctype html>\n<p>This request carries no answer from GitHub.</p>\n", + ); + } + await fetch(`http://127.0.0.1:${l.port}/callback?code=c3&state=s1`); + expect(await l.code).toBe("c3"); + } finally { + l.close(); + } + }); + + it("other paths are 404 and keep it waiting", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page }); + try { + expect((await fetch(`http://127.0.0.1:${l.port}/favicon.ico`)).status).toBe(404); + await fetch(`http://127.0.0.1:${l.port}/callback?code=c2&state=s1`); + expect(await l.code).toBe("c2"); + } finally { + l.close(); + } + }); + + it("refuses a request for another host name (DNS rebinding)", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page }); + try { + const res = await fetch(`http://127.0.0.1:${l.port}/`, { + headers: { Host: `attacker.example.com:${l.port}` }, + }); + expect(res.status).toBe(403); + expect(await res.text()).toBe(""); + } finally { + l.close(); + } + }); + + it("no answer in time: rejects and stops", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 30, page }); + expect(await l.code.catch((err: unknown) => err)).toEqual(new CallbackError(TIMEOUT)); + expect(await refused(l.port)).toBe(true); + }); + + it("close() stops it without settling the code", async () => { + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page }); + l.close(); + expect(await refused(l.port)).toBe(true); + }); + + describe("binding", () => { + const fail = (code: string) => Object.assign(new Error(code), { code }); + function binder(v6: Array<string | "ok">) { + const calls: string[] = []; + const bind: Bind = (hostname, port, fetch) => { + calls.push(`${hostname}:${port === 0 ? "any" : "same"}`); + if (hostname === "::1") { + const next = v6.shift() ?? "ok"; + if (next !== "ok") throw fail(next); + // Stands in for a ::1 socket; this machine may have no IPv6. + return { port, stop: () => {} }; + } + return Bun.serve({ hostname, port, fetch }); + }; + return { bind, calls }; + } + + it("::1's port taken: a new port for both", async () => { + const b = binder(["EADDRINUSE", "ok"]); + const ports: number[] = []; + const l = await startCallbackListener({ + state: "s1", + timeoutMs: 60_000, + page: (p) => { + ports.push(p); + return "form"; + }, + bind: b.bind, + }); + try { + expect(b.calls).toEqual(["127.0.0.1:any", "::1:same", "127.0.0.1:any", "::1:same"]); + expect(ports).toEqual([l.port]); + expect(await (await fetch(`http://127.0.0.1:${l.port}/`)).text()).toBe("form"); + } finally { + l.close(); + } + }); + + it("no IPv6 loopback: 127.0.0.1 alone", async () => { + const b = binder(["EADDRNOTAVAIL"]); + const l = await startCallbackListener({ state: "s1", timeoutMs: 60_000, page, bind: b.bind }); + try { + expect(b.calls).toEqual(["127.0.0.1:any", "::1:same"]); + expect((await fetch(`http://127.0.0.1:${l.port}/`)).status).toBe(200); + } finally { + l.close(); + } + }); + + it("::1 taken every time: gives up after five ports", async () => { + const b = binder(Array(5).fill("EADDRINUSE")); + expect( + await startCallbackListener({ state: "s1", timeoutMs: 60_000, page, bind: b.bind }).catch( + (e: unknown) => e, + ), + ).toEqual( + new CallbackError( + "No local port free on both 127.0.0.1 and ::1: run bun run github:setup again", + ), + ); + expect(b.calls).toHaveLength(10); + }); + }); +}); diff --git a/control-plane/scripts/github/listener.ts b/control-plane/scripts/github/listener.ts new file mode 100644 index 0000000..9ddf1d8 --- /dev/null +++ b/control-plane/scripts/github/listener.ts @@ -0,0 +1,152 @@ +// The local end of GitHub's manifest flow: a one-shot HTTP listener on the +// loopback address only. It serves the page that posts the manifest at `/`, +// and takes GitHub's redirect at `/callback`. The `state` sent with the +// manifest must come back unchanged before the code is used; after one +// answer, right or wrong, or the timeout, it stops. +import { timingSafeEqual } from "node:crypto"; + +export class CallbackError extends Error {} + +export type CallbackListener = { + port: number; + // GitHub's one-time code; rejects with a CallbackError. + code: Promise<string>; + close(): void; +}; + +const WRONG_STATE = "The answer from GitHub doesn't match this run: run bun run github:setup again"; + +const html = (body: string, status = 200, last = false): Response => + new Response(body, { + status, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + ...(last ? { Connection: "close" } : {}), + }, + }); + +const DONE_PAGE = + "<!doctype html>\n<p>devbox got the App from GitHub. You can close this tab and go back to the terminal.</p>\n"; +const WRONG_PAGE = + "<!doctype html>\n<p>This answer doesn't match the running bun run github:setup. Run it again.</p>\n"; + +const sameState = (got: string | null, want: string): boolean => { + if (got === null) return false; + const a = Buffer.from(got); + const b = Buffer.from(want); + return a.length === b.length && timingSafeEqual(a, b); +}; + +const NO_CODE_PAGE = "<!doctype html>\n<p>This request carries no answer from GitHub.</p>\n"; + +// A listening socket; Bun.serve's, or a test's. +export type Bound = { port: number | undefined; stop(): unknown }; +export type Bind = (hostname: string, port: number, fetch: (req: Request) => Response) => Bound; + +const bunBind: Bind = (hostname, port, fetch) => Bun.serve({ hostname, port, fetch }); + +const errCode = (err: unknown): string => String((err as { code?: unknown } | null)?.code ?? ""); + +// Tries for a port free on both loopback addresses this many times. +const BIND_ATTEMPTS = 5; + +// How long GitHub's answer is waited for; the timeout message names it. +export const CALLBACK_TIMEOUT_MS = 15 * 60_000; + +export async function startCallbackListener(opts: { + state: string; + // CALLBACK_TIMEOUT_MS, shorter in tests. + timeoutMs: number; + // The page served at `/`, given the port the listener got. + page: (port: number) => string; + bind?: Bind; +}): Promise<CallbackListener> { + const bind = opts.bind ?? bunBind; + let resolveCode: (code: string) => void = () => {}; + let rejectCode: (err: Error) => void = () => {}; + const code = new Promise<string>((resolve, reject) => { + resolveCode = resolve; + rejectCode = reject; + }); + // Settled before the caller awaits it is not an unhandled rejection. + code.catch(() => {}); + + const servers: Bound[] = []; + let timer: ReturnType<typeof setTimeout> | undefined; + let open = true; + const close = () => { + if (!open) return; + open = false; + clearTimeout(timer); + // In-flight answers finish; the listening sockets close now. + for (const s of servers) s.stop(); + }; + const settle = (outcome: () => void) => { + if (!open) return; + outcome(); + close(); + }; + + let page = ""; + let hosts = new Set<string>(); + const handle = (req: Request): Response => { + // Only names that resolve to this machine: a page elsewhere that + // re-points its own host name here (DNS rebinding) is refused. + if (!hosts.has(req.headers.get("host") ?? "")) return new Response(null, { status: 403 }); + const url = new URL(req.url); + if (url.pathname === "/" && req.method === "GET") return html(page); + if (url.pathname !== "/callback" || req.method !== "GET") { + return new Response(null, { status: 404 }); + } + // Only an answer carrying a code is judged; anything else is turned away + // and the wait goes on. + const got = url.searchParams.get("code"); + if (!got) return html(NO_CODE_PAGE, 400); + if (!sameState(url.searchParams.get("state"), opts.state)) { + settle(() => rejectCode(new CallbackError(WRONG_STATE))); + return html(WRONG_PAGE, 400, true); + } + settle(() => resolveCode(got)); + return html(DONE_PAGE, 200, true); + }; + + let port = 0; + for (let attempt = 1; ; attempt++) { + const main = bind("127.0.0.1", 0, handle); + port = main.port ?? 0; + try { + // Browsers may resolve localhost to ::1. + const v6 = bind("::1", port, handle); + servers.push(main, v6); + break; + } catch (err) { + if (errCode(err) !== "EADDRINUSE") { + // No IPv6 loopback here: 127.0.0.1 alone. + servers.push(main); + break; + } + // Taken on ::1 by something else: a new port for both. + main.stop(); + if (attempt === BIND_ATTEMPTS) { + throw new CallbackError( + "No local port free on both 127.0.0.1 and ::1: run bun run github:setup again", + ); + } + } + } + hosts = new Set([`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]); + page = opts.page(port); + timer = setTimeout( + () => + settle(() => + rejectCode( + new CallbackError( + "No answer from GitHub after 15 minutes: run bun run github:setup again", + ), + ), + ), + opts.timeoutMs, + ); + return { port, code, close }; +} diff --git a/control-plane/scripts/github/manifest.test.ts b/control-plane/scripts/github/manifest.test.ts new file mode 100644 index 0000000..cd4ea89 --- /dev/null +++ b/control-plane/scripts/github/manifest.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { formPage, githubManifest, manifestPostUrl, registrationLink } from "./manifest"; + +const PUBLIC = "https://hooks.example.com"; +const REDIRECT = "http://localhost:4567/callback"; + +const MANIFEST = { + name: "devbox", + url: "https://github.com/rudderlabs/devbox", + description: "devbox coding agent", + public: false, + redirect_url: REDIRECT, + hook_attributes: { url: `${PUBLIC}/webhook/github`, active: true }, + default_permissions: { + contents: "write", + pull_requests: "write", + issues: "write", + actions: "write", + metadata: "read", + members: "read", + }, + default_events: [ + "issue_comment", + "pull_request_review", + "pull_request_review_comment", + "workflow_run", + ], +}; + +describe("githubManifest", () => { + it("the manifest in full", () => { + expect(githubManifest({ name: "devbox", publicUrl: PUBLIC, redirectUrl: REDIRECT })).toEqual( + MANIFEST, + ); + }); + + it("takes the App's name", () => { + expect( + githubManifest({ name: "example-devbox", publicUrl: PUBLIC, redirectUrl: REDIRECT }), + ).toEqual({ ...MANIFEST, name: "example-devbox" }); + }); +}); + +describe("manifestPostUrl", () => { + it("the user's account", () => { + expect(manifestPostUrl("s1")).toBe("https://github.com/settings/apps/new?state=s1"); + }); + + it("an organization", () => { + expect(manifestPostUrl("s1", "example-org")).toBe( + "https://github.com/organizations/example-org/settings/apps/new?state=s1", + ); + }); +}); + +describe("formPage", () => { + it("posts the manifest, HTML-escaped, and submits itself", () => { + const manifest = { name: `a"b<c>&'d` }; + expect(formPage("https://github.com/settings/apps/new?state=s1", manifest)).toBe( + [ + "<!doctype html>", + '<html lang="en">', + '<head><meta charset="utf-8"><title>devbox: create the GitHub App', + "", + '
', + '', + "

Opening GitHub's page to create the App...

", + '', + "
", + "", + "", + "", + "", + ].join("\n"), + ); + }); + + it("an organization's create page", () => { + const page = formPage(manifestPostUrl("s2", "example-org"), MANIFEST); + expect(page).toContain( + '
', + ); + }); +}); + +describe("registrationLink", () => { + const QUERY = [ + "name=devbox", + "description=devbox%20coding%20agent", + "url=https%3A%2F%2Fgithub.com%2Frudderlabs%2Fdevbox", + "public=false", + "webhook_active=true", + "webhook_url=https%3A%2F%2Fhooks.example.com%2Fwebhook%2Fgithub", + "events[]=issue_comment", + "events[]=pull_request_review", + "events[]=pull_request_review_comment", + "events[]=workflow_run", + "contents=write", + "pull_requests=write", + "issues=write", + "actions=write", + "metadata=read", + "members=read", + ].join("&"); + + it("the user's account", () => { + expect(registrationLink({ name: "devbox", publicUrl: PUBLIC })).toBe( + `https://github.com/settings/apps/new?${QUERY}`, + ); + }); + + it("an organization, and a name that needs encoding", () => { + expect(registrationLink({ org: "example-org", name: "devbox & co", publicUrl: PUBLIC })).toBe( + `https://github.com/organizations/example-org/settings/apps/new?${QUERY.replace("name=devbox", "name=devbox%20%26%20co")}`, + ); + }); +}); diff --git a/control-plane/scripts/github/manifest.ts b/control-plane/scripts/github/manifest.ts new file mode 100644 index 0000000..bb4ff7a --- /dev/null +++ b/control-plane/scripts/github/manifest.ts @@ -0,0 +1,100 @@ +// The GitHub App's manifest: its name, webhook, permissions and events. +// GitHub takes a manifest only as a browser form POST, so `github:setup` +// serves a page that posts it (formPage). A machine without a local browser +// uses the URL-parameter link instead (registrationLink), which carries the +// same settings but no redirect, so GitHub returns no credentials from it. + +const DEVBOX_URL = "https://github.com/rudderlabs/devbox"; +const DESCRIPTION = "devbox coding agent"; + +// From the calls devbox makes: push and contents, PRs and their reviews, +// issue comments and reactions, CI jobs, logs and re-runs, and the trusted +// org members. +const PERMISSIONS = { + contents: "write", + pull_requests: "write", + issues: "write", + actions: "write", + metadata: "read", + members: "read", +} as const; + +// Exactly the events devbox's webhook handles. +const EVENTS = [ + "issue_comment", + "pull_request_review", + "pull_request_review_comment", + "workflow_run", +] as const; + +export function githubManifest(opts: { + name: string; + publicUrl: string; + redirectUrl: string; +}): object { + return { + name: opts.name, + url: DEVBOX_URL, + description: DESCRIPTION, + public: false, + redirect_url: opts.redirectUrl, + hook_attributes: { url: `${opts.publicUrl}/webhook/github`, active: true }, + default_permissions: { ...PERMISSIONS }, + default_events: [...EVENTS], + }; +} + +// GitHub's "create App" page, under the user's account or an organization. +const createPage = (org?: string): string => + org + ? `https://github.com/organizations/${encodeURIComponent(org)}/settings/apps/new` + : "https://github.com/settings/apps/new"; + +// Where the form posts the manifest; GitHub sends `state` back with the code. +export const manifestPostUrl = (state: string, org?: string): string => + `${createPage(org)}?state=${encodeURIComponent(state)}`; + +const escapeHtml = (text: string): string => + text + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +// A page that posts the manifest to GitHub as soon as it loads, with a +// button for a browser that blocks scripts. +export function formPage(postUrl: string, manifest: object): string { + return [ + "", + '', + 'devbox: create the GitHub App', + "", + ``, + ``, + "

Opening GitHub's page to create the App...

", + '', + "
", + "", + "", + "", + "", + ].join("\n"); +} + +// GitHub's URL-parameter registration: the same settings, as a link to open +// anywhere. `events[]` repeats; each permission is `=`. +export function registrationLink(opts: { org?: string; name: string; publicUrl: string }): string { + const e = encodeURIComponent; + const params = [ + `name=${e(opts.name)}`, + `description=${e(DESCRIPTION)}`, + `url=${e(DEVBOX_URL)}`, + "public=false", + "webhook_active=true", + `webhook_url=${e(`${opts.publicUrl}/webhook/github`)}`, + ...EVENTS.map((ev) => `events[]=${ev}`), + ...Object.entries(PERMISSIONS).map(([perm, level]) => `${perm}=${level}`), + ]; + return `${createPage(opts.org)}?${params.join("&")}`; +} diff --git a/control-plane/scripts/github/setup.test.ts b/control-plane/scripts/github/setup.test.ts new file mode 100644 index 0000000..509f0ff --- /dev/null +++ b/control-plane/scripts/github/setup.test.ts @@ -0,0 +1,1904 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { GithubAppIntegration } from "../lib/integrations"; +import { type Deployment, type Keychain, MemoryKeychain } from "../lib/keychain"; +import type { ResolverDeps } from "../lib/secrets"; +import type { RepoConfig } from "../up/config"; +import type { GithubDeployment } from "./deployment"; +import { CallbackError } from "./listener"; +import { formPage, githubManifest, manifestPostUrl, registrationLink } from "./manifest"; +import { type SetupDeps, SetupError, type SetupOptions, setup } from "./setup"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-github-setup-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const { privateKey: PEM } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); + +const API = "https://api.github.com"; +const PUBLIC = "https://hooks.example.com"; +const OLD = "https://old.example.com"; +const HOOK = `${PUBLIC}/webhook/github`; +const COMPOSE: Deployment = { kind: "compose" }; +const K8S: Deployment = { kind: "k8s", context: "prod", namespace: "devbox" }; +const SLUG = "example-devbox"; +const HTML = `https://github.com/apps/${SLUG}`; +const INSTALL_URL = `${HTML}/installations/new`; +const STATE = "a".repeat(32); +const GENERATED = "a".repeat(64); +const BOT_ID = 1234567; +const ORG = { login: "example-org", type: "Organization" }; +const USER = { login: "example-user", type: "User" }; +const REPO: RepoConfig = { + url: "https://github.com/example-org/example-repo.git", + branch: "main", + owner: "example-org", + name: "example-repo", +}; +const OTHER: RepoConfig = { ...REPO, url: "x", name: "other" }; + +const CONVERSION = { + id: 123, + slug: SLUG, + pem: PEM, + webhook_secret: "whsec-1", + html_url: HTML, + owner: { ...ORG, id: 9 }, +}; +const ENABLED: GithubAppIntegration = { + enabled: true, + appId: 123, + slug: SLUG, + installationId: 7, + botUserId: BOT_ID, + tokens: "devbox", + tokenScope: "repos", + ciResults: true, + // The fixture App belongs to the example-org organization. + org: "example-org", +}; +const PROVISIONAL = { + enabled: false, + appId: 123, + slug: SLUG, + tokens: "devbox", + tokenScope: "repos", + ciResults: true, + org: "example-org", +}; + +// A working App that --reregister replaces. +const OLD_APP: GithubAppIntegration = { + ...ENABLED, + appId: 99, + slug: "old-devbox", + installationId: 5, + botUserId: 55, +}; +const PENDING = { appId: 123, slug: SLUG, org: "example-org" }; +const OLD_SAVED = { + "compose/GITHUB_APP_PRIVATE_KEY": "old-key", + "compose/GITHUB_WEBHOOK_SECRET": "old-secret", +}; +const PENDING_SAVED = { + "compose/GITHUB_APP_PRIVATE_KEY_PENDING": PEM, + "compose/GITHUB_WEBHOOK_SECRET_PENDING": "whsec-1", +}; +const KEEPS_WORKING = "The App old-devbox keeps working until the new App is installed."; +const EARLIER_KEY = + "The saved GITHUB_APP_PRIVATE_KEY for compose is from an earlier App or key: remove it with bun run secrets rm GITHUB_APP_PRIVATE_KEY"; +const EARLIER_HOOK = + "The saved GITHUB_WEBHOOK_SECRET for compose is from an earlier App or key: remove it with bun run secrets rm GITHUB_WEBHOOK_SECRET"; +const SHOW_HOOK_ONCE = + "Press Enter to show the webhook secret (set it as GITHUB_WEBHOOK_SECRET before bun run up, once the new App is installed) "; +const OLD_REPLACED = "The old App's saved key and webhook secret were replaced by the new App's"; +const REPLACE_Q = + "A new App stale-devbox is already waiting for its install. Replace it with another one? [y/N] "; +const STALE_SAVED = { + "compose/GITHUB_APP_PRIVATE_KEY_PENDING": "stale-key", + "compose/GITHUB_WEBHOOK_SECRET_PENDING": "stale-secret", +}; +const STALE_UNUSED = + "The App stale-devbox is no longer used: delete it on GitHub: https://github.com/settings/apps/stale-devbox/advanced"; +const STALE_DROPPED = [ + STALE_UNUSED, + "Removed the saved secrets of the App stale-devbox from the system keychain", +]; +const STALE_PENDING_LEFT = + "The saved GITHUB_APP_PRIVATE_KEY_PENDING and GITHUB_WEBHOOK_SECRET_PENDING for compose are from an earlier App: remove them with bun run secrets rm GITHUB_APP_PRIVATE_KEY_PENDING and bun run secrets rm GITHUB_WEBHOOK_SECRET_PENDING"; +const OLD_UNUSED = + "The App old-devbox is no longer used: delete it on GitHub: https://github.com/organizations/example-org/settings/apps/old-devbox/advanced"; +const MOVE_FAILED = + "Moving the new App's secrets in the system keychain failed: The keychain is locked. Nothing changed: the App old-devbox keeps working and the new App's secrets stay saved as GITHUB_APP_PRIVATE_KEY_PENDING and GITHUB_WEBHOOK_SECRET_PENDING; run bun run github:setup again to finish"; + +// Lines, in order. +const PUBLIC_LINE = `Public URL: ${PUBLIC}`; +const REGISTER = [ + "Register the GitHub App: your browser opens GitHub's create page; review it and click Create.", + "If it doesn't open, open http://localhost:4567/ in a browser on this machine.", +]; +const INSTALL = [ + "Install the App on the repos devbox works on:", + ` ${INSTALL_URL}`, + "Waiting for the installation (press Enter to check now)...", +]; +const INSTALLED_ORG = "The App is installed on example-org (installation 7)"; +const NEXT_COMPOSE = ["Next: bun run up", "Then check GitHub with: bun run github:status"]; +const SAVE_BOTH = + "Save the App's private key and webhook secret to the system keychain for compose? [Y/n] "; +const SAVE_HOOK = "Save the App's webhook secret to the system keychain for compose? [Y/n] "; +const SHOW_HOOK = + "Press Enter to show the webhook secret (set it as GITHUB_WEBHOOK_SECRET before bun run up) "; +const NO_KEY = "No private key for the App here: skipping the installation and repo checks"; +const ADOPT_HOOK_Q = "The App's webhook secret (input hidden; press Enter to generate a new one): "; + +type Call = { method: string; path: string; auth: boolean; body?: unknown }; +type Answer = [number, unknown]; + +type World = { + log: string[]; + asked: string[]; + opened: string[]; + shown: string[]; + calls: Call[]; + commands: string[][]; + keychain: MemoryKeychain; + listened: { state: string; page: string; closed: boolean }[]; + slept: number[]; +}; + +let n = 0; + +function harness(o: { + deployment?: Deployment; + githubApp?: GithubAppIntegration; + linear?: boolean; + publicUrl?: string; + ingressUrl?: string; + repos?: RepoConfig[]; + trust?: { trustedOrgs: string[]; trustedUsers: string[] }; + byoKeySecret?: string; + byoIntegrationsSecret?: string; + env?: Record; + keychain?: Keychain | "none"; + saved?: Record; + save?: boolean; + // Answers to the yes/no questions in turn; `save` (or yes) after them. + confirms?: boolean[]; + lines?: string[]; + hidden?: string[]; + isTTY?: boolean; + // GitHub's answers by "METHOD path"; a list is answered in turn (its last + // answer repeats). + github?: Record; + code?: Promise; + // Enter pressed while polling: next() resolves at once. + enter?: boolean; + extraConfig?: Record; +}): { d: GithubDeployment; deps: SetupDeps; w: World; cwd: string } { + const deployment = o.deployment ?? COMPOSE; + const w: World = { + log: [], + asked: [], + opened: [], + shown: [], + calls: [], + commands: [], + keychain: new MemoryKeychain(o.saved), + listened: [], + slept: [], + }; + const cwd = mkdtempSync(join(dir, "cwd-")); + const configPath = join(dir, `c${n++}.json`); + writeFileSync( + configPath, + JSON.stringify({ + ...(deployment.kind === "compose" ? { repos: [{ url: REPO.url }] } : { context: "prod" }), + ...(o.publicUrl !== undefined ? { publicUrl: o.publicUrl } : {}), + ...(o.githubApp ? { integrations: { githubApp: o.githubApp } } : {}), + ...(o.extraConfig ?? {}), + }), + ); + const lines = [...(o.lines ?? [])]; + const hidden = [...(o.hidden ?? [])]; + const confirms = [...(o.confirms ?? [])]; + const github: Record = { + "POST /app-manifests/code-1/conversions": [201, CONVERSION], + "GET /app": [200, { id: 123, slug: SLUG, html_url: HTML, owner: ORG }], + "PATCH /app/hook/config": [200, {}], + "GET /app/installations?per_page=100&page=1": [200, [{ id: 7, account: ORG }]], + [`GET /users/${SLUG}%5Bbot%5D`]: [200, { id: BOT_ID }], + "GET /repos/example-org/example-repo/installation": [200, { id: 7 }], + ...o.github, + }; + let t = Date.parse("2026-10-19T12:00:00Z"); + const resolver: ResolverDeps = { + env: o.env ?? {}, + keychain: o.keychain === "none" ? undefined : (o.keychain ?? w.keychain), + deployment, + ...(deployment.kind === "k8s" ? { targetPath: configPath } : {}), + isTTY: o.isTTY ?? true, + prompt: async (q) => { + w.asked.push(q); + return hidden.shift() ?? ""; + }, + confirm: async (q) => { + w.asked.push(q); + return confirms.shift() ?? o.save ?? true; + }, + run: async (cmd) => { + w.commands.push(cmd); + throw new Error("setup runs no command"); + }, + log: (line) => w.log.push(line), + }; + const target = deployment.kind === "k8s" ? ` --target ${configPath}` : ""; + const d: GithubDeployment = { + deployment, + configPath, + githubApp: o.githubApp, + linear: o.linear ?? false, + repos: o.repos ?? [REPO], + trust: o.trust ?? { trustedOrgs: [], trustedUsers: [] }, + ...(o.publicUrl !== undefined ? { publicUrl: o.publicUrl } : {}), + ...(o.ingressUrl !== undefined ? { ingressUrl: o.ingressUrl } : {}), + applyCommand: deployment.kind === "k8s" ? `bun run k8s:install${target}` : "bun run up", + statusCommand: `bun run github:status${target}`, + ...(o.byoKeySecret ? { byoKeySecret: o.byoKeySecret } : {}), + ...(o.byoIntegrationsSecret ? { byoIntegrationsSecret: o.byoIntegrationsSecret } : {}), + api: async () => { + throw new Error("setup calls no devbox API"); + }, + }; + const deps: SetupDeps = { + resolver, + promptLine: async (q) => { + w.asked.push(q); + return lines.shift() ?? ""; + }, + openUrl: (url) => w.opened.push(url), + fetch: async (url, init) => { + const path = String(url).slice(API.length); + const method = init?.method ?? "GET"; + const auth = new Headers(init?.headers).get("authorization"); + w.calls.push({ + method, + path, + auth: auth !== null, + ...(init?.body !== undefined ? { body: JSON.parse(String(init.body)) } : {}), + }); + const route = github[`${method} ${path}`]; + if (!route) return Response.json({ message: "Not Found" }, { status: 404 }); + let answer = route as Answer; + if (Array.isArray(route[0])) { + const list = route as Answer[]; + answer = (list.length > 1 ? list.shift() : list[0]) as Answer; + } + return Response.json(answer[1], { status: answer[0] }); + }, + now: () => new Date(t), + sleep: async (ms) => { + if (o.enter) return new Promise(() => {}); + w.slept.push(ms); + t += ms; + }, + enter: () => ({ + next: () => (o.enter ? Promise.resolve() : new Promise(() => {})), + stop: () => {}, + }), + listen: async (l) => { + const entry = { state: l.state, page: l.page(4567), closed: false }; + w.listened.push(entry); + return { + port: 4567, + code: o.code ?? Promise.resolve("code-1"), + close: () => { + entry.closed = true; + }, + }; + }, + random: (bytes) => "a".repeat(bytes * 2), + cwd, + show: (text) => w.shown.push(text), + log: (line) => w.log.push(line), + }; + return { d, deps, w, cwd }; +} + +const opts = (o: Partial = {}): SetupOptions => ({ + name: "devbox", + externalTokens: false, + reregister: false, + printRegistrationLink: false, + ...o, +}); + +const readJson = (path: string): unknown => JSON.parse(readFileSync(path, "utf8")); +const calls = (w: World) => w.calls.map((c) => `${c.method} ${c.path}`); +const configOf = (githubApp: unknown, github?: unknown) => ({ + repos: [{ url: REPO.url }], + publicUrl: PUBLIC, + ...(github !== undefined ? { github } : {}), + integrations: { githubApp }, +}); +// The log of a first registration on Compose, the keychain taking both secrets. +const registeredLog = (configPath: string, installed: string[], trusted = "example-org") => [ + PUBLIC_LINE, + ...REGISTER, + ...INSTALL, + ...installed, + `Trusted: ${trusted} (edit ${configPath} to change)`, + `Wrote integrations.githubApp to ${configPath}`, + ...NEXT_COMPOSE, +]; + +describe("setup: registering", () => { + it("first run on Compose: register, save to the keychain, install, write, next step", async () => { + const { d, deps, w } = harness({ publicUrl: PUBLIC }); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...REGISTER, + ...INSTALL, + INSTALLED_ORG, + `Trusted: example-org (edit ${d.configPath} to change)`, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + expect(w.asked).toEqual([SAVE_BOTH]); + expect(w.opened).toEqual(["http://localhost:4567/", INSTALL_URL]); + expect(w.listened).toEqual([ + { + state: STATE, + page: formPage( + manifestPostUrl(STATE), + githubManifest({ + name: "devbox", + publicUrl: PUBLIC, + redirectUrl: "http://localhost:4567/callback", + }), + ), + closed: true, + }, + ]); + expect(readJson(d.configPath)).toEqual({ + repos: [{ url: REPO.url }], + publicUrl: PUBLIC, + github: { trustedOrgs: ["example-org"] }, + integrations: { githubApp: ENABLED }, + }); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "whsec-1", + }); + expect(w.calls).toEqual([ + { method: "POST", path: "/app-manifests/code-1/conversions", auth: false }, + { method: "GET", path: "/app/installations?per_page=100&page=1", auth: true }, + { method: "GET", path: `/users/${SLUG}%5Bbot%5D`, auth: false }, + { method: "GET", path: "/repos/example-org/example-repo/installation", auth: true }, + ]); + expect(w.shown).toEqual([]); + expect(w.commands).toEqual([]); + // Nothing secret reached the log. + expect(w.log.join("\n")).not.toContain("whsec-1"); + expect(w.log.join("\n")).not.toContain("PRIVATE KEY"); + }); + + it("under an organization: the form posts to the organization's create page", async () => { + const { d, deps, w } = harness({ publicUrl: PUBLIC }); + await setup(d, opts({ org: "example-org", name: "example-devbox" }), deps); + expect(w.listened[0]?.page).toBe( + formPage( + manifestPostUrl(STATE, "example-org"), + githubManifest({ + name: "example-devbox", + publicUrl: PUBLIC, + redirectUrl: "http://localhost:4567/callback", + }), + ), + ); + }); + + it("asks for the public URL on Compose when none is saved", async () => { + const { d, deps, w } = harness({ lines: [PUBLIC] }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([ + "Public HTTPS URL that forwards to port 3000 on this machine (a tunnel or reverse proxy): ", + SAVE_BOTH, + ]); + expect((readJson(d.configPath) as { publicUrl: string }).publicUrl).toBe(PUBLIC); + }); + + it("a null webhook secret: one is generated and set on the App", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + github: { + "POST /app-manifests/code-1/conversions": [201, { ...CONVERSION, webhook_secret: null }], + }, + }); + await setup(d, opts(), deps); + expect(w.keychain.entries()["compose/GITHUB_WEBHOOK_SECRET"]).toBe(GENERATED); + expect(w.calls.slice(0, 2)).toEqual([ + { method: "POST", path: "/app-manifests/code-1/conversions", auth: false }, + { method: "PATCH", path: "/app/hook/config", auth: true, body: { secret: GENERATED } }, + ]); + }); + + it("a state that doesn't match: fails, writes and asks nothing", async () => { + const wrong = new CallbackError( + "The answer from GitHub doesn't match this run: run bun run github:setup again", + ); + const { d, deps, w } = harness({ publicUrl: PUBLIC, code: Promise.reject(wrong) }); + const before = readFileSync(d.configPath, "utf8"); + expect(await setup(d, opts(), deps).catch((e) => e)).toBe(wrong); + expect(readFileSync(d.configPath, "utf8")).toBe(before); + expect(w.calls).toEqual([]); + expect(w.asked).toEqual([]); + expect(w.listened[0]?.closed).toBe(true); + }); + + it("needs a terminal", async () => { + const { d, deps, w } = harness({ publicUrl: PUBLIC, isTTY: false }); + expect(await setup(d, opts(), deps).catch((e) => e)).toEqual( + new SetupError( + "registering or adopting a GitHub App needs a terminal: run bun run github:setup in one", + ), + ); + expect(w.listened).toEqual([]); + }); +}); + +describe("setup: installation", () => { + it("two installations: the one owning the repos is used", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + github: { + "GET /app/installations?per_page=100&page=1": [ + 200, + [ + { id: 8, account: USER }, + { id: 7, account: ORG }, + ], + ], + }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual(registeredLog(d.configPath, [INSTALLED_ORG])); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED, { trustedOrgs: ["example-org"] })); + }); + it("several, none owning the repos alone: listed and asked", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + lines: ["2"], + github: { + "GET /app/installations?per_page=100&page=1": [ + 200, + [ + { id: 8, account: USER }, + { id: 9, account: { login: "example-other", type: "Organization" } }, + ], + ], + }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual( + registeredLog( + d.configPath, + [ + "The App is installed on several accounts:", + " 1. example-user (User, installation 8)", + " 2. example-other (Organization, installation 9)", + "The App is installed on example-other (installation 9)", + "example-org/example-repo is not in the App's installation: add it at https://github.com/organizations/example-other/settings/installations/9", + ], + "example-other", + ), + ); + expect(w.asked).toEqual([SAVE_BOTH, "Which one does devbox use? [1-2]: "]); + expect(readJson(d.configPath)).toEqual( + configOf({ ...ENABLED, installationId: 9 }, { trustedOrgs: ["example-other"] }), + ); + }); + it("polls every 3 s until the App is installed", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + github: { + "GET /app/installations?per_page=100&page=1": [ + [200, []], + [200, []], + [200, [{ id: 7, account: ORG }]], + ], + }, + }); + await setup(d, opts(), deps); + expect(w.slept).toEqual([3000, 3000]); + expect(calls(w).filter((c) => c.includes("installations"))).toHaveLength(3); + expect(w.log).toEqual(registeredLog(d.configPath, [INSTALLED_ORG])); + }); + + it("Enter checks again at once", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + enter: true, + github: { + "GET /app/installations?per_page=100&page=1": [ + [200, []], + [200, [{ id: 7, account: ORG }]], + ], + }, + }); + await setup(d, opts(), deps); + expect(w.slept).toEqual([]); + expect(w.log).toEqual(registeredLog(d.configPath, [INSTALLED_ORG])); + }); + + it("not installed in 15 minutes: fails, the App already saved (disabled) so a re-run installs it", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + github: { "GET /app/installations?per_page=100&page=1": [200, []] }, + }); + expect(await setup(d, opts(), deps).catch((e) => e)).toEqual( + new SetupError( + `The App isn't installed yet: install it from ${INSTALL_URL}, then run bun run github:setup again`, + ), + ); + expect(w.slept).toEqual(Array(300).fill(3000)); + expect(readJson(d.configPath)).toEqual({ + repos: [{ url: REPO.url }], + publicUrl: PUBLIC, + integrations: { githubApp: PROVISIONAL }, + }); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "whsec-1", + }); + + // The re-run: no second App; straight to the installation. + const again = harness({ + publicUrl: PUBLIC, + githubApp: PROVISIONAL as GithubAppIntegration, + saved: w.keychain.entries(), + }); + await setup(again.d, opts(), again.deps); + expect(again.w.listened).toEqual([]); + expect(again.w.asked).toEqual([]); + expect(again.w.log).toEqual([ + PUBLIC_LINE, + "GITHUB_APP_PRIVATE_KEY: from the system keychain (compose)", + INSTALLED_ORG, + `Trusted: example-org (edit ${again.d.configPath} to change)`, + `Wrote integrations.githubApp to ${again.d.configPath}`, + ...NEXT_COMPOSE, + ]); + expect(calls(again.w)).toEqual([ + "GET /app", + "GET /app/installations?per_page=100&page=1", + `GET /users/${SLUG}%5Bbot%5D`, + "GET /repos/example-org/example-repo/installation", + ]); + expect(readJson(again.d.configPath)).toEqual({ + repos: [{ url: REPO.url }], + publicUrl: PUBLIC, + github: { trustedOrgs: ["example-org"] }, + integrations: { githubApp: ENABLED }, + }); + }); + + it("the re-run with the App still not installed: shows the install page again", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + githubApp: PROVISIONAL as GithubAppIntegration, + saved: { "compose/GITHUB_APP_PRIVATE_KEY": PEM }, + github: { + "GET /app/installations?per_page=100&page=1": [ + [200, []], + [200, [{ id: 7, account: ORG }]], + ], + }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + "GITHUB_APP_PRIVATE_KEY: from the system keychain (compose)", + ...INSTALL, + INSTALLED_ORG, + `Trusted: example-org (edit ${d.configPath} to change)`, + `Wrote integrations.githubApp to ${d.configPath}`, + "Set GITHUB_WEBHOOK_SECRET in your environment before bun run up, or it asks for it", + ...NEXT_COMPOSE, + ]); + // Listed once before the install page, then found by the first poll. + expect(w.slept).toEqual([]); + expect(w.opened).toEqual([INSTALL_URL]); + }); +}); + +describe("setup: the App's owner", () => { + const userOwned = { id: 123, slug: SLUG, html_url: HTML, owner: USER }; + const { org: _org, ...ENABLED_NO_ORG } = ENABLED; + + it("a user-owned registered App has no org key", async () => { + const { d, deps } = harness({ + publicUrl: PUBLIC, + github: { + "POST /app-manifests/code-1/conversions": [ + 201, + { ...CONVERSION, owner: { ...USER, id: 9 } }, + ], + "GET /app/installations?per_page=100&page=1": [200, [{ id: 7, account: USER }]], + "GET /repos/example-org/example-repo/installation": [200, { id: 7 }], + }, + }); + await setup(d, opts(), deps); + expect(readJson(d.configPath)).toEqual( + configOf(ENABLED_NO_ORG, { trustedUsers: ["example-user"] }), + ); + }); + + it("a user-owned adopted App drops a stale org key", async () => { + const keyPath = join(dir, "owner.pem"); + writeFileSync(keyPath, PEM); + const { d, deps } = harness({ + publicUrl: PUBLIC, + githubApp: ENABLED, + hidden: ["typed-secret"], + github: { "GET /app": [200, userOwned] }, + }); + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps); + expect( + (readJson(d.configPath) as { integrations: { githubApp: unknown } }).integrations.githubApp, + ).toEqual(ENABLED_NO_ORG); + }); + + it("without the key, the saved org stands", async () => { + const { d, deps } = harness({ + publicUrl: PUBLIC, + githubApp: ENABLED, + keychain: "none", + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + }); + await setup(d, opts(), deps); + expect( + (readJson(d.configPath) as { integrations: { githubApp: unknown } }).integrations.githubApp, + ).toEqual(ENABLED); + }); +}); + +describe("setup: what it writes", () => { + it("a User installation: trustedUsers", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + repos: [{ ...REPO, owner: "example-user" }], + github: { + "GET /app/installations?per_page=100&page=1": [200, [{ id: 7, account: USER }]], + "GET /repos/example-user/example-repo/installation": [200, { id: 7 }], + }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual( + registeredLog( + d.configPath, + ["The App is installed on example-user (installation 7)"], + "example-user", + ), + ); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED, { trustedUsers: ["example-user"] })); + }); + it("an existing trust list is kept", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + trust: { trustedOrgs: [], trustedUsers: ["someone"] }, + extraConfig: { github: { trustedUsers: ["someone"] } }, + }); + await setup(d, opts(), deps); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED, { trustedUsers: ["someone"] })); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...REGISTER, + ...INSTALL, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + }); + it("tokenScope and ciResults set earlier are kept", async () => { + const app = { ...ENABLED, tokenScope: "installation" as const, ciResults: false }; + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + githubApp: app, + saved: { "compose/GITHUB_APP_PRIVATE_KEY": PEM, "compose/GITHUB_WEBHOOK_SECRET": "w" }, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + }); + await setup(d, opts(), deps); + expect(readJson(d.configPath)).toEqual(configOf(app)); + expect(w.log).toEqual([ + PUBLIC_LINE, + "GITHUB_APP_PRIVATE_KEY: from the system keychain (compose)", + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + }); + it("a repo outside the installation: named, with where to add it; not a failure", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + repos: [REPO, OTHER], + github: { "GET /repos/example-org/other/installation": [404, { message: "Not Found" }] }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual( + registeredLog(d.configPath, [ + INSTALLED_ORG, + "example-org/other is not in the App's installation: add it at https://github.com/organizations/example-org/settings/installations/7", + ]), + ); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED, { trustedOrgs: ["example-org"] })); + }); + it("re-running a complete setup changes nothing and asks nothing", async () => { + const saved = { + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "whsec-1", + }; + const first = harness({ + publicUrl: PUBLIC, + githubApp: ENABLED, + saved, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + extraConfig: { github: { trustedOrgs: ["example-org"] } }, + }); + const before = readJson(first.d.configPath); + await setup(first.d, opts(), first.deps); + expect(readJson(first.d.configPath)).toEqual({ ...(before as object), publicUrl: PUBLIC }); + expect(first.w.asked).toEqual([]); + expect(first.w.opened).toEqual([]); + expect(first.w.log).toEqual([ + PUBLIC_LINE, + "GITHUB_APP_PRIVATE_KEY: from the system keychain (compose)", + INSTALLED_ORG, + `Wrote integrations.githubApp to ${first.d.configPath}`, + ...NEXT_COMPOSE, + ]); + // The bot's id is reused. + expect(calls(first.w)).toEqual([ + "GET /app", + "GET /app/installations?per_page=100&page=1", + "GET /repos/example-org/example-repo/installation", + ]); + const written = readFileSync(first.d.configPath, "utf8"); + await setup(first.d, opts(), first.deps); + expect(readFileSync(first.d.configPath, "utf8")).toBe(written); + }); + + it("an App renamed on GitHub: the re-run takes the new slug and its bot", async () => { + const RENAMED = "renamed-devbox"; + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + githubApp: ENABLED, + saved: { "compose/GITHUB_APP_PRIVATE_KEY": PEM, "compose/GITHUB_WEBHOOK_SECRET": "w" }, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + github: { + "GET /app": [ + 200, + { id: 123, slug: RENAMED, html_url: `https://github.com/apps/${RENAMED}`, owner: ORG }, + ], + [`GET /users/${RENAMED}%5Bbot%5D`]: [200, { id: 42 }], + }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + "GITHUB_APP_PRIVATE_KEY: from the system keychain (compose)", + `The App was renamed on GitHub: its slug is now ${RENAMED} (was ${SLUG}); mention it as @${RENAMED}`, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual(configOf({ ...ENABLED, slug: RENAMED, botUserId: 42 })); + }); + + it("no key here on a re-run: the installation and repo checks are skipped", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + githubApp: ENABLED, + saved: { "compose/GITHUB_WEBHOOK_SECRET": "w" }, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + NO_KEY, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + expect(w.calls).toEqual([]); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + }); + + it("no key and not installed: fails, naming the key's variable", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + githubApp: PROVISIONAL as GithubAppIntegration, + }); + expect(await setup(d, opts(), deps).catch((e) => e)).toEqual( + new SetupError( + "The App isn't installed yet, and there is no private key for it here: set GITHUB_APP_PRIVATE_KEY_FILE to its path, then run bun run github:setup again", + ), + ); + expect(w.log).toEqual([PUBLIC_LINE, NO_KEY]); + }); + + it("k8s: the trust list goes to the target's values; the GitOps values line", async () => { + const { d, deps, w } = harness({ deployment: K8S, ingressUrl: PUBLIC, save: false }); + await setup(d, opts(), deps); + const t = d.configPath; + expect(readJson(t)).toEqual({ + context: "prod", + publicUrl: PUBLIC, + values: { github: { trustedOrgs: ["example-org"] } }, + integrations: { githubApp: ENABLED }, + }); + expect(w.log.slice(-3)).toEqual([ + `Next: bun run k8s:install --target ${t}`, + "With GitOps, set these chart values instead: integrations.githubApp.enabled: true, integrations.githubApp.appId: 123, integrations.githubApp.installationId: 7, integrations.githubApp.botLogin: example-devbox, integrations.githubApp.botUserId: 1234567, integrations.githubApp.tokens: devbox, integrations.githubApp.tokenScope: repos, integrations.githubApp.ciResults: true", + `Then check GitHub with: bun run github:status --target ${t}`, + ]); + }); +}); + +describe("setup: secrets", () => { + it("keychain declined: the key goes to a 0600 file, the webhook secret is shown once", async () => { + const { d, deps, w, cwd } = harness({ publicUrl: PUBLIC, save: false }); + await setup(d, opts(), deps); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + expect(readFileSync(keyFile, "utf8")).toBe(PEM); + expect(statSync(keyFile).mode & 0o777).toBe(0o600); + expect(w.asked).toEqual([SAVE_BOTH, SHOW_HOOK]); + expect(w.shown).toEqual(["whsec-1"]); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...REGISTER, + `Saved the App's private key to ${keyFile}. Keep it somewhere safe and set GITHUB_APP_PRIVATE_KEY_FILE=${keyFile} before bun run up`, + ...INSTALL, + INSTALLED_ORG, + `Trusted: example-org (edit ${d.configPath} to change)`, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + expect(w.keychain.entries()).toEqual({}); + }); + + it("no keychain: nothing asked about it; the same file and secret", async () => { + const { d, deps, w, cwd } = harness({ publicUrl: PUBLIC, keychain: "none" }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([SHOW_HOOK]); + expect(existsSync(join(cwd, `${SLUG}.private-key.pem`))).toBe(true); + expect(w.shown).toEqual(["whsec-1"]); + }); + + it("the key file exists already: refused, the App kept (disabled) with how to recover", async () => { + const { d, deps, w, cwd } = harness({ publicUrl: PUBLIC, keychain: "none" }); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + writeFileSync(keyFile, "old"); + expect(await setup(d, opts(), deps).catch((e) => e)).toEqual( + new SetupError( + `${keyFile} exists already: nothing was overwritten. Generate a new private key on the App's settings page (https://github.com/organizations/example-org/settings/apps/${SLUG}), then run bun run github:setup --app-id 123 --private-key-file `, + ), + ); + expect(readFileSync(keyFile, "utf8")).toBe("old"); + expect(readJson(d.configPath)).toEqual(configOf(PROVISIONAL)); + expect(w.shown).toEqual([]); + }); + + it("registering again: env values and saved entries from before are named", async () => { + const { d, deps, w, cwd } = harness({ + publicUrl: PUBLIC, + githubApp: OLD_APP, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + save: false, + env: { GITHUB_APP_PRIVATE_KEY_FILE: "/old.pem", GITHUB_WEBHOOK_SECRET: "old" }, + saved: { "compose/GITHUB_APP_PRIVATE_KEY": "old", "compose/GITHUB_WEBHOOK_SECRET": "old" }, + }); + await setup(d, opts({ reregister: true }), deps); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + expect(w.log).toEqual([ + PUBLIC_LINE, + KEEPS_WORKING, + ...REGISTER, + `Saved the App's private key to ${keyFile}. Keep it somewhere safe and set GITHUB_APP_PRIVATE_KEY_FILE=${keyFile} before bun run up, once the new App is installed`, + ...INSTALL, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + "GITHUB_APP_PRIVATE_KEY_FILE is set in your environment from before this setup: set it to the new key's path, or unset it, before bun run up", + "The saved GITHUB_APP_PRIVATE_KEY for compose is from an earlier App or key: remove it with bun run secrets rm GITHUB_APP_PRIVATE_KEY", + "GITHUB_WEBHOOK_SECRET is set in your environment from before this setup: set it to the new value, or unset it, before bun run up", + "The saved GITHUB_WEBHOOK_SECRET for compose is from an earlier App or key: remove it with bun run secrets rm GITHUB_WEBHOOK_SECRET", + ...NEXT_COMPOSE, + ]); + expect(w.asked).toEqual([ + SAVE_BOTH, + "Press Enter to show the webhook secret (set it as GITHUB_WEBHOOK_SECRET before bun run up, once the new App is installed) ", + ]); + expect(w.listened).toHaveLength(1); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": "old", + "compose/GITHUB_WEBHOOK_SECRET": "old", + }); + }); + + it("a re-run names where the webhook secret will come from", async () => { + const base = { + publicUrl: PUBLIC, + githubApp: ENABLED, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + }; + const missing = harness({ ...base, saved: { "compose/GITHUB_APP_PRIVATE_KEY": PEM } }); + await setup(missing.d, opts(), missing.deps); + expect(missing.w.log.slice(-3)).toEqual([ + "Set GITHUB_WEBHOOK_SECRET in your environment before bun run up, or it asks for it", + ...NEXT_COMPOSE, + ]); + const env = harness({ + ...base, + saved: { "compose/GITHUB_APP_PRIVATE_KEY": PEM }, + env: { GITHUB_WEBHOOK_SECRET: "w" }, + }); + await setup(env.d, opts(), env.deps); + expect(env.w.log.slice(-3)).toEqual([ + "bun run up will use GITHUB_WEBHOOK_SECRET from your environment", + ...NEXT_COMPOSE, + ]); + }); + + it("k8s with the user's own Secrets: nothing saved; which Secret takes what", async () => { + const { d, deps, w, cwd } = harness({ + deployment: K8S, + ingressUrl: PUBLIC, + byoKeySecret: "my-app-key", + byoIntegrationsSecret: "my-integrations", + }); + await setup(d, opts(), deps); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + expect(w.asked).toEqual([ + 'Press Enter to show the webhook secret (put it in Secret "my-integrations" as GITHUB_WEBHOOK_SECRET) ', + ]); + expect(w.shown).toEqual(["whsec-1"]); + expect(w.log[3]).toBe( + `Saved the App's private key to ${keyFile}. Put it in Secret "my-app-key" (key private-key) in namespace devbox, then delete the file`, + ); + expect(w.keychain.entries()).toEqual({}); + }); + + it("external tokens on k8s: the key goes to a file for the user's tooling", async () => { + const { d, deps, w, cwd } = harness({ deployment: K8S, ingressUrl: PUBLIC }); + await setup(d, opts({ externalTokens: true }), deps); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + const t = d.configPath; + expect(w.asked).toEqual([ + "Save the App's webhook secret to the system keychain for k8s:prod/devbox? [Y/n] ", + ]); + expect(w.keychain.entries()).toEqual({ "k8s:prod/devbox/GITHUB_WEBHOOK_SECRET": "whsec-1" }); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...REGISTER, + `Saved the App's private key to ${keyFile}.`, + ...INSTALL, + INSTALLED_ORG, + `Trusted: example-org (edit ${t} to change)`, + `Wrote integrations.githubApp to ${t}`, + "Store this key where your token tooling reads it (for example the External Secrets GithubAccessToken generator: appID 123, installID 7), then delete the file", + `Next: bun run k8s:install --target ${t}`, + "With GitOps, set these chart values instead: integrations.githubApp.enabled: true, integrations.githubApp.appId: 123, integrations.githubApp.installationId: 7, integrations.githubApp.botLogin: example-devbox, integrations.githubApp.botUserId: 1234567, integrations.githubApp.tokens: external, integrations.githubApp.tokenScope: repos, integrations.githubApp.ciResults: true", + `Then check GitHub with: bun run github:status --target ${t}`, + ]); + expect(readJson(t)).toEqual({ + context: "prod", + publicUrl: PUBLIC, + values: { github: { trustedOrgs: ["example-org"] } }, + integrations: { githubApp: { ...ENABLED, tokens: "external" } }, + }); + }); + it("external tokens on Compose: asks for the token file first", async () => { + const { d, deps, w, cwd } = harness({ + publicUrl: PUBLIC, + lines: ["/var/lib/devbox-token/token"], + }); + await setup(d, opts({ externalTokens: true }), deps); + expect(w.asked).toEqual([ + "Path of the token file your tooling keeps fresh (absolute): ", + SAVE_HOOK, + ]); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...REGISTER, + `Saved the App's private key to ${join(cwd, `${SLUG}.private-key.pem`)}.`, + ...INSTALL, + INSTALLED_ORG, + `Trusted: example-org (edit ${d.configPath} to change)`, + `Wrote integrations.githubApp to ${d.configPath}`, + "Store this key where your token tooling reads it (for example the External Secrets GithubAccessToken generator: appID 123, installID 7), then delete the file", + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual( + configOf( + { ...ENABLED, tokens: "external", tokenFile: "/var/lib/devbox-token/token" }, + { trustedOrgs: ["example-org"] }, + ), + ); + }); + + it("a keychain save that fails counts as not saved: the key goes to its file", async () => { + const memory = new MemoryKeychain(); + const failing: Keychain = { + get: (name) => memory.get(name), + set: async () => { + throw Object.assign(new Error("The keychain is locked"), { + code: "ERR_SECRETS_INTERACTION_NOT_ALLOWED", + }); + }, + delete: (name) => memory.delete(name), + }; + const { d, deps, w, cwd } = harness({ publicUrl: PUBLIC, keychain: failing }); + await setup(d, opts(), deps); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + expect(readFileSync(keyFile, "utf8")).toBe(PEM); + expect(w.asked).toEqual([SAVE_BOTH, SHOW_HOOK]); + expect(w.shown).toEqual(["whsec-1"]); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...REGISTER, + "Saving GITHUB_APP_PRIVATE_KEY to the system keychain failed: The keychain is locked", + "Saving GITHUB_WEBHOOK_SECRET to the system keychain failed: The keychain is locked", + `Saved the App's private key to ${keyFile}. Keep it somewhere safe and set GITHUB_APP_PRIVATE_KEY_FILE=${keyFile} before bun run up`, + ...INSTALL, + INSTALLED_ORG, + `Trusted: example-org (edit ${d.configPath} to change)`, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED, { trustedOrgs: ["example-org"] })); + }); +}); + +describe("setup: adopting an App", () => { + const keyPath = join(dir, "adopt.pem"); + writeFileSync(keyPath, PEM); + + it("--app-id with --private-key-file: checks the key, sets the webhook, then installs", async () => { + const { d, deps, w } = harness({ publicUrl: PUBLIC, hidden: ["typed-secret"] }); + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps); + expect(w.listened).toEqual([]); + expect(w.asked).toEqual([ADOPT_HOOK_Q, SAVE_BOTH]); + expect(w.log.slice(0, 2)).toEqual([ + PUBLIC_LINE, + `Set the App's webhook URL to ${HOOK}. Check that the webhook is Active on the App's settings page: https://github.com/organizations/example-org/settings/apps/${SLUG}`, + ]); + expect(w.calls.slice(0, 3)).toEqual([ + { method: "GET", path: "/app", auth: true }, + { + method: "PATCH", + path: "/app/hook/config", + auth: true, + body: { url: HOOK, secret: "typed-secret" }, + }, + { method: "GET", path: "/app/installations?per_page=100&page=1", auth: true }, + ]); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "typed-secret", + }); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED, { trustedOrgs: ["example-org"] })); + }); + + it("an empty webhook secret: a new one is generated; keychain declined → the env lines", async () => { + const { d, deps, w } = harness({ publicUrl: PUBLIC, save: false }); + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps); + expect(w.calls[1]?.body).toEqual({ url: HOOK, secret: GENERATED }); + expect(w.shown).toEqual([GENERATED]); + expect(w.log[2]).toBe(`Set GITHUB_APP_PRIVATE_KEY_FILE=${keyPath} before bun run up`); + }); + + it("a key GitHub rejects", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + github: { "GET /app": [401, { message: "Bad credentials" }] }, + }); + expect( + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps).catch((e) => e.message), + ).toBe( + "GitHub rejected the key for App 123: Bad credentials. Check --app-id, the private key and the system clock", + ); + expect(w.asked).toEqual([]); + expect(readJson(d.configPath)).toEqual({ repos: [{ url: REPO.url }], publicUrl: PUBLIC }); + }); + + it("an App id GitHub doesn't know", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + github: { "GET /app": [404, { message: "Integration not found" }] }, + }); + expect( + await setup(d, opts({ appId: 999, privateKeyFile: keyPath }), deps).catch((e) => e.message), + ).toBe( + "GitHub has no App with id 999: check --app-id (the App's settings page shows its App ID)", + ); + expect(w.asked).toEqual([]); + }); + + it("a relative --private-key-file is read from where setup runs", async () => { + const { d, deps, w, cwd } = harness({ publicUrl: PUBLIC, save: false }); + writeFileSync(join(cwd, "mine.pem"), PEM); + await setup(d, opts({ appId: 123, privateKeyFile: "mine.pem" }), deps); + expect(w.log[2]).toBe( + `Set GITHUB_APP_PRIVATE_KEY_FILE=${join(cwd, "mine.pem")} before bun run up`, + ); + }); + + it("without --private-key-file: the key from the lookup; only the webhook secret is new", async () => { + const { d, deps, w } = harness({ + publicUrl: PUBLIC, + env: { GITHUB_APP_PRIVATE_KEY: PEM }, + hidden: ["typed-secret"], + }); + await setup(d, opts({ appId: 123 }), deps); + expect(w.asked).toEqual([ADOPT_HOOK_Q, SAVE_HOOK]); + expect(w.log[1]).toBe("GITHUB_APP_PRIVATE_KEY: from your environment"); + expect(w.keychain.entries()).toEqual({ "compose/GITHUB_WEBHOOK_SECRET": "typed-secret" }); + }); + + it("without any key: fails before calling GitHub", async () => { + const { d, deps, w } = harness({ publicUrl: PUBLIC }); + expect(await setup(d, opts({ appId: 123 }), deps).catch((e) => e)).toEqual( + new SetupError( + "No private key for App 123: pass --private-key-file , or set GITHUB_APP_PRIVATE_KEY_FILE", + ), + ); + expect(w.calls).toEqual([]); + }); +}); + +describe("setup: --print-registration-link", () => { + it("prints the link and what to do next, and writes nothing", async () => { + const { d, deps, w } = harness({ publicUrl: PUBLIC }); + const before = readFileSync(d.configPath, "utf8"); + await setup(d, opts({ printRegistrationLink: true, org: "example-org" }), deps); + expect(w.log).toEqual([ + registrationLink({ org: "example-org", name: "devbox", publicUrl: PUBLIC }), + "Create the App, generate a private key, then run: bun run github:setup --app-id --private-key-file ", + ]); + expect(readFileSync(d.configPath, "utf8")).toBe(before); + expect(w.calls).toEqual([]); + expect(w.opened).toEqual([]); + }); +}); + +describe("setup: a new public URL", () => { + const base = { + publicUrl: OLD, + githubApp: ENABLED, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + linear: true, + }; + + it("devbox tokens with the key: the App's webhook URL is updated", async () => { + const { d, deps, w } = harness({ + ...base, + saved: { "compose/GITHUB_APP_PRIVATE_KEY": PEM, "compose/GITHUB_WEBHOOK_SECRET": "w" }, + }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.calls).toContainEqual({ + method: "PATCH", + path: "/app/hook/config", + auth: true, + body: { url: HOOK }, + }); + expect(w.log.slice(3, 6)).toEqual([ + `Wrote integrations.githubApp to ${d.configPath}`, + `Update the webhook URL in the Linear app to ${PUBLIC}/webhook/linear`, + `Updated the GitHub App's webhook URL to ${HOOK}`, + ]); + expect((readJson(d.configPath) as { publicUrl: string }).publicUrl).toBe(PUBLIC); + }); + + it("devbox tokens without the key: the manual line", async () => { + const { d, deps, w } = harness({ ...base, saved: { "compose/GITHUB_WEBHOOK_SECRET": "w" } }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.log.slice(1, 5)).toEqual([ + NO_KEY, + `Wrote integrations.githubApp to ${d.configPath}`, + `Update the webhook URL in the Linear app to ${PUBLIC}/webhook/linear`, + `Update the webhook URL in the GitHub App to ${HOOK}: https://github.com/organizations/example-org/settings/apps/${SLUG}`, + ]); + expect(w.calls).toEqual([]); + }); + + it("external tokens with the key here: the organization's settings link", async () => { + const { d, deps, w } = harness({ + ...base, + githubApp: { ...ENABLED, tokens: "external", tokenFile: "/var/lib/devbox-token/token" }, + env: { GITHUB_APP_PRIVATE_KEY: PEM }, + saved: { "compose/GITHUB_WEBHOOK_SECRET": "w" }, + }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + "GITHUB_APP_PRIVATE_KEY: from your environment", + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + `Update the webhook URL in the Linear app to ${PUBLIC}/webhook/linear`, + `Update the webhook URL in the GitHub App to ${HOOK}: https://github.com/organizations/example-org/settings/apps/${SLUG}`, + ...NEXT_COMPOSE, + ]); + expect(w.calls.filter((c) => c.method === "PATCH")).toEqual([]); + }); + + it("external tokens: the manual line, nothing sent", async () => { + const { d, deps, w } = harness({ + ...base, + deployment: K8S, + githubApp: { ...ENABLED, tokens: "external" }, + saved: { "k8s:prod/devbox/GITHUB_WEBHOOK_SECRET": "w" }, + }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.log).toContain( + `Update the webhook URL in the GitHub App to ${HOOK}: https://github.com/organizations/example-org/settings/apps/${SLUG}`, + ); + expect(w.calls.filter((c) => c.method === "PATCH")).toEqual([]); + }); +}); + +describe("setup --reregister over a working App", () => { + const base = { + publicUrl: PUBLIC, + githubApp: OLD_APP, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + }; + const finalLog = (configPath: string, head: string[], tail: string[] = []) => [ + PUBLIC_LINE, + ...head, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${configPath}`, + OLD_UNUSED, + OLD_REPLACED, + ...tail, + ...NEXT_COMPOSE, + ]; + + it("the new App is pending until installed, then replaces the old one", async () => { + const { d, deps, w } = harness({ ...base, saved: OLD_SAVED }); + await setup(d, opts({ reregister: true }), deps); + expect(w.asked).toEqual([SAVE_BOTH]); + expect(w.log).toEqual(finalLog(d.configPath, [KEEPS_WORKING, ...REGISTER, ...INSTALL])); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "whsec-1", + }); + }); + + it("abandoned before the install: the old App stays as it was; a re-run finishes the swap", async () => { + const { d, deps, w } = harness({ + ...base, + saved: OLD_SAVED, + github: { "GET /app/installations?per_page=100&page=1": [200, []] }, + }); + expect(await setup(d, opts({ reregister: true }), deps).catch((e) => e)).toEqual( + new SetupError( + `The App isn't installed yet: install it from ${INSTALL_URL}, then run bun run github:setup again`, + ), + ); + expect(w.log).toEqual([PUBLIC_LINE, KEEPS_WORKING, ...REGISTER, ...INSTALL]); + expect(readJson(d.configPath)).toEqual(configOf({ ...OLD_APP, pending: PENDING })); + expect(w.keychain.entries()).toEqual({ ...OLD_SAVED, ...PENDING_SAVED }); + + const again = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + saved: w.keychain.entries(), + }); + await setup(again.d, opts(), again.deps); + expect(again.w.asked).toEqual([]); + expect(again.w.listened).toEqual([]); + expect(again.w.log).toEqual( + finalLog(again.d.configPath, [ + "The new App's private key: from the system keychain (compose)", + ]), + ); + expect(calls(again.w)).toEqual([ + "GET /app", + "GET /app/installations?per_page=100&page=1", + `GET /users/${SLUG}%5Bbot%5D`, + "GET /repos/example-org/example-repo/installation", + ]); + expect(readJson(again.d.configPath)).toEqual(configOf(ENABLED)); + expect(again.w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "whsec-1", + }); + }); + + it("the re-run finds the new App's key in its file when the keychain has none", async () => { + const { d, deps, w, cwd } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + keychain: "none", + }); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + writeFileSync(keyFile, PEM); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + `The new App's private key: from ${keyFile}`, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + `Set GITHUB_APP_PRIVATE_KEY_FILE=${keyFile} before bun run up`, + "Set GITHUB_WEBHOOK_SECRET in your environment before bun run up, or it asks for it", + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + }); + + it("the re-run takes the new App's key file over another App's _PENDING entries", async () => { + const { d, deps, w, cwd } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + saved: { ...OLD_SAVED, ...STALE_SAVED }, + }); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + writeFileSync(keyFile, PEM); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + `The new App's private key: from ${keyFile}`, + STALE_PENDING_LEFT, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + `Set GITHUB_APP_PRIVATE_KEY_FILE=${keyFile} before bun run up`, + EARLIER_KEY, + EARLIER_HOOK, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + expect(w.keychain.entries()).toEqual({ ...OLD_SAVED, ...STALE_SAVED }); + }); + + it("a new --public-url survives an abandoned run; the re-run saves it and names the change", async () => { + const NEW = "https://new.example.com"; + const { d, deps, w } = harness({ + ...base, + linear: true, + saved: OLD_SAVED, + github: { "GET /app/installations?per_page=100&page=1": [200, []] }, + }); + expect( + await setup(d, opts({ reregister: true, publicUrl: NEW }), deps).catch((e) => e), + ).toBeInstanceOf(SetupError); + expect(readJson(d.configPath)).toEqual( + configOf({ ...OLD_APP, pending: { ...PENDING, publicUrl: NEW } }), + ); + expect(w.listened[0]?.page).toBe( + formPage( + manifestPostUrl(STATE), + githubManifest({ + name: "devbox", + publicUrl: NEW, + redirectUrl: "http://localhost:4567/callback", + }), + ), + ); + + const again = harness({ + ...base, + linear: true, + githubApp: { ...OLD_APP, pending: { ...PENDING, publicUrl: NEW } }, + saved: w.keychain.entries(), + }); + await setup(again.d, opts(), again.deps); + expect(again.w.log).toEqual([ + `Public URL: ${NEW}`, + "The new App's private key: from the system keychain (compose)", + INSTALLED_ORG, + `Wrote integrations.githubApp to ${again.d.configPath}`, + OLD_UNUSED, + OLD_REPLACED, + `Update the webhook URL in the Linear app to ${NEW}/webhook/linear`, + ...NEXT_COMPOSE, + ]); + expect(readJson(again.d.configPath)).toEqual({ ...configOf(ENABLED), publicUrl: NEW }); + }); + + it("disabled while the new App waits: the re-run installs the new App, not the old one", async () => { + const { d, deps, w } = harness({ + ...base, + githubApp: { ...OLD_APP, enabled: false, pending: PENDING }, + saved: { ...OLD_SAVED, ...PENDING_SAVED }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual( + finalLog(d.configPath, ["The new App's private key: from the system keychain (compose)"]), + ); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "whsec-1", + }); + }); + + it("the re-run after the new secrets went unsaved: the old ones in force are named", async () => { + const { d, deps, w, cwd } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + saved: OLD_SAVED, + env: { GITHUB_APP_PRIVATE_KEY_FILE: "/old.pem" }, + }); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + writeFileSync(keyFile, PEM); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + `The new App's private key: from ${keyFile}`, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + "GITHUB_APP_PRIVATE_KEY_FILE is set in your environment from before this setup: set it to the new key's path, or unset it, before bun run up", + EARLIER_KEY, + EARLIER_HOOK, + ...NEXT_COMPOSE, + ]); + expect(w.keychain.entries()).toEqual(OLD_SAVED); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + }); + + it("the re-run with the new webhook secret unsaved and the old one in env", async () => { + const { d, deps, w } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + saved: { "compose/GITHUB_APP_PRIVATE_KEY_PENDING": PEM }, + env: { GITHUB_WEBHOOK_SECRET: "old-secret" }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + "The new App's private key: from the system keychain (compose)", + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + "GITHUB_WEBHOOK_SECRET is set in your environment from before this setup: set it to the new value, or unset it, before bun run up", + ...NEXT_COMPOSE, + ]); + expect(w.keychain.entries()).toEqual({ "compose/GITHUB_APP_PRIVATE_KEY": PEM }); + }); + + it("writing the config fails after the move: the entries are put back", async () => { + const memory = { ...OLD_SAVED, ...PENDING_SAVED }; + const { d, deps, w } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + saved: memory, + }); + rmSync(d.configPath); + expect(await setup(d, opts(), deps).catch((e) => (e as NodeJS.ErrnoException).code)).toBe( + "ENOENT", + ); + expect(w.keychain.entries()).toEqual(memory); + }); + + it("the re-run without the new App's key: fails, nothing changed", async () => { + const { d, deps, w, cwd } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + saved: OLD_SAVED, + }); + expect(await setup(d, opts(), deps).catch((e) => e)).toEqual( + new SetupError( + `No private key for the new App ${SLUG} here (no GITHUB_APP_PRIVATE_KEY_PENDING in the system keychain, no ${join(cwd, `${SLUG}.private-key.pem`)}): adopt it again with bun run github:setup --app-id 123 --private-key-file , or register another one with bun run github:setup --reregister`, + ), + ); + expect(w.calls).toEqual([]); + expect(readJson(d.configPath)).toEqual(configOf({ ...OLD_APP, pending: PENDING })); + }); + + it("moving the keychain entries fails: the old App and both sets of entries stay", async () => { + const memory = new MemoryKeychain({ ...OLD_SAVED, ...PENDING_SAVED }); + const locked: Keychain = { + get: (name) => memory.get(name), + set: async () => { + throw Object.assign(new Error("The keychain is locked"), { + code: "ERR_SECRETS_INTERACTION_NOT_ALLOWED", + }); + }, + delete: (name) => memory.delete(name), + }; + const { d, deps, w } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + keychain: locked, + }); + expect(await setup(d, opts(), deps).catch((e) => e)).toEqual(new SetupError(MOVE_FAILED)); + expect(w.log).toEqual([ + PUBLIC_LINE, + "The new App's private key: from the system keychain (compose)", + INSTALLED_ORG, + ]); + expect(readJson(d.configPath)).toEqual(configOf({ ...OLD_APP, pending: PENDING })); + expect(memory.entries()).toEqual({ ...OLD_SAVED, ...PENDING_SAVED }); + }); + + it("moving the keychain entries fails with the old App disabled: says it stays disabled", async () => { + const memory = new MemoryKeychain({ ...PENDING_SAVED }); + const locked: Keychain = { + get: (name) => memory.get(name), + set: async () => { + throw new Error("The keychain is locked"); + }, + delete: (name) => memory.delete(name), + }; + const { d, deps } = harness({ + ...base, + githubApp: { ...OLD_APP, enabled: false, pending: PENDING }, + keychain: locked, + }); + expect(await setup(d, opts(), deps).catch((e) => e)).toEqual( + new SetupError( + "Moving the new App's secrets in the system keychain failed: The keychain is locked. Nothing changed: the App old-devbox stays disabled and the new App's secrets stay saved as GITHUB_APP_PRIVATE_KEY_PENDING and GITHUB_WEBHOOK_SECRET_PENDING; run bun run github:setup again to finish", + ), + ); + expect(readJson(d.configPath)).toEqual( + configOf({ ...OLD_APP, enabled: false, pending: PENDING }), + ); + }); + + it("the pending App renamed on GitHub before its install: the re-run takes the new slug", async () => { + const RENAMED = "renamed-devbox"; + const { d, deps, w } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + saved: { ...OLD_SAVED, ...PENDING_SAVED }, + github: { + "GET /app": [ + 200, + { id: 123, slug: RENAMED, html_url: `https://github.com/apps/${RENAMED}`, owner: ORG }, + ], + [`GET /users/${RENAMED}%5Bbot%5D`]: [200, { id: 42 }], + }, + }); + await setup(d, opts(), deps); + expect(w.log).toEqual( + finalLog(d.configPath, [ + "The new App's private key: from the system keychain (compose)", + `The new App was renamed on GitHub: its slug is now ${RENAMED} (was ${SLUG}); mention it as @${RENAMED}`, + ]), + ); + expect(readJson(d.configPath)).toEqual(configOf({ ...ENABLED, slug: RENAMED, botUserId: 42 })); + }); + + it("the re-run with the key in its file and GITHUB_APP_PRIVATE_KEY in env: one key line", async () => { + const { d, deps, w, cwd } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + keychain: "none", + env: { GITHUB_APP_PRIVATE_KEY: "old-pem" }, + }); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + writeFileSync(keyFile, PEM); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + `The new App's private key: from ${keyFile}`, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + `Set GITHUB_APP_PRIVATE_KEY_FILE=${keyFile} before bun run up`, + "Set GITHUB_WEBHOOK_SECRET in your environment before bun run up, or it asks for it", + ...NEXT_COMPOSE, + ]); + }); + + it("a second move failure halfway puts the first entry back", async () => { + const memory = new MemoryKeychain({ ...OLD_SAVED, ...PENDING_SAVED }); + let sets = 0; + const flaky: Keychain = { + get: (name) => memory.get(name), + set: async (name, value) => { + sets++; + if (sets === 2) { + throw Object.assign(new Error("The keychain is locked"), { + code: "ERR_SECRETS_INTERACTION_NOT_ALLOWED", + }); + } + await memory.set(name, value); + }, + delete: (name) => memory.delete(name), + }; + const { d, deps } = harness({ + ...base, + githubApp: { ...OLD_APP, pending: PENDING }, + keychain: flaky, + }); + expect(await setup(d, opts(), deps).catch((e) => e)).toEqual(new SetupError(MOVE_FAILED)); + expect(memory.entries()).toEqual({ ...OLD_SAVED, ...PENDING_SAVED }); + }); + + describe("while a new App is pending", () => { + const pendingBase = { + ...base, + githubApp: { ...OLD_APP, pending: { appId: 120, slug: "stale-devbox" } }, + }; + + it("without a terminal: refused", async () => { + const { d, deps, w } = harness({ ...pendingBase, isTTY: false }); + expect(await setup(d, opts({ reregister: true }), deps).catch((e) => e)).toEqual( + new SetupError( + "A new App stale-devbox is already waiting for its install: install it from https://github.com/apps/stale-devbox/installations/new and run bun run github:setup, or remove it with bun run github:disable --forget", + ), + ); + expect(w.listened).toEqual([]); + }); + + it("asked, no: nothing changes", async () => { + const { d, deps, w } = harness({ ...pendingBase, save: false }); + const before = readFileSync(d.configPath, "utf8"); + expect(await setup(d, opts({ reregister: true }), deps).catch((e) => e)).toEqual( + new SetupError( + "Nothing was changed: install stale-devbox from https://github.com/apps/stale-devbox/installations/new, then run bun run github:setup", + ), + ); + expect(w.asked).toEqual([REPLACE_Q]); + expect(readFileSync(d.configPath, "utf8")).toBe(before); + expect(w.listened).toEqual([]); + }); + + it("asked, yes: another App replaces the pending one, whose entries go", async () => { + const { d, deps, w } = harness({ ...pendingBase, saved: { ...OLD_SAVED, ...STALE_SAVED } }); + await setup(d, opts({ reregister: true }), deps); + expect(w.asked).toEqual([REPLACE_Q, SAVE_BOTH]); + expect(w.log).toEqual( + finalLog(d.configPath, [...STALE_DROPPED, KEEPS_WORKING, ...REGISTER, ...INSTALL]), + ); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "whsec-1", + }); + }); + + it("asked, yes, then the new secrets not saved: stale entries are never moved", async () => { + const { d, deps, w, cwd } = harness({ + ...pendingBase, + saved: { ...OLD_SAVED, ...STALE_SAVED }, + confirms: [true, false], + }); + await setup(d, opts({ reregister: true }), deps); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + expect(w.asked).toEqual([REPLACE_Q, SAVE_BOTH, SHOW_HOOK_ONCE]); + expect(w.shown).toEqual(["whsec-1"]); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...STALE_DROPPED, + KEEPS_WORKING, + ...REGISTER, + `Saved the App's private key to ${keyFile}. Keep it somewhere safe and set GITHUB_APP_PRIVATE_KEY_FILE=${keyFile} before bun run up, once the new App is installed`, + ...INSTALL, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + EARLIER_KEY, + EARLIER_HOOK, + ...NEXT_COMPOSE, + ]); + expect(w.keychain.entries()).toEqual(OLD_SAVED); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + }); + + it("asked, yes, with --no-keychain: says the pending App's saved secrets are left", async () => { + const { d, deps, w, cwd } = harness({ ...pendingBase, keychain: "none" }); + await setup(d, opts({ reregister: true }), deps); + const keyFile = join(cwd, `${SLUG}.private-key.pem`); + expect(w.asked).toEqual([REPLACE_Q, SHOW_HOOK_ONCE]); + expect(w.log).toEqual([ + PUBLIC_LINE, + STALE_UNUSED, + "--no-keychain left any saved secrets of the App stale-devbox in the system keychain: remove them with bun run secrets rm GITHUB_APP_PRIVATE_KEY_PENDING and bun run secrets rm GITHUB_WEBHOOK_SECRET_PENDING", + KEEPS_WORKING, + ...REGISTER, + `Saved the App's private key to ${keyFile}. Keep it somewhere safe and set GITHUB_APP_PRIVATE_KEY_FILE=${keyFile} before bun run up, once the new App is installed`, + ...INSTALL, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + }); + + const DROP_Q = + "A new App stale-devbox is waiting for its install; adopting App 123 again drops it. Drop it? [y/N] "; + const ownBase = { + ...base, + githubApp: { ...ENABLED, pending: { appId: 120, slug: "stale-devbox" } }, + }; + + it("adopting the configured App again (a new key): asked; no changes nothing", async () => { + const keyPath = join(dir, "adopt-own-no.pem"); + writeFileSync(keyPath, PEM); + const { d, deps, w } = harness({ ...ownBase, save: false }); + const before = readFileSync(d.configPath, "utf8"); + expect( + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps).catch((e) => e), + ).toEqual( + new SetupError( + "Nothing was changed: install stale-devbox from https://github.com/apps/stale-devbox/installations/new, then run bun run github:setup", + ), + ); + expect(w.asked).toEqual([DROP_Q]); + expect(readFileSync(d.configPath, "utf8")).toBe(before); + expect(w.calls).toEqual([]); + }); + + it("adopting the configured App again, yes: the pending App and its entries go", async () => { + const keyPath = join(dir, "adopt-own-yes.pem"); + writeFileSync(keyPath, PEM); + const { d, deps, w } = harness({ + ...ownBase, + saved: { ...STALE_SAVED }, + hidden: ["typed-secret"], + }); + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps); + expect(w.asked).toEqual([DROP_Q, ADOPT_HOOK_Q, SAVE_BOTH]); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...STALE_DROPPED, + `Set the App's webhook URL to ${HOOK}. Check that the webhook is Active on the App's settings page: https://github.com/organizations/example-org/settings/apps/${SLUG}`, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "typed-secret", + }); + }); + + it("adopting the configured App again, yes, then a failure: the pending App is gone already", async () => { + const keyPath = join(dir, "adopt-own-fail.pem"); + writeFileSync(keyPath, PEM); + const { d, deps } = harness({ + ...ownBase, + saved: { ...STALE_SAVED }, + hidden: ["typed-secret"], + github: { "PATCH /app/hook/config": [500, { message: "boom" }] }, + }); + expect( + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps).catch((e) => e), + ).toBeInstanceOf(Error); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + }); + + it("adopting another App: the same question; no changes nothing", async () => { + const keyPath = join(dir, "adopt-pending.pem"); + writeFileSync(keyPath, PEM); + const { d, deps, w } = harness({ ...pendingBase, save: false }); + const before = readFileSync(d.configPath, "utf8"); + expect( + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps).catch((e) => e), + ).toEqual( + new SetupError( + "Nothing was changed: install stale-devbox from https://github.com/apps/stale-devbox/installations/new, then run bun run github:setup", + ), + ); + expect(w.asked).toEqual([REPLACE_Q]); + expect(readFileSync(d.configPath, "utf8")).toBe(before); + expect(w.calls).toEqual([]); + }); + + it("adopting another App, yes, secrets not saved: stale entries go, none moved", async () => { + const keyPath = join(dir, "adopt-pending2.pem"); + writeFileSync(keyPath, PEM); + const { d, deps, w } = harness({ + ...pendingBase, + saved: { ...OLD_SAVED, ...STALE_SAVED }, + confirms: [true, false], + hidden: ["typed-secret"], + }); + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps); + expect(w.asked).toEqual([REPLACE_Q, ADOPT_HOOK_Q, SAVE_BOTH, SHOW_HOOK_ONCE]); + expect(w.shown).toEqual(["typed-secret"]); + expect(w.log).toEqual([ + PUBLIC_LINE, + ...STALE_DROPPED, + KEEPS_WORKING, + `Set the App's webhook URL to ${HOOK}. Check that the webhook is Active on the App's settings page: https://github.com/organizations/example-org/settings/apps/${SLUG}`, + `Set GITHUB_APP_PRIVATE_KEY_FILE=${keyPath} before bun run up, once the new App is installed`, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + EARLIER_KEY, + EARLIER_HOOK, + ...NEXT_COMPOSE, + ]); + expect(w.keychain.entries()).toEqual(OLD_SAVED); + }); + }); +}); + +describe("setup --app-id over a working App", () => { + const keyPath = join(dir, "adopt-over.pem"); + writeFileSync(keyPath, PEM); + const base = { + publicUrl: PUBLIC, + githubApp: OLD_APP, + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + saved: OLD_SAVED, + hidden: ["typed-secret"], + }; + const SET_HOOK = `Set the App's webhook URL to ${HOOK}. Check that the webhook is Active on the App's settings page: https://github.com/organizations/example-org/settings/apps/${SLUG}`; + + it("another App, already installed: it replaces the working one at once", async () => { + const { d, deps, w } = harness(base); + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps); + expect(w.asked).toEqual([ADOPT_HOOK_Q, SAVE_BOTH]); + expect(w.log).toEqual([ + PUBLIC_LINE, + KEEPS_WORKING, + SET_HOOK, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + OLD_UNUSED, + OLD_REPLACED, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "typed-secret", + }); + }); + + it("another App, not installed: it waits as pending and the working App stays on", async () => { + const { d, deps, w } = harness({ + ...base, + github: { "GET /app/installations?per_page=100&page=1": [200, []] }, + }); + expect( + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps).catch((e) => e), + ).toEqual( + new SetupError( + `The App isn't installed yet: install it from ${INSTALL_URL}, then run bun run github:setup again`, + ), + ); + expect(w.log).toEqual([PUBLIC_LINE, KEEPS_WORKING, SET_HOOK, ...INSTALL]); + expect(readJson(d.configPath)).toEqual(configOf({ ...OLD_APP, pending: PENDING })); + expect(w.keychain.entries()).toEqual({ + ...OLD_SAVED, + "compose/GITHUB_APP_PRIVATE_KEY_PENDING": PEM, + "compose/GITHUB_WEBHOOK_SECRET_PENDING": "typed-secret", + }); + }); + + it("the working App itself: as before, no pending", async () => { + const { d, deps, w } = harness({ ...base, githubApp: ENABLED }); + await setup(d, opts({ appId: 123, privateKeyFile: keyPath }), deps); + expect(w.log).toEqual([ + PUBLIC_LINE, + SET_HOOK, + INSTALLED_ORG, + `Wrote integrations.githubApp to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual(configOf(ENABLED)); + expect(w.keychain.entries()).toEqual({ + "compose/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_WEBHOOK_SECRET": "typed-secret", + }); + }); +}); diff --git a/control-plane/scripts/github/setup.ts b/control-plane/scripts/github/setup.ts new file mode 100644 index 0000000..6d712d1 --- /dev/null +++ b/control-plane/scripts/github/setup.ts @@ -0,0 +1,949 @@ +// `bun run github:setup`: resolve the public URL, register the GitHub App +// through GitHub's manifest flow (or adopt an existing one), save its private +// key and webhook secret, wait for it to be installed, record its settings +// and a default trust list in the deployment's config, check that the +// installation covers the repos, and name the command that applies it. +// setup never applies: no `up`, no `k8s:install`, no cluster access; its +// network calls go only to GitHub. The key and the webhook secret are never +// logged, put in argv or written to the config file. +// --reregister (or --app-id with another App) over a working App keeps that +// App working: the new one waits as `pending`, its secrets under _PENDING +// names (and a new public URL in it), until it is installed. A pending App is +// dropped only when the user agrees (or with github:disable --forget); +// otherwise the next run installs it, even after a plain github:disable. +// Re-running is safe: each step reuses what is already configured. +import { existsSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { GithubAppError, githubApiBaseUrl } from "../../src/githubToken/mint"; +import { writeConfigKeys } from "../lib/configFile"; +import { + appSettingsUrl, + callWithAppKey, + checkCoverage, + installationSettingsUrl, + savedKeyFix, + verifyApp, +} from "../lib/githubApp"; +import { readPrivateKeyFile, resolvePrivateKey } from "../lib/githubAppKey"; +import { type PendingGithubApp, parseIntegrations } from "../lib/integrations"; +import { deploymentScope, entryName, keychainUsable } from "../lib/keychain"; +import { publicUrlChangeLines, resolvePublicUrl } from "../lib/publicUrl"; +import type { ResolverDeps } from "../lib/secrets"; +import type { Fetch } from "../up/preflight"; +import { type AppAccount, type AppApi, type AppKey, appApi, type Installation } from "./appApi"; +import type { GithubDeployment } from "./deployment"; +import { CALLBACK_TIMEOUT_MS, type CallbackListener } from "./listener"; +import { formPage, githubManifest, manifestPostUrl, registrationLink } from "./manifest"; + +export class SetupError extends Error {} + +export type SetupOptions = { + publicUrl?: string; // --public-url + org?: string; // --org: register under this organization + name: string; // --name, the App's name on GitHub + externalTokens: boolean; // --external-tokens + appId?: number; // --app-id: adopt this App + privateKeyFile?: string; // --private-key-file + reregister: boolean; + printRegistrationLink: boolean; +}; + +export type SetupDeps = { + resolver: ResolverDeps; + // Asks with echo; empty at EOF. + promptLine: (q: string) => Promise; + // Opens a page in a browser, best effort: it is always printed too. + openUrl: (url: string) => void; + fetch: Fetch; + now: () => Date; + sleep: (ms: number) => Promise; + // Enter pressed while waiting for the installation checks again at once. + enter: () => { next(): Promise; stop(): void }; + listen: (opts: { + state: string; + timeoutMs: number; + page: (port: number) => string; + }) => Promise; + // `bytes` random bytes, as hex. + random: (bytes: number) => string; + // Where a key file is written. + cwd: string; + // Shows a secret on the terminal, once; never through `log`. + show: (secret: string) => void; + log: (line: string) => void; +}; + +const KEY = "GITHUB_APP_PRIVATE_KEY"; +const KEY_FILE = "GITHUB_APP_PRIVATE_KEY_FILE"; +const HOOK_SECRET = "GITHUB_WEBHOOK_SECRET"; +// A new App registered to replace a working one keeps its secrets under +// these names until it is installed, so the working App's stay as they are. +const PENDING = "_PENDING"; +const POLL_MS = 3_000; +const INSTALL_WAIT_MS = 15 * 60_000; +const NO_KEY = "No private key for the App here: skipping the installation and repo checks"; + +// The App this run works with. +type App = { appId: number; slug: string; htmlUrl: string; owner?: AppAccount }; + +// What this run got that is new: the private key (and the file it came from, +// when adopted) and the webhook secret. `saved*` once the keychain took them; +// `keyFile` when setup wrote the key to a file. +type Fresh = { + key?: { pem: string; path?: string }; + webhook?: string; + savedKey?: boolean; + savedWebhook?: boolean; + keyFile?: string; + // Resuming a new App whose webhook secret was shown, not saved: whatever + // is saved or set under the main name belongs to the old App. + webhookShown?: boolean; + // Resuming a new App whose key was found in the file setup wrote for it. + keyFromPendingFile?: boolean; +}; + +type Ctx = { + d: GithubDeployment; + opts: SetupOptions; + deps: SetupDeps; + api: AppApi; + publicUrl: string; + tokens: "devbox" | "external"; + tokenFile?: string; + // " --target " on k8s, for the commands named in messages. + flag: string; + fresh: Fresh; + // The new App replaces the configured, working one once installed: until + // then it is `pending` and its secrets are saved under the _PENDING names. + replacing: boolean; +}; + +const hookUrl = (publicUrl: string) => `${publicUrl}/webhook/github`; + +const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err)); + +// tokens "external" on Compose needs the file the user's tooling keeps fresh. +async function askTokenFile(d: GithubDeployment, deps: SetupDeps): Promise { + if (!deps.resolver.isTTY) { + throw new SetupError( + `tokens "external" on Compose needs integrations.githubApp.tokenFile: set it in ${d.configPath} to the absolute path of the token file your tooling keeps fresh, or run bun run github:setup in a terminal`, + ); + } + const answer = ( + await deps.promptLine("Path of the token file your tooling keeps fresh (absolute): ") + ).trim(); + if (!answer) { + throw new SetupError( + "No token file entered: nothing was written. Run bun run github:setup --external-tokens again when you have it.", + ); + } + parseIntegrations( + { githubApp: { enabled: false, tokenFile: answer } }, + (msg) => { + throw new SetupError(msg); + }, + { platform: "compose" }, + ); + return answer; +} + +// The App's settings before it is installed, so a re-run skips registration +// and goes to the installation. A working configuration of the same App is +// left as it is. +function saveRegistered(ctx: Ctx, app: App): void { + const { d } = ctx; + if (ctx.replacing) { + const org = app.owner?.type === "Organization" ? app.owner.login : undefined; + // The saved public URL is the working App's until the new one is installed. + const publicUrl = ctx.publicUrl !== d.publicUrl ? ctx.publicUrl : undefined; + writeConfigKeys(d.configPath, { + "integrations.githubApp.pending": { + appId: app.appId, + slug: app.slug, + ...(org !== undefined ? { org } : {}), + ...(publicUrl !== undefined ? { publicUrl } : {}), + }, + }); + return; + } + if (d.githubApp?.enabled && d.githubApp.appId === app.appId) return; + writeConfigKeys(d.configPath, { + "integrations.githubApp": { + enabled: false, + appId: app.appId, + slug: app.slug, + tokens: ctx.tokens, + tokenScope: d.githubApp?.tokenScope ?? "repos", + ciResults: d.githubApp?.ciResults ?? true, + ...(ctx.tokenFile !== undefined ? { tokenFile: ctx.tokenFile } : {}), + ...(app.owner?.type === "Organization" ? { org: app.owner.login } : {}), + }, + publicUrl: ctx.publicUrl, + }); +} + +// The key goes to a new file next to where setup runs, readable by the user +// only; an existing file is never overwritten. +function writeKeyFile(ctx: Ctx, app: App, pem: string): string { + const path = join(ctx.deps.cwd, `${app.slug}.private-key.pem`); + try { + writeFileSync(path, pem, { mode: 0o600, flag: "wx" }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + throw new SetupError( + `${path} exists already: nothing was overwritten. Generate a new private key on the App's settings page (${appSettingsUrl(app.slug, app.owner)}), then run bun run github:setup${ctx.flag} --app-id ${app.appId} --private-key-file `, + ); + } + return path; +} + +// Saves what is new: to the keychain when the user agrees; otherwise the key +// to a file (or its own file, when adopted) and the webhook secret shown once. +// With tokens "external" the key never goes to devbox; the user's own k8s +// Secrets take what they hold. +async function storeSecrets(ctx: Ctx, app: App): Promise { + const { d, deps, fresh, tokens } = ctx; + const { resolver, log } = deps; + const k = resolver.keychain; + const suffix = ctx.replacing ? PENDING : ""; + // A replacing App's secrets are used only once it is installed. + const once = ctx.replacing ? ", once the new App is installed" : ""; + const keyToKeychain = fresh.key !== undefined && tokens === "devbox" && !d.byoKeySecret; + const hookToKeychain = fresh.webhook !== undefined && !d.byoIntegrationsSecret; + if ( + (keyToKeychain || hookToKeychain) && + k && + (await keychainUsable(k, entryName(d.deployment, KEY)).catch(() => false)) + ) { + const what = + keyToKeychain && hookToKeychain + ? "private key and webhook secret" + : keyToKeychain + ? "private key" + : "webhook secret"; + const yes = await resolver.confirm( + `Save the App's ${what} to the system keychain for ${deploymentScope(d.deployment)}? [Y/n] `, + { defaultYes: true }, + ); + if (yes) { + // A save that fails (a locked keychain, a refused access, a value too + // large) counts as not saved: the value then goes the other way, never lost. + const save = async (name: string, value: string): Promise => { + try { + await k.set(entryName(d.deployment, name), value); + } catch (err) { + log(`Saving ${name} to the system keychain failed: ${errorText(err)}`); + return false; + } + // A keychain that switched itself off during `set` saved nothing. + return !k.disabled; + }; + if (keyToKeychain && fresh.key) fresh.savedKey = await save(KEY + suffix, fresh.key.pem); + if (hookToKeychain && fresh.webhook) { + fresh.savedWebhook = await save(HOOK_SECRET + suffix, fresh.webhook); + } + } + } + + const ns = d.deployment.kind === "k8s" ? d.deployment.namespace : ""; + if (fresh.key && !fresh.savedKey) { + const { path } = fresh.key; + if (path) { + if (tokens === "devbox") { + log( + d.byoKeySecret + ? `Put the key from ${path} in Secret "${d.byoKeySecret}" (key private-key) in namespace ${ns}${once}` + : `Set ${KEY_FILE}=${path} before ${d.applyCommand}${once}`, + ); + } + } else { + const file = writeKeyFile(ctx, app, fresh.key.pem); + fresh.keyFile = file; + const saved = `Saved the App's private key to ${file}.`; + if (tokens === "external") log(saved); + else if (d.byoKeySecret) { + log( + `${saved} Put it in Secret "${d.byoKeySecret}" (key private-key) in namespace ${ns}${once}, then delete the file`, + ); + } else { + log( + `${saved} Keep it somewhere safe and set ${KEY_FILE}=${file} before ${d.applyCommand}${once}`, + ); + } + } + } + + if (fresh.webhook && !fresh.savedWebhook) { + const where = d.byoIntegrationsSecret + ? `put it in Secret "${d.byoIntegrationsSecret}" as ${HOOK_SECRET}${once}` + : `set it as ${HOOK_SECRET} before ${d.applyCommand}${once}`; + await deps.promptLine(`Press Enter to show the webhook secret (${where}) `); + deps.show(fresh.webhook); + } +} + +// GitHub's manifest flow: the local page posts the manifest, GitHub +// redirects back with a code, and the code buys the App's credentials. +async function registerApp(ctx: Ctx): Promise<{ app: App; key: AppKey }> { + const { opts, deps, publicUrl } = ctx; + const state = deps.random(16); + const listener = await deps.listen({ + state, + timeoutMs: CALLBACK_TIMEOUT_MS, + page: (port) => + formPage( + manifestPostUrl(state, opts.org), + githubManifest({ + name: opts.name, + publicUrl, + redirectUrl: `http://localhost:${port}/callback`, + }), + ), + }); + let code: string; + try { + const local = `http://localhost:${listener.port}/`; + deps.log( + "Register the GitHub App: your browser opens GitHub's create page; review it and click Create.", + ); + deps.log(`If it doesn't open, open ${local} in a browser on this machine.`); + deps.openUrl(local); + code = await listener.code; + } finally { + listener.close(); + } + + const made = await ctx.api.convertManifest(code); + const app: App = { appId: made.id, slug: made.slug, htmlUrl: made.htmlUrl, owner: made.owner }; + const key = { appId: made.id, pem: made.pem }; + saveRegistered(ctx, app); + ctx.fresh.key = { pem: made.pem }; + ctx.fresh.webhook = made.webhookSecret ?? ctx.deps.random(32); + await storeSecrets(ctx, app); + if (made.webhookSecret === null) { + try { + await ctx.api.updateHookConfig(key, { secret: ctx.fresh.webhook }); + } catch (err) { + if (!(err instanceof GithubAppError)) throw err; + throw new SetupError( + `Setting the new App's webhook secret failed: ${err.message}. Run bun run github:setup${ctx.flag} --app-id ${app.appId} to set a new one`, + ); + } + } + return { app, key }; +} + +// --app-id: an App made elsewhere. Its key is checked, its webhook URL and +// secret are set. +async function adoptApp(ctx: Ctx, appId: number): Promise<{ app: App; key: AppKey }> { + const { opts, deps } = ctx; + let pem: string; + if (opts.privateKeyFile !== undefined) { + const path = resolve(deps.cwd, opts.privateKeyFile); + pem = readPrivateKeyFile(path, "--private-key-file"); + ctx.fresh.key = { pem, path }; + } else { + const found = await resolvePrivateKey(deps.resolver); + if (!found) { + throw new SetupError( + `No private key for App ${appId}: pass --private-key-file , or set ${KEY_FILE}`, + ); + } + pem = found.pem; + } + const info = await verifyApp({ appId }, pem, deps.fetch, { + rejected: (reason) => + `GitHub rejected the key for App ${appId}: ${reason}. Check --app-id, the private key and the system clock`, + notFound: `GitHub has no App with id ${appId}: check --app-id (the App's settings page shows its App ID)`, + }); + const app: App = { appId, ...info }; + const typed = ( + await deps.resolver.prompt( + "The App's webhook secret (input hidden; press Enter to generate a new one): ", + ) + ).trim(); + ctx.fresh.webhook = typed || deps.random(32); + const key = { appId, pem }; + const url = hookUrl(ctx.publicUrl); + await ctx.api.updateHookConfig(key, { url, secret: ctx.fresh.webhook }); + deps.log( + `Set the App's webhook URL to ${url}. Check that the webhook is Active on the App's settings page: ${appSettingsUrl(app.slug, app.owner)}`, + ); + saveRegistered(ctx, app); + await storeSecrets(ctx, app); + return { app, key }; +} + +// The pending App's key: the file setup wrote for it, else its keychain +// entry. The file is named after the App; the _PENDING entries are not, and +// may be left from an App dropped with --no-keychain, so they are used only +// when there is no file. What is found is recorded as this run's new +// secrets, so they move to the main names, or the old App's values still in +// force are named. +async function pendingKey(ctx: Ctx, pending: PendingGithubApp): Promise { + const { d, deps, fresh } = ctx; + const read = (name: string) => + deps.resolver.keychain?.get(entryName(d.deployment, name)).catch(() => undefined); + const file = join(deps.cwd, `${pending.slug}.private-key.pem`); + if (existsSync(file)) { + const pem = readPrivateKeyFile(file, "the new App's key file"); + deps.log(`The new App's private key: from ${file}`); + fresh.key = { pem, path: file }; + fresh.keyFromPendingFile = true; + // Its webhook secret was shown, not saved. + fresh.webhookShown = true; + if ((await read(KEY + PENDING)) || (await read(HOOK_SECRET + PENDING))) { + const scope = deploymentScope(d.deployment); + deps.log( + `The saved ${KEY}${PENDING} and ${HOOK_SECRET}${PENDING} for ${scope} are from an earlier App: remove them with bun run secrets rm ${KEY}${PENDING}${ctx.flag} and bun run secrets rm ${HOOK_SECRET}${PENDING}${ctx.flag}`, + ); + } + return pem; + } + const webhook = await read(HOOK_SECRET + PENDING); + if (webhook) { + fresh.webhook = webhook; + fresh.savedWebhook = true; + } else fresh.webhookShown = true; + const saved = await read(KEY + PENDING); + if (saved) { + deps.log( + `The new App's private key: from the system keychain (${deploymentScope(d.deployment)})`, + ); + fresh.key = { pem: saved }; + fresh.savedKey = true; + return saved; + } + throw new SetupError( + `No private key for the new App ${pending.slug} here (no ${KEY}${PENDING} in the system keychain, no ${file}): adopt it again with bun run github:setup${ctx.flag} --app-id ${pending.appId} --private-key-file , or register another one with bun run github:setup${ctx.flag} --reregister`, + ); +} + +// Puts the new App's saved secrets under the main names: only those this +// run saved or found saved for the App being installed, never another App's +// leftovers. All or nothing: a failure puts back what was set. Returns how to +// undo it (if writing the config fails next), how to finish it (removing the +// _PENDING entries), and which old values it replaced. +async function movePendingSecrets( + ctx: Ctx, + old: { slug: string; enabled: boolean }, +): Promise<{ undo(): Promise; finish(): Promise; replaced: string[] }> { + const { d, deps, fresh } = ctx; + const k = deps.resolver.keychain; + const none = { undo: async () => {}, finish: async () => {}, replaced: [] }; + if (!k) return none; + const entry = (name: string) => entryName(d.deployment, name); + const read = (name: string) => k.get(entry(name)).catch(() => undefined); + const moves: { name: string; value: string; old: string | undefined }[] = []; + if (fresh.savedKey && fresh.key) { + moves.push({ name: KEY, value: fresh.key.pem, old: await read(KEY) }); + } + if (fresh.savedWebhook && fresh.webhook) { + moves.push({ name: HOOK_SECRET, value: fresh.webhook, old: await read(HOOK_SECRET) }); + } + const done: typeof moves = []; + const undo = async () => { + for (const m of done) { + try { + if (m.old === undefined) await k.delete(entry(m.name)); + else await k.set(entry(m.name), m.old); + } catch { + // best effort; the caller's message says what is left + } + } + }; + try { + for (const m of moves) { + await k.set(entry(m.name), m.value); + done.push(m); + } + } catch (err) { + await undo(); + throw new SetupError( + `Moving the new App's secrets in the system keychain failed: ${errorText(err)}. Nothing changed: the App ${old.slug} ${old.enabled ? "keeps working" : "stays disabled"} and the new App's secrets stay saved as ${KEY}${PENDING} and ${HOOK_SECRET}${PENDING}; run bun run github:setup${ctx.flag} again to finish`, + ); + } + const finish = async () => { + for (const m of moves) { + try { + await k.delete(entry(m.name + PENDING)); + } catch (err) { + deps.log( + `Removing ${m.name}${PENDING} from the system keychain failed: ${errorText(err)}; it is no longer used`, + ); + } + } + }; + return { undo, finish, replaced: moves.filter((m) => m.old !== undefined).map((m) => m.name) }; +} + +// The saved secrets of a pending App that is being dropped, at the user's +// word. Returns whether any was removed. +async function dropPendingSecrets(d: GithubDeployment, deps: SetupDeps): Promise { + const k = deps.resolver.keychain; + let removed = false; + for (const name of [KEY + PENDING, HOOK_SECRET + PENDING]) { + try { + if (await k?.delete(entryName(d.deployment, name))) removed = true; + } catch (err) { + deps.log(`Removing ${name} from the system keychain failed: ${errorText(err)}`); + } + } + return removed; +} + +const deleteLink = (slug: string, org?: string): string => + `${appSettingsUrl(slug, org ? { login: org, type: "Organization" } : undefined)}/advanced`; + +// One installation, the one configured, or the one whose account owns the +// repos; otherwise the user picks (with a terminal). +async function pickInstallation( + ctx: Ctx, + list: Installation[], + preferred?: number, +): Promise { + const { d, deps } = ctx; + const configured = list.find((i) => i.id === preferred); + if (configured) return configured; + if (list.length === 1 && list[0]) return list[0]; + const owners = new Set(d.repos.map((r) => r.owner.toLowerCase())); + const owning = list.filter((i) => owners.has(i.account.login.toLowerCase())); + if (owning.length === 1 && owning[0]) return owning[0]; + if (!deps.resolver.isTTY) { + throw new SetupError( + `The App is installed on several accounts (${list.map((i) => i.account.login).join(", ")}): run bun run github:setup${ctx.flag} in a terminal to choose one`, + ); + } + deps.log("The App is installed on several accounts:"); + list.forEach((i, n) => { + deps.log(` ${n + 1}. ${i.account.login} (${i.account.type}, installation ${i.id})`); + }); + const answer = (await deps.promptLine(`Which one does devbox use? [1-${list.length}]: `)).trim(); + const choice = /^\d+$/.test(answer) ? list[Number(answer) - 1] : undefined; + if (!choice) throw new SetupError("No installation chosen: run bun run github:setup again"); + return choice; +} + +// Waits for the App's installation: listed every few seconds (sooner on +// Enter) for up to 15 minutes. A new App is shown its install page first. +async function findInstallation( + ctx: Ctx, + app: App, + key: AppKey, + isNew: boolean, +): Promise { + const { d, deps } = ctx; + const keyFix = savedKeyFix(d.deployment.kind === "k8s" ? d.configPath : undefined); + const list = () => callWithAppKey(() => ctx.api.listInstallations(key), keyFix); + let found = isNew ? [] : await list(); + if (found.length === 0) { + const url = `${app.htmlUrl}/installations/new`; + deps.log("Install the App on the repos devbox works on:"); + deps.log(` ${url}`); + deps.openUrl(url); + deps.log("Waiting for the installation (press Enter to check now)..."); + const enter = deps.enter(); + const start = deps.now().getTime(); + try { + for (;;) { + found = await list(); + if (found.length > 0) break; + if (deps.now().getTime() - start >= INSTALL_WAIT_MS) { + throw new SetupError( + `The App isn't installed yet: install it from ${url}, then run bun run github:setup again`, + ); + } + await Promise.race([deps.sleep(POLL_MS), enter.next()]); + } + } finally { + enter.stop(); + } + } + const preferred = d.githubApp?.appId === app.appId ? d.githubApp.installationId : undefined; + const chosen = await pickInstallation(ctx, found, preferred); + deps.log(`The App is installed on ${chosen.account.login} (installation ${chosen.id})`); + return chosen; +} + +// What env and the keychain will do with the App's secrets at the next apply. +// Compared in memory, never printed. +async function secretLines(ctx: Ctx, appId: number, installationId: number): Promise { + const { d, deps, fresh, tokens, flag } = ctx; + const { env, keychain } = deps.resolver; + const apply = d.applyCommand; + const scope = deploymentScope(d.deployment); + const earlier = (name: string) => + `The saved ${name} for ${scope} is from an earlier App or key: remove it with bun run secrets rm ${name}${flag}`; + // A keychain that can't be read here holds nothing for these lines. + const savedValue = (name: string) => + keychain?.get(entryName(d.deployment, name)).catch(() => undefined); + const lines: string[] = []; + if (tokens === "external" && fresh.keyFile) { + lines.push( + `Store this key where your token tooling reads it (for example the External Secrets GithubAccessToken generator: appID ${appId}, installID ${installationId}), then delete the file`, + ); + } + if (tokens === "devbox" && fresh.key && !d.byoKeySecret) { + const file = env[KEY_FILE] || undefined; + const sameFile = file !== undefined && fresh.key.path === resolve(deps.cwd, file); + if (file === undefined && fresh.keyFromPendingFile && fresh.key.path) { + // The run that wrote the file named it with "once the new App is + // installed". The file wins over a GITHUB_APP_PRIVATE_KEY in env. + lines.push(`Set ${KEY_FILE}=${fresh.key.path} before ${apply}`); + } else if (file !== undefined && !sameFile) { + lines.push( + `${KEY_FILE} is set in your environment from before this setup: set it to the new key's path, or unset it, before ${apply}`, + ); + } else if (file === undefined && env[KEY]) { + lines.push( + `${KEY} is set in your environment from before this setup: set it to the new key, or unset it, before ${apply}`, + ); + } + if (!fresh.savedKey && (await savedValue(KEY))) { + lines.push(earlier(KEY)); + } + } + if (!d.byoIntegrationsSecret) { + const value = env[HOOK_SECRET] || undefined; + if (fresh.webhook !== undefined) { + if (value !== undefined && value !== fresh.webhook) { + lines.push( + `${HOOK_SECRET} is set in your environment from before this setup: set it to the new value, or unset it, before ${apply}`, + ); + } + if (!fresh.savedWebhook && (await savedValue(HOOK_SECRET))) { + lines.push(earlier(HOOK_SECRET)); + } + } else if (fresh.webhookShown) { + // The new App's secret was shown once; what is here is the old App's. + if (value !== undefined) { + lines.push( + `${HOOK_SECRET} is set in your environment from before this setup: set it to the new value, or unset it, before ${apply}`, + ); + } else if (await savedValue(HOOK_SECRET)) { + lines.push(earlier(HOOK_SECRET)); + } else { + lines.push(`Set ${HOOK_SECRET} in your environment before ${apply}, or it asks for it`); + } + } else if (value !== undefined) { + lines.push(`${apply} will use ${HOOK_SECRET} from your environment`); + } else if (!(await savedValue(HOOK_SECRET))) { + lines.push(`Set ${HOOK_SECRET} in your environment before ${apply}, or it asks for it`); + } + } + return lines; +} + +export async function setup( + d: GithubDeployment, + opts: SetupOptions, + deps: SetupDeps, +): Promise { + const { log, resolver } = deps; + const flag = d.deployment.kind === "k8s" ? ` --target ${d.configPath}` : ""; + const existing = d.githubApp; + // A new App waiting for its install, even if the block was disabled since: + // a plain run installs it, with the public URL it was set up with. + const pending = existing?.slug !== undefined ? existing.pending : undefined; + const resuming = opts.appId === undefined && !opts.reregister && pending !== undefined; + const publicUrl = await resolvePublicUrl( + d, + { publicUrl: opts.publicUrl ?? (resuming ? pending?.publicUrl : undefined) }, + deps, + ); + + // Headless: the URL-parameter link, then the adopt path. + if (opts.printRegistrationLink) { + log(registrationLink({ ...(opts.org ? { org: opts.org } : {}), name: opts.name, publicUrl })); + log( + `Create the App, generate a private key, then run: bun run github:setup${flag} --app-id --private-key-file `, + ); + return; + } + log(`Public URL: ${publicUrl}`); + + const tokens = opts.externalTokens ? "external" : (existing?.tokens ?? "devbox"); + const adopting = opts.appId !== undefined; + // A working App stays configured while its replacement waits for its install. + const working = existing?.enabled === true && existing.slug !== undefined ? existing : undefined; + // The App a new one replaces: the working one, or the one a pending App + // was registered to replace. + const replaced = working ?? (pending !== undefined ? existing : undefined); + const registering = + !adopting && + !resuming && + (existing?.appId === undefined || existing.slug === undefined || opts.reregister); + // Adopting another App over the working one replaces it the same way. + const adoptingAnother = adopting && replaced !== undefined && replaced.appId !== opts.appId; + const replacing = (registering && working !== undefined) || resuming || adoptingAnother; + // A new App would take the place of one already waiting for its install, + // or the configured App is adopted again (a new key) in its place. + const adoptingConfigured = adopting && !adoptingAnother; + const dropping = + pending !== undefined && (registering || (adopting && pending.appId !== opts.appId)); + if (pending && dropping) { + const installUrl = `https://github.com/apps/${pending.slug}/installations/new`; + if (!resolver.isTTY) { + throw new SetupError( + `A new App ${pending.slug} is already waiting for its install: install it from ${installUrl} and run bun run github:setup${flag}, or remove it with bun run github:disable${flag} --forget`, + ); + } + const another = await resolver.confirm( + adoptingConfigured + ? `A new App ${pending.slug} is waiting for its install; adopting App ${opts.appId} again drops it. Drop it? [y/N] ` + : `A new App ${pending.slug} is already waiting for its install. Replace it with another one? [y/N] `, + { defaultYes: false }, + ); + if (!another) { + throw new SetupError( + `Nothing was changed: install ${pending.slug} from ${installUrl}, then run bun run github:setup${flag}`, + ); + } + log( + `The App ${pending.slug} is no longer used: delete it on GitHub: ${deleteLink(pending.slug, pending.org)}`, + ); + if (!resolver.keychain) { + log( + `--no-keychain left any saved secrets of the App ${pending.slug} in the system keychain: remove them with bun run secrets rm ${KEY}${PENDING}${flag} and bun run secrets rm ${HOOK_SECRET}${PENDING}${flag}`, + ); + } else if (await dropPendingSecrets(d, deps)) { + log(`Removed the saved secrets of the App ${pending.slug} from the system keychain`); + } + // Nothing else rewrites the block before the install when the configured + // App is adopted again: the dropped App goes from the config now. + if (adoptingConfigured) { + writeConfigKeys(d.configPath, { "integrations.githubApp.pending": undefined }); + } + } + + if ((registering || adopting) && !resolver.isTTY) { + throw new SetupError( + "registering or adopting a GitHub App needs a terminal: run bun run github:setup in one", + ); + } + const tokenFile = + tokens === "external" && d.deployment.kind === "compose" + ? (existing?.tokenFile ?? (await askTokenFile(d, deps))) + : undefined; + const ctx: Ctx = { + d, + opts, + deps, + api: appApi({ fetch: deps.fetch, apiBase: githubApiBaseUrl(), now: deps.now }), + publicUrl, + tokens, + ...(tokenFile !== undefined ? { tokenFile } : {}), + flag, + fresh: {}, + replacing, + }; + + // The App, and its key when there is one here. + let app: App; + let key: AppKey | undefined; + if (opts.appId !== undefined) { + if (adoptingAnother && working) { + log(`The App ${working.slug} keeps working until the new App is installed.`); + } + ({ app, key } = await adoptApp(ctx, opts.appId)); + } else if (registering) { + if (working) log(`The App ${working.slug} keeps working until the new App is installed.`); + ({ app, key } = await registerApp(ctx)); + } else if (pending) { + // The new App's install, after an earlier run stopped waiting for it. + const pem = await pendingKey(ctx, pending); + key = { appId: pending.appId, pem }; + // GitHub's slug: the App may have been renamed before its install. + const info = await verifyApp({ appId: pending.appId }, pem, deps.fetch, { + target: flag ? d.configPath : undefined, + }); + if (info.slug !== pending.slug) { + log( + `The new App was renamed on GitHub: its slug is now ${info.slug} (was ${pending.slug}); mention it as @${info.slug}`, + ); + } + app = { appId: pending.appId, ...info }; + } else { + const appId = existing?.appId as number; + const slug = existing?.slug as string; + app = { appId, slug, htmlUrl: `https://github.com/apps/${slug}` }; + const found = await resolvePrivateKey(resolver); + if (found) { + key = { appId, pem: found.pem }; + // GitHub's slug, not the saved one: renaming the App changes it, and + // this run is what records the new one. + const info = await verifyApp({ appId }, found.pem, deps.fetch, { + target: flag ? d.configPath : undefined, + }); + if (info.slug !== slug) { + log( + `The App was renamed on GitHub: its slug is now ${info.slug} (was ${slug}); mention it as @${info.slug}`, + ); + } + app = { appId, ...info }; + } + } + const isNew = registering || adopting || resuming; + const sameApp = existing?.appId === app.appId; + + // Install. + let installationId: number; + let account: AppAccount | undefined; + if (key) { + const found = await findInstallation(ctx, app, key, registering); + installationId = found.id; + account = found.account; + } else { + log(NO_KEY); + if (!sameApp || existing?.installationId === undefined) { + throw new SetupError( + `The App isn't installed yet, and there is no private key for it here: set ${KEY_FILE} to its path, then run bun run github:setup again`, + ); + } + installationId = existing.installationId; + } + + // The bot user, for the commit identity. + const botUserId = + sameApp && existing?.slug === app.slug && existing.botUserId !== undefined + ? existing.botUserId + : await ctx.api.getBotUserId(app.slug); + + // Repo coverage: named, not a failure. + if (key) { + const pem = key.pem; + const keyFix = savedKeyFix(flag ? d.configPath : undefined); + const uncovered = await callWithAppKey( + () => checkCoverage({ appId: app.appId, installationId }, pem, d.repos, deps.fetch), + keyFix, + ); + const where = installationSettingsUrl({ slug: app.slug, installationId }, account); + for (const repo of uncovered) { + log(`${repo} is not in the App's installation: add it at ${where}`); + } + } + + // A new public URL: the App's webhook follows when setup holds the key. + let hookUpdated = false; + if ( + !isNew && + key && + tokens === "devbox" && + d.publicUrl !== undefined && + d.publicUrl !== publicUrl + ) { + try { + await ctx.api.updateHookConfig(key, { url: hookUrl(publicUrl) }); + hookUpdated = true; + } catch (err) { + if (!(err instanceof GithubAppError)) throw err; + log(`Updating the GitHub App's webhook URL failed: ${err.message}`); + } + } + + // Write. Without a key here GitHub was not asked, so the saved owner stands. + const org = app.owner + ? app.owner.type === "Organization" + ? app.owner.login + : undefined + : sameApp + ? existing?.org + : undefined; + const keys: Record = { + "integrations.githubApp": { + enabled: true, + appId: app.appId, + slug: app.slug, + installationId, + botUserId, + tokens, + tokenScope: existing?.tokenScope ?? "repos", + ciResults: existing?.ciResults ?? true, + ...(tokenFile !== undefined ? { tokenFile } : {}), + ...(org !== undefined ? { org } : {}), + }, + publicUrl, + }; + let trusted: string | undefined; + if (account && d.trust.trustedOrgs.length === 0 && d.trust.trustedUsers.length === 0) { + const list = account.type === "Organization" ? "trustedOrgs" : "trustedUsers"; + const parent = d.deployment.kind === "k8s" ? "values.github" : "github"; + keys[`${parent}.${list}`] = [account.login]; + trusted = `Trusted: ${account.login} (edit ${d.configPath} to change)`; + } + const moved = + replacing && replaced + ? await movePendingSecrets(ctx, { + slug: replaced.slug as string, + enabled: replaced.enabled, + }) + : undefined; + // The whole block is replaced: a pending App becomes the App. + try { + writeConfigKeys(d.configPath, keys); + } catch (err) { + await moved?.undo(); + throw err; + } + await moved?.finish(); + if (trusted) log(trusted); + log(`Wrote integrations.githubApp to ${d.configPath}`); + if (replacing && replaced) { + log( + `The App ${replaced.slug} is no longer used: delete it on GitHub: ${deleteLink(replaced.slug as string, replaced.org)}`, + ); + } + if (moved && moved.replaced.length > 0) { + log( + moved.replaced.length === 2 + ? "The old App's saved key and webhook secret were replaced by the new App's" + : moved.replaced[0] === KEY + ? "The old App's saved key was replaced by the new App's" + : "The old App's saved webhook secret was replaced by the new App's", + ); + } + // A new or adopted App already has this URL. + for (const line of publicUrlChangeLines( + d.publicUrl, + publicUrl, + { + linear: d.linear, + ...(isNew + ? {} + : { + githubApp: { + tokens, + slug: app.slug, + hookUpdated, + ...(org !== undefined ? { org } : {}), + }, + }), + }, + "github", + )) { + log(line); + } + + // Secrets. + for (const line of await secretLines(ctx, app.appId, installationId)) log(line); + + // Next step. + log(`Next: ${d.applyCommand}`); + if (d.deployment.kind === "k8s") { + const v = "integrations.githubApp"; + const block = keys[v] as Record; + log( + `With GitOps, set these chart values instead: ${[ + `${v}.enabled: true`, + `${v}.appId: ${app.appId}`, + `${v}.installationId: ${installationId}`, + `${v}.botLogin: ${app.slug}`, + `${v}.botUserId: ${botUserId}`, + `${v}.tokens: ${tokens}`, + `${v}.tokenScope: ${block.tokenScope}`, + `${v}.ciResults: ${block.ciResults}`, + ].join(", ")}`, + ); + } + log(`Then check GitHub with: ${d.statusCommand}`); +} diff --git a/control-plane/scripts/github/status.test.ts b/control-plane/scripts/github/status.test.ts new file mode 100644 index 0000000..a431cfa --- /dev/null +++ b/control-plane/scripts/github/status.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from "bun:test"; +import { type Deployment, MemoryKeychain } from "../lib/keychain"; +import type { PeekDeps } from "../lib/secrets"; +import type { GithubDeployment } from "./deployment"; +import { status } from "./status"; + +const COMPOSE: Deployment = { kind: "compose" }; +const K8S: Deployment = { kind: "k8s", context: "kind-devbox", namespace: "devbox" }; +const APP = { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 7, + botUserId: 1234567, + tokens: "devbox" as const, + tokenScope: "repos" as const, + ciResults: true, +}; +const PUBLIC = "https://hooks.example.com"; +const PROBE_URL = `${PUBLIC}/webhook/github`; +const NOW = new Date("2026-10-19T12:00:00Z"); +const BODY = { + token: { + source: "file", + expiresAt: "2026-10-19T12:31:00Z", + readAt: "2026-10-19T11:32:10Z", + lastError: null, + }, + webhooks: { enabled: true, lastWebhookAt: "2026-10-19T11:20:02Z", ciResults: true }, +}; +const unsigned = () => Response.json({ error: "invalid signature" }, { status: 401 }); + +async function run(o: { + app?: Partial> & { + tokens?: "devbox" | "external"; + tokenFile?: string; + org?: string; + pending?: { appId: number; slug: string; org?: string }; + }; + none?: boolean; + deployment?: Deployment; + publicUrl?: string | null; + api?: () => Promise; + fetch?: (url: string, init?: RequestInit) => Promise; + byoGithub?: string; + byoKey?: string; + byoIntegrations?: string; + fromApiIngress?: boolean; + env?: Record; + keychain?: Record; + tokenFileExists?: boolean; +}): Promise<{ log: string[]; probed: string[] }> { + const log: string[] = []; + const probed: string[] = []; + const deployment = o.deployment ?? COMPOSE; + const k8s = deployment.kind === "k8s"; + const d: GithubDeployment = { + deployment, + configPath: "devbox.config.json", + githubApp: o.none ? undefined : { ...APP, ...o.app }, + linear: false, + repos: [], + trust: { trustedOrgs: ["example-org"], trustedUsers: [] }, + ...(o.publicUrl === null ? {} : { publicUrl: o.publicUrl ?? PUBLIC }), + applyCommand: k8s ? "bun run k8s:kind" : "bun run up", + statusCommand: "bun run github:status", + ...(o.byoGithub ? { byoGithubSecret: o.byoGithub } : {}), + ...(o.byoKey ? { byoKeySecret: o.byoKey } : {}), + ...(o.byoIntegrations ? { byoIntegrationsSecret: o.byoIntegrations } : {}), + ...(o.fromApiIngress ? { fromApiIngress: true } : {}), + api: o.api ?? (async () => Response.json(BODY)), + }; + const scope = k8s ? "k8s:kind-devbox/devbox" : "compose"; + const resolver = { + env: o.env ?? { GITHUB_WEBHOOK_SECRET: "whsec" }, + keychain: new MemoryKeychain( + o.keychain ?? { [`${scope}/GITHUB_APP_PRIVATE_KEY`]: "-----BEGIN-----" }, + ), + deployment, + run: async () => ({ code: 1, stdout: "" }), + } as unknown as PeekDeps; + await status(d, { + resolver, + now: () => NOW, + fileExists: () => o.tokenFileExists === true, + fetch: async (url, init) => { + probed.push(url); + return (o.fetch ?? unsigned)(url, init); + }, + log: (l) => log.push(l), + }); + return { log, probed }; +} + +const SETTINGS_URL = "https://github.com/settings/apps/example-devbox"; +const settings = ( + scope: string, + o: { tokens?: string; settingsUrl?: string; publicUrl?: string } = {}, +) => [ + `GitHub App (${scope})`, + " appId 123", + " slug example-devbox", + ` settings ${o.settingsUrl ?? SETTINGS_URL}`, + " installationId 7", + " botUserId 1234567", + ` tokens ${o.tokens ?? "devbox"}`, + " tokenScope repos", + " ciResults true", + " trustedOrgs example-org", + " trustedUsers none", + ` publicUrl ${o.publicUrl ?? "https://hooks.example.com"}`, +]; +const KEY_LINE = " GITHUB_APP_PRIVATE_KEY keychain"; +const SECRET_LINE = " GITHUB_WEBHOOK_SECRET env"; +const HEAD = [...settings("compose"), KEY_LINE, SECRET_LINE]; +const CP = [ + "Control plane: token expires 2026-10-19T12:31:00Z (refreshed 2026-10-19T11:32:10Z)", + "Control plane: last webhook from GitHub 2026-10-19T11:20:02Z", +]; +const OK_LINE = `Webhook URL ${PROBE_URL}: reaches devbox with GitHub on`; +const MINTER_COMPOSE = "docker compose -f control-plane/docker-compose.yml logs github-token"; +const MINTER_K8S = "kubectl --context kind-devbox -n devbox logs deploy/devbox-github-token"; + +describe("github status", () => { + it("no App: the personal token and its source", async () => { + const r = await run({ none: true, env: { GITHUB_TOKEN: "ghp_secret" } }); + expect(r.log).toEqual([ + "GitHub uses a personal token for compose (no App). Webhooks need an App: bun run github:setup", + " GITHUB_TOKEN env", + ]); + expect(r.probed).toEqual([]); + const missing = await run({ none: true, env: {}, keychain: {} }); + expect(missing.log[1]).toBe(" GITHUB_TOKEN missing"); + const saved = await run({ + none: true, + env: {}, + keychain: { "compose/GITHUB_TOKEN": "ghp_secret" }, + }); + expect(saved.log[1]).toBe(" GITHUB_TOKEN keychain"); + }); + + it("no App on k8s with the user's own token Secret: not read", async () => { + const r = await run({ none: true, deployment: K8S, byoGithub: "my-token" }); + expect(r.log).toEqual([ + "GitHub uses a personal token for k8s:kind-devbox/devbox (no App). Webhooks need an App: bun run github:setup", + ' GITHUB_TOKEN from Secret "my-token" (not read)', + ]); + }); + + it("disabled", async () => { + const r = await run({ app: { enabled: false } }); + expect(r.log).toEqual([ + "The GitHub App is disabled for compose (enabled: false in devbox.config.json); GitHub uses a personal token. Turn it on again: bun run github:setup, or set enabled to true, then bun run up.", + ]); + expect(r.probed).toEqual([]); + }); + + it("configured: settings, sources, control plane, probe ok", async () => { + const r = await run({}); + expect(r.log).toEqual([...HEAD, ...CP, OK_LINE]); + expect(r.probed).toEqual([PROBE_URL]); + }); + + it("an organization's App links the organization's settings page", async () => { + const r = await run({ app: { org: "example-org" } }); + expect(r.log).toEqual([ + ...settings("compose", { + settingsUrl: "https://github.com/organizations/example-org/settings/apps/example-devbox", + }), + KEY_LINE, + SECRET_LINE, + ...CP, + OK_LINE, + ]); + }); + + it("a new App waiting for its install: the pending line", async () => { + const r = await run({ app: { pending: { appId: 124, slug: "new-devbox" } } }); + expect(r.log).toEqual([ + ...settings("compose"), + " pending App new-devbox (id 124) waits for its install: run bun run github:setup", + KEY_LINE, + SECRET_LINE, + ...CP, + OK_LINE, + ]); + const k8s = await run({ + deployment: K8S, + app: { pending: { appId: 124, slug: "new-devbox" } }, + keychain: {}, + }); + expect(k8s.log[12]).toBe( + " pending App new-devbox (id 124) waits for its install: run bun run github:setup --target devbox.config.json", + ); + }); + + it("the user's Secrets: sources say from Secret (not read)", async () => { + const r = await run({ + deployment: K8S, + byoKey: "my-key", + byoIntegrations: "my-integrations", + }); + expect(r.log).toEqual([ + ...settings("k8s:kind-devbox/devbox"), + ' GITHUB_APP_PRIVATE_KEY from Secret "my-key" (not read)', + ' GITHUB_WEBHOOK_SECRET from Secret "my-integrations" (not read)', + ...CP, + OK_LINE, + ]); + }); + + it("missing sources, and a key file in the environment", async () => { + const missing = await run({ env: {}, keychain: {} }); + expect(missing.log).toEqual([ + ...settings("compose"), + " GITHUB_APP_PRIVATE_KEY missing", + " GITHUB_WEBHOOK_SECRET missing", + ...CP, + OK_LINE, + ]); + const file = await run({ env: { GITHUB_APP_PRIVATE_KEY_FILE: "/k.pem" }, keychain: {} }); + expect(file.log).toEqual([ + ...settings("compose"), + " GITHUB_APP_PRIVATE_KEY env", + " GITHUB_WEBHOOK_SECRET missing", + ...CP, + OK_LINE, + ]); + }); + + it("external tokens on Compose: the token file, present or missing; no key line", async () => { + const ext = { tokens: "external" as const, tokenFile: "/home/me/gh-token" }; + const external = settings("compose", { tokens: "external" }); + const present = await run({ app: ext, tokenFileExists: true }); + expect(present.log).toEqual([ + ...external, + " tokenFile /home/me/gh-token (exists; not read)", + SECRET_LINE, + ...CP, + OK_LINE, + ]); + const absent = await run({ app: ext, tokenFileExists: false }); + expect(absent.log).toEqual([ + ...external, + " tokenFile /home/me/gh-token (missing; not read)", + SECRET_LINE, + ...CP, + OK_LINE, + ]); + }); + + it("control plane unreachable: the probe still runs", async () => { + const r = await run({ + api: async () => { + throw new Error("connect ECONNREFUSED"); + }, + }); + expect(r.log).toEqual([ + ...HEAD, + "Control plane: not reachable (connect ECONNREFUSED)", + OK_LINE, + ]); + }); + + it("an expired token names the minter's logs, per deployment", async () => { + const expired = async () => + Response.json({ ...BODY, token: { ...BODY.token, expiresAt: "2026-10-19T11:59:00Z" } }); + const lines = (minter: string) => [ + `Control plane: the token has expired (2026-10-19T11:59:00Z): check the minter's logs: ${minter}`, + "Control plane: last webhook from GitHub 2026-10-19T11:20:02Z", + OK_LINE, + ]; + const compose = await run({ api: expired }); + expect(compose.log).toEqual([...HEAD, ...lines(MINTER_COMPOSE)]); + const k8s = await run({ api: expired, deployment: K8S }); + expect(k8s.log).toEqual([ + ...settings("k8s:kind-devbox/devbox"), + KEY_LINE, + SECRET_LINE, + ...lines(MINTER_K8S), + ]); + }); + + it("an expired token with external tokens does not name a minter", async () => { + const r = await run({ + app: { tokens: "external", tokenFile: "/home/me/gh-token" }, + tokenFileExists: true, + api: async () => + Response.json({ ...BODY, token: { ...BODY.token, expiresAt: "2026-10-19T11:59:00Z" } }), + }); + expect(r.log.slice(-3)).toEqual([ + "Control plane: the token has expired (2026-10-19T11:59:00Z): check the tooling that writes it", + "Control plane: last webhook from GitHub 2026-10-19T11:20:02Z", + OK_LINE, + ]); + }); + + it("no expiry known, last error, no webhook yet", async () => { + const r = await run({ + api: async () => + Response.json({ + token: { source: "env", expiresAt: null, readAt: null, lastError: "unreadable" }, + webhooks: { enabled: true, lastWebhookAt: null, ciResults: true }, + }), + }); + expect(r.log).toEqual([ + ...HEAD, + "Control plane: token from env, no expiry known", + "Last GitHub token error: unreadable", + "Control plane: last webhook from GitHub none yet", + OK_LINE, + ]); + }); + + it("webhooks not on in the control plane", async () => { + const r = await run({ + api: async () => Response.json({ ...BODY, webhooks: { enabled: false } }), + }); + expect(r.log).toEqual([ + ...HEAD, + CP[0] as string, + "Control plane: GitHub webhooks are not on there yet. Run: bun run up", + OK_LINE, + ]); + }); + + it("an HTTP error from the control plane", async () => { + const r = await run({ api: async () => new Response("no", { status: 500 }) }); + expect(r.log).toEqual([...HEAD, "Control plane: not reachable (HTTP 500)", OK_LINE]); + }); + + it("probe 404 / no answer / VPN hint / another answer", async () => { + const notFound = await run({ fetch: async () => new Response("nope", { status: 404 }) }); + expect(notFound.log).toEqual([ + ...HEAD, + ...CP, + `the webhook URL ${PROBE_URL} got 404: something answered, but not devbox with GitHub on. Check that the tunnel or proxy forwards to the control plane (port 3000 on Compose), that integrations.githubApp.enabled is true and applied, or restart the control plane`, + ]); + const down = async () => { + throw new Error("ECONNREFUSED"); + }; + const noAnswer = await run({ fetch: down }); + expect(noAnswer.log).toEqual([ + ...HEAD, + ...CP, + `the webhook URL ${PROBE_URL} got no answer (ECONNREFUSED): the public URL does not reach devbox. Is the tunnel down, or the Ingress or DNS not ready yet?`, + ]); + const vpn = await run({ fetch: down, fromApiIngress: true }); + expect(vpn.log).toEqual([ + ...HEAD, + ...CP, + `the webhook URL ${PROBE_URL} got no answer (ECONNREFUSED): the public URL does not reach devbox. Is the tunnel down, or the Ingress or DNS not ready yet? The URL comes from ingress.api, which may be reachable only over a VPN: enable a public ingress.webhooks in the target file`, + ]); + const wrong = await run({ fetch: async () => new Response("hi", { status: 200 }) }); + expect(wrong.log).toEqual([...HEAD, ...CP, `the webhook URL ${PROBE_URL} got 200: hi`]); + }); + + it("no publicUrl: no probe", async () => { + const r = await run({ publicUrl: null }); + expect(r.log).toEqual([ + ...settings("compose", { publicUrl: "not set" }), + KEY_LINE, + SECRET_LINE, + ...CP, + ]); + expect(r.probed).toEqual([]); + }); + + it("never prints a secret value", async () => { + const r = await run({ + env: { GITHUB_WEBHOOK_SECRET: "whsec-VALUE" }, + keychain: { "compose/GITHUB_APP_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----" }, + }); + expect(r.log).toEqual([...HEAD, ...CP, OK_LINE]); + }); +}); diff --git a/control-plane/scripts/github/status.ts b/control-plane/scripts/github/status.ts new file mode 100644 index 0000000..c35e34c --- /dev/null +++ b/control-plane/scripts/github/status.ts @@ -0,0 +1,151 @@ +// `bun run github:status`: what is configured, where each secret would come +// from, what the running control plane reports and whether the webhook URL +// reaches it. Read-only, never prompts, never reads a Secret or prints a +// secret value; a control plane or URL that cannot be reached is reported, +// not a failure. +import { existsSync } from "node:fs"; +import { appSettingsUrl } from "../lib/githubApp"; +import { peekPrivateKey } from "../lib/githubAppKey"; +import { deploymentScope } from "../lib/keychain"; +import { errorMessage } from "../lib/knownError"; +import { probeWebhook } from "../lib/probe"; +import { type PeekDeps, peekSecret } from "../lib/secrets"; +import type { Fetch } from "../up/preflight"; +import type { GithubDeployment } from "./deployment"; + +export const STATUS_PATH = "/api/integrations/github"; + +export type GithubStatusBody = { + token?: { + source?: string; + expiresAt?: string | null; + readAt?: string | null; + lastError?: string | null; + }; + webhooks?: { enabled?: boolean; lastWebhookAt?: string | null; ciResults?: boolean }; +}; + +export type StatusDeps = { + resolver: PeekDeps; + fetch: Fetch; + now: () => Date; + fileExists?: (path: string) => boolean; + log: (line: string) => void; +}; + +async function readStatus( + d: GithubDeployment, +): Promise<{ body: GithubStatusBody } | { error: string }> { + try { + const res = await d.api(STATUS_PATH); + if (!res.ok) { + await res.body?.cancel(); + return { error: `HTTP ${res.status}` }; + } + return { body: (await res.json()) as GithubStatusBody }; + } catch (err) { + return { error: errorMessage(err) }; + } +} + +// Where to read the minter's output. +function minterLogs(d: GithubDeployment): string { + return d.deployment.kind === "k8s" + ? `kubectl --context ${d.deployment.context} -n ${d.deployment.namespace} logs deploy/devbox-github-token` + : "docker compose -f control-plane/docker-compose.yml logs github-token"; +} + +export async function status(d: GithubDeployment, deps: StatusDeps): Promise { + const { log, resolver } = deps; + const scope = deploymentScope(d.deployment); + const app = d.githubApp; + if (!app) { + log( + `GitHub uses a personal token for ${scope} (no App). Webhooks need an App: bun run github:setup`, + ); + const where = d.byoGithubSecret + ? `from Secret "${d.byoGithubSecret}" (not read)` + : await peekSecret("GITHUB_TOKEN", resolver); + log(` GITHUB_TOKEN ${where}`); + return; + } + if (!app.enabled) { + log( + `The GitHub App is disabled for ${scope} (enabled: false in ${d.configPath}); GitHub uses a personal token. Turn it on again: bun run github:setup, or set enabled to true, then ${d.applyCommand}.`, + ); + return; + } + + log(`GitHub App (${scope})`); + log(` appId ${app.appId}`); + log(` slug ${app.slug}`); + log( + ` settings ${appSettingsUrl(app.slug as string, app.org ? { login: app.org, type: "Organization" } : undefined)}`, + ); + log(` installationId ${app.installationId}`); + log(` botUserId ${app.botUserId}`); + log(` tokens ${app.tokens}`); + log(` tokenScope ${app.tokenScope}`); + log(` ciResults ${app.ciResults}`); + log(` trustedOrgs ${d.trust.trustedOrgs.join(", ") || "none"}`); + log(` trustedUsers ${d.trust.trustedUsers.join(", ") || "none"}`); + log(` publicUrl ${d.publicUrl ?? "not set"}`); + if (app.pending) { + const flag = d.deployment.kind === "k8s" ? ` --target ${d.configPath}` : ""; + log( + ` pending App ${app.pending.slug} (id ${app.pending.appId}) waits for its install: run bun run github:setup${flag}`, + ); + } + if (app.tokens === "external" && app.tokenFile) { + const exists = (deps.fileExists ?? existsSync)(app.tokenFile); + log(` tokenFile ${app.tokenFile} (${exists ? "exists" : "missing"}; not read)`); + } + if (app.tokens === "devbox") { + const where = d.byoKeySecret + ? `from Secret "${d.byoKeySecret}" (not read)` + : await peekPrivateKey(resolver); + log(` GITHUB_APP_PRIVATE_KEY ${where}`); + } + const webhookSecret = d.byoIntegrationsSecret + ? `from Secret "${d.byoIntegrationsSecret}" (not read)` + : await peekSecret("GITHUB_WEBHOOK_SECRET", resolver); + log(` GITHUB_WEBHOOK_SECRET ${webhookSecret}`); + + const r = await readStatus(d); + if ("error" in r) { + log(`Control plane: not reachable (${r.error})`); + } else { + const { token, webhooks } = r.body; + const expiresAt = token?.expiresAt; + if (!expiresAt) { + log(`Control plane: token from ${token?.source ?? "unknown"}, no expiry known`); + } else if (Date.parse(expiresAt) <= deps.now().getTime()) { + const fix = + app.tokens === "devbox" + ? `check the minter's logs: ${minterLogs(d)}` + : "check the tooling that writes it"; + log(`Control plane: the token has expired (${expiresAt}): ${fix}`); + } else { + log( + `Control plane: token expires ${expiresAt}${token?.readAt ? ` (refreshed ${token.readAt})` : ""}`, + ); + } + if (token?.lastError) log(`Last GitHub token error: ${token.lastError}`); + if (webhooks?.enabled === false) { + log(`Control plane: GitHub webhooks are not on there yet. Run: ${d.applyCommand}`); + } else { + log(`Control plane: last webhook from GitHub ${webhooks?.lastWebhookAt ?? "none yet"}`); + } + } + + if (!d.publicUrl) return; + const probe = await probeWebhook(d.publicUrl, "/webhook/github", deps.fetch, { + fromApiIngress: d.fromApiIngress === true, + integration: "GitHub", + }); + log( + probe.ok + ? `Webhook URL ${d.publicUrl}/webhook/github: reaches devbox with GitHub on` + : probe.message, + ); +} diff --git a/control-plane/scripts/harness-sandbox.test.ts b/control-plane/scripts/harness-sandbox.test.ts new file mode 100644 index 0000000..549387e --- /dev/null +++ b/control-plane/scripts/harness-sandbox.test.ts @@ -0,0 +1,311 @@ +import { expect, spyOn, test } from "bun:test"; +import { unlinkSync, writeFileSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Sandbox } from "@alibaba-group/opensandbox"; +import { + acquireLockFile, + createSourceArchive, + type HarnessTarget, + installHarnessSource, + loadSandboxConfig, + runCheckedProcess, + sandboxHarnessTarget, + syncHarnessDependencies, + validateHarnessWorktree, +} from "./harness-sandbox"; + +test("shared configuration leaves identity and lifetime decisions to the caller", () => { + const devTimeout = process.env.DEV_TEST_SANDBOX_TIMEOUT_SECONDS; + const evalTimeout = process.env.EVAL_SANDBOX_TIMEOUT_SECONDS; + try { + // Neither tool-specific setting should be parsed by the shared loader. + process.env.DEV_TEST_SANDBOX_TIMEOUT_SECONDS = "not-an-infrastructure-setting"; + process.env.EVAL_SANDBOX_TIMEOUT_SECONDS = "not-an-infrastructure-setting"; + const config = loadSandboxConfig(); + expect(config).not.toHaveProperty("metadata"); + expect(config).not.toHaveProperty("timeoutSeconds"); + expect(config.daemonEnv.WORKSPACE).toBe("/workspace"); + } finally { + if (devTimeout === undefined) delete process.env.DEV_TEST_SANDBOX_TIMEOUT_SECONDS; + else process.env.DEV_TEST_SANDBOX_TIMEOUT_SECONDS = devTimeout; + if (evalTimeout === undefined) delete process.env.EVAL_SANDBOX_TIMEOUT_SECONDS; + else process.env.EVAL_SANDBOX_TIMEOUT_SECONDS = evalTimeout; + } +}); + +test("source capture includes current source and scripts, excludes metadata, and freezes the bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "harness-source-test-")); + const worktree = join(root, "worktree with spaces"); + const archive = join(root, "source.tar.gz"); + try { + await mkdir(join(worktree, "src/harness/__pycache__"), { recursive: true }); + await mkdir(join(worktree, "src/harness/.git")); + await mkdir(join(worktree, "scripts")); + await writeFile(join(worktree, "src/harness/main.py"), "original source\n"); + await writeFile(join(worktree, "scripts/helper.py"), "new untracked helper\n"); + await writeFile(join(worktree, "src/harness/__pycache__/main.pyc"), "bytecode"); + await writeFile(join(worktree, "src/harness/.git/config"), "git metadata"); + await writeFile(join(worktree, "pyproject.toml"), "image-owned manifest"); + await validateHarnessWorktree(worktree); + await createSourceArchive(worktree, archive); + await writeFile(join(worktree, "src/harness/main.py"), "changed after capture\n"); + const files = await runCheckedProcess(["tar", "-tzf", archive], "Listing test archive"); + expect(files).toContain("src/harness/main.py"); + expect(files).toContain("scripts/helper.py"); + expect(files).not.toContain("__pycache__"); + expect(files).not.toContain(".git"); + expect(files).not.toContain("pyproject.toml"); + expect( + await runCheckedProcess( + ["tar", "-xOzf", archive, "src/harness/main.py"], + "Reading captured source", + ), + ).toBe("original source\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("dependency sync uses the target and image-owned manifests", async () => { + const calls: Array<[string, string, number]> = []; + const target: HarnessTarget = { + copyFile: async () => { + throw new Error("sync must not copy files"); + }, + run: async (command, description, timeout) => { + calls.push([command, description, timeout]); + }, + }; + await syncHarnessDependencies(target); + expect(calls).toEqual([ + [ + "cd /opt/harness && uv sync --frozen --no-dev", + "Synchronizing frozen production dependencies", + 600, + ], + ]); +}); + +test("installation uses the supplied archive and cleans remote staging without deleting caller data", async () => { + const root = await mkdtemp(join(tmpdir(), "harness-install-test-")); + const archive = join(root, "retained source.tar.gz"); + const invocation = join(root, "kubectl.json"); + const kubectl = join(root, "kubectl"); + const previousKubectl = process.env.KUBECTL_BIN; + try { + await writeFile(archive, "retained archive bytes"); + await writeFile( + kubectl, + `#!${process.execPath}\nawait Bun.write(${JSON.stringify(invocation)}, JSON.stringify(process.argv.slice(2)));\n`, + { mode: 0o700 }, + ); + process.env.KUBECTL_BIN = kubectl; + for (const exitCode of [0, 1]) { + const commands: string[] = []; + const timeouts: number[] = []; + const sandbox = { + id: "sandbox-one", + commands: { + run: async (command: string, options: { timeoutSeconds: number }) => { + commands.push(command); + timeouts.push(options.timeoutSeconds); + return { + exitCode: commands.length === 1 ? exitCode : 0, + logs: { stdout: [], stderr: [] }, + }; + }, + }, + } as unknown as Sandbox; + const target = sandboxHarnessTarget(sandbox, "test-namespace"); + if (exitCode) { + await expect(installHarnessSource(target, archive)).rejects.toThrow("remote exit code 1"); + } else { + await installHarnessSource(target, archive); + } + const args = JSON.parse(await readFile(invocation, "utf8")); + expect(args).toEqual([ + "cp", + archive, + expect.stringMatching(/^test-namespace\/sandbox-one-0:\/tmp\/harness-source-.*\.tar\.gz$/), + "-c", + "sandbox", + ]); + expect(commands).toHaveLength(2); + expect(commands[0]).toContain("trap restore_source EXIT"); + expect(commands[1]).toMatch(/^rm -rf /); + expect(timeouts).toEqual([300, 30]); + expect(await readFile(archive, "utf8")).toBe("retained archive bytes"); + } + } finally { + if (previousKubectl === undefined) delete process.env.KUBECTL_BIN; + else process.env.KUBECTL_BIN = previousKubectl; + await rm(root, { recursive: true, force: true }); + } +}); + +test("copy failure skips source replacement and cleanup failure preserves the copy error", async () => { + const calls: Array<[string, string, number]> = []; + const target: HarnessTarget = { + copyFile: async () => { + throw new Error("copy failed"); + }, + run: async (command, description, timeout) => { + calls.push([command, description, timeout]); + throw new Error("cleanup failed"); + }, + }; + + await expect(installHarnessSource(target, "/local/source.tar.gz")).rejects.toThrow("copy failed"); + expect(calls).toEqual([ + [expect.stringMatching(/^rm -rf /), "Cleaning harness source staging", 30], + ]); +}); + +test("lockfile excludes a live holder and recovers a stale one", async () => { + const dir = await mkdtemp(join(tmpdir(), "lockfile-")); + const path = join(dir, "evaluation.lock"); + try { + const release = await acquireLockFile(path, "busy"); + await expect(acquireLockFile(path, "busy")).rejects.toThrow("busy"); + await release(); + await writeFile(path, "2147483646\n"); + const again = await acquireLockFile(path, "busy"); + expect((await readFile(path, "utf8")).trim()).toBe(String(process.pid)); + await again(); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("lockfile recovery keeps a lock another controller took after the stale check", async () => { + const dir = await mkdtemp(join(tmpdir(), "lockfile-")); + const path = join(dir, "evaluation.lock"); + const stalePid = 2147483646; + const otherPid = 2147483645; + const realKill = process.kill.bind(process); + // Between reading the stale pid and removing the lock, another controller + // recovers it first: the stale file is gone and a fresh lock (a new file) + // holds the path. + const kill = spyOn(process, "kill").mockImplementation((pid, signal) => { + if (pid !== stalePid) return realKill(pid, signal); + unlinkSync(path); + writeFileSync(path, `${otherPid}\n`); + throw Object.assign(new Error("kill ESRCH"), { code: "ESRCH" }); + }); + try { + await writeFile(path, `${stalePid}\n`); + await expect(acquireLockFile(path, "busy")).rejects.toThrow("busy"); + expect((await readFile(path, "utf8")).trim()).toBe(String(otherPid)); + } finally { + kill.mockRestore(); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("lockfile surfaces an unreadable lock instead of reporting it busy", async () => { + const dir = await mkdtemp(join(tmpdir(), "lockfile-")); + const path = join(dir, "evaluation.lock"); + try { + await mkdir(path); + await expect(acquireLockFile(path, "busy")).rejects.toThrow(/EISDIR/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("shared configuration forwards HARNESS_POLL_INTERVAL_SECONDS only when set", () => { + const name = "HARNESS_POLL_INTERVAL_SECONDS"; + const previous = process.env[name]; + try { + delete process.env[name]; + expect(loadSandboxConfig().daemonEnv).not.toHaveProperty(name); + process.env[name] = "2.5"; + expect(loadSandboxConfig().daemonEnv[name]).toBe("2.5"); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +}); + +test("shared configuration forwards stage-specific HARNESS settings to the daemon", () => { + const name = "HARNESS_PLANNING_REASONING_EFFORT"; + const previous = process.env[name]; + try { + process.env[name] = "xhigh"; + expect(loadSandboxConfig().daemonEnv[name]).toBe("xhigh"); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +}); + +test("shared configuration sets no LLM endpoint or key of its own", () => { + const names = ["HARNESS_LLM_BASE_URL", "HARNESS_LLM_API_KEY", "AZURE_FOUNDRY_KEY"]; + const previous = Object.fromEntries(names.map((name) => [name, process.env[name]])); + try { + for (const name of names) delete process.env[name]; + const daemonEnv = loadSandboxConfig().daemonEnv; + for (const name of names) expect(daemonEnv).not.toHaveProperty(name); + process.env.HARNESS_LLM_API_KEY = "proxy-llm-dummy-token"; + expect(loadSandboxConfig().daemonEnv.HARNESS_LLM_API_KEY).toBe("proxy-llm-dummy-token"); + } finally { + for (const name of names) { + const value = previous[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +}); + +test("shared configuration derives trusted orgs and users from GitHub settings", () => { + const saved = { ...process.env }; + try { + for (const name of Object.keys(process.env)) { + if (name.startsWith("HARNESS_") || name.startsWith("GITHUB_TRUSTED_")) + delete process.env[name]; + } + Object.assign(process.env, { + GITHUB_TRUSTED_ORGS: "acme, widgets", + GITHUB_TRUSTED_USERS: "frodo samwise", + HARNESS_TRUSTED_ORGS: "evil", + HARNESS_TRUSTED_USERS: "gollum", + }); + expect(loadSandboxConfig().daemonEnv).toMatchObject({ + HARNESS_TRUSTED_ORGS: "acme,widgets", + HARNESS_TRUSTED_USERS: "frodo,samwise", + }); + } finally { + for (const name of Object.keys(process.env)) { + if (!(name in saved)) delete process.env[name]; + } + Object.assign(process.env, saved); + } +}); + +test("shared configuration never forwards a real secret or the CLI token", () => { + const set = { GITHUB_TOKEN: "real", LLM_API_KEY: "real", DEVBOX_CLI_TOKEN: "x" }; + const saved = { ...process.env }; + try { + for (const name of Object.keys(process.env)) { + if ( + name.startsWith("HARNESS_") || + name.startsWith("DEVBOX_") || + name.startsWith("GITHUB_TRUSTED_") || + name === "GITHUB_SANDBOX_TOKEN" + ) + delete process.env[name]; + } + Object.assign(process.env, set); + expect(loadSandboxConfig().daemonEnv).toEqual({ + WORKSPACE: "/workspace", + GITHUB_TOKEN: "proxy-github-dummy-token", + }); + } finally { + for (const name of Object.keys(process.env)) { + if (!(name in saved)) delete process.env[name]; + } + Object.assign(process.env, saved); + } +}); diff --git a/control-plane/scripts/harness-sandbox.ts b/control-plane/scripts/harness-sandbox.ts new file mode 100644 index 0000000..26a7f83 --- /dev/null +++ b/control-plane/scripts/harness-sandbox.ts @@ -0,0 +1,434 @@ +import { type FileHandle, open, stat, unlink } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { type CommandExecution, ConnectionConfig, Sandbox } from "@alibaba-group/opensandbox"; +import { pickForwardedEnv } from "../src/forwardedEnv"; +import { parseTrustList } from "../src/integrationsConfig"; + +export const WORKSPACE = "/workspace"; +export const HARNESS_DIR = `${WORKSPACE}/.harness`; +const HARNESS_ROOT = "/opt/harness"; +const DAEMON_LOG = `${HARNESS_DIR}/daemon.log`; +const DAEMON_PID = `${HARNESS_DIR}/daemon.pid`; + +// Infrastructure settings only. Callers own sandbox lifetime, identity, metadata, +// local state, and the order in which these operations run. +export interface SandboxConfig { + connectionConfig: ConnectionConfig; + namespace: string; + storageClass: string; + storageSize: string; + snapshotPrefix: string; + image: string; + resourceCpu: string; + resourceMemory: string; + readyTimeoutSeconds: number; + daemonEnv: Record; +} + +export function envPositiveInteger(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === "") return defaultValue; + if (!/^\d+$/.test(raw)) throw new Error(`${name} must be a positive integer`); + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive integer`); + } + return value; +} + +function joinTrustList(name: "GITHUB_TRUSTED_ORGS" | "GITHUB_TRUSTED_USERS"): string | undefined { + const values = parseTrustList(process.env[name]); + return values.length > 0 ? values.join(",") : undefined; +} + +export function loadSandboxConfig(): SandboxConfig { + const daemonEnv: Record = { + WORKSPACE, + GITHUB_TOKEN: process.env.GITHUB_SANDBOX_TOKEN ?? "proxy-github-dummy-token", + ...pickForwardedEnv(process.env), + }; + delete daemonEnv.HARNESS_TRUSTED_ORGS; + delete daemonEnv.HARNESS_TRUSTED_USERS; + const trustedOrgs = joinTrustList("GITHUB_TRUSTED_ORGS"); + if (trustedOrgs !== undefined) daemonEnv.HARNESS_TRUSTED_ORGS = trustedOrgs; + const trustedUsers = joinTrustList("GITHUB_TRUSTED_USERS"); + if (trustedUsers !== undefined) daemonEnv.HARNESS_TRUSTED_USERS = trustedUsers; + return { + connectionConfig: new ConnectionConfig({ + domain: process.env.OPENSANDBOX_DOMAIN ?? "localhost:8080", + apiKey: process.env.OPENSANDBOX_API_KEY, + requestTimeoutSeconds: envPositiveInteger("SANDBOX_REQUEST_TIMEOUT_SECONDS", 300), + useServerProxy: true, + }), + namespace: process.env.PVC_NAMESPACE ?? "devbox-sandboxes", + storageClass: process.env.PVC_STORAGE_CLASS ?? "", + storageSize: process.env.PVC_STORAGE_SIZE ?? "15Gi", + snapshotPrefix: process.env.SNAPSHOT_NAME_PREFIX ?? "repos-", + image: process.env.SANDBOX_IMAGE ?? "devbox-dev:local", + resourceCpu: process.env.SANDBOX_RESOURCE_CPU ?? "2", + resourceMemory: process.env.SANDBOX_RESOURCE_MEMORY ?? "8Gi", + readyTimeoutSeconds: envPositiveInteger("SANDBOX_READY_TIMEOUT_SECONDS", 300), + daemonEnv, + }; +} + +export function isNodeError(error: unknown, code: string): boolean { + return ( + error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code + ); +} + +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !isNodeError(error, "ESRCH"); + } +} + +// Removes the lock at `path` when the process it names no longer exists, and +// reports whether the path is free to try again. The pid is read through a +// handle, and the file is unlinked only if `path` still names that same file +// (device and inode) when checked, so a lock another controller created while +// the pid was being checked is left alone. A replacement between that check +// and the unlink can still be removed: there is no unlink-by-handle, and Bun +// exposes no flock. That leaves a window of two syscalls, acceptable for a +// developer-run controller. An unreadable lock (EACCES, a directory) throws +// its error rather than reporting the lock busy. +async function removeStaleLock(path: string): Promise { + let held: FileHandle; + try { + held = await open(path, "r"); + } catch (error) { + if (isNodeError(error, "ENOENT")) return true; + throw error; + } + try { + const pid = Number((await held.readFile("utf8")).trim()); + if (!Number.isSafeInteger(pid) || pid <= 0 || processAlive(pid)) return false; + const heldStat = await held.stat(); + const current = await stat(path).catch((error) => { + if (isNodeError(error, "ENOENT")) return undefined; + throw error; + }); + if (!current) return true; + if (current.ino !== heldStat.ino || current.dev !== heldStat.dev) return false; + await unlink(path).catch(() => {}); + return true; + } finally { + await held.close().catch(() => {}); + } +} + +// Single-controller lock. A lock left by a process that no longer exists is +// removed automatically, so an interrupted controller never needs manual cleanup. +export async function acquireLockFile( + path: string, + busyMessage: string, +): Promise<() => Promise> { + let lock: FileHandle | undefined; + for (let attempt = 0; attempt < 2 && !lock; attempt++) { + try { + lock = await open(path, "wx"); + } catch (error) { + if (!isNodeError(error, "EEXIST")) throw error; + if (attempt === 0 && (await removeStaleLock(path))) continue; + throw new Error(busyMessage); + } + } + if (!lock) throw new Error(busyMessage); + const handle = lock; + try { + await handle.writeFile(`${process.pid}\n`); + } catch (error) { + await handle.close().catch(() => {}); + await unlink(path).catch(() => {}); + throw error; + } + return async () => { + try { + await handle.close(); + } finally { + await unlink(path).catch((error) => { + if (!isNodeError(error, "ENOENT")) throw error; + }); + } + }; +} + +export async function connectSandbox( + sandboxId: string, + config: SandboxConfig = loadSandboxConfig(), +): Promise { + return Sandbox.connect({ + sandboxId, + connectionConfig: config.connectionConfig, + skipHealthCheck: true, + }); +} + +export async function validateHarnessWorktree(path: string): Promise { + const worktree = resolve(path); + try { + if (!(await stat(worktree)).isDirectory()) + throw new Error(`Worktree is not a directory: ${worktree}`); + } catch (error) { + if (isNodeError(error, "ENOENT")) throw new Error(`Worktree does not exist: ${worktree}`); + throw error; + } + for (const relativePath of ["src/harness", "scripts"]) { + const sourcePath = join(worktree, relativePath); + try { + if (!(await stat(sourcePath)).isDirectory()) + throw new Error(`Expected a directory at ${sourcePath}`); + } catch (error) { + if (isNodeError(error, "ENOENT")) + throw new Error(`Not a harness worktree; missing ${sourcePath}`); + throw error; + } + } + return worktree; +} + +export async function runCheckedProcess(command: string[], description: string): Promise { + const proc = Bun.spawn(command, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (exitCode !== 0) { + throw new Error( + `${description} failed with exit code ${exitCode}: ${stderr.trim() || stdout.trim() || "no output"}`, + ); + } + return stdout; +} + +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function printExecution(execution: CommandExecution, sanitize = false): void { + const output = { + stdout: execution.logs.stdout.map((entry) => entry.text).join(""), + stderr: execution.logs.stderr.map((entry) => entry.text).join(""), + }; + const stdout = sanitize ? errorMessage(output.stdout) : output.stdout; + const stderr = sanitize ? errorMessage(output.stderr) : output.stderr; + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); +} + +export function requireSuccessfulExecution(execution: CommandExecution, description: string): void { + if (execution.exitCode !== 0) { + throw new Error( + `${description} failed with remote exit code ${execution.exitCode ?? "unknown"}`, + ); + } +} + +export async function runRemoteChecked( + sandbox: Sandbox, + command: string, + description: string, + timeoutSeconds = 300, +): Promise { + const execution = await sandbox.commands.run(command, { timeoutSeconds }); + if (execution.exitCode !== 0) printExecution(execution, true); + requireSuccessfulExecution(execution, description); + return execution; +} + +export async function createSourceArchive(worktree: string, path: string): Promise { + await runCheckedProcess( + [ + "tar", + "-czf", + path, + "--exclude=.git", + "--exclude=__pycache__", + "--exclude=*.pyc", + "-C", + worktree, + "src/harness", + "scripts", + ], + "Creating the harness source archive", + ); +} + +export type HarnessTarget = { + copyFile(localPath: string, remotePath: string): Promise; + run(command: string, description: string, timeoutSeconds: number): Promise; +}; + +export function sandboxHarnessTarget(sandbox: Sandbox, namespace: string): HarnessTarget { + return { + copyFile: async (localPath, remotePath) => { + await runCheckedProcess( + [ + process.env.KUBECTL_BIN ?? "kubectl", + "cp", + localPath, + `${namespace}/${sandbox.id}-0:${remotePath}`, + "-c", + "sandbox", + ], + "Copying harness source", + ); + }, + run: async (command, description, timeoutSeconds) => { + await runRemoteChecked(sandbox, command, description, timeoutSeconds); + }, + }; +} + +// Installs an existing archive. The caller owns its creation, checksum, retention, +// and daemon lifecycle; this operation owns remote staging and replacement rollback. +export async function installHarnessSource( + target: HarnessTarget, + archivePath: string, +): Promise { + const operationId = crypto.randomUUID(); + const remoteArchive = `/tmp/harness-source-${operationId}.tar.gz`; + const stagingRoot = `/tmp/harness-source-${operationId}`; + const backupRoot = `/tmp/harness-source-backup-${operationId}`; + try { + await target.copyFile(archivePath, remoteArchive); + const replaceScript = [ + "set -eu", + "old_harness_moved=0", + "old_scripts_moved=0", + "new_harness_moved=0", + "new_scripts_moved=0", + "committed=0", + "restore_source() {", + ' if [ "$committed" -eq 0 ]; then', + ` if [ "$new_harness_moved" -eq 1 ]; then rm -rf ${shellQuote(`${HARNESS_ROOT}/src/harness`)}; fi`, + ` if [ "$new_scripts_moved" -eq 1 ]; then rm -rf ${shellQuote(`${HARNESS_ROOT}/scripts`)}; fi`, + ` if [ "$old_harness_moved" -eq 1 ]; then mv ${shellQuote(`${backupRoot}/src/harness`)} ${shellQuote(`${HARNESS_ROOT}/src/harness`)}; fi`, + ` if [ "$old_scripts_moved" -eq 1 ]; then mv ${shellQuote(`${backupRoot}/scripts`)} ${shellQuote(`${HARNESS_ROOT}/scripts`)}; fi`, + " fi", + ` rm -rf ${shellQuote(stagingRoot)} ${shellQuote(backupRoot)}`, + "}", + "trap restore_source EXIT", + `rm -rf ${shellQuote(stagingRoot)} ${shellQuote(backupRoot)}`, + `mkdir -p ${shellQuote(stagingRoot)} ${shellQuote(`${backupRoot}/src`)}`, + `tar -xzf ${shellQuote(remoteArchive)} -C ${shellQuote(stagingRoot)}`, + `test -d ${shellQuote(`${stagingRoot}/src/harness`)}`, + `test -d ${shellQuote(`${stagingRoot}/scripts`)}`, + `mv ${shellQuote(`${HARNESS_ROOT}/src/harness`)} ${shellQuote(`${backupRoot}/src/harness`)}`, + "old_harness_moved=1", + `mv ${shellQuote(`${HARNESS_ROOT}/scripts`)} ${shellQuote(`${backupRoot}/scripts`)}`, + "old_scripts_moved=1", + `mv ${shellQuote(`${stagingRoot}/src/harness`)} ${shellQuote(`${HARNESS_ROOT}/src/harness`)}`, + "new_harness_moved=1", + `mv ${shellQuote(`${stagingRoot}/scripts`)} ${shellQuote(`${HARNESS_ROOT}/scripts`)}`, + "new_scripts_moved=1", + "committed=1", + `rm -rf ${shellQuote(stagingRoot)} ${shellQuote(backupRoot)}`, + "trap - EXIT", + ].join("\n"); + await target.run(`bash -lc ${shellQuote(replaceScript)}`, "Extracting harness source", 300); + } finally { + await target + .run( + `rm -rf ${shellQuote(remoteArchive)} ${shellQuote(stagingRoot)}`, + "Cleaning harness source staging", + 30, + ) + .catch(() => {}); + } +} + +export async function syncHarnessDependencies(target: HarnessTarget): Promise { + await target.run( + `cd ${HARNESS_ROOT} && uv sync --frozen --no-dev`, + "Synchronizing frozen production dependencies", + 600, + ); +} + +export async function bootstrapSandbox( + sandbox: Sandbox, + logPath = `${HARNESS_DIR}/bootstrap.log`, +): Promise { + await runRemoteChecked( + sandbox, + `mkdir -p ${HARNESS_DIR} && bash ${HARNESS_ROOT}/scripts/bootstrap.sh >${shellQuote(logPath)} 2>&1 || { tail -n 100 ${shellQuote(logPath)} >&2; exit 1; }`, + "Bootstrapping the sandbox", + 300, + ); +} + +export async function stopDaemon(sandbox: Sandbox, pidPath = DAEMON_PID): Promise { + await runRemoteChecked( + sandbox, + [ + `if [ -f ${shellQuote(pidPath)} ]; then`, + ` pid=$(cat ${shellQuote(pidPath)})`, + ' kill "$pid" 2>/dev/null || true', + ' for _ in 1 2 3 4 5; do kill -0 "$pid" 2>/dev/null || break; sleep 1; done', + ' kill -9 "$pid" 2>/dev/null || true', + ` rm -f ${shellQuote(pidPath)}`, + "fi", + "pkill -f '^/opt/harness/.venv/bin/harness daemon$' 2>/dev/null || true", + ].join("\n"), + "Stopping the harness daemon", + 30, + ); +} + +export async function daemonRunning(sandbox: Sandbox, pidPath = DAEMON_PID): Promise { + const execution = await sandbox.commands.run( + `[ -f ${shellQuote(pidPath)} ] && kill -0 "$(cat ${shellQuote(pidPath)})" 2>/dev/null`, + { timeoutSeconds: 30 }, + ); + return execution.exitCode === 0; +} + +export async function startDaemon( + sandbox: Sandbox, + logPath = DAEMON_LOG, + pidPath = DAEMON_PID, +): Promise { + await runRemoteChecked( + sandbox, + [ + `mkdir -p ${HARNESS_DIR}/inbox`, + `nohup ${HARNESS_ROOT}/.venv/bin/harness daemon >>${shellQuote(logPath)} 2>&1 ${shellQuote(pidPath)}`, + "sleep 1", + 'kill -0 "$pid"', + ].join("\n"), + "Starting the harness daemon", + 30, + ); +} + +export function errorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + let sanitized = raw; + for (const [name, secret] of Object.entries(process.env)) { + if ( + !secret || + !/(authorization|credential|password|secret|token|api_?key|foundry_key)/i.test(name) + ) + continue; + sanitized = sanitized.replaceAll(secret, "[REDACTED]"); + } + return sanitized + .replace( + /(authorization)(["']?\s*[:=]\s*["']?)(?:(?:basic|bearer)\s+)?[^\s,;}"']+/gi, + "$1$2[REDACTED]", + ) + .replace( + /(api[-_ ]?key|password|token|secret)(["']?\s*[:=]\s*["']?)[^\s,;}"']+/gi, + "$1$2[REDACTED]", + ) + .replace(/bearer\s+[^\s,;}]+/gi, "Bearer [REDACTED]") + .replace(/([?&](?:api_?key|password|token|secret)=)[^&#\s]+/gi, "$1[REDACTED]"); +} diff --git a/control-plane/scripts/k8s.test.ts b/control-plane/scripts/k8s.test.ts new file mode 100644 index 0000000..db920e1 --- /dev/null +++ b/control-plane/scripts/k8s.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { parseArgs } from "node:util"; +import { checkKindArgs, INSTALL_FLAGS, installOptions, requireTarget } from "./k8s"; +import { keychainFor } from "./lib/keychain"; + +describe("installOptions", () => { + it("defaults to the first-install NetworkPolicy check and a 15m timeout", () => { + expect(installOptions({})).toEqual({ + render: undefined, + skipPushCheck: false, + skipLlmCheck: false, + skipLinearCheck: false, + netpol: "auto", + timeout: "15m", + }); + }); + + it("maps the flags", () => { + expect( + installOptions({ + render: "v.yaml", + "skip-push-check": true, + "skip-llm-check": true, + "skip-linear-check": true, + "netpol-check": true, + timeout: "30m", + }), + ).toEqual({ + render: "v.yaml", + skipPushCheck: true, + skipLlmCheck: true, + skipLinearCheck: true, + netpol: "force", + timeout: "30m", + }); + expect(installOptions({ "skip-netpol-check": true }).netpol).toBe("skip"); + }); + + it("refuses both NetworkPolicy flags", () => { + expect(() => installOptions({ "skip-netpol-check": true, "netpol-check": true })).toThrow( + "--skip-netpol-check and --netpol-check exclude each other", + ); + }); +}); + +describe("requireTarget", () => { + it("requires --target", () => { + expect(() => requireTarget({})).toThrow( + "--target is required: the file naming the cluster to act on (docs/kubernetes/README.md#1-configure)", + ); + }); +}); + +describe("checkKindArgs", () => { + it("refuses --target, in either form", () => { + const message = + "k8s:kind takes no --target: it writes and uses devbox.kind.json (values of an existing one are kept)"; + expect(() => checkKindArgs(["--target", "devbox.eks.json"])).toThrow(message); + expect(() => checkKindArgs(["--skip-llm-check", "--target=devbox.eks.json"])).toThrow(message); + }); + + it("accepts the install flags", () => { + expect(() => checkKindArgs(["--skip-llm-check", "--timeout", "20m"])).not.toThrow(); + }); + + it("parses --skip-linear-check", () => { + const { values } = parseArgs({ args: ["--skip-linear-check"], options: INSTALL_FLAGS }); + expect(installOptions(values).skipLinearCheck).toBe(true); + }); + + it("--no-keychain gives the install no keychain", () => { + const { values } = parseArgs({ args: ["--no-keychain"], options: INSTALL_FLAGS }); + expect(values).toEqual({ "no-keychain": true }); + expect(keychainFor(values["no-keychain"])).toBeUndefined(); + // Without the flag, the system keychain behind the silent-disable guard. + const without = parseArgs({ args: [], options: INSTALL_FLAGS }).values; + expect(keychainFor(without["no-keychain"])).toMatchObject({ disabled: false }); + }); +}); diff --git a/control-plane/scripts/k8s.ts b/control-plane/scripts/k8s.ts new file mode 100644 index 0000000..87b22bc --- /dev/null +++ b/control-plane/scripts/k8s.ts @@ -0,0 +1,262 @@ +// The Kubernetes installer and its companion commands: +// bun run k8s:install --target [--render ] [--skip-push-check] +// [--skip-llm-check] [--skip-linear-check] +// [--skip-netpol-check | --netpol-check] +// [--timeout 15m] [--no-keychain] +// bun run k8s:connect | k8s:refresh-snapshot | k8s:uninstall [--purge [--yes]] --target +// bun run k8s:kind [install flags] (writes and uses devbox.kind.json) +// bun run k8s:check --target [--skip-push-check] (reads the Secrets) +// The target file names the cluster; there is no fallback to the current +// kube context. +// Secrets are found as for `bun run up` (scripts/lib/secrets.ts): the +// environment, then the OS keychain entry for this cluster's context and +// namespace, then `gh auth token` for GITHUB_TOKEN, then a hidden prompt, after +// which the typed value may be saved in the keychain. --no-keychain never reads +// or writes it. Values reach the cluster only as Secrets, never a file. The +// Linear secrets are resolved only when the target file has +// `integrations.linear`; devbox.config.json's `integrations` is not read. +// With the target file's `integrations.githubApp` and devbox-minted tokens, +// the App's private key replaces GITHUB_TOKEN: GITHUB_APP_PRIVATE_KEY_FILE (a +// path), then GITHUB_APP_PRIVATE_KEY, then the keychain; never prompted. +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { k8sCheck } from "./k8s/check"; +import { backgroundPortForward, connect, foregroundPortForward, isPortFree } from "./k8s/connect"; +import { type InstallDeps, type InstallOptions, install } from "./k8s/install"; +import { KIND_TARGET, KIND_TARGET_FILE, kindSetup } from "./k8s/kind"; +import { K8sPreflightError } from "./k8s/preflight"; +import { refreshSnapshot } from "./k8s/refresh"; +import { KubeError, kube, realRunner } from "./k8s/run"; +import { loadTarget, type Target } from "./k8s/target"; +import { uninstall } from "./k8s/uninstall"; +import { keychainFor } from "./lib/keychain"; +import { errorMessage, isKnownError } from "./lib/knownError"; +import { loadConfig } from "./up/config"; +import { confirm, promptHidden } from "./up/prompt"; + +const ROOT = join(import.meta.dir, "..", ".."); +const CONFIG_PATH = join(ROOT, "devbox.config.json"); +const COMMANDS = ["install", "kind", "connect", "refresh-snapshot", "uninstall", "check"]; + +const USAGE = `Usage: + bun run k8s:install --target [--render ] [--skip-push-check] + [--skip-llm-check] [--skip-linear-check] + [--skip-netpol-check | --netpol-check] [--timeout 15m] [--no-keychain] + bun run k8s:kind [install flags] (writes and uses devbox.kind.json) + bun run k8s:connect --target + bun run k8s:refresh-snapshot --target + bun run k8s:uninstall --target [--purge [--yes]] + bun run k8s:check --target [--skip-push-check] + Checks the Secrets the chart uses and the credentials in them; needs read + access to Secrets in namespace devbox. Never prints a value. +`; + +class UsageError extends Error {} + +const log = (line: string) => process.stdout.write(`${line}\n`); + +const TARGET_FLAG = { target: { type: "string" } } as const; + +export function requireTarget(values: { target?: string }): { path: string; target: Target } { + if (!values.target) { + throw new UsageError( + "--target is required: the file naming the cluster to act on (docs/kubernetes/README.md#1-configure)", + ); + } + const path = resolve(values.target); + return { path, target: loadTarget(path) }; +} + +// `linear:setup` passes the Linear secrets it resolved in `env`. +export function installDeps( + target: Target, + targetPath: string, + values: { "no-keychain"?: boolean }, + env: Record = process.env, +): InstallDeps { + return { + runner: realRunner, + // The integrations' settings come from the target file. + cfg: loadConfig(CONFIG_PATH, { integrations: false }), + target, + targetPath, + env, + keychain: keychainFor(values["no-keychain"]), + isTTY: process.stdin.isTTY === true, + home: homedir(), + fetch, + log, + sleep: Bun.sleep, + now: () => new Date(), + prompt: promptHidden, + confirm, + portForward: backgroundPortForward, + isPortFree, + writeFile: (path, text) => writeFileSync(path, text), + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw err; + } + }, + }; +} + +// Parses the flags of `install` (and of `k8s:kind`, which passes them on). +export function installOptions(values: { + render?: string; + "skip-push-check"?: boolean; + "skip-llm-check"?: boolean; + "skip-linear-check"?: boolean; + "skip-netpol-check"?: boolean; + "netpol-check"?: boolean; + timeout?: string; +}): InstallOptions { + if (values["skip-netpol-check"] && values["netpol-check"]) { + throw new UsageError("--skip-netpol-check and --netpol-check exclude each other"); + } + return { + render: values.render, + skipPushCheck: values["skip-push-check"] === true, + skipLlmCheck: values["skip-llm-check"] === true, + skipLinearCheck: values["skip-linear-check"] === true, + netpol: values["skip-netpol-check"] ? "skip" : values["netpol-check"] ? "force" : "auto", + timeout: values.timeout ?? "15m", + }; +} + +// k8s:kind picks its own target file: a --target would otherwise fail as an +// unknown option, which does not say why. +export function checkKindArgs(argv: string[]): void { + if (argv.some((a) => a === "--target" || a.startsWith("--target="))) { + throw new UsageError( + "k8s:kind takes no --target: it writes and uses devbox.kind.json (values of an existing one are kept)", + ); + } +} + +export const INSTALL_FLAGS = { + render: { type: "string" }, + "skip-push-check": { type: "boolean" }, + "skip-llm-check": { type: "boolean" }, + "skip-linear-check": { type: "boolean" }, + "skip-netpol-check": { type: "boolean" }, + "netpol-check": { type: "boolean" }, + timeout: { type: "string" }, + "no-keychain": { type: "boolean" }, +} as const; + +async function main(command: string | undefined, argv: string[]): Promise { + if (argv.includes("--help") || argv.includes("-h")) { + process.stdout.write(USAGE); + return 0; + } + switch (command) { + case "install": { + const { values } = parseArgs({ args: argv, options: { ...INSTALL_FLAGS, ...TARGET_FLAG } }); + const { path, target } = requireTarget(values); + const opts = installOptions(values); + await install(installDeps(target, path, values), opts); + return 0; + } + case "kind": { + // No --target: k8s:kind writes its own, devbox.kind.json, keeping the + // values of an existing one, and installs from it. + checkKindArgs(argv); + const { values } = parseArgs({ args: argv, options: INSTALL_FLAGS }); + const deps = installDeps( + { ...KIND_TARGET, values: {} }, + join(ROOT, KIND_TARGET_FILE), + values, + ); + await kindSetup(deps, installOptions(values), { + install, + mkdtemp: () => mkdtempSync(join(tmpdir(), "devbox-csi-hostpath-")), + }); + return 0; + } + case "connect": { + const { values } = parseArgs({ args: argv, options: TARGET_FLAG }); + const { target } = requireTarget(values); + return connect({ + runner: realRunner, + target, + env: process.env, + home: homedir(), + log, + isPortFree, + forwardForeground: foregroundPortForward, + }); + } + case "refresh-snapshot": { + const { values } = parseArgs({ args: argv, options: TARGET_FLAG }); + const { target } = requireTarget(values); + const k = kube(realRunner, target.context); + await refreshSnapshot(k, { sleep: Bun.sleep, log, now: () => new Date() }); + return 0; + } + case "uninstall": { + const { values } = parseArgs({ + args: argv, + options: { ...TARGET_FLAG, purge: { type: "boolean" }, yes: { type: "boolean" } }, + }); + if (values.yes && !values.purge) throw new UsageError("--yes goes with --purge"); + const { target } = requireTarget(values); + await uninstall( + { + runner: realRunner, + target, + env: process.env, + home: homedir(), + isTTY: process.stdin.isTTY === true, + confirm, + log, + }, + { purge: values.purge === true, yes: values.yes === true }, + ); + return 0; + } + case "check": { + const { values } = parseArgs({ + args: argv, + options: { ...TARGET_FLAG, "skip-push-check": { type: "boolean" } }, + }); + const { path, target } = requireTarget(values); + return k8sCheck( + { + // The integrations' settings come from the target file. + cfg: loadConfig(CONFIG_PATH, { integrations: false }), + target, + targetPath: path, + runner: realRunner, + fetch, + log, + }, + { skipPushCheck: values["skip-push-check"] === true }, + ); + } + default: + throw new UsageError(`unknown command "${command ?? ""}": use one of ${COMMANDS.join(", ")}`); + } +} + +if (import.meta.main) { + const [command, ...argv] = process.argv.slice(2); + try { + process.exit(await main(command, argv)); + } catch (err) { + process.stderr.write(`\nbun run k8s:${command ?? ""}: ${errorMessage(err)}\n`); + if ( + !isKnownError(err, UsageError, KubeError, K8sPreflightError) && + err instanceof Error && + err.stack + ) { + process.stderr.write(`${err.stack}\n`); + } + process.exit(1); + } +} diff --git a/control-plane/scripts/k8s/check.test.ts b/control-plane/scripts/k8s/check.test.ts new file mode 100644 index 0000000..4703479 --- /dev/null +++ b/control-plane/scripts/k8s/check.test.ts @@ -0,0 +1,694 @@ +import { describe, expect, it } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import type { Integrations } from "../lib/integrations"; +import type { DevboxConfig } from "../up/config"; +import type { Fetch } from "../up/preflight"; +import { k8sCheck } from "./check"; +import { fakeRunner, type Step } from "./fakeRunner"; +import type { Target } from "./target"; +import type { Values } from "./values"; + +const kc = (...args: string[]) => ["kubectl", "--context", "kind-devbox", ...args]; +const getSecret = (name: string) => kc("get", "secret", name, "-n", "devbox", "-o", "json"); +const b64 = (v: string) => Buffer.from(v).toString("base64"); + +// A Secret that exists, with these decoded keys. +const found = (name: string, data: Record): Step => ({ + cmd: getSecret(name), + result: { + stdout: JSON.stringify({ + metadata: { name }, + data: Object.fromEntries(Object.entries(data).map(([k, v]) => [k, b64(v)])), + }), + }, +}); +const missing = (name: string): Step => ({ + cmd: getSecret(name), + result: { code: 1, stderr: `Error from server (NotFound): secrets "${name}" not found\n` }, +}); + +const GITHUB_APP = { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 456, + botUserId: 789, + tokens: "devbox", + tokenScope: "repos", + ciResults: true, +} as const; +const config = (): DevboxConfig => ({ + repos: [ + { url: "https://github.com/acme/widgets.git", branch: "main", owner: "acme", name: "widgets" }, + ], + llm: { provider: "openai", baseUrl: "https://llm.example.com/v1", model: "m" }, + github: { trustedOrgs: [], trustedUsers: [] }, + integrations: {}, +}); + +const GITHUB_TOKEN = "ghp_PLANTED_TOKEN_1"; +const LLM_KEY = "sk-PLANTED_KEY_2"; +const LINEAR_SECRET = "lin_PLANTED_SECRET_3"; +const WEBHOOK_SECRET = "lin_wh_PLANTED_4"; +const SLACK_TOKEN = "xoxb-PLANTED-5"; +const GH_WEBHOOK_SECRET = "ghwh_PLANTED_6"; +const DATABASE_URL = "postgres://u:PLANTED_PW_7@db.example.com/devbox"; +const APP_TOKEN = "ghs_PLANTED_MINTED_8"; +const { privateKey: PEM } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); + +const LINEAR_ON: Integrations = { linear: { enabled: true, clientId: "lin_client" } }; + +// GitHub, the LLM endpoint and Linear; each accepts only the planted value. +// The GitHub App's calls: `app` is how its coverage and token calls answer. +function fakeFetch( + opts: { + github?: number; + llm?: number; + linear?: "ok" | "invalid"; + app?: "ok" | "uncovered" | "rejected"; + } = {}, +) { + const calls: string[] = []; + const fetch: Fetch = async (url, init) => { + calls.push(`${init?.method ?? "GET"} ${url}`); + const auth = new Headers(init?.headers).get("Authorization"); + if (opts.app === "rejected" && /^Bearer eyJ/.test(auth ?? "")) { + return Response.json({ message: "A JSON web token could not be decoded" }, { status: 401 }); + } + if (url === "https://api.github.com/app") { + return Response.json({ id: 123, slug: "example-devbox" }); + } + if (url === "https://api.github.com/repos/acme/widgets/installation") { + return opts.app === "uncovered" + ? Response.json({ message: "Not Found" }, { status: 404 }) + : Response.json({ id: 456 }); + } + if (url === "https://api.github.com/app/installations/456") { + return Response.json({ account: { login: "acme", type: "Organization" } }); + } + if (url === "https://api.github.com/app/installations/456/access_tokens") { + return Response.json( + { token: APP_TOKEN, expires_at: "2026-10-19T13:00:00Z" }, + { status: 201 }, + ); + } + if (url === "https://api.github.com/user" && auth === `Bearer ${APP_TOKEN}`) { + return Response.json({ message: "Resource not accessible by integration" }, { status: 403 }); + } + if (url === "https://api.github.com/user") { + if (opts.github !== undefined || auth !== `Bearer ${GITHUB_TOKEN}`) { + return Response.json({ message: "Bad credentials" }, { status: opts.github ?? 401 }); + } + return Response.json({ id: 7, login: "octo", name: "Octo Cat" }); + } + if (url === "https://api.github.com/repos/acme/widgets") return Response.json({}); + if (url === "https://github.com/acme/widgets.git/info/refs?service=git-receive-pack") + return new Response("001f# service=git-receive-pack\n"); + if (url === "https://api.github.com/repos/acme/widgets/pulls") + return Response.json({ message: "Validation Failed" }, { status: 422 }); + if (url === "https://llm.example.com/v1/responses") { + if (opts.llm !== undefined || auth !== `Bearer ${LLM_KEY}`) { + return Response.json({ error: "bad key" }, { status: opts.llm ?? 401 }); + } + return Response.json({}); + } + if (url === "https://api.linear.app/oauth/token") { + const body = new URLSearchParams(String(init?.body)); + if (opts.linear === "invalid" || body.get("client_secret") !== LINEAR_SECRET) { + return Response.json({ error: "invalid_client" }, { status: 401 }); + } + return Response.json({ access_token: "lin_tok", token_type: "Bearer", expires_in: 2592000 }); + } + if (url === "https://api.linear.app/graphql") { + return Response.json({ + data: { viewer: { name: "devbox" }, organization: { name: "Example", urlKey: "example" } }, + }); + } + return new Response("", { status: 404 }); + }; + return { fetch, calls }; +} + +async function run( + script: Step[], + opts: { + values?: Values; + integrations?: Integrations; + skipPushCheck?: boolean; + fetch?: ReturnType; + } = {}, +) { + const f = fakeRunner(script); + const fetch = opts.fetch ?? fakeFetch(); + const logs: string[] = []; + const target: Target = { + context: "kind-devbox", + values: opts.values ?? {}, + ...(opts.integrations ? { integrations: opts.integrations } : {}), + }; + const code = await k8sCheck( + { + cfg: config(), + target, + targetPath: "devbox.k8s.json", + runner: f.runner, + fetch: fetch.fetch, + log: (line) => void logs.push(line), + }, + { skipPushCheck: opts.skipPushCheck === true }, + ); + f.done(); + return { code, logs, fetchCalls: fetch.calls }; +} + +const HEADER = "Checking Secrets in namespace devbox (context kind-devbox)"; +const GITHUB_OK = found("devbox-github", { token: GITHUB_TOKEN }); +const LLM_OK = found("devbox-llm", { "api-key": LLM_KEY }); +const APP_KEY_OK = found("devbox-github-app", { "private-key": PEM }); + +describe("k8sCheck", () => { + it("before install: installer-owned Secrets not created yet are skipped", async () => { + const r = await run( + [missing("devbox-github"), missing("devbox-llm"), missing("devbox-integrations")], + { integrations: LINEAR_ON }, + ); + expect(r.logs).toEqual([ + HEADER, + " skip Secret devbox-github: not created yet (k8s:install creates it)", + " skip Secret devbox-llm: not created yet (k8s:install creates it)", + " skip Secret devbox-integrations: not created yet (k8s:install creates it)", + ' skip GitHub token: no usable key "token" in Secret devbox-github', + ' skip LLM key: no usable key "api-key" in Secret devbox-llm', + ' skip Linear client secret: no usable key "LINEAR_CLIENT_SECRET" in Secret devbox-integrations', + " skip Secret devbox-integrations: LINEAR_CLIENT_SECRET: not created yet (k8s:install creates it)", + " skip Secret devbox-integrations: LINEAR_WEBHOOK_SECRET: not created yet (k8s:install creates it)", + "All checks passed.", + ]); + expect(r.code).toBe(0); + expect(r.fetchCalls).toEqual([]); + }); + + it("a user's Secret not found fails", async () => { + const r = await run( + [missing("my-github"), LLM_OK, missing("my-db"), missing("my-integrations")], + { + values: { + secrets: { + github: { existingSecret: "my-github" }, + integrations: { existingSecret: "my-integrations" }, + }, + postgresql: { enabled: false }, + database: { existingSecret: "my-db" }, + }, + integrations: { slack: { enabled: true, channelId: "C1" } }, + }, + ); + expect(r.logs).toEqual([ + HEADER, + " FAIL Secret my-github: not found (secrets.github.existingSecret)", + " ok Secret devbox-llm", + " FAIL Secret my-db: not found (database.existingSecret)", + " FAIL Secret my-integrations: not found (secrets.integrations.existingSecret)", + ' skip GitHub token: no usable key "token" in Secret my-github', + " ok LLM key", + " skip Secret my-integrations: SLACK_BOT_TOKEN: not found (secrets.integrations.existingSecret)", + "3 failed.", + ]); + expect(r.code).toBe(1); + }); + + it("missing, empty and trailing-newline keys", async () => { + const r = await run( + [ + found("devbox-github", { other: "x" }), + found("devbox-llm", { "api-key": "" }), + found("my-db", { DATABASE_URL: `${DATABASE_URL}\n` }), + ], + { values: { postgresql: { enabled: false }, database: { existingSecret: "my-db" } } }, + ); + expect(r.logs).toEqual([ + HEADER, + ' FAIL Secret devbox-github: no key "token"', + ' FAIL Secret devbox-llm: key "api-key" is empty', + // Only the GitHub token and the LLM key go out verbatim (iron-proxy). + " ok Secret my-db", + ' skip GitHub token: no usable key "token" in Secret devbox-github', + ' skip LLM key: no usable key "api-key" in Secret devbox-llm', + "2 failed.", + ]); + expect(r.code).toBe(1); + + const nl = await run([ + found("devbox-github", { token: `${GITHUB_TOKEN}\n` }), + found("devbox-llm", { "api-key": `${LLM_KEY}\r\n` }), + ]); + expect(nl.logs).toEqual([ + HEADER, + ' FAIL Secret devbox-github: key "token" ends with a newline', + ' FAIL Secret devbox-llm: key "api-key" ends with a newline', + ' skip GitHub token: no usable key "token" in Secret devbox-github', + ' skip LLM key: no usable key "api-key" in Secret devbox-llm', + "2 failed.", + ]); + expect(nl.code).toBe(1); + }); + + it("token checks pass and fail", async () => { + const pass = await run([GITHUB_OK, LLM_OK]); + expect(pass.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret devbox-llm", + " ok GitHub token", + " ok LLM key", + "All checks passed.", + ]); + expect(pass.code).toBe(0); + expect(pass.fetchCalls).toEqual([ + "GET https://api.github.com/user", + "GET https://api.github.com/repos/acme/widgets", + "GET https://github.com/acme/widgets.git/info/refs?service=git-receive-pack", + "POST https://api.github.com/repos/acme/widgets/pulls", + "POST https://llm.example.com/v1/responses", + ]); + + const fail = await run([GITHUB_OK, LLM_OK], { fetch: fakeFetch({ github: 401, llm: 500 }) }); + expect(fail.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret devbox-llm", + " FAIL GitHub token: GitHub rejected GITHUB_TOKEN (GET /user returned 401: Bad credentials)", + ' FAIL LLM key: LLM check failed: POST https://llm.example.com/v1/responses returned 500 (check llm.baseUrl, llm.model and LLM_API_KEY): {"error":"bad key"}', + "2 failed.", + ]); + expect(fail.code).toBe(1); + }); + + it("Linear enabled: client secret checked, keys required", async () => { + const ok = await run( + [ + GITHUB_OK, + LLM_OK, + found("devbox-integrations", { + LINEAR_CLIENT_SECRET: LINEAR_SECRET, + LINEAR_WEBHOOK_SECRET: WEBHOOK_SECRET, + }), + ], + { integrations: LINEAR_ON }, + ); + expect(ok.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + " ok GitHub token", + " ok LLM key", + " ok Linear client secret (devbox acts as devbox in workspace Example)", + " ok Secret devbox-integrations: LINEAR_CLIENT_SECRET", + " ok Secret devbox-integrations: LINEAR_WEBHOOK_SECRET", + "All checks passed.", + ]); + expect(ok.code).toBe(0); + + const rejected = await run( + [ + GITHUB_OK, + LLM_OK, + found("devbox-integrations", { + LINEAR_CLIENT_SECRET: LINEAR_SECRET, + LINEAR_WEBHOOK_SECRET: "", + }), + ], + { integrations: LINEAR_ON, fetch: fakeFetch({ linear: "invalid" }) }, + ); + expect(rejected.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + " ok GitHub token", + " ok LLM key", + " FAIL Linear client secret: Linear rejected the client ID or LINEAR_CLIENT_SECRET (invalid_client): copy them again from the app's page in Linear (Settings → API → Applications)", + " ok Secret devbox-integrations: LINEAR_CLIENT_SECRET", + ' FAIL Secret devbox-integrations: LINEAR_WEBHOOK_SECRET: key "LINEAR_WEBHOOK_SECRET" is empty', + "2 failed.", + ]); + expect(rejected.code).toBe(1); + + const noKeys = await run([GITHUB_OK, LLM_OK, found("devbox-integrations", {})], { + integrations: LINEAR_ON, + }); + expect(noKeys.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + " ok GitHub token", + " ok LLM key", + ' skip Linear client secret: no usable key "LINEAR_CLIENT_SECRET" in Secret devbox-integrations', + ' FAIL Secret devbox-integrations: LINEAR_CLIENT_SECRET: no key "LINEAR_CLIENT_SECRET"', + ' FAIL Secret devbox-integrations: LINEAR_WEBHOOK_SECRET: no key "LINEAR_WEBHOOK_SECRET"', + "2 failed.", + ]); + expect(noKeys.code).toBe(1); + }); + + it("slack and github app webhook keys required when enabled", async () => { + const r = await run( + [ + GITHUB_OK, + APP_KEY_OK, + LLM_OK, + found("devbox-integrations", { SLACK_BOT_TOKEN: SLACK_TOKEN }), + ], + { + integrations: { + linear: { enabled: false }, + slack: { enabled: true, channelId: "C1" }, + githubApp: GITHUB_APP, + }, + }, + ); + expect(r.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret devbox-github-app", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + " ok GitHub App token (minted for installation 456)", + " ok GitHub token", + " ok LLM key", + " ok Secret devbox-integrations: SLACK_BOT_TOKEN", + ' FAIL Secret devbox-integrations: GITHUB_WEBHOOK_SECRET: no key "GITHUB_WEBHOOK_SECRET"', + "1 failed.", + ]); + expect(r.code).toBe(1); + }); + + it("forbidden stops with the access message", async () => { + const r = await run([ + GITHUB_OK, + { + cmd: getSecret("devbox-llm"), + result: { + code: 1, + stderr: + 'Error from server (Forbidden): secrets "devbox-llm" is forbidden: User "ci" cannot get resource "secrets"\n', + }, + }, + ]); + expect(r.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + "k8s:check needs read access to Secrets in namespace devbox: skip it, or run it with an account that has it", + ]); + expect(r.code).toBe(1); + expect(r.fetchCalls).toEqual([]); + }); + + it("--skip-push-check", async () => { + const r = await run([GITHUB_OK, LLM_OK], { skipPushCheck: true }); + expect(r.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret devbox-llm", + " ok GitHub token", + " ok LLM key", + "All checks passed.", + ]); + expect(r.fetchCalls).toEqual([ + "GET https://api.github.com/user", + "GET https://api.github.com/repos/acme/widgets", + "POST https://llm.example.com/v1/responses", + ]); + }); + + it("no value ever printed", async () => { + const planted = [ + GITHUB_TOKEN, + APP_TOKEN, + PEM, + "PRIVATE KEY", + LLM_KEY, + LINEAR_SECRET, + WEBHOOK_SECRET, + SLACK_TOKEN, + GH_WEBHOOK_SECRET, + DATABASE_URL, + "PLANTED", + ]; + const all = { + LINEAR_CLIENT_SECRET: LINEAR_SECRET, + LINEAR_WEBHOOK_SECRET: WEBHOOK_SECRET, + SLACK_BOT_TOKEN: SLACK_TOKEN, + GITHUB_WEBHOOK_SECRET: GH_WEBHOOK_SECRET, + }; + const values = { postgresql: { enabled: false }, database: { existingSecret: "my-db" } }; + const integrations: Integrations = { + ...LINEAR_ON, + slack: { enabled: true, channelId: "C1" }, + githubApp: GITHUB_APP, + }; + // Every check passes, then every check fails, then every Secret is broken. + const runs = [ + await run( + [ + GITHUB_OK, + APP_KEY_OK, + LLM_OK, + found("my-db", { DATABASE_URL }), + found("devbox-integrations", all), + ], + { values, integrations }, + ), + await run( + [ + GITHUB_OK, + APP_KEY_OK, + LLM_OK, + found("my-db", { DATABASE_URL }), + found("devbox-integrations", all), + ], + { + values, + integrations, + fetch: fakeFetch({ app: "rejected", llm: 401, linear: "invalid" }), + }, + ), + await run( + [ + found("devbox-github", { token: `${GITHUB_TOKEN}\n` }), + found("devbox-github-app", { "private-key": PEM.replace("BEGIN", "BEGAN") }), + found("devbox-llm", { "api-key": `${LLM_KEY}\n` }), + found("my-db", { DATABASE_URL: "" }), + found("devbox-integrations", { ...all, SLACK_BOT_TOKEN: "" }), + ], + { values, integrations }, + ), + ]; + expect(runs.map((r) => r.code)).toEqual([0, 1, 1]); + for (const line of runs.flatMap((r) => r.logs)) { + for (const value of planted) expect(line).not.toContain(value); + } + }); + + describe("GitHub App", () => { + const APP_ON: Integrations = { githubApp: GITHUB_APP }; + const WEBHOOK_OK = found("devbox-integrations", { GITHUB_WEBHOOK_SECRET: GH_WEBHOOK_SECRET }); + const APP_CALLS = [ + "GET https://api.github.com/app", + "GET https://api.github.com/repos/acme/widgets/installation", + "POST https://api.github.com/app/installations/456/access_tokens", + ]; + + it("devbox tokens: the key Secret, a token minted with it, then the token checks", async () => { + const r = await run([GITHUB_OK, APP_KEY_OK, LLM_OK, WEBHOOK_OK], { integrations: APP_ON }); + expect(r.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret devbox-github-app", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + " ok GitHub App token (minted for installation 456)", + " ok GitHub token", + " ok LLM key", + " ok Secret devbox-integrations: GITHUB_WEBHOOK_SECRET", + "All checks passed.", + ]); + expect(r.code).toBe(0); + expect(r.fetchCalls).toEqual([ + ...APP_CALLS, + "GET https://api.github.com/user", + "GET https://api.github.com/repos/acme/widgets", + "GET https://github.com/acme/widgets.git/info/refs?service=git-receive-pack", + "POST https://api.github.com/repos/acme/widgets/pulls", + "POST https://llm.example.com/v1/responses", + ]); + }); + + it("devbox tokens: the user's key Secret is the one read", async () => { + const r = await run( + [GITHUB_OK, found("my-app-key", { "private-key": PEM }), LLM_OK, WEBHOOK_OK], + { + integrations: APP_ON, + values: { secrets: { githubApp: { existingSecret: "my-app-key" } } }, + skipPushCheck: true, + }, + ); + expect(r.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " ok Secret my-app-key", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + " ok GitHub App token (minted for installation 456)", + " ok GitHub token", + " ok LLM key", + " ok Secret devbox-integrations: GITHUB_WEBHOOK_SECRET", + "All checks passed.", + ]); + }); + + it("devbox tokens: a missing or broken key Secret skips the mint and the token checks", async () => { + const missingKey = await run([GITHUB_OK, missing("my-app-key"), LLM_OK, WEBHOOK_OK], { + integrations: APP_ON, + values: { secrets: { githubApp: { existingSecret: "my-app-key" } } }, + }); + expect(missingKey.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " FAIL Secret my-app-key: not found (secrets.githubApp.existingSecret)", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + ' skip GitHub App token: no usable key "private-key" in Secret my-app-key', + " skip GitHub token: no GitHub App token to check", + " ok LLM key", + " ok Secret devbox-integrations: GITHUB_WEBHOOK_SECRET", + "1 failed.", + ]); + expect(missingKey.code).toBe(1); + + const notPem = await run( + [GITHUB_OK, found("devbox-github-app", { "private-key": "not a key" }), LLM_OK, WEBHOOK_OK], + { integrations: APP_ON }, + ); + expect(notPem.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + ' FAIL Secret devbox-github-app: key "private-key" does not hold a PEM private key', + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + ' skip GitHub App token: no usable key "private-key" in Secret devbox-github-app', + " skip GitHub token: no GitHub App token to check", + " ok LLM key", + " ok Secret devbox-integrations: GITHUB_WEBHOOK_SECRET", + "1 failed.", + ]); + expect(notPem.fetchCalls).toEqual(["POST https://llm.example.com/v1/responses"]); + + const notYet = await run([GITHUB_OK, missing("devbox-github-app"), LLM_OK, WEBHOOK_OK], { + integrations: APP_ON, + }); + expect(notYet.logs).toEqual([ + HEADER, + " ok Secret devbox-github", + " skip Secret devbox-github-app: not created yet (k8s:install creates it)", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + ' skip GitHub App token: no usable key "private-key" in Secret devbox-github-app', + " skip GitHub token: no GitHub App token to check", + " ok LLM key", + " ok Secret devbox-integrations: GITHUB_WEBHOOK_SECRET", + "All checks passed.", + ]); + }); + + // The whole output when the mint fails with `why`. + const mintFailed = (keySecret: string, why: string) => [ + HEADER, + " ok Secret devbox-github", + ` ok Secret ${keySecret}`, + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + ` FAIL GitHub App token: ${why}`, + " skip GitHub token: no GitHub App token to check", + " ok LLM key", + " ok Secret devbox-integrations: GITHUB_WEBHOOK_SECRET", + "1 failed.", + ]; + const REJECTED = + "GitHub rejected the App's credentials: A JSON web token could not be decoded. Check the system clock and integrations.githubApp.appId; if the key itself is wrong, "; + + it("devbox tokens: a rejected key says how to fix it where it came from", async () => { + const installers = await run([GITHUB_OK, APP_KEY_OK, LLM_OK, WEBHOOK_OK], { + integrations: APP_ON, + fetch: fakeFetch({ app: "rejected" }), + }); + expect(installers.logs).toEqual( + mintFailed( + "devbox-github-app", + `${REJECTED}fix the saved key (bun run secrets rm GITHUB_APP_PRIVATE_KEY --target devbox.k8s.json, then bun run github:setup --target devbox.k8s.json) and re-run bun run k8s:install --target devbox.k8s.json.`, + ), + ); + expect(installers.code).toBe(1); + + const users = await run( + [GITHUB_OK, found("my-app-key", { "private-key": PEM }), LLM_OK, WEBHOOK_OK], + { + integrations: APP_ON, + values: { secrets: { githubApp: { existingSecret: "my-app-key" } } }, + fetch: fakeFetch({ app: "rejected" }), + }, + ); + expect(users.logs).toEqual( + mintFailed( + "my-app-key", + `${REJECTED}put the right key in Secret "my-app-key" (key private-key).`, + ), + ); + expect(users.code).toBe(1); + }); + + it("devbox tokens: uncovered repos fail the mint", async () => { + const uncovered = await run([GITHUB_OK, APP_KEY_OK, LLM_OK, WEBHOOK_OK], { + integrations: APP_ON, + fetch: fakeFetch({ app: "uncovered" }), + }); + expect(uncovered.logs).toEqual( + mintFailed( + "devbox-github-app", + 'These repos are not in the GitHub App\'s installation: acme/widgets. Add them at https://github.com/organizations/acme/settings/installations/456, or run with tokenScope "installation"', + ), + ); + expect(uncovered.code).toBe(1); + }); + + it("external tokens: today's checks on the user's token Secret, no key", async () => { + const r = await run([found("my-github", { token: GITHUB_TOKEN }), LLM_OK, WEBHOOK_OK], { + integrations: { githubApp: { ...GITHUB_APP, tokens: "external" } }, + values: { secrets: { github: { existingSecret: "my-github" } } }, + }); + expect(r.logs).toEqual([ + HEADER, + " ok Secret my-github", + " ok Secret devbox-llm", + " ok Secret devbox-integrations", + " ok GitHub token", + " ok LLM key", + " ok Secret devbox-integrations: GITHUB_WEBHOOK_SECRET", + "All checks passed.", + ]); + expect(r.fetchCalls).toEqual([ + "GET https://api.github.com/user", + "GET https://api.github.com/repos/acme/widgets", + "GET https://github.com/acme/widgets.git/info/refs?service=git-receive-pack", + "POST https://api.github.com/repos/acme/widgets/pulls", + "POST https://llm.example.com/v1/responses", + ]); + }); + }); +}); diff --git a/control-plane/scripts/k8s/check.ts b/control-plane/scripts/k8s/check.ts new file mode 100644 index 0000000..7b24839 --- /dev/null +++ b/control-plane/scripts/k8s/check.ts @@ -0,0 +1,273 @@ +// `bun run k8s:check --target [--skip-push-check]`: +// the only command that reads Secrets. For every Secret the chart will use, it +// checks the Secret and its keys, then the GitHub token, the LLM key and the +// Linear client secret against their services, and each enabled integration's +// keys. With the GitHub App's devbox-minted tokens, the token checks run on a +// token minted with the key from the App's key Secret (after checking that the +// installation covers the repos). One line per item, never a value: Secrets +// are decoded in memory only. It never saves, removes or prompts. +import { createPrivateKey } from "node:crypto"; +import { GithubAppError } from "../../src/githubToken/mint"; +import { GithubAppSetupError, mintAppToken } from "../lib/githubApp"; +import { isPem, PrivateKeyError } from "../lib/githubAppKey"; +import { enabledGithubApp, enabledLinear } from "../lib/integrations"; +import { checkLinear } from "../linear/check"; +import type { DevboxConfig } from "../up/config"; +import { checkGithub, checkLlm, type Fetch, PreflightError } from "../up/preflight"; +import { KubeError, kube, NAMESPACE, type Runner } from "./run"; +import { existingSecret, GITHUB_APP_SECRET, GITHUB_SECRET, INTEGRATIONS_SECRET } from "./secrets"; +import type { Target } from "./target"; +import { userValue } from "./values"; + +export type CheckDeps = { + cfg: DevboxConfig; + target: Target; + targetPath: string; + runner: Runner; + fetch: Fetch; + log: (line: string) => void; +}; + +const FORBIDDEN = + "k8s:check needs read access to Secrets in namespace devbox: skip it, or run it with an account that has it"; +const NOT_CREATED = "not created yet (k8s:install creates it)"; + +// Each enabled integration's keys in the integrations Secret. +const INTEGRATION_KEYS = { + linear: ["LINEAR_CLIENT_SECRET", "LINEAR_WEBHOOK_SECRET"], + slack: ["SLACK_BOT_TOKEN"], + githubApp: ["GITHUB_WEBHOOK_SECRET"], +} as const; + +class Forbidden extends Error {} + +// A Secret as read: its decoded keys, or why there are none (`failed` when +// that is a failure, not a skip); `problem` is what secretItem found wrong +// with the key it checked. +type Read = { + name: string; + data?: Record; + reason?: string; + failed?: boolean; + problem?: string; +}; + +export async function k8sCheck(deps: CheckDeps, opts: { skipPushCheck: boolean }): Promise { + const { cfg, target, log } = deps; + const k = kube(deps.runner, target.context); + let failed = 0; + const ok = (what: string) => log(` ok ${what}`); + const skip = (what: string, why: string) => log(` skip ${what}: ${why}`); + const fail = (what: string, why: string) => { + failed++; + log(` FAIL ${what}: ${why}`); + }; + + async function read(name: string, path: string, owned: boolean): Promise { + const args = ["get", "secret", name, "-n", NAMESPACE, "-o", "json"]; + const r = await k.kubectl(args); + if (/forbidden/i.test(r.stderr)) throw new Forbidden(); + if (/\(NotFound\)/.test(r.stderr)) { + return owned + ? { name, reason: NOT_CREATED } + : { name, reason: `not found (${path}.existingSecret)`, failed: true }; + } + if (r.code !== 0) { + throw new KubeError( + `kubectl get secret ${name} failed: ${r.stderr.trim() || `exit ${r.code}`}`, + ); + } + const { data } = JSON.parse(r.stdout) as { data?: Record }; + return { + name, + data: Object.fromEntries( + Object.entries(data ?? {}).map(([key, v]) => [ + key, + Buffer.from(v, "base64").toString("utf8"), + ]), + ), + }; + } + + // Why `key` is unusable, or undefined when it is fine. iron-proxy sends the + // GitHub token and the LLM key verbatim, so a trailing newline breaks them. + const keyProblem = (s: Read, key: string, verbatim: boolean): string | undefined => { + const value = s.data?.[key]; + if (value === undefined) return `no key "${key}"`; + if (!value) return `key "${key}" is empty`; + if (verbatim && /[\r\n]$/.test(value)) return `key "${key}" ends with a newline`; + return undefined; + }; + + // A Secret's line, checking `key` when given (`problem` adds a check of + // its value); returns the Secret as read. + async function secretItem( + secret: { name: string; path: string; owned: boolean }, + key?: { name: string; verbatim: boolean; problem?: (value: string) => string | undefined }, + ): Promise { + const s = await read(secret.name, secret.path, secret.owned); + const what = `Secret ${s.name}`; + if (!s.data) { + (s.failed ? fail : skip)(what, s.reason ?? ""); + return s; + } + const problem = + key && (keyProblem(s, key.name, key.verbatim) ?? key.problem?.(s.data[key.name] as string)); + if (problem) fail(what, problem); + else ok(what); + return problem ? { ...s, problem } : s; + } + + // The value a service check needs, or a skip line when there is none. + const usable = (s: Read, key: string, verbatim: boolean, what: string): string | undefined => { + if (s.data && !keyProblem(s, key, verbatim)) return s.data[key]; + skip(what, `no usable key "${key}" in Secret ${s.name}`); + return undefined; + }; + + // A service's refusals, each a FAIL line; anything else stops the run. + const REFUSALS = [PreflightError, PrivateKeyError, GithubAppSetupError, GithubAppError]; + async function serviceItem(what: string, check: () => Promise) { + try { + const extra = await check(); + ok(extra ? `${what} ${extra}` : what); + } catch (err) { + if (!REFUSALS.some((cls) => err instanceof cls)) throw err; + fail(what, (err as Error).message); + } + } + + // Why the App's private key is unusable: it must be a PEM block that parses. + const pemProblem = (pem: string): string | undefined => { + try { + if (isPem(pem)) { + createPrivateKey(pem); + return undefined; + } + } catch { + // not a key + } + return 'key "private-key" does not hold a PEM private key'; + }; + + const values = target.values; + const integrations = target.integrations ?? {}; + const app = enabledGithubApp(integrations); + const minting = app?.tokens === "devbox" ? app : undefined; + const userKeySecret = existingSecret(values, "githubApp"); + const enabled = (Object.keys(INTEGRATION_KEYS) as Array).filter( + (name) => integrations[name]?.enabled, + ); + // The user's existingSecret, else the installer's name for it. + const secretOf = (key: string, installerName: string) => { + const name = existingSecret(values, key); + return { name: name ?? installerName, path: `secrets.${key}`, owned: !name }; + }; + + log(`Checking Secrets in namespace ${NAMESPACE} (context ${target.context})`); + try { + const github = await secretItem(secretOf("github", GITHUB_SECRET), { + name: "token", + verbatim: true, + }); + // The App's key Secret: present, with a key that parses. + const appKey = minting + ? await secretItem(secretOf("githubApp", GITHUB_APP_SECRET), { + name: "private-key", + verbatim: false, + problem: pemProblem, + }) + : undefined; + const llm = await secretItem(secretOf("llm", "devbox-llm"), { + name: "api-key", + verbatim: true, + }); + // The chart reads database.existingSecret only without the bundled Postgres. + const db = userValue(target, ["database", "existingSecret"]); + if (userValue(target, ["postgresql", "enabled"]) === false && typeof db === "string" && db) { + await secretItem( + { name: db, path: "database", owned: false }, + { name: "DATABASE_URL", verbatim: false }, + ); + } + // The chart's integrations Secret is optional: used only with one enabled. + const ints = enabled.length + ? await secretItem(secretOf("integrations", INTEGRATIONS_SECRET)) + : undefined; + + let token: string | undefined; + let tokenName: string | undefined; + if (minting && appKey) { + // The token Secret holds the minter's latest token; the checks need one + // minted now with the key, which is what is being checked. + const pem = appKey.data && !appKey.problem ? appKey.data["private-key"] : undefined; + if (pem === undefined) { + skip("GitHub App token", `no usable key "private-key" in Secret ${appKey.name}`); + } else { + await serviceItem("GitHub App token", async () => { + ({ token } = await mintAppToken(minting, pem, cfg.repos, { + fetch: deps.fetch, + checkCoverage: true, + target: deps.targetPath, + keyFix: userKeySecret + ? `put the right key in Secret "${userKeySecret}" (key private-key).` + : `fix the saved key (bun run secrets rm GITHUB_APP_PRIVATE_KEY --target ${deps.targetPath}, then bun run github:setup --target ${deps.targetPath}) and re-run bun run k8s:install --target ${deps.targetPath}.`, + })); + return `(minted for installation ${minting.installationId})`; + }); + } + tokenName = "the GitHub App's token"; + if (token === undefined) skip("GitHub token", "no GitHub App token to check"); + } else { + token = usable(github, "token", true, "GitHub token"); + } + if (token !== undefined) { + const checked = token; + await serviceItem("GitHub token", async () => { + await checkGithub(checked, cfg.repos, deps.fetch, { + requirePush: !opts.skipPushCheck, + ...(tokenName ? { tokenName } : {}), + }); + return undefined; + }); + } + const key = usable(llm, "api-key", true, "LLM key"); + if (key !== undefined) { + await serviceItem("LLM key", async () => { + await checkLlm(cfg.llm, key, deps.fetch); + return undefined; + }); + } + const linear = enabledLinear(integrations); + if (linear && ints) { + const secret = usable(ints, "LINEAR_CLIENT_SECRET", false, "Linear client secret"); + if (secret !== undefined) { + await serviceItem("Linear client secret", async () => { + const id = await checkLinear(linear.clientId, secret, deps.fetch); + return `(devbox acts as ${id.viewerName} in workspace ${id.workspaceName})`; + }); + } + } + + if (ints) { + for (const name of enabled) { + for (const k of INTEGRATION_KEYS[name]) { + const what = `Secret ${ints.name}: ${k}`; + if (!ints.data) skip(what, ints.reason ?? ""); + else { + const problem = keyProblem(ints, k, false); + if (problem) fail(what, problem); + else ok(what); + } + } + } + } + } catch (err) { + if (!(err instanceof Forbidden)) throw err; + log(FORBIDDEN); + return 1; + } + + log(failed ? `${failed} failed.` : "All checks passed."); + return failed ? 1 : 0; +} diff --git a/control-plane/scripts/k8s/clusterIP.test.ts b/control-plane/scripts/k8s/clusterIP.test.ts new file mode 100644 index 0000000..fdc4c82 --- /dev/null +++ b/control-plane/scripts/k8s/clusterIP.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "bun:test"; +import { DNS_IP_ARGS, nextFreeAddress, pickClusterIP, TAKEN_IPS_ARGS } from "./clusterIP"; +import { fakeRunner } from "./fakeRunner"; +import { kube } from "./run"; + +describe("nextFreeAddress", () => { + it("is the DNS address plus 10", () => { + expect(nextFreeAddress("10.96.0.10", new Set())).toBe("10.96.0.20"); + }); + it("skips taken addresses and carries across octets", () => { + expect(nextFreeAddress("10.96.0.250", new Set(["10.96.1.4", "10.96.1.5"]))).toBe("10.96.1.6"); + }); + it("refuses IPv6", () => { + expect(() => nextFreeAddress("fd00::a", new Set())).toThrow( + "the cluster DNS address fd00::a is not IPv4: set values.ironProxy.clusterIP in the target file", + ); + }); +}); + +describe("pickClusterIP", () => { + it("reads the DNS Service and the taken addresses", async () => { + const f = fakeRunner([ + { cmd: ["kubectl", "--context", "c", ...DNS_IP_ARGS], result: { stdout: "10.96.0.10" } }, + { + cmd: ["kubectl", "--context", "c", ...TAKEN_IPS_ARGS], + result: { stdout: "10.96.0.1\n10.96.0.20\nNone\n" }, + }, + ]); + expect(await pickClusterIP(kube(f.runner, "c"))).toBe("10.96.0.21"); + f.done(); + }); + + it("asks for the documented jsonpath", () => { + expect(DNS_IP_ARGS).toEqual([ + "get", + "service", + "kube-dns", + "-n", + "kube-system", + "-o", + "jsonpath={.spec.clusterIP}", + ]); + expect(TAKEN_IPS_ARGS).toEqual([ + "get", + "services", + "-A", + "-o", + 'jsonpath={range .items[*]}{.spec.clusterIP}{"\\n"}{end}', + ]); + }); +}); diff --git a/control-plane/scripts/k8s/clusterIP.ts b/control-plane/scripts/k8s/clusterIP.ts new file mode 100644 index 0000000..ca1fe73 --- /dev/null +++ b/control-plane/scripts/k8s/clusterIP.ts @@ -0,0 +1,61 @@ +// iron-proxy's pinned ClusterIP is the cluster DNS Service's address plus a +// fixed offset, skipping addresses Services already hold. The service +// range is not re-checked here: the API server validates the address, and +// the installer shows its error with the fix. +import { type Kube, KubeError } from "./run"; + +export const CLUSTER_IP_OFFSET = 10; +const MAX_TRIES = 50; + +export const DNS_IP_ARGS = [ + "get", + "service", + "kube-dns", + "-n", + "kube-system", + "-o", + "jsonpath={.spec.clusterIP}", +]; +export const TAKEN_IPS_ARGS = [ + "get", + "services", + "-A", + "-o", + 'jsonpath={range .items[*]}{.spec.clusterIP}{"\\n"}{end}', +]; + +const toInt = (ip: string) => ip.split(".").reduce((n, octet) => n * 256 + Number(octet), 0); +const toIp = (n: number) => [24, 16, 8, 0].map((s) => Math.floor(n / 2 ** s) % 256).join("."); + +export function nextFreeAddress( + dnsIp: string, + taken: Set, + offset = CLUSTER_IP_OFFSET, +): string { + if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(dnsIp)) { + throw new KubeError( + `the cluster DNS address ${dnsIp} is not IPv4: set values.ironProxy.clusterIP in the target file`, + ); + } + for (let i = 0; i < MAX_TRIES; i++) { + const candidate = toIp(toInt(dnsIp) + offset + i); + if (!taken.has(candidate)) return candidate; + } + throw new KubeError( + "no free address near the cluster DNS Service: set values.ironProxy.clusterIP in the target file", + ); +} + +export async function pickClusterIP(k: Kube): Promise { + const dns = (await k.kubectlOk(DNS_IP_ARGS)).trim(); + const taken = await k.kubectlOk(TAKEN_IPS_ARGS); + return nextFreeAddress( + dns, + new Set( + taken + .split("\n") + .map((s) => s.trim()) + .filter(Boolean), + ), + ); +} diff --git a/control-plane/scripts/k8s/connect.test.ts b/control-plane/scripts/k8s/connect.test.ts new file mode 100644 index 0000000..11ecd1a --- /dev/null +++ b/control-plane/scripts/k8s/connect.test.ts @@ -0,0 +1,161 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertPortFree, connect, freePort, isPortFree } from "./connect"; +import { fakeRunner } from "./fakeRunner"; +import { KubeError } from "./run"; +import type { Target } from "./target"; + +const root = mkdtempSync(join(tmpdir(), "devbox-connect-")); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +let n = 0; +function homeWith(profiles: Record): string { + const home = join(root, `home-${++n}`); + mkdirSync(join(home, ".config", "devbox"), { recursive: true }); + writeFileSync( + join(home, ".config", "devbox", "config.json"), + JSON.stringify({ current: undefined, profiles }), + ); + return home; +} + +function deps(over: { target: Target; home: string; free?: boolean }) { + const f = fakeRunner([]); + const logs: string[] = []; + const forwarded: string[][] = []; + return { + f, + logs, + forwarded, + deps: { + runner: f.runner, + target: over.target, + env: {}, + home: over.home, + log: (line: string) => void logs.push(line), + isPortFree: async () => over.free ?? true, + forwardForeground: async (cmd: string[]) => { + forwarded.push(cmd); + return 0; + }, + }, + }; +} + +describe("assertPortFree", () => { + it("names the port and the profile when the port is taken", async () => { + await expect(assertPortFree(13000, "k8s-kind-devbox", async () => false)).rejects.toThrow( + new KubeError( + 'port 13000 is in use: another `bun run k8s:connect` for profile "k8s-kind-devbox" may still be running (stop it, or free the port)', + ), + ); + }); + + it("passes on a free port", async () => { + await assertPortFree(13000, "k8s-kind-devbox", async () => true); + }); +}); + +describe("freePort", () => { + it("skips busy ports", async () => { + const asked: number[] = []; + const free = async (port: number) => { + asked.push(port); + return port === 13002; + }; + expect(await freePort(13000, free)).toBe(13002); + expect(asked).toEqual([13000, 13001, 13002]); + }); + + it("gives up after 100 ports", async () => { + await expect(freePort(13000, async () => false)).rejects.toThrow( + new KubeError("no free local port in 13000-13099"), + ); + }); +}); + +describe("connect", () => { + it("needs a profile from k8s:install", async () => { + const d = deps({ target: { context: "prod", values: {} }, home: homeWith({}) }); + await expect(connect(d.deps)).rejects.toThrow( + new KubeError('no CLI profile "k8s-prod": run `bun run k8s:install --target ` first'), + ); + d.f.done(); + expect(d.forwarded).toEqual([]); + }); + + it("on kind, needs a profile from k8s:kind", async () => { + const d = deps({ target: { context: "kind-devbox", values: {} }, home: homeWith({}) }); + await expect(connect(d.deps)).rejects.toThrow( + new KubeError('no CLI profile "k8s-kind-devbox": run `bun run k8s:kind` first'), + ); + d.f.done(); + expect(d.forwarded).toEqual([]); + }); + + it("does nothing for a profile behind an Ingress", async () => { + const d = deps({ + target: { context: "kind-devbox", profile: "k8s-x", values: {} }, + home: homeWith({ "k8s-x": { url: "https://devbox.example.com", token: "dbx_t" } }), + }); + expect(await connect(d.deps)).toBe(0); + d.f.done(); + expect(d.f.calls).toEqual([]); + expect(d.forwarded).toEqual([]); + expect(d.logs).toEqual([ + 'profile "k8s-x" uses https://devbox.example.com: no port-forward needed', + ]); + }); + + it("port-forwards on the profile's port", async () => { + const d = deps({ + target: { context: "kind-devbox", values: {} }, + home: homeWith({ "k8s-kind-devbox": { url: "http://localhost:13000", token: "dbx_t" } }), + }); + expect(await connect(d.deps)).toBe(0); + d.f.done(); + expect(d.forwarded).toEqual([ + [ + "kubectl", + "--context", + "kind-devbox", + "port-forward", + "-n", + "devbox", + "svc/devbox-control-plane", + "13000:3000", + "--address", + "127.0.0.1", + ], + ]); + expect(d.logs).toEqual([ + 'devbox (profile "k8s-kind-devbox") at http://localhost:13000: press Ctrl-C to stop', + ]); + }); + + it("refuses a taken port with a clear message", async () => { + const d = deps({ + target: { context: "kind-devbox", values: {} }, + home: homeWith({ "k8s-kind-devbox": { url: "http://localhost:13000", token: "dbx_t" } }), + free: false, + }); + await expect(connect(d.deps)).rejects.toThrow( + new KubeError( + 'port 13000 is in use: another `bun run k8s:connect` for profile "k8s-kind-devbox" may still be running (stop it, or free the port)', + ), + ); + expect(d.forwarded).toEqual([]); + }); +}); + +describe("isPortFree", () => { + it("sees a bound port as taken", async () => { + const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); + const port = server.port; + expect(await isPortFree(port)).toBe(false); + server.stop(true); + expect(await isPortFree(port)).toBe(true); + }); +}); diff --git a/control-plane/scripts/k8s/connect.ts b/control-plane/scripts/k8s/connect.ts new file mode 100644 index 0000000..6308a0f --- /dev/null +++ b/control-plane/scripts/k8s/connect.ts @@ -0,0 +1,123 @@ +// `bun run k8s:connect` port-forwards the control plane on the profile's pinned +// local port, so the saved URL keeps working. `k8s:install` +// runs the same port-forward in the background while it logs the CLI in. +import { cliConfigPath, readCliProfile } from "../up/cliLogin"; +import { installCommand } from "./kind"; +import { profileNameForContext } from "./profileName"; +import { type Kube, KubeError, kube, NAMESPACE, type Runner } from "./run"; +import type { Target } from "./target"; + +const HEALTH_POLL_MS = 500; +const HEALTH_LIMIT_MS = 30_000; + +export async function isPortFree(port: number): Promise { + try { + const server = Bun.listen({ hostname: "127.0.0.1", port, socket: { data() {} } }); + server.stop(true); + return true; + } catch { + return false; + } +} + +export async function freePort( + start: number, + free: (port: number) => Promise, +): Promise { + for (let port = start; port < start + 100; port++) if (await free(port)) return port; + throw new KubeError(`no free local port in ${start}-${start + 99}`); +} + +export async function assertPortFree( + port: number, + profile: string, + free: (port: number) => Promise, +): Promise { + if (!(await free(port))) { + throw new KubeError( + `port ${port} is in use: another \`bun run k8s:connect\` for profile "${profile}" may still be running (stop it, or free the port)`, + ); + } +} + +export function portForwardCommand(k: Kube, port: number): string[] { + return [ + "kubectl", + "--context", + k.context, + "port-forward", + "-n", + NAMESPACE, + "svc/devbox-control-plane", + `${port}:3000`, + "--address", + "127.0.0.1", + ]; +} + +// The CLI profile for a target: its profile, or one named after its context. +export const profileFor = (target: Target): string => + target.profile ?? profileNameForContext(target.context); + +export type ConnectDeps = { + runner: Runner; + target: Target; + env: Record; + home: string; + log: (line: string) => void; + isPortFree: (port: number) => Promise; + // Runs the command in the foreground and resolves with its exit code. + forwardForeground: (cmd: string[]) => Promise; +}; + +// Resolves with the port-forward's exit code. +export async function connect(deps: ConnectDeps): Promise { + const context = deps.target.context; + const profile = profileFor(deps.target); + const saved = readCliProfile(cliConfigPath(deps.env, deps.home), profile); + if (!saved) { + throw new KubeError( + `no CLI profile "${profile}": run \`${installCommand(context, "")}\` first`, + ); + } + const url = new URL(saved.url); + if (url.hostname !== "localhost") { + deps.log(`profile "${profile}" uses ${saved.url}: no port-forward needed`); + return 0; + } + const port = Number(url.port); + await assertPortFree(port, profile, deps.isPortFree); + deps.log(`devbox (profile "${profile}") at ${saved.url}: press Ctrl-C to stop`); + return deps.forwardForeground(portForwardCommand(kube(deps.runner, context), port)); +} + +// The production port-forwards. In the background (k8s:install): returns +// once the control plane answers through it. +export async function backgroundPortForward(k: Kube, port: number): Promise<{ stop: () => void }> { + // kubectl prints a line per connection on stdout: never read, so ignored. + const proc = Bun.spawn(portForwardCommand(k, port), { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + for (let waited = 0; waited < HEALTH_LIMIT_MS; waited += HEALTH_POLL_MS) { + if (proc.exitCode !== null) break; + try { + if ((await fetch(`http://localhost:${port}/health`)).ok) return { stop: () => proc.kill() }; + } catch { + // not forwarding yet + } + await Bun.sleep(HEALTH_POLL_MS); + } + proc.kill(); + const stderr = (await new Response(proc.stderr).text()).trim(); + throw new KubeError( + `the port-forward to the control plane did not come up${stderr ? `: ${stderr}` : ""}`, + ); +} + +// In the foreground (k8s:connect): kubectl's own output and Ctrl-C. +export async function foregroundPortForward(cmd: string[]): Promise { + const proc = Bun.spawn(cmd, { stdin: "inherit", stdout: "inherit", stderr: "inherit" }); + return proc.exited; +} diff --git a/control-plane/scripts/k8s/e2e.sh b/control-plane/scripts/k8s/e2e.sh new file mode 100755 index 0000000..2a412ef --- /dev/null +++ b/control-plane/scripts/k8s/e2e.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# End-to-end checks on a kind install. Expects `bun run k8s:kind` +# to have run and `bun run k8s:connect --target devbox.kind.json` to be +# running; the bun run k8s:* steps use devbox.kind.json, the target file +# k8s:kind wrote. Usage: +# e2e.sh / the only configured repo +# GITHUB_TOKEN must be the token the install used (to prove it is not in the +# sandbox). No agent run: there is no LLM key. +set -euo pipefail + +REPO="${1:?usage: e2e.sh /}" +NAME="${REPO#*/}" +K=(kubectl --context kind-devbox) +SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." >/dev/null 2>&1 && pwd)" +SNS=devbox-sandboxes +# Same labels as control-plane/src/clients/repoSnapshots.ts. +SNAPSHOTS=app.kubernetes.io/component=repo-snapshot,app.kubernetes.io/managed-by=devbox-snapshot-builder +pass() { echo "ok $*"; } +fail() { echo "FAIL $*" >&2; exit 1; } + +# 0. Only the local kind cluster, for the bun run k8s:* steps too. +source "$SCRIPT_DIR/kind-guard.sh" +kind_guard + +# 1. Everything is ready and a snapshot exists. +"${K[@]}" -n devbox wait --for=condition=Available deployment --all --timeout=5m >/dev/null +ready="$("${K[@]}" -n "$SNS" get volumesnapshot -l "$SNAPSHOTS" \ + -o 'jsonpath={range .items[?(@.status.readyToUse==true)]}{.metadata.name}{"\n"}{end}')" +[ -n "$ready" ] || fail "no ready repo snapshot" +pass "deployments available; snapshots: $(echo "$ready" | tr '\n' ' ')" + +# 2. A task gets a sandbox whose workspace is restored from the snapshot. +# `create --json` prints the task itself, so its key is the top-level `key`. +task="$(bun run --silent devbox create "e2e: list the files in the repo" --json | bun -e 'console.log(JSON.parse(await Bun.stdin.text()).key)')" +pass "created task $task" +pod="" +for _ in $(seq 1 60); do + pod="$("${K[@]}" -n "$SNS" get pods -l app.kubernetes.io/component=sandbox \ + --field-selector=status.phase=Running -o 'jsonpath={.items[0].metadata.name}' 2>/dev/null || true)" + [ -n "$pod" ] && break + sleep 5 +done +[ -n "$pod" ] || fail "no running sandbox pod after 5 minutes" +pvc="$("${K[@]}" -n "$SNS" get pod "$pod" -o 'jsonpath={.spec.volumes[?(@.persistentVolumeClaim)].persistentVolumeClaim.claimName}')" +source_kind="$("${K[@]}" -n "$SNS" get pvc "$pvc" -o 'jsonpath={.spec.dataSource.kind}/{.spec.dataSource.name}')" +case "$source_kind" in VolumeSnapshot/repos-*) pass "PVC $pvc restored from $source_kind" ;; *) fail "PVC $pvc source is $source_kind" ;; esac + +in_sandbox() { "${K[@]}" -n "$SNS" exec "$pod" -c sandbox -- bash -c "$1"; } +in_sandbox "test -d /workspace/$NAME/.git" && pass "/workspace/$NAME is a git checkout" || fail "/workspace/$NAME missing" + +# The pod is Running before the harness entrypoint's bootstrap has added +# iron-proxy's CA to the system bundle, so wait for it before the TLS checks. +ca_line="$(in_sandbox 'sed -n 2p /run/iron-proxy/ca.crt')" +ca_trusted() { in_sandbox "grep -qF '$ca_line' /etc/ssl/certs/ca-certificates.crt"; } +for _ in $(seq 1 30); do ca_trusted && break; sleep 2; done +ca_trusted && pass "iron-proxy CA in the system bundle" || fail "iron-proxy CA not in the system bundle after 60s" + +# Git works in the restored repo as the harness runs it (root, after bootstrap): +# the snapshot builder clones as another uid, which git refuses by default. +# bootstrap may still be running, so allow it a few seconds. +git_works() { in_sandbox "git -C /workspace/$NAME status --porcelain=v1 >/dev/null 2>&1"; } +for _ in $(seq 1 10); do git_works && break; sleep 2; done +git_works && pass "git status works in /workspace/$NAME" || fail "git status failed in /workspace/$NAME" + +# 3. Egress: allowlisted GitHub reads work through iron-proxy; nothing else does. +# curl sends the dummy bearer token the sandbox holds; iron-proxy swaps it for the real one. +in_sandbox "curl -fsS -o /dev/null -H 'Authorization: Bearer proxy-github-dummy-token' https://api.github.com/repos/$REPO" && pass "allowlisted GitHub read works" || fail "allowlisted GitHub read failed" +in_sandbox "git ls-remote https://github.com/$REPO.git HEAD >/dev/null" && pass "git ls-remote through iron-proxy works" || fail "git ls-remote failed" + +# A negative check must prove it ran: the probe prints its exit code inside the +# sandbox, so a failed exec or an exited pod is a FAIL, not a pass. +blocked() { # blocked + local out rc + out="$(in_sandbox "$2 >/dev/null 2>&1; echo rc=\$?" 2>/dev/null || true)" + rc="$(grep -o '^rc=[0-9]*$' <<<"$out" | tail -1 || true)" + [ -n "$rc" ] || fail "could not run the check in the sandbox: $1" + [ "$rc" != "rc=0" ] && pass "$1" || fail "$1: the probe succeeded" +} +blocked "non-allowlisted host refused" "curl -fsS -m 10 -o /dev/null https://example.com" +blocked "raw IP blocked" "curl -sS -m 5 -o /dev/null http://1.1.1.1" +k8s_ip="$("${K[@]}" -n default get service kubernetes -o 'jsonpath={.spec.clusterIP}')" +blocked "Kubernetes API blocked" "timeout 5 bash -c '/dev/null; echo END')" +grep -q '^END$' <<<"$out" || fail "could not read the sandbox environment" +# Kubernetes always injects API discovery variables under KUBERNETES_ even +# when enableServiceLinks is false. They do not come from control-plane +# forwarding; every other Service-link-shaped variable is unexpected. +out_without_kubernetes_api="$(sed '/^KUBERNETES_/d' <<<"$out")" +service_links="$( + sed -n 's/=.*//p' <<<"$out_without_kubernetes_api" | + sort -u | + (cd "$REPO_ROOT" && bun -e 'import { isServiceLinkEnvName } from "./control-plane/src/forwardedEnv.ts"; +const names = (await Bun.stdin.text()).split(/\n/).filter( + (name) => name && isServiceLinkEnvName(name), +); +process.stdout.write(names.join("\n"));') +)" +[ -z "$service_links" ] || fail "unexpected Kubernetes Service-link variables reached the sandbox: $(tr '\n' ' ' <<<"$service_links")" +if grep -qE '^[A-Z0-9_]+_PORT=tcp://' <<<"$out_without_kubernetes_api"; then fail "an unexpected Kubernetes tcp:// Service link reached the sandbox"; fi +pass "no unexpected Kubernetes Service-link variables in sandbox process environments" +if grep -qF "$GITHUB_TOKEN" <<<"$out"; then fail "the real GitHub token is in the sandbox"; fi +pass "no real token in the sandbox env or git config" + +# 4. Refresh and purge. +before="$(echo "$ready" | sort | tail -1)" +bun run k8s:refresh-snapshot --target devbox.kind.json +after="$("${K[@]}" -n "$SNS" get volumesnapshot -l "$SNAPSHOTS" \ + --sort-by=.metadata.creationTimestamp -o 'jsonpath={.items[-1:].metadata.name}')" +[ "$after" != "$before" ] && pass "refresh made $after" || fail "refresh made no newer snapshot" +bun run k8s:uninstall --target devbox.kind.json --purge --yes +# Uninstall deletes the namespaces without waiting, so wait for them. +for ns in "$SNS" devbox; do + "${K[@]}" wait --for=delete "namespace/$ns" --timeout=3m 2>/dev/null || true + # A missing namespace is the expected NotFound; any other error fails here. + if err="$("${K[@]}" get namespace "$ns" 2>&1 >/dev/null)"; then fail "namespace $ns left"; fi + case "$err" in *NotFound*) pass "namespace $ns gone" ;; *) fail "could not check namespace $ns: $err" ;; esac +done +[ -z "$("${K[@]}" get volumesnapshotcontents -o name)" ] && pass "no snapshot contents left" || fail "snapshot contents left" +left_pv="$("${K[@]}" get pv -o 'jsonpath={range .items[*]}{.spec.claimRef.namespace}{"\n"}{end}' | grep -xE "devbox|$SNS" || true)" +[ -z "$left_pv" ] && pass "no PersistentVolume left" || fail "PersistentVolume left for a purged namespace" +[ -z "$(helm --kube-context kind-devbox list -A -q)" ] && pass "no Helm release left" || fail "Helm release left" +echo "all end-to-end checks passed" diff --git a/control-plane/scripts/k8s/fakeRunner.ts b/control-plane/scripts/k8s/fakeRunner.ts new file mode 100644 index 0000000..68981f9 --- /dev/null +++ b/control-plane/scripts/k8s/fakeRunner.ts @@ -0,0 +1,29 @@ +// A scripted Runner for installer tests: each call must match the next step's +// command exactly; its result defaults to success with empty output. +import type { Runner, RunOptions, RunResult } from "./run"; + +export type Step = { cmd: string[]; result?: Partial }; +export type Call = { cmd: string[]; stdin?: string }; + +export function fakeRunner(script: Step[]): { runner: Runner; calls: Call[]; done: () => void } { + const calls: Call[] = []; + let i = 0; + const runner: Runner = async (cmd: string[], opts?: RunOptions) => { + calls.push({ cmd, ...(opts?.stdin !== undefined ? { stdin: opts.stdin } : {}) }); + const step = script[i++]; + if (!step || JSON.stringify(step.cmd) !== JSON.stringify(cmd)) { + throw new Error( + `unexpected command #${i}: ${cmd.join(" ")}\n expected: ${step?.cmd.join(" ") ?? "(none)"}`, + ); + } + return { code: 0, stdout: "", stderr: "", ...step.result }; + }; + return { + runner, + calls, + done: () => { + if (i !== script.length) + throw new Error(`${script.length - i} scripted command(s) never ran`); + }, + }; +} diff --git a/control-plane/scripts/k8s/install.test.ts b/control-plane/scripts/k8s/install.test.ts new file mode 100644 index 0000000..d593a3b --- /dev/null +++ b/control-plane/scripts/k8s/install.test.ts @@ -0,0 +1,2707 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { appIdentity } from "../lib/githubApp"; +import { MemoryKeychain } from "../lib/keychain"; +import type { DevboxConfig } from "../up/config"; +import { type Fetch, PreflightError } from "../up/preflight"; +import { DNS_IP_ARGS, TAKEN_IPS_ARGS } from "./clusterIP"; +import { fakeRunner, type Step } from "./fakeRunner"; +import { CHART_PATH, type InstallDeps, type InstallOptions, install } from "./install"; +import { denyAllPolicy, probePod } from "./preflight"; +import { KubeError, type RunResult } from "./run"; +import { secretManifest } from "./secrets"; +import type { Target } from "./target"; +import { chartValues, type Values } from "./values"; + +const root = mkdtempSync(join(tmpdir(), "devbox-install-")); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +const kc = (...args: string[]) => ["kubectl", "--context", "kind-devbox", ...args]; +const hc = (...args: string[]) => ["helm", "--kube-context", "kind-devbox", ...args]; +const step = (cmd: string[], result?: Partial): Step => ({ cmd, result }); +const NOW = new Date("2026-10-14T12:00:00Z"); +const SUFFIX = "20261014120000"; + +const OVERLAY: Values = { + storage: { storageClass: "csi-hostpath-sc", volumeSnapshotClass: "csi-hostpath-snapclass" }, +}; +const GITHUB_APP = { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 456, + botUserId: 789, + tokens: "devbox", + tokenScope: "repos", + ciResults: true, +} as const; +const { privateKey: PEM } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); +const config = (): DevboxConfig => ({ + repos: [ + { url: "https://github.com/acme/widgets.git", branch: "main", owner: "acme", name: "widgets" }, + ], + llm: { provider: "openai", baseUrl: "https://llm.example.com/v1", model: "m" }, + github: { trustedOrgs: [], trustedUsers: [] }, + integrations: {}, +}); +const target = (values: Values = {}): Target => ({ context: "kind-devbox", values }); +const IDENTITY = { gitName: "Octo Cat", gitEmail: "7+octo@users.noreply.github.com" }; +const URL_ = "http://localhost:13000"; + +const LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token"; +const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"; + +// GitHub, the LLM endpoint, Linear and the control plane's /health and /api/me. +function fakeFetch(me: number, base = URL_) { + const calls: string[] = []; + const fetch: Fetch = async (url, init) => { + const auth = new Headers(init?.headers).get("Authorization"); + calls.push(`${init?.method ?? "GET"} ${url} ${auth}`); + if (url === "https://api.github.com/user") + return Response.json({ id: 7, login: "octo", name: "Octo Cat" }); + if (url === "https://api.github.com/repos/acme/widgets") return Response.json({}); + if (url === "https://github.com/acme/widgets.git/info/refs?service=git-receive-pack") + return new Response("001f# service=git-receive-pack\n"); + if (url === "https://api.github.com/repos/acme/widgets/pulls") + return Response.json({ message: "Validation Failed" }, { status: 422 }); + if (url === "https://llm.example.com/v1/responses") return Response.json({}); + if (url === LINEAR_TOKEN_URL) { + const body = new URLSearchParams(String(init?.body)); + calls.push(`linear client ${body.get("client_id")} secret ${body.get("client_secret")}`); + return Response.json({ access_token: "lin_tok", token_type: "Bearer", expires_in: 2592000 }); + } + if (url === LINEAR_GRAPHQL_URL) { + return Response.json({ + data: { viewer: { name: "devbox" }, organization: { name: "Example", urlKey: "example" } }, + }); + } + if (url === `${base}/health`) return new Response("ok"); + if (url === `${base}/api/me`) return new Response("", { status: me }); + return new Response("", { status: 404 }); + }; + return { fetch, calls }; +} + +let n = 0; +function setup( + script: Step[], + opts: { + target?: Target; + me?: number; + url?: string; + profiles?: Record; + env?: Record; + keychain?: MemoryKeychain; + isTTY?: boolean; + answers?: string[]; + } = {}, +) { + const f = fakeRunner(script); + const home = join(root, `home-${++n}`); + const path = join(home, ".config", "devbox", "config.json"); + if (opts.profiles) { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, JSON.stringify({ current: undefined, profiles: opts.profiles })); + } + const fetch = fakeFetch(opts.me ?? 401, opts.url); + const logs: string[] = []; + const forwards: number[] = []; + const written: Record = {}; + const answers = [...(opts.answers ?? [])]; + const asked: string[] = []; + const keychain = opts.keychain ?? new MemoryKeychain(); + const deps: InstallDeps = { + runner: f.runner, + cfg: config(), + target: opts.target ?? target(), + targetPath: "devbox.k8s.json", + readFile: () => undefined, + env: opts.env ?? { GITHUB_TOKEN: "ghp_x", LLM_API_KEY: "sk-y" }, + keychain, + isTTY: opts.isTTY ?? false, + home, + fetch: fetch.fetch, + log: (line) => void logs.push(line), + sleep: async () => {}, + now: () => NOW, + prompt: async (q) => { + const answer = answers.shift(); + if (answer === undefined) throw new Error(`no prompt expected: ${q}`); + asked.push(q); + return answer; + }, + confirm: async (q) => { + asked.push(q); + return true; + }, + portForward: async (_k, port) => { + forwards.push(port); + return { stop: () => {} }; + }, + isPortFree: async (port) => port === 13000, + writeFile: (p, text) => { + written[p] = text; + }, + }; + return { f, deps, path, logs, forwards, written, asked, keychain, fetchCalls: fetch.calls }; +} + +const OPTS: InstallOptions = { + skipPushCheck: false, + skipLlmCheck: false, + skipLinearCheck: false, + netpol: "auto", + timeout: "15m", +}; + +const NETPOL_NS = `devbox-netpol-check-${SUFFIX}`; +const phase = (pod: string) => + kc("get", "pod", pod, "-n", NETPOL_NS, "-o", "jsonpath={.status.phase}"); +const APPLY = kc("apply", "-f", "-"); +// With no devbox-integrations to write, the installer's own one goes. +const DELETE_INTEGRATIONS = kc( + "delete", + "secret", + "-n", + "devbox", + "-l", + "app.kubernetes.io/managed-by=devbox-installer", + "--field-selector", + "metadata.name=devbox-integrations", + "--ignore-not-found", +); +// An installer-owned key Secret the run does not write goes, as does the +// installer's token Secret with external tokens; the user's own never. +const deleteOwned = (name: string) => + kc( + "delete", + "secret", + "-n", + "devbox", + "-l", + "app.kubernetes.io/managed-by=devbox-installer", + "--field-selector", + `metadata.name=${name}`, + "--ignore-not-found", + ); +const DELETE_APP_KEY = deleteOwned("devbox-github-app"); +const DELETE_TOKEN_SECRET = deleteOwned("devbox-github"); +// The same script when devbox-integrations is written: one more apply instead. +const writingIntegrations = (script: Step[]): Step[] => + script.map((st) => (st.cmd === DELETE_INTEGRATIONS ? step(APPLY) : st)); +// The same script with the user's own integrations Secret: neither. +const byoIntegrations = (script: Step[]): Step[] => + script.filter((st) => st.cmd !== DELETE_INTEGRATIONS); +// The same script with the user's own GitHub token Secret: one apply less. +const byoGithub = (script: Step[]): Step[] => { + // Back from the cluster IP lookup over the Secret writes to the namespaces; + // devbox-github comes right after them. + let ns = script.indexOf(CLUSTER_IP[0] as Step); + const writes = [APPLY, DELETE_INTEGRATIONS, DELETE_APP_KEY]; + while (writes.includes(script[ns - 1]?.cmd as string[])) ns--; + return script.filter((_, k) => k !== ns + 1); +}; + +const API = "https://api.github.com"; +const GET_APP = `GET ${API}/app`; +const APP_OK: [number, unknown] = [200, { id: 123, slug: "example-devbox" }]; +const COVERAGE = `GET ${API}/repos/acme/widgets/installation`; +const MINT = `POST ${API}/app/installations/456/access_tokens`; +const INSTALLATION = `GET ${API}/app/installations/456`; +const EXPIRES = "2026-10-14T13:00:00Z"; +const APP_ROUTES: Record = { + [GET_APP]: APP_OK, + [COVERAGE]: [200, { id: 456 }], + [MINT]: [201, { token: "ghs_minted", expires_at: EXPIRES }], + [`GET ${API}/user`]: [403, { message: "Resource not accessible by integration" }], +}; +// Answers the GitHub App's calls; every request is recorded, in order. +function appFetch( + s: { deps: InstallDeps }, + routes: Record = APP_ROUTES, +): string[] { + const inner = s.deps.fetch; + const calls: string[] = []; + s.deps.fetch = async (url, init) => { + const call = `${init?.method ?? "GET"} ${url}`; + calls.push(call); + const route = routes[call]; + return route ? Response.json(route[1], { status: route[0] }) : inner(url, init); + }; + return calls; +} + +const NETPOL: Step[] = [ + step(kc("create", "namespace", NETPOL_NS)), + step(APPLY), + step(kc("wait", "--for=condition=Ready", "pod/server", "-n", NETPOL_NS, "--timeout=120s")), + step(kc("get", "pod", "server", "-n", NETPOL_NS, "-o", "jsonpath={.status.podIP}"), { + stdout: "10.244.0.7", + }), + step(APPLY), + step(phase("client-before"), { stdout: "Succeeded" }), + step(APPLY), + step(APPLY), + step(phase("client-after"), { stdout: "Failed" }), + step(kc("delete", "namespace", NETPOL_NS, "--wait=false")), +]; +const NETPOL_STDIN = [ + probePod(NETPOL_NS, "server", ["netexec", "--http-port=8080"]), + probePod(NETPOL_NS, "client-before", ["connect", "10.244.0.7:8080", "--timeout=5s"]), + denyAllPolicy(NETPOL_NS), + probePod(NETPOL_NS, "client-after", ["connect", "10.244.0.7:8080", "--timeout=5s"]), +]; + +const NOT_FOUND = { code: 1, stderr: "Error from server (NotFound): not found\n" }; +const TOOLS: Step[] = [ + step(["kubectl", "version", "--client", "-o", "json"], { stdout: "{}" }), + step(["helm", "version", "--short"], { stdout: "v3.22.0+g1a2b3c4\n" }), + step(kc("version", "-o", "json"), { + stdout: JSON.stringify({ serverVersion: { major: "1", minor: "33" } }), + }), +]; +const HELM_STATUS = hc("status", "devbox", "-n", "devbox"); +const STORAGE: Step[] = [ + step(kc("get", "storageclass", "csi-hostpath-sc", "-o", "json"), { + stdout: JSON.stringify({ provisioner: "hostpath.csi.k8s.io" }), + }), + step(kc("get", "volumesnapshotclass", "csi-hostpath-snapclass", "-o", "json"), { + stdout: JSON.stringify({ driver: "hostpath.csi.k8s.io" }), + }), +]; +const NODES = step(kc("get", "nodes", "-o", "json"), { + stdout: JSON.stringify({ items: [{ status: { allocatable: { cpu: "8", memory: "32Gi" } } }] }), +}); +const OPENSANDBOX = (crd: Partial): Step[] => [ + step( + kc( + "get", + "deployments", + "-A", + "-l", + "app.kubernetes.io/name=opensandbox,control-plane=controller-manager", + "-o", + 'jsonpath={range .items[*]}{.metadata.namespace}{"\\n"}{end}', + ), + ), + step( + kc( + "get", + "crd", + "batchsandboxes.sandbox.opensandbox.io", + "-o", + "jsonpath={.metadata.annotations.meta\\.helm\\.sh/release-name}", + ), + crd, + ), +]; +const CLUSTER_IP: Step[] = [ + step(kc(...DNS_IP_ARGS), { stdout: "10.96.0.10" }), + step(kc(...TAKEN_IPS_ARGS), { stdout: "10.96.0.1\n10.96.0.10\n" }), +]; +const TEMPLATE = kc( + "get", + "configmap", + "devbox-sandbox-template", + "-n", + "devbox", + "-o", + "jsonpath={.data}", +); +const HELM_UPGRADE = hc( + "upgrade", + "--install", + "devbox", + CHART_PATH, + "-n", + "devbox", + "-f", + "-", + "--wait", + "--timeout", + "15m", +); +const GENERATION = kc( + "get", + "deploy", + "devbox-control-plane", + "-n", + "devbox", + "-o", + "jsonpath={.metadata.generation}", +); +const READY = step( + kc( + "get", + "volumesnapshot", + "-n", + "devbox-sandboxes", + "-l", + "app.kubernetes.io/component=repo-snapshot,app.kubernetes.io/managed-by=devbox-snapshot-builder", + "-o", + 'jsonpath={range .items[?(@.status.readyToUse==true)]}{.metadata.name}{"\\n"}{end}', + ), + { stdout: "repos-20261014120100\n" }, +); +const TOKEN = step( + kc( + "exec", + "-n", + "devbox", + "deploy/devbox-control-plane", + "--", + "bun", + "run", + "api-token", + "create", + `devbox-cli-${SUFFIX}`, + ), + { stdout: "Created API token devbox-cli\ndbx_new\n" }, +); +const userSecretStdin = [ + secretManifest({ name: "devbox-github", data: { token: "ghp_x" } }), + secretManifest({ name: "devbox-llm", data: { "api-key": "sk-y" } }), +]; +const namespaces = { + apiVersion: "v1", + kind: "List", + items: ["devbox", "devbox-sandboxes"].map((name) => ({ + apiVersion: "v1", + kind: "Namespace", + metadata: { name, labels: { "app.kubernetes.io/part-of": "devbox" } }, + })), +}; +// The token's checks with --skip-push-check: the user and the repo. +const GITHUB_READ_FETCHES = (token: string) => [ + `GET https://api.github.com/user Bearer ${token}`, + `GET https://api.github.com/repos/acme/widgets Bearer ${token}`, +]; +const GITHUB_FETCHES = (token: string) => [ + ...GITHUB_READ_FETCHES(token), + `GET https://github.com/acme/widgets.git/info/refs?service=git-receive-pack Basic ${btoa(`x-access-token:${token}`)}`, + `POST https://api.github.com/repos/acme/widgets/pulls Bearer ${token}`, +]; + +// `helm get values -o json` is Go's encoding/json, which sorts map keys. +const sortedKeys = (v: unknown): unknown => + Array.isArray(v) + ? v.map(sortedKeys) + : v && typeof v === "object" + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, sortedKeys((v as Values)[k])]), + ) + : v; +const helmValues = (v: Values) => JSON.stringify(sortedKeys(v)); +const BOOTSTRAP_LOGS = kc("logs", "job/devbox-bootstrap", "-n", "devbox", "--tail=50"); +const GET_VALUES = hc("get", "values", "devbox", "-n", "devbox", "-o", "json"); + +const commands = (f: { calls: { cmd: string[] }[] }) => f.calls.map((c) => c.cmd.join(" ")); +const stdinOf = (f: { calls: { cmd: string[]; stdin?: string }[] }, cmd: string[]) => + f.calls.filter((c) => c.cmd.join(" ") === cmd.join(" ")).map((c) => c.stdin); + +describe("install", () => { + it("installs on a fresh cluster and logs the CLI in", async () => { + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + READY, + TOKEN, + ]; + const s = setup(script); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + ...userSecretStdin.map((t) => JSON.parse(t)), + ]); + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues( + config(), + {}, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY }, + OVERLAY, + ), + ); + expect(JSON.parse(readFileSync(s.path, "utf8"))).toEqual({ + current: "k8s-kind-devbox", + profiles: { "k8s-kind-devbox": { url: URL_, token: "dbx_new" } }, + }); + expect(s.forwards).toEqual([13000]); + expect(s.fetchCalls).toEqual([ + ...GITHUB_FETCHES("ghp_x"), + "POST https://llm.example.com/v1/responses Bearer sk-y", + ]); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `, + ]); + }); + + it("keeps the CLI on a port-forward with a webhooks Ingress", async () => { + const ingress = { + webhooks: { + enabled: true, + host: "hooks.example.com", + tls: [{ hosts: ["hooks.example.com"], secretName: "hooks-tls" }], + }, + }; + const tgt = target({ ingress }); + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + READY, + TOKEN, + ]; + const s = setup(script, { target: tgt }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues( + config(), + tgt.values, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY }, + OVERLAY, + ), + ); + expect(JSON.parse(readFileSync(s.path, "utf8"))).toEqual({ + current: "k8s-kind-devbox", + profiles: { "k8s-kind-devbox": { url: URL_, token: "dbx_new" } }, + }); + expect(s.forwards).toEqual([13000]); + expect(s.logs.at(-1)).toEqual(` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `); + }); + + const API_TARGET = () => + target({ + ingress: { + api: { + enabled: true, + host: "devbox.example.com", + tls: [{ hosts: ["devbox.example.com"], secretName: "devbox-tls" }], + }, + }, + }); + const FRESH = (last: Step[]): Step[] => [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + READY, + ...last, + ]; + + it("uses the api Ingress URL for the CLI profile and task links", async () => { + const tgt = API_TARGET(); + const script = FRESH([TOKEN]); + const s = setup(script, { target: tgt, url: "https://devbox.example.com" }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues( + config(), + tgt.values, + { + clusterIP: "10.96.0.20", + publicBaseUrl: "https://devbox.example.com", + identity: IDENTITY, + }, + OVERLAY, + ), + ); + expect(JSON.parse(readFileSync(s.path, "utf8"))).toEqual({ + current: "k8s-kind-devbox", + profiles: { + "k8s-kind-devbox": { url: "https://devbox.example.com", token: "dbx_new" }, + }, + }); + expect(s.forwards).toEqual([]); + expect(s.fetchCalls.at(-1)).toEqual("GET https://devbox.example.com/health null"); + expect(s.logs.at(-1)).toEqual(` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `); + }); + + it("stops before the CLI login when the api Ingress does not answer", async () => { + const script = FRESH([]); + const s = setup(script, { target: API_TARGET() }); + const err = await install(s.deps, OPTS, OVERLAY).then( + () => undefined, + (e) => e, + ); + s.f.done(); + expect(err).toBeInstanceOf(KubeError); + expect(err.message).toContain("cannot reach https://devbox.example.com/health"); + expect(err.message).toContain("VPN"); + expect(err.message).toContain("HTTP 404"); + expect(err.message).toContain("provisioning"); + expect(s.logs.filter((l) => l.startsWith("waiting for"))).toEqual([ + "waiting for https://devbox.example.com/health", + ]); + expect(existsSync(s.path)).toBe(false); + }); + + it("waits for the api Ingress to answer", async () => { + const script = FRESH([TOKEN]); + const s = setup(script, { target: API_TARGET(), url: "https://devbox.example.com" }); + const inner = s.deps.fetch; + let health = 0; + let sleeps = 0; + s.deps.sleep = async () => void sleeps++; + s.deps.fetch = async (url, init) => { + if (url === "https://devbox.example.com/health" && ++health <= 2) + return new Response("", { status: 503 }); + return inner(url, init); + }; + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(s.logs.filter((l) => l.startsWith("waiting for"))).toEqual([ + "waiting for https://devbox.example.com/health", + ]); + expect(health).toBe(3); + expect(sleeps).toBeGreaterThanOrEqual(2); + expect(existsSync(s.path)).toBe(true); + }); + + it("reports the rejection when the api Ingress host cannot be reached", async () => { + const script = FRESH([]); + const s = setup(script, { target: API_TARGET() }); + const inner = s.deps.fetch; + s.deps.fetch = async (url, init) => { + if (url === "https://devbox.example.com/health") throw new Error("getaddrinfo ENOTFOUND"); + return inner(url, init); + }; + const err = await install(s.deps, OPTS, OVERLAY).then( + () => undefined, + (e) => e, + ); + s.f.done(); + expect(err).toBeInstanceOf(KubeError); + expect(err.message).toContain("cannot reach https://devbox.example.com/health"); + expect(err.message).toContain("getaddrinfo ENOTFOUND"); + expect(existsSync(s.path)).toBe(false); + }); + + it("uses the api Ingress URL when the webhooks Ingress is enabled too", async () => { + const tgt = target({ + ingress: { + webhooks: { enabled: true, host: "hooks.example.com" }, + api: { + enabled: true, + host: "devbox.example.com", + tls: [{ hosts: ["devbox.example.com"], secretName: "devbox-tls" }], + }, + }, + }); + const script = FRESH([TOKEN]); + const s = setup(script, { target: tgt, url: "https://devbox.example.com" }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues( + config(), + tgt.values, + { + clusterIP: "10.96.0.20", + publicBaseUrl: "https://devbox.example.com", + identity: IDENTITY, + }, + OVERLAY, + ), + ); + expect(JSON.parse(readFileSync(s.path, "utf8")).profiles["k8s-kind-devbox"].url).toBe( + "https://devbox.example.com", + ); + expect(s.forwards).toEqual([]); + expect(s.fetchCalls.at(-1)).toEqual("GET https://devbox.example.com/health null"); + }); + + it("moves the CLI back to a port-forward when the Ingress is disabled on a re-run", async () => { + const ingress = { api: { enabled: true, host: "devbox.example.com" } }; + const script = [ + ...TOOLS, + step(HELM_STATUS, { stdout: "STATUS: deployed\n" }), + step(GET_VALUES, { + stdout: helmValues( + chartValues( + config(), + { ingress }, + { clusterIP: "10.96.0.20", publicBaseUrl: "http://devbox.example.com" }, + OVERLAY, + ), + ), + }), + ...STORAGE, + NODES, + ...OPENSANDBOX({ stdout: "devbox" }), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + READY, + TOKEN, + ]; + const s = setup(script, { + profiles: { "k8s-kind-devbox": { url: "http://devbox.example.com", token: "dbx_old" } }, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues( + config(), + {}, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY }, + OVERLAY, + ), + ); + expect(JSON.parse(readFileSync(s.path, "utf8"))).toEqual({ + current: "k8s-kind-devbox", + profiles: { "k8s-kind-devbox": { url: URL_, token: "dbx_new" } }, + }); + expect(s.forwards).toEqual([13000]); + }); + + it("installs with a GitHub App token: no commit identity, so the chart's git defaults apply", async () => { + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + READY, + TOKEN, + ]; + const s = setup(script); + const fetch = s.deps.fetch; + s.deps.fetch = async (url, init) => + url === "https://api.github.com/user" + ? Response.json({ message: "Resource not accessible by integration" }, { status: 403 }) + : fetch(url, init); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues(config(), {}, { clusterIP: "10.96.0.20", publicBaseUrl: URL_ }, OVERLAY), + ); + }); + + it("upgrades: restarts the server on a template change and refreshes changed repos", async () => { + const previous = { + ...chartValues(config(), {}, { clusterIP: "10.96.0.20", publicBaseUrl: URL_ }, OVERLAY), + repos: [{ url: "https://github.com/acme/old.git", branch: "main" }], + }; + const JOB = `devbox-snapshot-builder-manual-${SUFFIX}`; + const script = [ + ...TOOLS, + step(HELM_STATUS, { stdout: "NAME: devbox\nSTATUS: deployed\n" }), + step(GET_VALUES, { + stdout: helmValues(previous), + }), + ...STORAGE, + NODES, + ...OPENSANDBOX({ stdout: "devbox" }), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"b"}' }), + step(GENERATION, { stdout: "3" }), + step(kc("rollout", "restart", "deployment/opensandbox-server", "-n", "devbox")), + step( + kc("rollout", "status", "deployment/opensandbox-server", "-n", "devbox", "--timeout=5m"), + ), + step( + kc( + "create", + "job", + JOB, + "--from=cronjob/devbox-snapshot-builder", + "-n", + "devbox-sandboxes", + ), + ), + step( + kc( + "get", + "job", + JOB, + "-n", + "devbox-sandboxes", + "-o", + "jsonpath={.status.succeeded}/{.status.failed}", + ), + { stdout: "1/" }, + ), + step( + kc( + "get", + "volumesnapshot", + "-n", + "devbox-sandboxes", + "-l", + "app.kubernetes.io/component=repo-snapshot,app.kubernetes.io/managed-by=devbox-snapshot-builder", + "--sort-by=.metadata.creationTimestamp", + "-o", + "jsonpath={.items[-1:].metadata.name}", + ), + { stdout: "repos-20261014120200" }, + ), + READY, + ]; + const s = setup(script, { + me: 200, + profiles: { "k8s-kind-devbox": { url: URL_, token: "dbx_old" } }, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + namespaces, + ...userSecretStdin.map((t) => JSON.parse(t)), + ]); + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues( + config(), + {}, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY }, + OVERLAY, + ), + ); + expect(JSON.parse(readFileSync(s.path, "utf8"))).toEqual({ + current: "k8s-kind-devbox", + profiles: { "k8s-kind-devbox": { url: URL_, token: "dbx_old" } }, + }); + expect(s.fetchCalls).toEqual([ + ...GITHUB_FETCHES("ghp_x"), + "POST https://llm.example.com/v1/responses Bearer sk-y", + `GET ${URL_}/api/me Bearer dbx_old`, + ]); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "sandbox template changed: restarting the OpenSandbox server", + "[6/7] Repo snapshot", + "repos changed: building a fresh snapshot", + `running ${JOB}`, + "snapshot repos-20261014120200 is ready", + "[7/7] CLI login", + 'CLI profile "k8s-kind-devbox" is still valid', + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `, + ]); + }); + + it("builds a snapshot when none is ready after an upgrade", async () => { + const previous = chartValues( + config(), + {}, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY }, + OVERLAY, + ); + const JOB = `devbox-snapshot-builder-manual-${SUFFIX}`; + const script = [ + ...TOOLS, + step(HELM_STATUS, { stdout: "NAME: devbox\nSTATUS: failed\n" }), + step(GET_VALUES, { stdout: helmValues(previous) }), + ...STORAGE, + NODES, + ...OPENSANDBOX({ stdout: "devbox" }), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(READY.cmd, { stdout: "" }), + step( + kc( + "create", + "job", + JOB, + "--from=cronjob/devbox-snapshot-builder", + "-n", + "devbox-sandboxes", + ), + ), + step( + kc( + "get", + "job", + JOB, + "-n", + "devbox-sandboxes", + "-o", + "jsonpath={.status.succeeded}/{.status.failed}", + ), + { stdout: "1/" }, + ), + step( + kc( + "get", + "volumesnapshot", + "-n", + "devbox-sandboxes", + "-l", + "app.kubernetes.io/component=repo-snapshot,app.kubernetes.io/managed-by=devbox-snapshot-builder", + "--sort-by=.metadata.creationTimestamp", + "-o", + "jsonpath={.items[-1:].metadata.name}", + ), + { stdout: "repos-20261014120200" }, + ), + READY, + TOKEN, + ]; + const s = setup(script); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "no ready repo snapshot: building one", + `running ${JOB}`, + "snapshot repos-20261014120200 is ready", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `, + ]); + }); + + const NO_SNAPSHOT = + "no ready repo snapshot: run `bun run k8s:refresh-snapshot --target devbox.k8s.json` and read its output"; + + it("stops a first install that has no ready snapshot, without a refresh", async () => { + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(READY.cmd, { stdout: "" }), + ]; + const s = setup(script); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow(NO_SNAPSHOT); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + }); + + it("stops an upgrade when the refresh still leaves no ready snapshot", async () => { + const previous = chartValues( + config(), + {}, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY }, + OVERLAY, + ); + const JOB = `devbox-snapshot-builder-manual-${SUFFIX}`; + const script = [ + ...TOOLS, + step(HELM_STATUS, { stdout: "NAME: devbox\nSTATUS: deployed\n" }), + step(GET_VALUES, { stdout: helmValues(previous) }), + ...STORAGE, + NODES, + ...OPENSANDBOX({ stdout: "devbox" }), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(READY.cmd, { stdout: "" }), + step( + kc( + "create", + "job", + JOB, + "--from=cronjob/devbox-snapshot-builder", + "-n", + "devbox-sandboxes", + ), + ), + step( + kc( + "get", + "job", + JOB, + "-n", + "devbox-sandboxes", + "-o", + "jsonpath={.status.succeeded}/{.status.failed}", + ), + { stdout: "1/" }, + ), + step( + kc( + "get", + "volumesnapshot", + "-n", + "devbox-sandboxes", + "-l", + "app.kubernetes.io/component=repo-snapshot,app.kubernetes.io/managed-by=devbox-snapshot-builder", + "--sort-by=.metadata.creationTimestamp", + "-o", + "jsonpath={.items[-1:].metadata.name}", + ), + { stdout: "repos-20261014120200" }, + ), + step(READY.cmd, { stdout: "" }), + ]; + const s = setup(script); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow(NO_SNAPSHOT); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + }); + + it("skips the checks it is told to skip, with warnings", async () => { + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + READY, + TOKEN, + ]; + const s = setup(script); + await install( + s.deps, + { ...OPTS, netpol: "skip", skipPushCheck: true, skipLlmCheck: true }, + OVERLAY, + ); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.fetchCalls).toEqual(GITHUB_READ_FETCHES("ghp_x")); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "warning: push and pull request checks skipped (--skip-push-check): tasks fail when they push or open a pull request if the token cannot", + "warning: LLM check skipped (--skip-llm-check)", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "warning: NetworkPolicy check skipped (--skip-netpol-check): sandbox egress is not locked down without a CNI that enforces it", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `, + ]); + }); + + it("logs in through a port k8s:connect already holds", async () => { + const script = [ + ...TOOLS, + step(HELM_STATUS, { stdout: "STATUS: deployed\n" }), + step(GET_VALUES, { + stdout: helmValues( + chartValues(config(), {}, { clusterIP: "10.96.0.20", publicBaseUrl: URL_ }, OVERLAY), + ), + }), + ...STORAGE, + NODES, + ...OPENSANDBOX({ stdout: "devbox" }), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + READY, + ]; + const s = setup(script, { + me: 200, + profiles: { "k8s-kind-devbox": { url: URL_, token: "dbx_old" } }, + }); + s.deps.isPortFree = async () => false; + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.forwards).toEqual([]); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + "port 13000 is in use (by `bun run k8s:connect`?): logging in through it", + 'CLI profile "k8s-kind-devbox" is still valid', + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `, + ]); + }); + + it("upgrades without a refresh when only the key order of repos differs", async () => { + const script = [ + ...TOOLS, + step(HELM_STATUS, { stdout: "STATUS: deployed\n" }), + step(GET_VALUES, { + stdout: helmValues( + chartValues( + config(), + {}, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY }, + OVERLAY, + ), + ), + }), + ...STORAGE, + NODES, + ...OPENSANDBOX({ stdout: "devbox" }), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + READY, + ]; + const s = setup(script, { + me: 200, + profiles: { "k8s-kind-devbox": { url: URL_, token: "dbx_old" } }, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.logs).not.toContain("repos changed: building a fresh snapshot"); + }); + + it("stops when helm cannot tell whether the release exists", async () => { + const stderr = + 'Error: query: failed to query with labels: secrets is forbidden: User "me" cannot list resource "secrets"\n'; + const script = [...TOOLS, step(HELM_STATUS, { code: 1, stderr })]; + const s = setup(script); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + new KubeError(`helm status devbox failed: ${stderr.trim()}`), + ); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + }); + + it("explains a cluster IP the API server rejects", async () => { + const stderr = + 'Error: Service "devbox-iron-proxy" is invalid: spec.clusterIPs: Invalid value: []string{"10.96.0.20"}: failed to allocate IP 10.96.0.20: provided IP is not in the valid range\n'; + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE, { code: 1, stderr }), + ]; + const s = setup(script); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + new KubeError( + `the API server rejected iron-proxy's address 10.96.0.20: set values.ironProxy.clusterIP in the target file to a free address in the Service range (docs/kubernetes/README.md#iron-proxy-address)\n${stderr.trim()}`, + ), + ); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + }); + + it("shows the snapshot builder's logs when the first install fails", async () => { + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE, { + code: 1, + stderr: "Error: post-install hook failed: job devbox-snapshot-builder-install failed\n", + }), + // The bootstrap hook succeeded, so Helm deleted its Job. + step(BOOTSTRAP_LOGS, { + code: 1, + stderr: 'Error from server (NotFound): jobs.batch "devbox-bootstrap" not found\n', + }), + step( + kc("logs", "job/devbox-snapshot-builder-install", "-n", "devbox-sandboxes", "--tail=50"), + { + stdout: + "clone https://github.com/acme/widgets.git (main)\nfatal: could not read Username\n", + }, + ), + ]; + const s = setup(script); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + new KubeError( + "helm upgrade --install failed:\nError: post-install hook failed: job devbox-snapshot-builder-install failed\n\nsnapshot builder logs:\nclone https://github.com/acme/widgets.git (main)\nfatal: could not read Username", + ), + ); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + }); + + it("shows the bootstrap hook's logs when an upgrade fails", async () => { + const previous = chartValues( + config(), + {}, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY }, + OVERLAY, + ); + const refusal = + "the scratch volume devbox-repos-scratch is 15Gi but workspace.size is 20Gi: delete it to resize (kubectl -n devbox-sandboxes delete pvc devbox-repos-scratch), then run bun run k8s:refresh-snapshot --target "; + const script = [ + ...TOOLS, + step(HELM_STATUS, { stdout: "NAME: devbox\nSTATUS: deployed\n" }), + step(GET_VALUES, { stdout: helmValues(previous) }), + ...STORAGE, + NODES, + ...OPENSANDBOX({ stdout: "devbox" }), + step(APPLY), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, { stdout: "3" }), + step(HELM_UPGRADE, { + code: 1, + stderr: + "Error: UPGRADE FAILED: pre-upgrade hooks failed: job devbox-bootstrap failed: BackoffLimitExceeded\n", + }), + step(BOOTSTRAP_LOGS, { stdout: `${refusal}\n` }), + ]; + const s = setup(script); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + new KubeError( + `helm upgrade --install failed:\nError: UPGRADE FAILED: pre-upgrade hooks failed: job devbox-bootstrap failed: BackoffLimitExceeded\n\nbootstrap logs:\n${refusal}`, + ), + ); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + }); + + it("renders the values file and stops", async () => { + const script = CLUSTER_IP; + const s = setup(script); + await install(s.deps, { ...OPTS, render: "/tmp/x.yaml" }, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + const text = s.written["/tmp/x.yaml"] as string; + expect(Object.keys(s.written)).toEqual(["/tmp/x.yaml"]); + expect(text.split("\n")[0]).toBe( + "# Generated by bun run k8s:install --render from devbox.config.json and the target file. Contains no secrets.", + ); + expect(parse(text)).toEqual(chartValues(config(), {}, { clusterIP: "10.96.0.20" }, OVERLAY)); + expect(s.logs).toEqual(["wrote /tmp/x.yaml"]); + }); + + it("renders the Linear client ID, and no Linear secret", async () => { + const integrations = { linear: { enabled: true, clientId: "client-1" } }; + const s = setup(CLUSTER_IP, { + target: { ...target(), integrations }, + env: { LINEAR_CLIENT_SECRET: "lcs", LINEAR_WEBHOOK_SECRET: "lws" }, + }); + await install(s.deps, { ...OPTS, render: "/tmp/x.yaml" }, OVERLAY); + s.f.done(); + expect(parse(s.written["/tmp/x.yaml"] as string)).toEqual( + chartValues(config(), {}, { clusterIP: "10.96.0.20", integrations }, OVERLAY), + ); + }); + + it("slack and githubApp blocks reach the values", async () => { + const integrations = { + slack: { enabled: true, channelId: "C123" }, + githubApp: GITHUB_APP, + }; + const s = setup(CLUSTER_IP, { + target: { ...target({ github: { trustedUsers: ["octocat"] } }), integrations }, + }); + await install(s.deps, { ...OPTS, render: "/tmp/x.yaml" }, OVERLAY); + s.f.done(); + const values = parse(s.written["/tmp/x.yaml"] as string) as Values; + expect(values).toEqual( + chartValues( + config(), + { github: { trustedUsers: ["octocat"] } }, + { clusterIP: "10.96.0.20", identity: appIdentity("example-devbox", 789), integrations }, + OVERLAY, + ), + ); + expect(values.git).toEqual({ + name: "example-devbox[bot]", + email: "789+example-devbox[bot]@users.noreply.github.com", + }); + expect(values.integrations).toEqual({ + slack: { enabled: true, channelId: "C123" }, + githubApp: { + enabled: true, + appId: 123, + botLogin: "example-devbox", + installationId: 456, + botUserId: 789, + tokens: "devbox", + tokenScope: "repos", + ciResults: true, + }, + }); + expect(values.github).toEqual({ trustedUsers: ["octocat"] }); + }); + + it("never reads a user's Secret", async () => { + const tgt: Target = { + ...target({ + secrets: { + github: { existingSecret: "my-github" }, + llm: { existingSecret: "my-llm" }, + integrations: { existingSecret: "my-integrations" }, + }, + postgresql: { enabled: false }, + database: { existingSecret: "my-db" }, + }), + integrations: { linear: { enabled: true, clientId: "client-1" } }, + }; + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + READY, + TOKEN, + ]; + const s = setup(script, { + target: tgt, + env: { LINEAR_CLIENT_SECRET: "lcs", LINEAR_WEBHOOK_SECRET: "lws" }, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(commands(s.f).filter((c) => c.includes("get secret"))).toEqual([]); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + ]); + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues( + config(), + tgt.values, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, integrations: tgt.integrations }, + OVERLAY, + ), + ); + expect(s.fetchCalls).toEqual([]); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f + +Check Linear with: bun run linear:status --target devbox.k8s.json`, + ]); + }); + + it("identity from git values with the user's GitHub Secret", async () => { + const git = { name: "Devbox Bot", email: "bot@example.com" }; + const tgt = target({ secrets: { github: { existingSecret: "my-github" } }, git }); + const script = [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + step(APPLY), + step(DELETE_INTEGRATIONS), + step(DELETE_APP_KEY), + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + READY, + TOKEN, + ]; + const s = setup(script, { target: tgt, env: { LLM_API_KEY: "sk-y" } }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + JSON.parse(userSecretStdin[1] as string), + ]); + const values = JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string) as Values; + expect(values).toEqual( + chartValues(config(), tgt.values, { clusterIP: "10.96.0.20", publicBaseUrl: URL_ }, OVERLAY), + ); + expect(values.git).toEqual(git); + expect(s.fetchCalls).toEqual(["POST https://llm.example.com/v1/responses Bearer sk-y"]); + }); + + it("secrets come from the deployment's keychain scope", async () => { + const keychain = new MemoryKeychain({ + "k8s:kind-devbox/devbox/GITHUB_TOKEN": "ghp_saved", + "k8s:kind-devbox/devbox/LLM_API_KEY": "sk-saved", + "k8s:kind-x/devbox/LLM_API_KEY": "sk-other-cluster", + "compose/LLM_API_KEY": "sk-local", + }); + const script = FRESH([TOKEN]); + const s = setup(script, { env: {}, keychain }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + JSON.parse(secretManifest({ name: "devbox-github", data: { token: "ghp_saved" } })), + JSON.parse(secretManifest({ name: "devbox-llm", data: { "api-key": "sk-saved" } })), + ]); + expect(s.fetchCalls).toEqual([ + ...GITHUB_FETCHES("ghp_saved"), + "POST https://llm.example.com/v1/responses Bearer sk-saved", + ]); + expect(s.asked).toEqual([]); + }); + + // Records the checks' requests among the prompts, to pin their order. + const traceFetch = (s: ReturnType) => { + const inner = s.deps.fetch; + s.deps.fetch = (url, init) => { + s.asked.push(`${init?.method ?? "GET"} ${url}`); + return inner(url, init); + }; + }; + const GITHUB_CHECK = [ + "GET https://api.github.com/user", + "GET https://api.github.com/repos/acme/widgets", + "GET https://github.com/acme/widgets.git/info/refs?service=git-receive-pack", + "POST https://api.github.com/repos/acme/widgets/pulls", + ]; + const LLM_CHECK = "POST https://llm.example.com/v1/responses"; + + it("offers to save each prompted secret once its own check passes", async () => { + const script = [ + ...TOOLS, + step(["gh", "auth", "token"], { code: 1 }), + ...FRESH([TOKEN]).slice(TOOLS.length), + ]; + const s = setup(script, { env: {}, isTTY: true, answers: ["ghp_typed", "sk-typed"] }); + traceFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(s.asked).toEqual([ + "GitHub token (input hidden): ", + "LLM API key (input hidden): ", + ...GITHUB_CHECK, + "Save GITHUB_TOKEN to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + LLM_CHECK, + "Save LLM_API_KEY to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + ]); + expect(s.keychain.entries()).toEqual({ + "k8s:kind-devbox/devbox/GITHUB_TOKEN": "ghp_typed", + "k8s:kind-devbox/devbox/LLM_API_KEY": "sk-typed", + }); + }); + + it("saves the accepted GitHub token when the LLM check then fails", async () => { + const s = setup([...TOOLS, step(["gh", "auth", "token"], { code: 1 })], { + env: {}, + isTTY: true, + answers: ["ghp_typed", "sk-typed"], + }); + const inner = s.deps.fetch; + // A wrong llm.model: the endpoint answers 404, not a credential rejection. + s.deps.fetch = async (url, init) => + url === "https://llm.example.com/v1/responses" + ? Response.json({ error: "model not found" }, { status: 404 }) + : inner(url, init); + traceFetch(s); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + new PreflightError( + 'LLM check failed: POST https://llm.example.com/v1/responses returned 404 (check llm.baseUrl, llm.model and LLM_API_KEY): {"error":"model not found"}', + ), + ); + s.f.done(); + expect(s.asked).toEqual([ + "GitHub token (input hidden): ", + "LLM API key (input hidden): ", + ...GITHUB_CHECK, + "Save GITHUB_TOKEN to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + LLM_CHECK, + ]); + expect(s.keychain.entries()).toEqual({ "k8s:kind-devbox/devbox/GITHUB_TOKEN": "ghp_typed" }); + }); + + it("with --skip-llm-check offers the LLM key right away, before the GitHub check", async () => { + const script = [ + ...TOOLS, + step(["gh", "auth", "token"], { code: 1 }), + ...FRESH([TOKEN]).slice(TOOLS.length), + ]; + const s = setup(script, { env: {}, isTTY: true, answers: ["ghp_typed", "sk-typed"] }); + traceFetch(s); + await install(s.deps, { ...OPTS, skipLlmCheck: true }, OVERLAY); + s.f.done(); + expect(s.asked).toEqual([ + "GitHub token (input hidden): ", + "LLM API key (input hidden): ", + "Save LLM_API_KEY to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + ...GITHUB_CHECK, + "Save GITHUB_TOKEN to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + ]); + expect(s.keychain.entries()).toEqual({ + "k8s:kind-devbox/devbox/GITHUB_TOKEN": "ghp_typed", + "k8s:kind-devbox/devbox/LLM_API_KEY": "sk-typed", + }); + }); + + it("with --skip-llm-check saves the LLM key even when the GitHub check fails", async () => { + const s = setup([...TOOLS, step(["gh", "auth", "token"], { code: 1 })], { + env: {}, + isTTY: true, + answers: ["ghp_mistyped", "sk-typed", ""], + }); + const inner = s.deps.fetch; + s.deps.fetch = async (url, init) => + url === "https://api.github.com/user" + ? Response.json({ message: "Bad credentials" }, { status: 401 }) + : inner(url, init); + traceFetch(s); + await expect(install(s.deps, { ...OPTS, skipLlmCheck: true }, OVERLAY)).rejects.toThrow( + "no GitHub token: set GITHUB_TOKEN, log in with `gh auth login`, or enter one at the prompt", + ); + s.f.done(); + expect(s.asked).toEqual([ + "GitHub token (input hidden): ", + "LLM API key (input hidden): ", + "Save LLM_API_KEY to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + "GET https://api.github.com/user", + "GitHub token (input hidden): ", + ]); + expect(s.keychain.entries()).toEqual({ "k8s:kind-devbox/devbox/LLM_API_KEY": "sk-typed" }); + }); + + it("removes a rejected saved token and stops without a terminal", async () => { + const keychain = new MemoryKeychain({ + "k8s:kind-devbox/devbox/GITHUB_TOKEN": "ghp_revoked", + "k8s:kind-devbox/devbox/LLM_API_KEY": "sk-saved", + }); + const s = setup(TOOLS, { env: {}, keychain }); + const reject: Fetch = async (url) => + url === "https://api.github.com/user" + ? Response.json({ message: "Bad credentials" }, { status: 401 }) + : new Response("", { status: 404 }); + s.deps.fetch = reject; + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + "GitHub rejected GITHUB_TOKEN (GET /user returned 401: Bad credentials)", + ); + s.f.done(); + expect(keychain.entries()).toEqual({ "k8s:kind-devbox/devbox/LLM_API_KEY": "sk-saved" }); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from the system keychain (k8s:kind-devbox/devbox)", + "LLM_API_KEY: from the system keychain (k8s:kind-devbox/devbox)", + "The saved GITHUB_TOKEN for k8s:kind-devbox/devbox was rejected; removing it", + ]); + }); + + describe("Linear", () => { + const LINEAR = { linear: { enabled: true, clientId: "client-1" } }; + const linearTarget = (values: Values = {}): Target => ({ + ...target(values), + integrations: LINEAR, + }); + const ENV = { GITHUB_TOKEN: "ghp_x", LLM_API_KEY: "sk-y" }; + // The Linear check's requests, the secret it sent included. + const LINEAR_FETCHES = (secret: string) => [ + `POST ${LINEAR_TOKEN_URL} null`, + `linear client client-1 secret ${secret}`, + `POST ${LINEAR_GRAPHQL_URL} Bearer lin_tok`, + ]; + // A fresh install whose devbox-integrations is written: no delete. + const LINEAR_FRESH = (): Step[] => writingIntegrations(FRESH([TOKEN])); + + it("configured Linear: resolves, checks, writes the Secret and the client ID", async () => { + const keychain = new MemoryKeychain({ + "k8s:kind-devbox/devbox/LINEAR_CLIENT_SECRET": "lcs-saved", + "k8s:kind-devbox/devbox/LINEAR_WEBHOOK_SECRET": "lws-saved", + "compose/LINEAR_CLIENT_SECRET": "lcs-local", + }); + const tgt = linearTarget(); + const script = LINEAR_FRESH(); + const s = setup(script, { target: tgt, keychain, env: ENV }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + ...userSecretStdin.map((t) => JSON.parse(t)), + JSON.parse( + secretManifest({ + name: "devbox-integrations", + data: { LINEAR_CLIENT_SECRET: "lcs-saved", LINEAR_WEBHOOK_SECRET: "lws-saved" }, + }), + ), + ]); + expect(JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string)).toEqual( + chartValues( + config(), + {}, + { + clusterIP: "10.96.0.20", + publicBaseUrl: URL_, + identity: IDENTITY, + integrations: LINEAR, + }, + OVERLAY, + ), + ); + expect((JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string) as Values).integrations).toEqual( + LINEAR, + ); + expect(s.fetchCalls.slice(0, 8)).toEqual([ + ...GITHUB_FETCHES("ghp_x"), + "POST https://llm.example.com/v1/responses Bearer sk-y", + ...LINEAR_FETCHES("lcs-saved"), + ]); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "LINEAR_CLIENT_SECRET: from the system keychain (k8s:kind-devbox/devbox)", + "LINEAR_WEBHOOK_SECRET: from the system keychain (k8s:kind-devbox/devbox)", + "Linear: devbox acts as devbox in workspace Example", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f + +Check Linear with: bun run linear:status --target devbox.k8s.json`, + ]); + expect(s.asked).toEqual([]); + }); + + it("Linear stays off without integrations.linear in the target file", async () => { + const script = FRESH([TOKEN]); + const s = setup(script, { + env: { ...ENV, LINEAR_CLIENT_SECRET: "lcs", LINEAR_WEBHOOK_SECRET: "lws" }, + }); + s.deps.cfg = { ...config(), integrations: LINEAR }; + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.fetchCalls.some((c) => c.includes("linear"))).toBe(false); + expect( + (JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string) as Values).integrations, + ).toBeUndefined(); + }); + + it("Linear stays off with enabled false", async () => { + const keychain = new MemoryKeychain({ + "k8s:kind-devbox/devbox/LINEAR_CLIENT_SECRET": "lcs-saved", + "k8s:kind-devbox/devbox/LINEAR_WEBHOOK_SECRET": "lws-saved", + }); + // No devbox-integrations is written: neither secret was resolved. + const script = FRESH([TOKEN]); + const s = setup(script, { + target: { ...target(), integrations: { linear: { enabled: false, clientId: "client-1" } } }, + keychain, + env: { ...ENV, LINEAR_CLIENT_SECRET: "lcs", LINEAR_WEBHOOK_SECRET: "lws" }, + isTTY: true, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.fetchCalls.some((c) => c.includes("linear"))).toBe(false); + expect(s.asked).toEqual([]); + expect((JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string) as Values).integrations).toEqual({ + linear: { enabled: false, clientId: "client-1" }, + }); + }); + + it("prompts for the Linear secrets and offers them once the Linear check passes", async () => { + const script = LINEAR_FRESH(); + const s = setup(script, { + target: linearTarget(), + env: ENV, + isTTY: true, + answers: ["lcs-typed", "lws-typed"], + }); + traceFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(s.asked).toEqual([ + "Linear client secret (input hidden): ", + "Linear webhook signing secret (input hidden): ", + ...GITHUB_CHECK, + LLM_CHECK, + `POST ${LINEAR_TOKEN_URL}`, + `POST ${LINEAR_GRAPHQL_URL}`, + "Save LINEAR_CLIENT_SECRET to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + "Save LINEAR_WEBHOOK_SECRET to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + ]); + expect(s.keychain.entries()).toEqual({ + "k8s:kind-devbox/devbox/LINEAR_CLIENT_SECRET": "lcs-typed", + "k8s:kind-devbox/devbox/LINEAR_WEBHOOK_SECRET": "lws-typed", + }); + }); + + it("fails naming the missing Linear secret without a terminal", async () => { + const s = setup(TOOLS, { + target: linearTarget(), + env: { ...ENV, LINEAR_CLIENT_SECRET: "lcs" }, + }); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + "no Linear webhook signing secret: set LINEAR_WEBHOOK_SECRET or run `bun run k8s:kind` once in a terminal to save it in the keychain", + ); + s.f.done(); + expect(s.fetchCalls).toEqual([]); + }); + + it("--skip-linear-check skips only the Linear check", async () => { + const script = LINEAR_FRESH(); + const s = setup(script, { + target: linearTarget(), + env: { ...ENV, LINEAR_CLIENT_SECRET: "lcs", LINEAR_WEBHOOK_SECRET: "lws" }, + }); + await install(s.deps, { ...OPTS, skipLinearCheck: true }, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.fetchCalls.slice(0, 5)).toEqual([ + ...GITHUB_FETCHES("ghp_x"), + "POST https://llm.example.com/v1/responses Bearer sk-y", + ]); + expect(s.fetchCalls.some((c) => c.includes("linear"))).toBe(false); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "LINEAR_CLIENT_SECRET: from your environment", + "LINEAR_WEBHOOK_SECRET: from your environment", + "warning: Linear check skipped (--skip-linear-check)", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f + +Check Linear with: bun run linear:status --target devbox.k8s.json`, + ]); + expect( + stdinOf(s.f, APPLY) + .map((t) => JSON.parse(t as string)) + .at(-1), + ).toEqual( + JSON.parse( + secretManifest({ + name: "devbox-integrations", + data: { LINEAR_CLIENT_SECRET: "lcs", LINEAR_WEBHOOK_SECRET: "lws" }, + }), + ), + ); + }); + + it("with --skip-linear-check offers the Linear secrets before the other checks", async () => { + const s = setup([...TOOLS, step(["gh", "auth", "token"], { code: 1 })], { + target: linearTarget(), + env: {}, + isTTY: true, + answers: ["ghp_mistyped", "sk-typed", "lcs-typed", "lws-typed", ""], + }); + const inner = s.deps.fetch; + s.deps.fetch = async (url, init) => + url === "https://api.github.com/user" + ? Response.json({ message: "Bad credentials" }, { status: 401 }) + : inner(url, init); + traceFetch(s); + await expect(install(s.deps, { ...OPTS, skipLinearCheck: true }, OVERLAY)).rejects.toThrow( + "no GitHub token: set GITHUB_TOKEN, log in with `gh auth login`, or enter one at the prompt", + ); + s.f.done(); + expect(s.asked).toEqual([ + "GitHub token (input hidden): ", + "LLM API key (input hidden): ", + "Linear client secret (input hidden): ", + "Linear webhook signing secret (input hidden): ", + "Save LINEAR_CLIENT_SECRET to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + "Save LINEAR_WEBHOOK_SECRET to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + "GET https://api.github.com/user", + "GitHub token (input hidden): ", + ]); + expect(s.keychain.entries()).toEqual({ + "k8s:kind-devbox/devbox/LINEAR_CLIENT_SECRET": "lcs-typed", + "k8s:kind-devbox/devbox/LINEAR_WEBHOOK_SECRET": "lws-typed", + }); + }); + + it("ends with the linear:status line when Linear is enabled", async () => { + const keychain = new MemoryKeychain({ + "k8s:kind-devbox/devbox/LINEAR_CLIENT_SECRET": "lcs-saved", + "k8s:kind-devbox/devbox/LINEAR_WEBHOOK_SECRET": "lws-saved", + }); + const script = LINEAR_FRESH(); + const s = setup(script, { target: linearTarget(), keychain, env: ENV }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(s.logs.at(-1)).toEqual(` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f + +Check Linear with: bun run linear:status --target devbox.k8s.json`); + }); + }); + + it("deletes the installer's empty devbox-integrations", async () => { + const script = FRESH([TOKEN]); + const s = setup(script); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + const calls = s.f.calls.filter((c) => c.cmd[3] === "delete" && c.cmd[4] === "secret"); + expect(calls).toEqual([{ cmd: DELETE_INTEGRATIONS }, { cmd: DELETE_APP_KEY }]); + expect(DELETE_INTEGRATIONS).toEqual([ + "kubectl", + "--context", + "kind-devbox", + "delete", + "secret", + "-n", + "devbox", + "-l", + "app.kubernetes.io/managed-by=devbox-installer", + "--field-selector", + "metadata.name=devbox-integrations", + "--ignore-not-found", + ]); + }); + + const ALL_SECRETS = { + LINEAR_CLIENT_SECRET: "lcs", + LINEAR_WEBHOOK_SECRET: "lws", + SLACK_BOT_TOKEN: "xoxb", + GITHUB_WEBHOOK_SECRET: "whs", + }; + const ALL_INTEGRATIONS = (enabled: boolean) => ({ + linear: { enabled, clientId: "client-1" }, + slack: { enabled, channelId: "C123" }, + githubApp: { ...GITHUB_APP, enabled }, + }); + const ENV = { GITHUB_TOKEN: "ghp_x", LLM_API_KEY: "sk-y" }; + + it("integration secrets by switch", async () => { + const keychain = new MemoryKeychain( + Object.fromEntries( + Object.entries(ALL_SECRETS).map(([k, v]) => [`k8s:kind-devbox/devbox/${k}`, v]), + ), + ); + // The App's tokens: no GITHUB_TOKEN, and its key Secret is written too. + const fresh = writingIntegrations(FRESH([TOKEN])); + const on = [ + ...fresh + .slice(0, fresh.indexOf(CLUSTER_IP[0] as Step)) + .filter((st) => st.cmd !== DELETE_APP_KEY), + step(APPLY), + ...fresh.slice(fresh.indexOf(CLUSTER_IP[0] as Step)), + ]; + const s = setup(on, { + target: { ...target(), integrations: ALL_INTEGRATIONS(true) }, + keychain, + env: { LLM_API_KEY: "sk-y", GITHUB_APP_PRIVATE_KEY: PEM }, + }); + appFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(on.map((c) => c.cmd.join(" "))); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + JSON.parse( + secretManifest({ + name: "devbox-github", + data: { token: "ghs_minted", "expires-at": EXPIRES }, + }), + ), + JSON.parse(secretManifest({ name: "devbox-github-app", data: { "private-key": PEM } })), + JSON.parse(userSecretStdin[1] as string), + JSON.parse(secretManifest({ name: "devbox-integrations", data: ALL_SECRETS })), + ]); + + const off = FRESH([TOKEN]); + const s2 = setup(off, { + target: { ...target(), integrations: ALL_INTEGRATIONS(false) }, + env: { ...ENV, ...ALL_SECRETS }, + }); + await install(s2.deps, OPTS, OVERLAY); + s2.f.done(); + expect(commands(s2.f)).toEqual(off.map((c) => c.cmd.join(" "))); + expect(commands(s2.f)).toContain(DELETE_INTEGRATIONS.join(" ")); + expect(stdinOf(s2.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + ...userSecretStdin.map((t) => JSON.parse(t)), + ]); + }); + + it("secrets of a disabled integration are not resolved", async () => { + const script = FRESH([TOKEN]); + const s = setup(script, { + target: { ...target(), integrations: ALL_INTEGRATIONS(false) }, + env: ENV, + isTTY: true, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.asked).toEqual([]); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `, + ]); + }); + + it("offers the Slack and GitHub webhook secrets right away", async () => { + // External tokens: the user's token Secret, so no GitHub token to ask for. + const script = byoGithub(writingIntegrations(FRESH([TOKEN]))).flatMap((st) => + st.cmd === DELETE_APP_KEY ? [st, step(DELETE_TOKEN_SECRET)] : [st], + ); + const s = setup(script, { + target: { + ...target({ secrets: { github: { existingSecret: "my-github" } } }), + integrations: { + slack: { enabled: true, channelId: "C123" }, + githubApp: { ...GITHUB_APP, tokens: "external" }, + }, + }, + env: {}, + isTTY: true, + answers: ["sk-typed", "xoxb-typed", "whs-typed"], + }); + traceFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(s.asked).toEqual([ + "LLM API key (input hidden): ", + "Slack bot token (input hidden): ", + "GitHub webhook secret (input hidden): ", + "Save SLACK_BOT_TOKEN to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + "Save GITHUB_WEBHOOK_SECRET to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + LLM_CHECK, + "Save LLM_API_KEY to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + ]); + expect( + stdinOf(s.f, APPLY) + .map((t) => JSON.parse(t as string)) + .at(-1), + ).toEqual( + JSON.parse( + secretManifest({ + name: "devbox-integrations", + data: { SLACK_BOT_TOKEN: "xoxb-typed", GITHUB_WEBHOOK_SECRET: "whs-typed" }, + }), + ), + ); + }); + + describe("secrets-only changes", () => { + const SLACK = { slack: { enabled: true, channelId: "C123" } }; + const RESTART = `The integrations secrets changed; the control plane keeps the old values until it restarts: + kubectl --context kind-devbox -n devbox rollout restart deploy/devbox-control-plane +then: bun run k8s:check --target devbox.k8s.json`; + const previous = () => + chartValues( + config(), + {}, + { clusterIP: "10.96.0.20", publicBaseUrl: URL_, identity: IDENTITY, integrations: SLACK }, + OVERLAY, + ); + const upgrade = ( + applied: string, + before: Partial, + after: Partial, + ): Step[] => [ + ...TOOLS, + step(HELM_STATUS, { stdout: "NAME: devbox\nSTATUS: deployed\n" }), + step(GET_VALUES, { stdout: helmValues(previous()) }), + ...STORAGE, + NODES, + ...OPENSANDBOX({ stdout: "devbox" }), + step(APPLY), + step(APPLY), + step(APPLY), + step(APPLY, { stdout: `secret/devbox-integrations ${applied}\n` }), + step(DELETE_APP_KEY), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, before), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + step(GENERATION, after), + READY, + ]; + const run = async (script: Step[]) => { + const s = setup(script, { + target: { ...target(), integrations: SLACK }, + env: { ...ENV, SLACK_BOT_TOKEN: "xoxb" }, + me: 200, + profiles: { "k8s-kind-devbox": { url: URL_, token: "dbx_old" } }, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + return s; + }; + + const FINAL = ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `; + // The upgrade's whole log, with or without the restart message. + const upgradeLogs = (restart: boolean) => [ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "SLACK_BOT_TOKEN: from your environment", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + ...(restart ? [RESTART] : []), + "[6/7] Repo snapshot", + "[7/7] CLI login", + 'CLI profile "k8s-kind-devbox" is still valid', + FINAL, + ]; + + it("restart message when only the Secret changed", async () => { + const s = await run(upgrade("configured", { stdout: "3" }, { stdout: "3" })); + expect(s.logs).toEqual(upgradeLogs(true)); + }); + + it("no message when the control plane rolled", async () => { + const s = await run(upgrade("configured", { stdout: "3" }, { stdout: "4" })); + expect(s.logs).toEqual(upgradeLogs(false)); + }); + + it.each(["unchanged", "created"])("no message when %s", async (applied) => { + const s = await run(upgrade(applied, { stdout: "3" }, { stdout: "3" })); + expect(s.logs).toEqual(upgradeLogs(false)); + }); + + it("no message without the control plane's Deployment", async () => { + const notFound = { + code: 1, + stderr: 'Error from server (NotFound): deployments.apps "devbox-control-plane" not found\n', + }; + const s = await run(upgrade("configured", notFound, notFound)); + expect(s.logs).toEqual(upgradeLogs(false)); + }); + + it("no message and no failure when the generation cannot be read", async () => { + const refused = { + code: 1, + stderr: "The connection to the server 10.0.0.1:6443 was refused\n", + }; + const s = await run(upgrade("configured", { stdout: "3" }, refused)); + expect(s.logs).toEqual(upgradeLogs(false)); + }); + + it("first install: no generation, no message", async () => { + const script = FRESH([TOKEN]).map((st) => + st.cmd === DELETE_INTEGRATIONS + ? step(APPLY, { stdout: "secret/devbox-integrations configured\n" }) + : st, + ); + const s = setup(script, { + target: { ...target(), integrations: SLACK }, + env: { ...ENV, SLACK_BOT_TOKEN: "xoxb" }, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + "SLACK_BOT_TOKEN: from your environment", + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + FINAL, + ]); + }); + }); + + it("with the user's integrations Secret: resolves none, writes and deletes nothing", async () => { + const tgt = target({ secrets: { integrations: { existingSecret: "my-integrations" } } }); + const script = byoIntegrations(FRESH([TOKEN])); + const s = setup(script, { + target: { ...tgt, integrations: { slack: { enabled: true, channelId: "C123" } } }, + env: { GITHUB_TOKEN: "ghp_x", LLM_API_KEY: "sk-y", SLACK_BOT_TOKEN: "xoxb" }, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + ...userSecretStdin.map((t) => JSON.parse(t)), + ]); + expect(s.logs).not.toContain("SLACK_BOT_TOKEN: from your environment"); + }); + + describe("GitHub App", () => { + const TRUST = { github: { trustedOrgs: ["acme"] } }; + const appTarget = (values: Values = {}, app: Record = {}): Target => ({ + ...target({ ...TRUST, ...values }), + integrations: { githubApp: { ...GITHUB_APP, ...app } }, + }); + const APP_ENV = { + GITHUB_TOKEN: "ghp_unused", + GITHUB_APP_PRIVATE_KEY: PEM, + LLM_API_KEY: "sk-y", + GITHUB_WEBHOOK_SECRET: "whs", + }; + const CREATE = kc("create", "-f", "-"); + // A first install: the namespaces, then `before` (the empty token Secret's + // create), then `secrets` Secret applies, then `after` (the deletes). + const fresh = (secrets: number, before: Step[] = [], after: Step[] = []): Step[] => [ + ...TOOLS, + step(HELM_STATUS, { code: 1, stderr: "Error: release: not found\n" }), + ...STORAGE, + ...NETPOL, + NODES, + ...OPENSANDBOX(NOT_FOUND), + step(APPLY), + ...before, + ...Array.from({ length: secrets }, () => step(APPLY)), + ...after, + ...CLUSTER_IP, + step(TEMPLATE, NOT_FOUND), + step(HELM_UPGRADE), + step(TEMPLATE, { stdout: '{"template":"a"}' }), + READY, + TOKEN, + ]; + const tail = (s: { path: string }, after4: string[] = []) => [ + "[3/7] Cluster", + "StorageClass csi-hostpath-sc: hostpath.csi.k8s.io, Immediate", + "[4/7] Namespaces and Secrets", + ...after4, + "[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)", + "[6/7] Repo snapshot", + "[7/7] CLI login", + `CLI logged in: profile "k8s-kind-devbox" in ${s.path}`, + ` +devbox is installed in context kind-devbox. The CLI uses profile "k8s-kind-devbox". + + bun run k8s:connect --target devbox.k8s.json # keep it running in another terminal + bun run devbox create "Add a /health endpoint to acme/widgets" + bun run devbox logs -f `, + ]; + const secret = (name: string, data: Record) => + JSON.parse(secretManifest({ name, data })); + const LLM_SECRET = secret("devbox-llm", { "api-key": "sk-y" }); + const WEBHOOK_SECRET = secret("devbox-integrations", { GITHUB_WEBHOOK_SECRET: "whs" }); + const deleted = (name: string) => ({ stdout: `secret "${name}" deleted\n` }); + const KEY_GONE = + "Deleted Secret devbox-github-app: this install does not use the installer's copy of the GitHub App's private key"; + const helmValuesOf = (s: { f: { calls: { cmd: string[]; stdin?: string }[] } }) => + JSON.parse(stdinOf(s.f, HELM_UPGRADE)[0] as string) as Values; + + it("devbox tokens: the key, not GITHUB_TOKEN; coverage, a minted token, the App's identity", async () => { + const tgt = appTarget(); + const script = fresh(4); + const s = setup(script, { target: tgt, env: APP_ENV }); + const calls = appFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(calls).toEqual([ + GET_APP, + COVERAGE, + MINT, + `GET ${API}/user`, + `GET ${API}/repos/acme/widgets`, + "GET https://github.com/acme/widgets.git/info/refs?service=git-receive-pack", + `POST ${API}/repos/acme/widgets/pulls`, + "POST https://llm.example.com/v1/responses", + ]); + // The minted token, never GITHUB_TOKEN, is what the repo checks use. + expect(s.fetchCalls.filter((c) => c.includes("ghp_unused"))).toEqual([]); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + secret("devbox-github", { token: "ghs_minted", "expires-at": EXPIRES }), + secret("devbox-github-app", { "private-key": PEM }), + LLM_SECRET, + WEBHOOK_SECRET, + ]); + expect(helmValuesOf(s)).toEqual( + chartValues( + config(), + tgt.values, + { + clusterIP: "10.96.0.20", + publicBaseUrl: URL_, + identity: appIdentity("example-devbox", 789), + integrations: tgt.integrations, + }, + OVERLAY, + ), + ); + expect(helmValuesOf(s).git).toEqual({ + name: "example-devbox[bot]", + email: "789+example-devbox[bot]@users.noreply.github.com", + }); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_APP_PRIVATE_KEY: from your environment", + "LLM_API_KEY: from your environment", + "GITHUB_WEBHOOK_SECRET: from your environment", + "GitHub App example-devbox: minted a token for installation 456", + ...tail(s), + ]); + }); + + it("devbox tokens: git values the user set win over the App's identity", async () => { + const git = { name: "Devbox Bot", email: "bot@example.com" }; + const tgt = appTarget({ git }); + const s = setup(fresh(4), { target: tgt, env: APP_ENV }); + appFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(helmValuesOf(s).git).toEqual(git); + }); + + it("devbox tokens: the key from the deployment's keychain scope", async () => { + const keychain = new MemoryKeychain({ + "k8s:kind-devbox/devbox/GITHUB_APP_PRIVATE_KEY": PEM, + "compose/GITHUB_APP_PRIVATE_KEY": "not this one", + }); + const s = setup(fresh(4), { + target: appTarget(), + env: { LLM_API_KEY: "sk-y", GITHUB_WEBHOOK_SECRET: "whs" }, + keychain, + }); + appFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect( + stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))[NETPOL_STDIN.length + 2], + ).toEqual(secret("devbox-github-app", { "private-key": PEM })); + expect(s.logs).toContain( + "GITHUB_APP_PRIVATE_KEY: from the system keychain (k8s:kind-devbox/devbox)", + ); + }); + + it("devbox tokens: no key stops before the cluster, naming the target file", async () => { + const s = setup(TOOLS, { + target: appTarget(), + env: { LLM_API_KEY: "sk-y", GITHUB_WEBHOOK_SECRET: "whs" }, + }); + const calls = appFetch(s); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + "No private key for the GitHub App: set GITHUB_APP_PRIVATE_KEY_FILE to its path, or run bun run github:setup --target devbox.k8s.json", + ); + s.f.done(); + expect(calls).toEqual([]); + }); + + it("devbox tokens: repos outside the installation stop before helm", async () => { + const s = setup(TOOLS, { target: appTarget(), env: APP_ENV }); + const calls = appFetch(s, { + [GET_APP]: APP_OK, + [COVERAGE]: [404, { message: "Not Found" }], + [INSTALLATION]: [200, { account: { login: "acme", type: "Organization" } }], + }); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + 'These repos are not in the GitHub App\'s installation: acme/widgets. Add them at https://github.com/organizations/acme/settings/installations/456, or run with tokenScope "installation"', + ); + s.f.done(); + expect(calls).toEqual([GET_APP, COVERAGE, INSTALLATION]); + }); + + it("devbox tokens: a key GitHub rejects stops; the saved key stays and nothing is asked", async () => { + const keychain = new MemoryKeychain({ + "k8s:kind-devbox/devbox/GITHUB_APP_PRIVATE_KEY": PEM, + }); + const s = setup(TOOLS, { + target: appTarget(), + env: { LLM_API_KEY: "sk-y", GITHUB_WEBHOOK_SECRET: "whs" }, + keychain, + isTTY: true, + }); + appFetch(s, { [GET_APP]: [401, { message: "A JSON web token could not be decoded" }] }); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + "GitHub rejected the App's credentials: A JSON web token could not be decoded. Check the system clock and integrations.githubApp.appId; if the key itself is wrong, run bun run secrets rm GITHUB_APP_PRIVATE_KEY --target devbox.k8s.json, then bun run github:setup --target devbox.k8s.json.", + ); + s.f.done(); + expect(keychain.entries()).toEqual({ + "k8s:kind-devbox/devbox/GITHUB_APP_PRIVATE_KEY": PEM, + }); + expect(s.asked).toEqual([]); + }); + + it("devbox tokens: a wrong App id or slug stops before the coverage check", async () => { + const wrongId = setup(TOOLS, { target: appTarget(), env: APP_ENV }); + const idCalls = appFetch(wrongId, { + [GET_APP]: [404, { message: "Integration not found" }], + }); + await expect(install(wrongId.deps, OPTS, OVERLAY)).rejects.toThrow( + "GitHub has no App with id 123: check integrations.githubApp.appId (the App's settings page shows its App ID)", + ); + wrongId.f.done(); + expect(idCalls).toEqual([GET_APP]); + + const wrongSlug = setup(TOOLS, { target: appTarget(), env: APP_ENV }); + const slugCalls = appFetch(wrongSlug, { + [GET_APP]: [200, { id: 123, slug: "other-devbox" }], + }); + await expect(install(wrongSlug.deps, OPTS, OVERLAY)).rejects.toThrow( + 'integrations.githubApp.slug is "example-devbox" but App 123 is "other-devbox": run bun run github:setup --target devbox.k8s.json', + ); + wrongSlug.f.done(); + expect(slugCalls).toEqual([GET_APP]); + }); + + it("the user's key Secret: no key read, no GitHub checks, an empty token Secret for the seed job", async () => { + const tgt = appTarget({ secrets: { githubApp: { existingSecret: "my-app-key" } } }); + const script = fresh(2, [step(CREATE)], [step(DELETE_APP_KEY, deleted("devbox-github-app"))]); + const s = setup(script, { + target: tgt, + // Reading either would fail: the file does not exist, the value is no PEM. + env: { + GITHUB_APP_PRIVATE_KEY_FILE: join(root, "no-such-key.pem"), + GITHUB_APP_PRIVATE_KEY: "not a key", + LLM_API_KEY: "sk-y", + GITHUB_WEBHOOK_SECRET: "whs", + }, + }); + const calls = appFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(calls).toEqual(["POST https://llm.example.com/v1/responses"]); + expect(stdinOf(s.f, CREATE).map((t) => JSON.parse(t as string))).toEqual([ + secret("devbox-github", { token: "" }), + ]); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + LLM_SECRET, + WEBHOOK_SECRET, + ]); + expect(helmValuesOf(s).git).toEqual({ + name: "example-devbox[bot]", + email: "789+example-devbox[bot]@users.noreply.github.com", + }); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "LLM_API_KEY: from your environment", + "GITHUB_WEBHOOK_SECRET: from your environment", + 'Skipping the GitHub checks: the App\'s key is in your Secret "my-app-key"; run bun run k8s:check --target devbox.k8s.json after install', + ...tail(s, ["The token Secret is filled by the devbox-github-token-seed job", KEY_GONE]), + ]); + }); + + it("the user's key Secret: an existing token Secret is left as it is", async () => { + const tgt = appTarget({ secrets: { githubApp: { existingSecret: "my-app-key" } } }); + const script = fresh( + 2, + [ + step(CREATE, { + code: 1, + stderr: 'Error from server (AlreadyExists): secrets "devbox-github" already exists\n', + }), + ], + [step(DELETE_APP_KEY)], + ); + const s = setup(script, { + target: tgt, + env: { LLM_API_KEY: "sk-y", GITHUB_WEBHOOK_SECRET: "whs" }, + }); + appFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + // Only the create, which never replaces: no apply, patch or delete of it. + expect( + s.f.calls.filter((c) => c.stdin?.includes('"name":"devbox-github"')).map((c) => c.cmd), + ).toEqual([CREATE]); + expect(commands(s.f)).not.toContain(DELETE_TOKEN_SECRET.join(" ")); + }); + + it("the user's key Secret: another failure to create the token Secret stops", async () => { + const tgt = appTarget({ secrets: { githubApp: { existingSecret: "my-app-key" } } }); + const script = [ + ...fresh(0, [ + step(CREATE, { code: 1, stderr: "error: You must be logged in to the server\n" }), + ]).slice(0, -(CLUSTER_IP.length + 5)), + ]; + const s = setup(script, { + target: tgt, + env: { LLM_API_KEY: "sk-y", GITHUB_WEBHOOK_SECRET: "whs" }, + }); + appFetch(s); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow( + new KubeError( + "kubectl create secret devbox-github failed: error: You must be logged in to the server", + ), + ); + s.f.done(); + }); + + it("external tokens: the user's token Secret, neither GITHUB_TOKEN nor a key; the App's identity", async () => { + const tgt = appTarget( + { secrets: { github: { existingSecret: "my-github" } } }, + { tokens: "external" }, + ); + const script = fresh( + 2, + [], + [ + step(DELETE_APP_KEY, deleted("devbox-github-app")), + step(DELETE_TOKEN_SECRET, deleted("devbox-github")), + ], + ); + const s = setup(script, { target: tgt, env: APP_ENV }); + const calls = appFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(calls).toEqual(["POST https://llm.example.com/v1/responses"]); + expect(stdinOf(s.f, APPLY).map((t) => JSON.parse(t as string))).toEqual([ + ...NETPOL_STDIN, + namespaces, + LLM_SECRET, + WEBHOOK_SECRET, + ]); + expect(helmValuesOf(s).git).toEqual({ + name: "example-devbox[bot]", + email: "789+example-devbox[bot]@users.noreply.github.com", + }); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "LLM_API_KEY: from your environment", + "GITHUB_WEBHOOK_SECRET: from your environment", + ...tail(s, [ + KEY_GONE, + 'Deleted Secret devbox-github: with external tokens the token comes from Secret "my-github"', + ]), + ]); + }); + + it("external tokens: nothing to delete, nothing logged", async () => { + const tgt = appTarget( + { secrets: { github: { existingSecret: "my-github" } } }, + { tokens: "external" }, + ); + const script = fresh(2, [], [step(DELETE_APP_KEY), step(DELETE_TOKEN_SECRET)]); + const s = setup(script, { target: tgt, env: APP_ENV }); + appFetch(s); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "LLM_API_KEY: from your environment", + "GITHUB_WEBHOOK_SECRET: from your environment", + ...tail(s), + ]); + }); + + it("the App switched off: the installer's key Secret goes", async () => { + const script = FRESH([TOKEN]).map((st) => + st.cmd === DELETE_APP_KEY ? step(DELETE_APP_KEY, deleted("devbox-github-app")) : st, + ); + const s = setup(script, { + target: { + ...target(TRUST), + integrations: { githubApp: { ...GITHUB_APP, enabled: false } }, + }, + }); + await install(s.deps, OPTS, OVERLAY); + s.f.done(); + expect(commands(s.f)).toEqual(script.map((c) => c.cmd.join(" "))); + expect(s.logs).toEqual([ + "[1/7] Checks (context kind-devbox)", + "[2/7] Secrets", + "GITHUB_TOKEN: from your environment", + "LLM_API_KEY: from your environment", + ...tail(s, [KEY_GONE]), + ]); + }); + + it.each([ + [ + "devbox tokens with the user's token Secret", + appTarget({ secrets: { github: { existingSecret: "my-github" } } }), + "integrations.githubApp.enabled is true: tokens: devbox writes the token Secret itself: remove secrets.github.existingSecret, or use tokens: external", + ], + [ + "external tokens without the user's token Secret", + appTarget({}, { tokens: "external" }), + "integrations.githubApp.enabled is true: tokens: external needs secrets.github.existingSecret: the Secret your tooling keeps fresh", + ], + [ + "tokens narrowed to no repos", + appTarget({ repos: [] }), + "integrations.githubApp.enabled is true: tokenScope: repos needs at least one entry in repos (or use tokenScope: installation)", + ], + ])("refuses %s before anything else, as the chart would", async (_, tgt, message) => { + const s = setup([], { target: tgt, env: APP_ENV }); + const calls = appFetch(s); + await expect(install(s.deps, OPTS, OVERLAY)).rejects.toThrow(message); + await expect(install(s.deps, { ...OPTS, render: "/tmp/x.yaml" }, OVERLAY)).rejects.toThrow( + message, + ); + s.f.done(); + expect(calls).toEqual([]); + expect(s.written).toEqual({}); + }); + }); +}); diff --git a/control-plane/scripts/k8s/install.ts b/control-plane/scripts/k8s/install.ts new file mode 100644 index 0000000..d8d737f --- /dev/null +++ b/control-plane/scripts/k8s/install.ts @@ -0,0 +1,650 @@ +// `bun run k8s:install`: preflight → namespaces and user +// Secrets → helm upgrade --install with values rendered in memory → the first +// snapshot → an API token and a CLI profile. Re-running it upgrades: config +// changes apply, installer-created Secrets are rewritten (rotation), the +// OpenSandbox server restarts when the sandbox template changed (its chart +// rolls it only on its own config), and a repo change triggers a fresh +// snapshot. It never reads a Secret it does not own: the +// user's own are `k8s:check`'s. When only devbox-integrations changed, it says +// how to restart the control plane. With the GitHub App, GITHUB_TOKEN is not +// used: devbox-minted tokens come from the App's private key (one minted here +// for the checks and the token Secret; the minter takes over), external ones +// live in the user's Secret. +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { stringify } from "yaml"; +import { + appIdentity, + GithubAppSetupError, + mintAppToken, + missingKeyMessage, +} from "../lib/githubApp"; +import { type PrivateKey, PrivateKeyError, resolvePrivateKey } from "../lib/githubAppKey"; +import { type EnabledGithubApp, enabledGithubApp, enabledLinear } from "../lib/integrations"; +import type { Keychain } from "../lib/keychain"; +import { + checkWithRetry, + offerSave, + type Resolved, + type ResolverDeps, + resolveSecret, +} from "../lib/secrets"; +import { checkLinear, type LinearIdentity } from "../linear/check"; +import { cliConfigPath, ensureCliLogin, parseApiTokenOutput, readCliProfile } from "../up/cliLogin"; +import type { DevboxConfig } from "../up/config"; +import { checkGithub, checkLlm, type Fetch, type Identity } from "../up/preflight"; +import { pickClusterIP } from "./clusterIP"; +import { freePort, profileFor } from "./connect"; +import { + checkCluster, + checkNetworkPolicy, + checkNodeFits, + checkNoOtherOpenSandbox, + checkStorage, + checkTools, + requireImageValues, + requireStorageValues, +} from "./preflight"; +import { refreshSnapshot, stamp } from "./refresh"; +import { + type Kube, + KubeError, + kube, + NAMESPACE, + RELEASE, + REPO_SNAPSHOTS, + type Runner, + SANDBOX_NAMESPACE, +} from "./run"; +import { + existingSecret, + GITHUB_APP_SECRET, + GITHUB_SECRET, + INSTALLER_LABEL, + INTEGRATIONS_SECRET, + type IntegrationSecretName, + secretManifest, + userSecrets, +} from "./secrets"; +import type { Target } from "./target"; +import { apiIngressUrl, chartValues, userValue, type Values } from "./values"; + +export const CHART_PATH = join(import.meta.dir, "..", "..", "..", "deploy", "helm", "devbox"); +const FIRST_PORT = 13000; +const HEALTH_INTERVAL_MS = 5_000; +const HEALTH_TIMEOUT_MS = 300_000; +const HEALTH_REQUEST_TIMEOUT_MS = 5_000; +const HEALTH_ATTEMPTS = HEALTH_TIMEOUT_MS / HEALTH_INTERVAL_MS; + +export type InstallDeps = { + runner: Runner; + cfg: DevboxConfig; + // The cluster to act on, its CLI profile and chart values (--target). + target: Target; + targetPath: string; + env: Record; + // The OS keychain; undefined with --no-keychain. + keychain: Keychain | undefined; + // Whether stdin is a terminal: prompts and save offers need one. + isTTY: boolean; + home: string; + fetch: Fetch; + log: (line: string) => void; + sleep: (ms: number) => Promise; + now: () => Date; + prompt: (question: string) => Promise; + confirm: ResolverDeps["confirm"]; + // Runs `kubectl port-forward` in the background until stop() is called. + portForward: (k: Kube, port: number) => Promise<{ stop: () => void }>; + isPortFree: (port: number) => Promise; + writeFile: (path: string, text: string) => void; + // A file's text, or undefined when it does not exist. + readFile: (path: string) => string | undefined; +}; + +export type InstallOptions = { + render?: string; // --render + skipPushCheck: boolean; + skipLlmCheck: boolean; + skipLinearCheck: boolean; + netpol: "auto" | "force" | "skip"; // auto = first install only + timeout: string; // helm --timeout +}; + +// The values of the installed release, or undefined on a first install. +async function previousValues(k: Kube): Promise { + const status = await k.helm(["status", RELEASE, "-n", NAMESPACE]); + if (status.code !== 0) { + if (/release: not found/.test(status.stderr)) return undefined; + // Not a first install: guessing one would pick a new iron-proxy address. + throw new KubeError( + `helm status ${RELEASE} failed: ${status.stderr.trim() || status.stdout.trim()}`, + ); + } + return JSON.parse( + await k.helmOk(["get", "values", RELEASE, "-n", NAMESPACE, "-o", "json"]), + ) as Values; +} + +async function templateHash(k: Kube): Promise { + const r = await k.kubectl([ + "get", + "configmap", + "devbox-sandbox-template", + "-n", + NAMESPACE, + "-o", + "jsonpath={.data}", + ]); + return r.code === 0 ? createHash("sha256").update(r.stdout).digest("hex") : undefined; +} + +// The control plane Deployment's metadata.generation, or undefined when it +// does not exist or cannot be read (then no restart message, and the install +// goes on). Helm rolling it changes the generation; a Secret it reads changing +// does not. +async function controlPlaneGeneration(k: Kube): Promise { + const r = await k.kubectl([ + "get", + "deploy", + "devbox-control-plane", + "-n", + NAMESPACE, + "-o", + "jsonpath={.metadata.generation}", + ]); + return r.code === 0 ? r.stdout.trim() || undefined : undefined; +} + +// The repos as comparable strings: helm returns values with sorted keys. +const repoKeys = (repos: unknown): string[] => + (Array.isArray(repos) ? repos : []).map( + (r: { url?: unknown; branch?: unknown }) => `${r.url}#${r.branch ?? "main"}`, + ); + +// The chart refuses these when it renders, in the same words; refused here, +// they stop before anything is checked or written. +function refuseGithubApp(app: EnabledGithubApp | undefined, values: Values): void { + if (!app) return; + const fail = (why: string): never => { + throw new GithubAppSetupError(`integrations.githubApp.enabled is true: ${why}`); + }; + const userToken = existingSecret(values, "github"); + if (app.tokens === "devbox") { + // An empty list would make the minter ask for the whole installation. + const repos = values.repos; + if (app.tokenScope !== "installation" && !(Array.isArray(repos) && repos.length > 0)) { + fail("tokenScope: repos needs at least one entry in repos (or use tokenScope: installation)"); + } + // The minter writes only a Secret devbox owns, never the user's. + if (userToken) { + fail( + "tokens: devbox writes the token Secret itself: remove secrets.github.existingSecret, or use tokens: external", + ); + } + } else if (!userToken) { + fail( + "tokens: external needs secrets.github.existingSecret: the Secret your tooling keeps fresh", + ); + } +} + +function namespacesManifest(): string { + const ns = (name: string) => ({ + apiVersion: "v1", + kind: "Namespace", + metadata: { name, labels: { "app.kubernetes.io/part-of": "devbox" } }, + }); + return JSON.stringify({ + apiVersion: "v1", + kind: "List", + items: [ns(NAMESPACE), ns(SANDBOX_NAMESPACE)], + }); +} + +export async function install( + deps: InstallDeps, + opts: InstallOptions, + overlay: Values = {}, +): Promise { + const { cfg, log } = deps; + const context = deps.target.context; + const k = kube(deps.runner, context); + const userClusterIP = userValue(deps.target, ["ironProxy", "clusterIP"]) as string | undefined; + // Linear is on only with the target file's integrations.linear.enabled: + // devbox.config.json's block and LINEAR_* in env + // never count. The whole block goes to the chart, switches included. + const integrations = deps.target.integrations; + const linear = enabledLinear(integrations ?? {}); + const app = enabledGithubApp(integrations ?? {}); + refuseGithubApp(app, chartValues(cfg, deps.target.values, {}, overlay)); + // Commits link to the App's bot; git values the user set still win. + const appCommitIdentity = app ? appIdentity(app.slug, app.botUserId) : undefined; + + if (opts.render) { + const values = chartValues( + cfg, + deps.target.values, + { + clusterIP: userClusterIP ?? (await pickClusterIP(k)), + identity: appCommitIdentity, + integrations, + }, + overlay, + ); + deps.writeFile( + opts.render, + `# Generated by bun run k8s:install --render from devbox.config.json and the target file. Contains no secrets.\n${stringify(values)}`, + ); + log(`wrote ${opts.render}`); + return; + } + + log(`[1/7] Checks (context ${context})`); + await checkTools(deps.runner); + await checkCluster(k); + const values0 = chartValues(cfg, deps.target.values, {}, overlay); + requireStorageValues(values0); + requireImageValues(values0); + + // The tokens come before the cluster probes: a bad token fails in seconds, + // a probe can take minutes. + log("[2/7] Secrets"); + // The user's own Secrets are never read: what they hold + // shows when the pod starts, or with `k8s:check`. + const own = { + github: !existingSecret(values0, "github"), + llm: !existingSecret(values0, "llm"), + integrations: !existingSecret(values0, "integrations"), + }; + const secretDeps: ResolverDeps = { + env: deps.env, + keychain: deps.keychain, + deployment: { kind: "k8s", context, namespace: NAMESPACE }, + targetPath: deps.targetPath, + isTTY: deps.isTTY, + prompt: deps.prompt, + confirm: deps.confirm, + run: (cmd) => deps.runner(cmd), + log, + }; + // A typed value is offered for saving once its own check passed (or right + // away when it has no check or that check is skipped), so a mistyped one is + // not saved and an accepted one is not lost to another check failing. + // With devbox-minted tokens and the user's own key Secret, the key is not + // read here: the seed job mints the first token, and k8s:check checks it. + const userAppKey = app?.tokens === "devbox" ? existingSecret(values0, "githubApp") : undefined; + let githubR = own.github && !app ? await resolveSecret("GITHUB_TOKEN", secretDeps) : undefined; + let appKey: PrivateKey | undefined; + if (app?.tokens === "devbox" && !userAppKey) { + appKey = await resolvePrivateKey(secretDeps); + if (!appKey) throw new PrivateKeyError(missingKeyMessage(deps.targetPath)); + } + let llmR = own.llm ? await resolveSecret("LLM_API_KEY", secretDeps) : undefined; + // Only an enabled integration's secrets, and only for the installer's own + // devbox-integrations. + const resolveIf = async (on: boolean | undefined, name: IntegrationSecretName) => + on && own.integrations ? await resolveSecret(name, secretDeps) : undefined; + let linearR: { client: Resolved; webhook: Resolved } | undefined; + if (linear && own.integrations) { + linearR = { + client: await resolveSecret("LINEAR_CLIENT_SECRET", secretDeps), + webhook: await resolveSecret("LINEAR_WEBHOOK_SECRET", secretDeps), + }; + } + const slackR = await resolveIf(integrations?.slack?.enabled, "SLACK_BOT_TOKEN"); + const webhookR = await resolveIf(integrations?.githubApp?.enabled, "GITHUB_WEBHOOK_SECRET"); + // Unchecked: offered now, so a failing GitHub check does not lose them. + if (llmR && opts.skipLlmCheck) await offerSave(llmR, secretDeps); + if (linearR && opts.skipLinearCheck) { + await offerSave(linearR.client, secretDeps); + await offerSave(linearR.webhook, secretDeps); + } + if (slackR) await offerSave(slackR, secretDeps); + if (webhookR) await offerSave(webhookR, secretDeps); + // The commit identity is the App's bot, or comes from a token resolved + // here; otherwise the chart uses `git` from the values or its default. + let identity: Identity | undefined = appCommitIdentity; + let minted: { token: string; expiresAt: string } | undefined; + if (app && appKey) { + minted = await mintAppToken(app, appKey.pem, cfg.repos, { + fetch: deps.fetch, + checkCoverage: true, + target: deps.targetPath, + }); + log(`GitHub App ${app.slug}: minted a token for installation ${app.installationId}`); + await checkGithub(minted.token, cfg.repos, deps.fetch, { + requirePush: !opts.skipPushCheck, + tokenName: "the GitHub App's token", + }); + } else if (userAppKey) { + log( + `Skipping the GitHub checks: the App's key is in your Secret "${userAppKey}"; run bun run k8s:check --target ${deps.targetPath} after install`, + ); + } + if (githubR) { + ({ resolved: githubR, result: identity } = await checkWithRetry(githubR, secretDeps, (token) => + checkGithub(token, cfg.repos, deps.fetch, { requirePush: !opts.skipPushCheck }), + )); + await offerSave(githubR, secretDeps); + } + if (opts.skipPushCheck) { + log( + "warning: push and pull request checks skipped (--skip-push-check): tasks fail when they push or open a pull request if the token cannot", + ); + } + if (opts.skipLlmCheck) log("warning: LLM check skipped (--skip-llm-check)"); + else if (llmR) { + llmR = (await checkWithRetry(llmR, secretDeps, (key) => checkLlm(cfg.llm, key, deps.fetch))) + .resolved; + await offerSave(llmR, secretDeps); + } + if (linear) { + // Only the client secret can be checked here; the webhook secret is + // offered with it once the app itself is accepted. + let linearIdentity: LinearIdentity | undefined; + if (opts.skipLinearCheck) log("warning: Linear check skipped (--skip-linear-check)"); + else if (linearR) { + ({ resolved: linearR.client, result: linearIdentity } = await checkWithRetry( + linearR.client, + secretDeps, + (secret) => checkLinear(linear.clientId, secret, deps.fetch, { log, sleep: deps.sleep }), + )); + await offerSave(linearR.client, secretDeps); + await offerSave(linearR.webhook, secretDeps); + } + if (linearIdentity) { + log( + `Linear: devbox acts as ${linearIdentity.viewerName} in workspace ${linearIdentity.workspaceName}`, + ); + } + } + // None for a Secret the user brings, or one the seed job fills. + const github = minted ?? (githubR ? { token: githubR.value } : undefined); + const llm = llmR?.value ?? ""; + const integrationSecrets: Partial> = {}; + for (const r of [linearR?.client, linearR?.webhook, slackR, webhookR]) { + if (r) integrationSecrets[r.name as IntegrationSecretName] = r.value; + } + + log("[3/7] Cluster"); + const previous = await previousValues(k); + const storage = await checkStorage(k, values0); + log(`StorageClass ${storage.name}: ${storage.provisioner}, ${storage.bindingMode}`); + if (opts.netpol === "force" || (opts.netpol === "auto" && !previous)) { + await checkNetworkPolicy(k, { sleep: deps.sleep, suffix: stamp(deps.now()) }); + } else if (opts.netpol === "skip") { + log( + "warning: NetworkPolicy check skipped (--skip-netpol-check): sandbox egress is not locked down without a CNI that enforces it", + ); + } + await checkNodeFits(k, values0); + await checkNoOtherOpenSandbox(k); + + const path = cliConfigPath(deps.env, deps.home); + const profile = profileFor(deps.target); + // The CLI uses the api Ingress when there is one; a webhooks Ingress leaves + // it on a port-forward, like no Ingress. A reinstall keeps the profile's + // local port; a saved Ingress URL is dropped (it may be gone). + const saved = readCliProfile(path, profile)?.url; + const url = + apiIngressUrl(deps.target) ?? + (saved && new URL(saved).hostname === "localhost" + ? saved + : `http://localhost:${await freePort(FIRST_PORT, deps.isPortFree)}`); + + log("[4/7] Namespaces and Secrets"); + await k.kubectlOk(["apply", "-f", "-"], { stdin: namespacesManifest() }); + if (userAppKey) { + // The minter may only update its token Secret, so it must exist. Created + // empty when missing; an existing one is left as it is (create never + // replaces), so no token is ever read or overwritten here. + const r = await k.kubectl(["create", "-f", "-"], { + stdin: secretManifest({ name: GITHUB_SECRET, data: { token: "" } }), + }); + if (r.code !== 0 && !/\(AlreadyExists\)/.test(r.stderr)) { + throw new KubeError( + `kubectl create secret ${GITHUB_SECRET} failed: ${r.stderr.trim() || `exit ${r.code}`}`, + ); + } + log("The token Secret is filled by the devbox-github-token-seed job"); + } + const secrets = userSecrets(values0, { + github, + githubApp: appKey && { pem: appKey.pem }, + llm, + integrations: integrationSecrets, + }); + // kubectl says "configured" only when the Secret's data changed. + let integrationsChanged = false; + for (const s of secrets) { + const out = await k.kubectlOk(["apply", "-f", "-"], { stdin: secretManifest(s) }); + if (s.name === INTEGRATIONS_SECRET) { + integrationsChanged = /^secret\/devbox-integrations configured$/m.test(out); + } + } + // An installer-owned Secret this install does not use goes; the user's own + // (no installer label) is never touched. + const deleteOwned = async (name: string, why?: string) => { + const out = await k.kubectlOk([ + "delete", + "secret", + "-n", + NAMESPACE, + "-l", + INSTALLER_LABEL, + "--field-selector", + `metadata.name=${name}`, + "--ignore-not-found", + ]); + if (why && /deleted/.test(out)) log(`Deleted Secret ${name}: ${why}`); + }; + // devbox-integrations with nothing left to hold (linear:disable relies on it). + if (own.integrations && !secrets.some((s) => s.name === INTEGRATIONS_SECRET)) { + await deleteOwned(INTEGRATIONS_SECRET); + } + // No unused copy of the App's private key stays in the cluster: the App + // is off, its key is in the user's Secret, or its tokens are external. + if (!appKey) { + await deleteOwned( + GITHUB_APP_SECRET, + "this install does not use the installer's copy of the GitHub App's private key", + ); + } + // External tokens live in the user's Secret; a token the installer wrote + // before is stale. + if (app?.tokens === "external") { + await deleteOwned( + GITHUB_SECRET, + `with external tokens the token comes from Secret "${existingSecret(values0, "github")}"`, + ); + } + + log("[5/7] Chart (the first install also builds the repo snapshot; this can take minutes)"); + const clusterIP = + userClusterIP ?? + ((previous?.ironProxy as Values | undefined)?.clusterIP as string | undefined) ?? + (await pickClusterIP(k)); + const values = chartValues( + cfg, + deps.target.values, + { clusterIP, publicBaseUrl: url, identity, integrations }, + overlay, + ); + const before = await templateHash(k); + const generationBefore = previous ? await controlPlaneGeneration(k) : undefined; + const helm = await k.helm( + [ + "upgrade", + "--install", + RELEASE, + CHART_PATH, + "-n", + NAMESPACE, + "-f", + "-", + "--wait", + "--timeout", + opts.timeout, + ], + { stdin: JSON.stringify(values) }, + ); + if (helm.code !== 0) { + if (/clusterIPs?.*(not in the valid range|already allocated)/.test(helm.stderr)) { + throw new KubeError( + `the API server rejected iron-proxy's address ${clusterIP}: set values.ironProxy.clusterIP in the target file to a free address in the Service range (docs/kubernetes/README.md#iron-proxy-address)\n${helm.stderr.trim()}`, + ); + } + // Show the hooks' own errors: the bootstrap hook runs on every install + // and upgrade, the snapshot builder's only on a first install (its Job is + // deleted ten minutes after it fails, so pointing at it is not enough). + const logs = async (job: string, ns: string, title: string) => { + const r = await k.kubectl(["logs", `job/${job}`, "-n", ns, "--tail=50"]); + return r.code === 0 && r.stdout.trim() ? `\n\n${title} logs:\n${r.stdout.trim()}` : ""; + }; + const bootstrapLogs = await logs("devbox-bootstrap", NAMESPACE, "bootstrap"); + const builderLogs = previous + ? "" + : await logs("devbox-snapshot-builder-install", SANDBOX_NAMESPACE, "snapshot builder"); + throw new KubeError( + `helm upgrade --install failed:\n${helm.stderr.trim() || helm.stdout.trim()}${bootstrapLogs}${builderLogs}`, + ); + } + const after = await templateHash(k); + const generationAfter = previous ? await controlPlaneGeneration(k) : undefined; + if (previous && before !== after) { + log("sandbox template changed: restarting the OpenSandbox server"); + await k.kubectlOk(["rollout", "restart", "deployment/opensandbox-server", "-n", NAMESPACE]); + await k.kubectlOk([ + "rollout", + "status", + "deployment/opensandbox-server", + "-n", + NAMESPACE, + "--timeout=5m", + ]); + } + + // Helm did not roll the control plane, so it still runs with the old values. + if (integrationsChanged && generationBefore && generationBefore === generationAfter) { + log(`The integrations secrets changed; the control plane keeps the old values until it restarts: + kubectl --context ${context} -n ${NAMESPACE} rollout restart deploy/devbox-control-plane +then: bun run k8s:check --target ${deps.targetPath}`); + } + + log("[6/7] Repo snapshot"); + if (previous && repoKeys(previous.repos).join("\n") !== repoKeys(values.repos).join("\n")) { + log("repos changed: building a fresh snapshot"); + await refreshSnapshot(k, { sleep: deps.sleep, log, now: deps.now }); + } + const readySnapshots = async () => + ( + await k.kubectlOk([ + "get", + "volumesnapshot", + "-n", + SANDBOX_NAMESPACE, + "-l", + REPO_SNAPSHOTS, + "-o", + 'jsonpath={range .items[?(@.status.readyToUse==true)]}{.metadata.name}{"\\n"}{end}', + ]) + ).trim(); + let ready = await readySnapshots(); + // A failed first install leaves the release in place, so the next run is an + // upgrade and the post-install hook does not run again: build it here. + if (!ready && previous) { + log("no ready repo snapshot: building one"); + await refreshSnapshot(k, { sleep: deps.sleep, log, now: deps.now }); + ready = await readySnapshots(); + } + if (!ready) { + throw new KubeError( + `no ready repo snapshot: run \`bun run k8s:refresh-snapshot --target ${deps.targetPath}\` and read its output`, + ); + } + + log("[7/7] CLI login"); + const local = new URL(url).hostname === "localhost"; + // Before an API token exists: a VPN-only Ingress is unreachable from here. + if (!local) { + let cause = "no answer"; + let up = false; + for (let attempt = 0; attempt < HEALTH_ATTEMPTS && !up; attempt++) { + if (attempt > 0) await deps.sleep(HEALTH_INTERVAL_MS); + try { + const res = await deps.fetch(`${url}/health`, { + signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS), + }); + up = res.ok; + if (!up) cause = `HTTP ${res.status}`; + } catch (e) { + cause = e instanceof Error ? e.message : String(e); + } + if (!up && attempt === 0) log(`waiting for ${url}/health`); + } + if (!up) { + throw new KubeError( + `cannot reach ${url}/health after ${HEALTH_TIMEOUT_MS / 60_000} minutes (${cause}). The load balancer, DNS or certificate may still be provisioning: wait and run again. If the api Ingress is internal (VPN), connect this machine to it first. The chart is installed, only the CLI login is left.`, + ); + } + } + const forward = local ? await localForward(deps, k, Number(new URL(url).port)) : undefined; + try { + const outcome = await ensureCliLogin({ + path, + profile, + url, + isValid: async (p) => { + try { + const res = await deps.fetch(`${url}/api/me`, { + headers: { Authorization: `Bearer ${p.token}` }, + }); + return res.ok; + } catch { + return false; + } + }, + // stdout holds the token: never print it. + createToken: async () => + parseApiTokenOutput( + await k.kubectlOk([ + "exec", + "-n", + NAMESPACE, + "deploy/devbox-control-plane", + "--", + "bun", + "run", + "api-token", + "create", + `devbox-cli-${stamp(deps.now())}`, + ]), + ), + }); + log( + outcome === "kept" + ? `CLI profile "${profile}" is still valid` + : `CLI logged in: profile "${profile}" in ${path}`, + ); + } finally { + forward?.stop(); + } + + log(` +devbox is installed in context ${context}. The CLI uses profile "${profile}". +${local ? `\n bun run k8s:connect --target ${deps.targetPath} # keep it running in another terminal` : ""} + bun run devbox create "Add a /health endpoint to ${cfg.repos[0]?.owner}/${cfg.repos[0]?.name}" + bun run devbox logs -f ${linear ? `\n\nCheck Linear with: bun run linear:status --target ${deps.targetPath}` : ""}`); +} + +// A running `k8s:connect` already holds the profile's port: use it. +async function localForward( + deps: InstallDeps, + k: Kube, + port: number, +): Promise<{ stop: () => void } | undefined> { + if (await deps.isPortFree(port)) return deps.portForward(k, port); + deps.log(`port ${port} is in use (by \`bun run k8s:connect\`?): logging in through it`); + return undefined; +} diff --git a/control-plane/scripts/k8s/kind-guard.sh b/control-plane/scripts/k8s/kind-guard.sh new file mode 100644 index 0000000..764bbb9 --- /dev/null +++ b/control-plane/scripts/k8s/kind-guard.sh @@ -0,0 +1,28 @@ +# Sourced by e2e.sh before it touches a cluster. kind_guard refuses unless the +# kind-devbox context reaches the API server of the local kind node, then +# points KUBECONFIG at a copy holding only that context. The `bun run k8s:*` +# steps (refresh, purge) act on the target file they are given; the guard +# still keeps a wrong file from reaching another cluster through this +# kubeconfig. + +KIND_GUARD_CONTEXT=kind-devbox +KIND_GUARD_NODE=devbox-control-plane + +kind_guard() { + local server port only + server="$(kubectl config view --minify --context "$KIND_GUARD_CONTEXT" -o 'jsonpath={.clusters[0].cluster.server}')" + if ! port="$(docker port "$KIND_GUARD_NODE" 6443/tcp 2>/dev/null | head -n 1)" || [ -z "$port" ]; then + echo "FAIL no kind node $KIND_GUARD_NODE: run \`bun run k8s:kind\` first" >&2 + return 1 + fi + if [ "$server" != "https://$port" ]; then + echo "FAIL context $KIND_GUARD_CONTEXT points at $server, not the kind node's API server https://$port" >&2 + return 1 + fi + only="$(mktemp)" + # Expanded now: $only is gone by the time the shell exits. The caller's EXIT + # trap, if any, is replaced; e2e.sh sets none. + trap "rm -f $(printf '%q' "$only")" EXIT + kubectl config view --minify --flatten --context "$KIND_GUARD_CONTEXT" >"$only" + export KUBECONFIG="$only" +} diff --git a/control-plane/scripts/k8s/kind-guard.test.ts b/control-plane/scripts/k8s/kind-guard.test.ts new file mode 100644 index 0000000..0adc248 --- /dev/null +++ b/control-plane/scripts/k8s/kind-guard.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// kind-guard.sh with the real kubectl on a throwaway kubeconfig and a fake +// docker that reports the kind node's published API server port. +const GUARD = join(import.meta.dir, "kind-guard.sh"); +let dir: string; + +// The first real kubectl call is slow on a cold CI runner: one run went +// over the default 5s. +setDefaultTimeout(30_000); + +const kubeconfig = (kindServer: string) => `apiVersion: v1 +kind: Config +current-context: prod +clusters: +- name: kind-devbox + cluster: {server: "${kindServer}"} +- name: prod + cluster: {server: "https://prod.example:443"} +contexts: +- name: kind-devbox + context: {cluster: kind-devbox, user: kind-devbox} +- name: prod + context: {cluster: prod, user: prod} +users: +- name: kind-devbox + user: {token: a} +- name: prod + user: {token: b} +`; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "devbox-kind-guard-")); + writeFileSync( + join(dir, "docker"), + `#!/bin/sh +if [ "$*" = "port devbox-control-plane 6443/tcp" ] && [ -f "${dir}/port" ]; then cat "${dir}/port"; exit 0; fi +echo "Error: No such container: devbox-control-plane" >&2; exit 1 +`, + ); + chmodSync(join(dir, "docker"), 0o755); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +async function guard(kindServer: string, port?: string, then = "true") { + writeFileSync(join(dir, "config"), kubeconfig(kindServer)); + if (port) writeFileSync(join(dir, "port"), port); + const proc = Bun.spawn( + ["bash", "-c", `set -euo pipefail; source "${GUARD}"; kind_guard; ${then}`], + { + env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, KUBECONFIG: join(dir, "config") }, + stdout: "pipe", + stderr: "pipe", + }, + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stdout, stderr, code }; +} + +describe.skipIf(!Bun.which("kubectl"))("kind_guard", () => { + it("leaves only the kind context for everything that follows", async () => { + const r = await guard( + "https://127.0.0.1:50000", + "127.0.0.1:50000\n", + 'kubectl config get-contexts -o name; kubectl config current-context; echo "$KUBECONFIG"', + ); + expect(r.stderr).toBe(""); + expect(r.code).toBe(0); + const [contexts, current, copy] = r.stdout.trim().split("\n"); + expect([contexts, current]).toEqual(["kind-devbox", "kind-devbox"]); + // The copy is removed when the shell exits. + expect(copy).not.toBe(join(dir, "config")); + expect(existsSync(copy as string)).toBe(false); + }); + + it("refuses a kind-devbox context that points at another cluster", async () => { + const r = await guard("https://prod.example:443", "127.0.0.1:50000\n", "echo reached"); + expect(r.code).not.toBe(0); + expect(r.stdout).not.toContain("reached"); + expect(r.stderr).toContain( + "context kind-devbox points at https://prod.example:443, not the kind node's API server https://127.0.0.1:50000", + ); + }); + + it("refuses when the kind node is not running", async () => { + const r = await guard("https://127.0.0.1:50000", undefined, "echo reached"); + expect(r.code).not.toBe(0); + expect(r.stdout).not.toContain("reached"); + expect(r.stderr).toContain("no kind node devbox-control-plane: run `bun run k8s:kind` first"); + }); +}); diff --git a/control-plane/scripts/k8s/kind.test.ts b/control-plane/scripts/k8s/kind.test.ts new file mode 100644 index 0000000..0fa0e01 --- /dev/null +++ b/control-plane/scripts/k8s/kind.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from "bun:test"; +import type { DevboxConfig } from "../up/config"; +import { fakeRunner, type Step } from "./fakeRunner"; +import type { InstallDeps, InstallOptions } from "./install"; +import { KIND_VALUES, kindSetup, parseKindVersion } from "./kind"; +import { KubeError, type RunResult } from "./run"; +import { type Target, TargetError } from "./target"; + +const TMP = "/tmp/devbox-kind-test"; +const REPO = new URL("../../..", import.meta.url).pathname.replace(/\/$/, ""); +const K = (...args: string[]) => ["kubectl", "--context", "kind-devbox", ...args]; +const step = (cmd: string[], result?: Partial): Step => ({ cmd, result }); +const VERSION = "kind v0.32.0 go1.25.1 linux/amd64\n"; +const SNAPSHOTTER = "https://github.com/kubernetes-csi/external-snapshotter"; +const SNAPCLASS_YAML = `${TMP}/deploy/kubernetes-latest/hostpath/csi-hostpath-snapshotclass.yaml`; + +const OPTS: InstallOptions = { + skipPushCheck: true, + skipLlmCheck: true, + skipLinearCheck: false, + netpol: "auto", + timeout: "15m", +}; + +const PRELUDE: Step[] = [ + step(["kind", "version"], { stdout: VERSION }), + step(["docker", "info", "--format", "{{.ServerVersion}}"], { stdout: "27.0.0\n" }), +]; +const SNAPSHOT_CONTROLLER: Step[] = [ + step(K("apply", "-k", `${SNAPSHOTTER}/client/config/crd?ref=v8.7.0`)), + step(K("apply", "-k", `${SNAPSHOTTER}/deploy/kubernetes/snapshot-controller?ref=v8.7.0`)), + step( + K("rollout", "status", "deployment/snapshot-controller", "-n", "kube-system", "--timeout=3m"), + ), +]; +const HOSTPATH_CLASS = K("get", "storageclass", "csi-hostpath-sc"); +const SNAPCLASS = K("get", "volumesnapshotclass", "csi-hostpath-snapclass"); +const CURRENT_CONTEXT = ["kubectl", "config", "current-context"]; +const NO_CONTEXT = { code: 1, stderr: "error: current-context is not set\n" }; +const DEPLOY_SH = [`${TMP}/deploy/kubernetes-latest/deploy.sh`]; +const CLONE_HOSTPATH = step([ + "git", + "clone", + "--depth", + "1", + "--branch", + "v1.18.0", + "https://github.com/kubernetes-csi/csi-driver-host-path", + TMP, +]); +// The deploy script acts on the current context: it is switched to kind-devbox +// and restored after, when there was one. +const HOSTPATH_DEPLOY = ( + snapclass: Partial = {}, + previous: string | undefined = "my-eks", +): Step[] => [ + CLONE_HOSTPATH, + step(CURRENT_CONTEXT, previous ? { stdout: `${previous}\n` } : NO_CONTEXT), + step(["kubectl", "config", "use-context", "kind-devbox"]), + step(DEPLOY_SH), + ...(previous ? [step(["kubectl", "config", "use-context", previous])] : []), + step(K("apply", "-f", `${TMP}/examples/csi-storageclass.yaml`)), + step(SNAPCLASS, snapclass), + ...(snapclass.code ? [step(K("apply", "-f", SNAPCLASS_YAML))] : []), +]; +const IMAGES: Step[] = [ + step(["docker", "build", "-t", "devbox-control-plane:kind", `${REPO}/control-plane`]), + step(["make", "-C", `${REPO}/harness`, "build", "IMAGE=devbox-harness", "TAG=kind"]), + step([ + "kind", + "load", + "docker-image", + "devbox-control-plane:kind", + "devbox-harness:kind", + "--name", + "devbox", + ]), +]; + +function setup(script: Step[]) { + const f = fakeRunner(script); + const logs: string[] = []; + const installs: { target: Target; opts: InstallOptions; overlay: unknown }[] = []; + const files: Record = {}; + const deps = { + runner: f.runner, + cfg: {} as DevboxConfig, + log: (line: string) => void logs.push(line), + readFile: (p: string) => files[p], + writeFile: (p: string, t: string) => { + files[p] = t; + }, + } as unknown as InstallDeps; + const inject = { + install: async (d: InstallDeps, opts: InstallOptions, overlay?: unknown) => { + installs.push({ target: d.target, opts, overlay }); + }, + mkdtemp: () => TMP, + }; + return { f, deps, inject, logs, installs, files }; +} + +describe("parseKindVersion", () => { + it("reads major, minor and patch", () => { + expect(parseKindVersion("kind v0.32.0 go1.25.1 linux/amd64")).toEqual([0, 32, 0]); + }); + + it("rejects output that is not a kind version", () => { + expect(() => parseKindVersion("something else")).toThrow(KubeError); + }); +}); + +describe("kindSetup", () => { + it("rejects a kind that is too old", async () => { + const s = setup([step(["kind", "version"], { stdout: "kind v0.23.0 go1.22 linux/amd64\n" })]); + await expect(kindSetup(s.deps, OPTS, s.inject)).rejects.toThrow( + new KubeError( + "kind v0.23.0 is too old: devbox needs kind v0.24 or newer, whose default network enforces NetworkPolicy", + ), + ); + s.f.done(); + expect(s.installs).toEqual([]); + }); + + it("explains a Docker daemon that is not running", async () => { + const s = setup([ + ...PRELUDE.slice(0, 1), + step(PRELUDE[1]?.cmd ?? [], { code: 1, stderr: "Cannot connect to the Docker daemon" }), + ]); + await expect(kindSetup(s.deps, OPTS, s.inject)).rejects.toThrow( + "docker is not running: Cannot connect to the Docker daemon", + ); + }); + + it("builds a fresh machine from nothing, then installs", async () => { + const s = setup([ + ...PRELUDE, + step(["kind", "get", "clusters"], { stdout: "" }), + step(["kind", "create", "cluster", "--name", "devbox", "--wait", "120s"]), + ...SNAPSHOT_CONTROLLER, + step(HOSTPATH_CLASS, { code: 1, stderr: "NotFound" }), + ...HOSTPATH_DEPLOY({}, undefined), + ...IMAGES, + ]); + await kindSetup(s.deps, OPTS, s.inject); + s.f.done(); + expect(s.installs).toEqual([ + { + target: { context: "kind-devbox", profile: "k8s-kind-devbox", values: {} }, + opts: OPTS, + overlay: KIND_VALUES, + }, + ]); + expect(s.logs.some((l) => l.startsWith("Creating the kind cluster"))).toBe(true); + }); + + it("writes devbox.kind.json and installs from it", async () => { + const s = setup([ + ...PRELUDE, + step(["kind", "get", "clusters"], { stdout: "devbox\n" }), + ...SNAPSHOT_CONTROLLER, + step(HOSTPATH_CLASS), + ...IMAGES, + ]); + await kindSetup(s.deps, OPTS, s.inject); + expect(JSON.parse(s.files[`${REPO}/devbox.kind.json`] as string)).toEqual({ + context: "kind-devbox", + profile: "k8s-kind-devbox", + values: {}, + }); + expect(s.installs).toEqual([ + { + target: { context: "kind-devbox", profile: "k8s-kind-devbox", values: {} }, + opts: OPTS, + overlay: KIND_VALUES, + }, + ]); + s.f.done(); + }); + + it("keeps an existing file's values and rewrites its context and profile", async () => { + const s = setup([ + ...PRELUDE, + step(["kind", "get", "clusters"], { stdout: "devbox\n" }), + ...SNAPSHOT_CONTROLLER, + step(HOSTPATH_CLASS), + ...IMAGES, + ]); + s.files[`${REPO}/devbox.kind.json`] = JSON.stringify({ + context: "my-eks", + profile: "prod", + values: { sandbox: { cpu: "500m" } }, + }); + await kindSetup(s.deps, OPTS, s.inject); + expect(JSON.parse(s.files[`${REPO}/devbox.kind.json`] as string)).toEqual({ + context: "kind-devbox", + profile: "k8s-kind-devbox", + values: { sandbox: { cpu: "500m" } }, + }); + expect(s.installs[0]?.target).toEqual({ + context: "kind-devbox", + profile: "k8s-kind-devbox", + values: { sandbox: { cpu: "500m" } }, + }); + s.f.done(); + }); + + it("keeps an existing file's integrations and public URL", async () => { + const s = setup([ + ...PRELUDE, + step(["kind", "get", "clusters"], { stdout: "devbox\n" }), + ...SNAPSHOT_CONTROLLER, + step(HOSTPATH_CLASS), + ...IMAGES, + ]); + const integrations = { + linear: { enabled: true, clientId: "client-1" }, + slack: { enabled: false, channelId: "C123" }, + }; + s.files[`${REPO}/devbox.kind.json`] = JSON.stringify({ + publicUrl: "https://hooks.example.com", + values: {}, + integrations, + }); + await kindSetup(s.deps, OPTS, s.inject); + const expected = { + context: "kind-devbox", + profile: "k8s-kind-devbox", + publicUrl: "https://hooks.example.com", + values: {}, + integrations, + }; + expect(JSON.parse(s.files[`${REPO}/devbox.kind.json`] as string)).toEqual(expected); + expect(s.installs[0]?.target).toEqual(expected); + s.f.done(); + }); + + it("refuses a devbox.kind.json with an unknown key", async () => { + const s = setup([]); + s.files[`${REPO}/devbox.kind.json`] = JSON.stringify({ value: { sandbox: { cpu: "500m" } } }); + await expect(kindSetup(s.deps, OPTS, s.inject)).rejects.toThrow( + new TargetError( + `${REPO}/devbox.kind.json: unknown key "value"; chart values go under values`, + ), + ); + s.f.done(); + expect(s.installs).toEqual([]); + }); + + it("refuses a devbox.kind.json that is not a target file", async () => { + const s = setup([...PRELUDE]); + s.files[`${REPO}/devbox.kind.json`] = "{ nope"; + await expect(kindSetup(s.deps, OPTS, s.inject)).rejects.toThrow( + `${REPO}/devbox.kind.json: not valid JSON`, + ); + }); + + it("reuses an existing cluster that already has the hostpath driver", async () => { + const s = setup([ + ...PRELUDE, + step(["kind", "get", "clusters"], { stdout: "other\ndevbox\n" }), + ...SNAPSHOT_CONTROLLER, + step(HOSTPATH_CLASS), + ...IMAGES, + ]); + await kindSetup(s.deps, OPTS, s.inject); + s.f.done(); + expect(s.installs).toHaveLength(1); + }); + + it("applies the snapshot class itself when the driver's deploy did not", async () => { + const s = setup([ + ...PRELUDE, + step(["kind", "get", "clusters"], { stdout: "devbox\n" }), + ...SNAPSHOT_CONTROLLER, + step(HOSTPATH_CLASS, { code: 1 }), + ...HOSTPATH_DEPLOY({ code: 1, stderr: "NotFound" }), + ...IMAGES, + ]); + await kindSetup(s.deps, OPTS, s.inject); + s.f.done(); + }); + + it("restores the user's context when the hostpath driver's deploy fails", async () => { + const script = [ + ...PRELUDE, + step(["kind", "get", "clusters"], { stdout: "devbox\n" }), + ...SNAPSHOT_CONTROLLER, + step(HOSTPATH_CLASS, { code: 1 }), + CLONE_HOSTPATH, + step(CURRENT_CONTEXT, { stdout: "my-eks\n" }), + step(["kubectl", "config", "use-context", "kind-devbox"]), + step(DEPLOY_SH, { code: 1, stderr: "deploy failed" }), + step(["kubectl", "config", "use-context", "my-eks"]), + ]; + const s = setup(script); + await expect(kindSetup(s.deps, OPTS, s.inject)).rejects.toThrow( + `${DEPLOY_SH[0]} failed: deploy failed`, + ); + s.f.done(); + expect(s.f.calls.map((c) => c.cmd)).toEqual(script.map((c) => c.cmd)); + expect(s.installs).toEqual([]); + }); + + it("stops with the command's stderr when a step fails, without installing", async () => { + const s = setup([ + ...PRELUDE, + step(["kind", "get", "clusters"], { stdout: "devbox\n" }), + ...SNAPSHOT_CONTROLLER, + step(HOSTPATH_CLASS), + step(["docker", "build", "-t", "devbox-control-plane:kind", `${REPO}/control-plane`], { + code: 1, + stderr: "no space left on device", + }), + step(["make", "-C", `${REPO}/harness`, "build", "IMAGE=devbox-harness", "TAG=kind"]), + ]); + await expect(kindSetup(s.deps, OPTS, s.inject)).rejects.toThrow( + "docker build failed: no space left on device", + ); + expect(s.installs).toEqual([]); + }); + + it("carries the kind settings", () => { + expect(KIND_VALUES).toEqual({ + storage: { storageClass: "csi-hostpath-sc", volumeSnapshotClass: "csi-hostpath-snapclass" }, + images: { + controlPlane: "devbox-control-plane", + harness: "devbox-harness", + tag: "kind", + pullPolicy: "IfNotPresent", + }, + sandbox: { cpu: "1", memory: "2Gi", maxConcurrent: 5 }, + workspace: { size: "2Gi" }, + postgresql: { size: "1Gi" }, + }); + }); +}); diff --git a/control-plane/scripts/k8s/kind.ts b/control-plane/scripts/k8s/kind.ts new file mode 100644 index 0000000..6042ff2 --- /dev/null +++ b/control-plane/scripts/k8s/kind.ts @@ -0,0 +1,192 @@ +// `bun run k8s:kind`: a local cluster for trying devbox and +// for CI. kind ≥ 0.24, whose default network (kindnet) enforces +// NetworkPolicy; the CSI snapshot CRDs and controller; the CSI hostpath driver +// (single node: local and CI only); images built from this checkout and +// loaded into the cluster; then `k8s:install` with kind's settings, from the +// target file it writes, devbox.kind.json. +import { join } from "node:path"; +import type { InstallDeps, InstallOptions, install } from "./install"; +import { KubeError, kube, type Runner } from "./run"; +import { parseTarget, type Target, TargetError } from "./target"; + +export const KIND_CLUSTER = "devbox"; +export const KIND_CONTEXT = `kind-${KIND_CLUSTER}`; +const SNAPSHOTTER = "v8.7.0"; +const SNAPSHOTTER_URL = "https://github.com/kubernetes-csi/external-snapshotter"; +const HOSTPATH = "v1.18.0"; +const HOSTPATH_URL = "https://github.com/kubernetes-csi/csi-driver-host-path"; +const STORAGE_CLASS = "csi-hostpath-sc"; +const SNAPSHOT_CLASS = "csi-hostpath-snapclass"; +const ROOT = join(import.meta.dir, "..", "..", ".."); + +export const KIND_TARGET_FILE = "devbox.kind.json"; + +export const KIND_TARGET = { context: KIND_CONTEXT, profile: "k8s-kind-devbox" } as const; + +// The command that installs a target's deployment. kind's is `k8s:kind`, +// which adds KIND_VALUES: `k8s:install` alone would fail without them. +export const installCommand = (context: string, targetPath: string): string => + context === KIND_CONTEXT ? "bun run k8s:kind" : `bun run k8s:install --target ${targetPath}`; + +// Below the target file's values, above the installer's defaults. +export const KIND_VALUES = { + storage: { storageClass: STORAGE_CLASS, volumeSnapshotClass: SNAPSHOT_CLASS }, + images: { + controlPlane: "devbox-control-plane", + harness: "devbox-harness", + tag: "kind", + pullPolicy: "IfNotPresent", + }, + sandbox: { cpu: "1", memory: "2Gi", maxConcurrent: 5 }, + workspace: { size: "2Gi" }, + postgresql: { size: "1Gi" }, +}; + +export function parseKindVersion(out: string): [number, number, number] { + const m = /\bv(\d+)\.(\d+)\.(\d+)/.exec(out); + if (!m) throw new KubeError(`cannot read the kind version from "${out.trim()}"`); + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +// Runs a command, throwing its stderr when it fails. +async function must(runner: Runner, cmd: string[]): Promise { + const r = await runner(cmd); + if (r.code !== 0) { + throw new KubeError( + `${cmd.slice(0, 2).join(" ")} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.code}`}`, + ); + } + return r.stdout; +} + +// kind's target file: context and profile are kind's; a user's values are +// kept (the CI workflow lowers the sandbox CPU this way), and so are its +// integrations and public URL (written by linear:setup). +function kindTarget(deps: InstallDeps): { path: string; target: Target } { + const path = join(ROOT, KIND_TARGET_FILE); + const text = deps.readFile(path); + let kept: Pick = { values: {} }; + if (text !== undefined) { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new TargetError(`${path}: not valid JSON`); + } + // The whole file is checked (an unknown key fails rather than being + // dropped on rewrite), with kind's context and profile in place. + const { values, integrations, publicUrl } = parseTarget( + raw && typeof raw === "object" && !Array.isArray(raw) + ? { ...raw, context: KIND_CONTEXT, profile: KIND_TARGET.profile } + : raw, + path, + ); + kept = { + values, + ...(integrations !== undefined ? { integrations } : {}), + ...(publicUrl !== undefined ? { publicUrl } : {}), + }; + } + const target: Target = { ...KIND_TARGET, ...kept }; + deps.writeFile(path, `${JSON.stringify(target, null, 2)}\n`); + return { path, target }; +} + +export async function kindSetup( + deps: InstallDeps, + opts: InstallOptions, + inject: { install: typeof install; mkdtemp: () => string }, +): Promise { + const { runner, log } = deps; + const k = kube(runner, KIND_CONTEXT); + const { path, target } = kindTarget(deps); + + const versionOut = await must(runner, ["kind", "version"]); + const [major, minor] = parseKindVersion(versionOut); + if (major === 0 && minor < 24) { + const v = /v\d+\.\d+\.\d+/.exec(versionOut)?.[0]; + throw new KubeError( + `kind ${v} is too old: devbox needs kind v0.24 or newer, whose default network enforces NetworkPolicy`, + ); + } + const docker = await runner(["docker", "info", "--format", "{{.ServerVersion}}"]); + if (docker.code !== 0) { + throw new KubeError(`docker is not running: ${docker.stderr.trim() || `exit ${docker.code}`}`); + } + + const clusters = await must(runner, ["kind", "get", "clusters"]); + if (!clusters.split("\n").some((c) => c.trim() === KIND_CLUSTER)) { + log(`Creating the kind cluster "${KIND_CLUSTER}" (this can take a few minutes)`); + await must(runner, ["kind", "create", "cluster", "--name", KIND_CLUSTER, "--wait", "120s"]); + } + + log("Installing the CSI snapshot CRDs and controller"); + await k.kubectlOk(["apply", "-k", `${SNAPSHOTTER_URL}/client/config/crd?ref=${SNAPSHOTTER}`]); + await k.kubectlOk([ + "apply", + "-k", + `${SNAPSHOTTER_URL}/deploy/kubernetes/snapshot-controller?ref=${SNAPSHOTTER}`, + ]); + await k.kubectlOk([ + "rollout", + "status", + "deployment/snapshot-controller", + "-n", + "kube-system", + "--timeout=3m", + ]); + + if ((await k.kubectl(["get", "storageclass", STORAGE_CLASS])).code !== 0) { + log(`Installing the CSI hostpath driver ${HOSTPATH}`); + const dir = inject.mkdtemp(); + await must(runner, ["git", "clone", "--depth", "1", "--branch", HOSTPATH, HOSTPATH_URL, dir]); + // The upstream deploy script acts on the current context: switch to + // kind's for it, then give the user theirs back. + const current = await runner(["kubectl", "config", "current-context"]); + const previous = current.code === 0 ? current.stdout.trim() : ""; + await must(runner, ["kubectl", "config", "use-context", KIND_CONTEXT]); + try { + await must(runner, [join(dir, "deploy", "kubernetes-latest", "deploy.sh")]); + } finally { + if (previous) await must(runner, ["kubectl", "config", "use-context", previous]); + } + await k.kubectlOk(["apply", "-f", join(dir, "examples", "csi-storageclass.yaml")]); + if ((await k.kubectl(["get", "volumesnapshotclass", SNAPSHOT_CLASS])).code !== 0) { + await k.kubectlOk([ + "apply", + "-f", + join(dir, "deploy", "kubernetes-latest", "hostpath", "csi-hostpath-snapshotclass.yaml"), + ]); + } + } + + log("Building the control-plane and harness images (this can take several minutes)"); + await Promise.all([ + must(runner, [ + "docker", + "build", + "-t", + `${KIND_VALUES.images.controlPlane}:kind`, + join(ROOT, "control-plane"), + ]), + must(runner, [ + "make", + "-C", + join(ROOT, "harness"), + "build", + `IMAGE=${KIND_VALUES.images.harness}`, + "TAG=kind", + ]), + ]); + await must(runner, [ + "kind", + "load", + "docker-image", + `${KIND_VALUES.images.controlPlane}:kind`, + `${KIND_VALUES.images.harness}:kind`, + "--name", + KIND_CLUSTER, + ]); + + await inject.install({ ...deps, target, targetPath: path }, opts, KIND_VALUES); +} diff --git a/control-plane/scripts/k8s/preflight.test.ts b/control-plane/scripts/k8s/preflight.test.ts new file mode 100644 index 0000000..7060ac0 --- /dev/null +++ b/control-plane/scripts/k8s/preflight.test.ts @@ -0,0 +1,546 @@ +import { describe, expect, it } from "bun:test"; +import { fakeRunner, type Step } from "./fakeRunner"; +import { + checkCluster, + checkNetworkPolicy, + checkNodeFits, + checkNoOtherOpenSandbox, + checkStorage, + checkTools, + K8sPreflightError, + requireImageValues, +} from "./preflight"; +import { KubeError, kube, type RunResult } from "./run"; + +const kc = (...args: string[]) => ["kubectl", "--context", "c", ...args]; +const step = (cmd: string[], result?: Partial): Step => ({ cmd, result }); +const k8s = (script: Step[]) => { + const f = fakeRunner(script); + return { ...f, k: kube(f.runner, "c") }; +}; + +describe("checkTools", () => { + const KUBECTL = ["kubectl", "version", "--client", "-o", "json"]; + const HELM = ["helm", "version", "--short"]; + + it.each(["v3.14.0+g3fc9f4b", "v3.19.2+g8766e71", "v4.0.1+g12500dd"])( + "accepts helm %s", + async (version) => { + const f = fakeRunner([ + step(KUBECTL, { stdout: "{}" }), + step(HELM, { stdout: `${version}\n` }), + ]); + await checkTools(f.runner); + f.done(); + }, + ); + + it("rejects an old helm", async () => { + const f = fakeRunner([ + step(KUBECTL, { stdout: "{}" }), + step(HELM, { stdout: "v3.13.0+g825e86f\n" }), + ]); + await expect(checkTools(f.runner)).rejects.toThrow( + new K8sPreflightError("helm v3.13.0 is too old: devbox needs Helm 3.14 or newer"), + ); + }); + + it("reports a kubectl that does not work", async () => { + const f = fakeRunner([step(KUBECTL, { code: 127, stderr: "kubectl: broken\n" })]); + await expect(checkTools(f.runner)).rejects.toThrow( + new K8sPreflightError("kubectl is not working: kubectl: broken"), + ); + }); + + it("reports a helm that does not work", async () => { + const f = fakeRunner([ + step(KUBECTL, { stdout: "{}" }), + step(HELM, { code: 1, stderr: "helm: broken\n" }), + ]); + await expect(checkTools(f.runner)).rejects.toThrow( + new K8sPreflightError("helm is not working: helm: broken"), + ); + }); +}); + +describe("checkCluster", () => { + const VERSION = kc("version", "-o", "json"); + const server = (major: string, minor: string) => + JSON.stringify({ clientVersion: {}, serverVersion: { major, minor } }); + + it.each([ + ["1", "27"], + ["1", "29+"], + ["1", "34"], + ])("accepts Kubernetes %s.%s", async (major, minor) => { + const f = k8s([step(VERSION, { stdout: server(major, minor) })]); + await checkCluster(f.k); + f.done(); + }); + + it("rejects an old cluster", async () => { + const f = k8s([step(VERSION, { stdout: server("1", "26") })]); + await expect(checkCluster(f.k)).rejects.toThrow( + new K8sPreflightError("Kubernetes 1.26 is too old: devbox needs 1.27 or newer"), + ); + }); + + it("names the context it cannot reach, with the first stderr line", async () => { + const f = k8s([ + step(VERSION, { + code: 1, + stdout: JSON.stringify({ clientVersion: {} }), + stderr: "Unable to connect to the server: dial tcp 10.0.0.1:443: i/o timeout\nmore\n", + }), + ]); + await expect(checkCluster(f.k)).rejects.toThrow( + new K8sPreflightError( + 'cannot reach the cluster of context "c": Unable to connect to the server: dial tcp 10.0.0.1:443: i/o timeout', + ), + ); + }); +}); + +describe("checkStorage", () => { + const values = { storage: { storageClass: "gp3", volumeSnapshotClass: "ebs" } }; + const SC = kc("get", "storageclass", "gp3", "-o", "json"); + const VSC = kc("get", "volumesnapshotclass", "ebs", "-o", "json"); + const sc = (provisioner: string, volumeBindingMode?: string) => ({ + stdout: JSON.stringify({ provisioner, volumeBindingMode }), + }); + const vsc = (driver: string) => ({ stdout: JSON.stringify({ driver }) }); + + it("accepts a snapshot class of the StorageClass's driver", async () => { + const f = k8s([step(SC, sc("ebs.csi.aws.com")), step(VSC, vsc("ebs.csi.aws.com"))]); + expect(await checkStorage(f.k, values)).toEqual({ + name: "gp3", + provisioner: "ebs.csi.aws.com", + bindingMode: "Immediate", + }); + f.done(); + }); + + it("reports a WaitForFirstConsumer binding mode", async () => { + const f = k8s([ + step(SC, sc("ebs.csi.aws.com", "WaitForFirstConsumer")), + step(VSC, vsc("ebs.csi.aws.com")), + ]); + expect(await checkStorage(f.k, values)).toEqual({ + name: "gp3", + provisioner: "ebs.csi.aws.com", + bindingMode: "WaitForFirstConsumer", + }); + f.done(); + }); + + it("names a missing StorageClass", async () => { + const f = k8s([ + step(SC, { + code: 1, + stderr: 'Error from server (NotFound): storageclasses.storage.k8s.io "gp3" not found\n', + }), + ]); + await expect(checkStorage(f.k, values)).rejects.toThrow( + new K8sPreflightError('StorageClass "gp3" not found (kubectl get storageclass)'), + ); + }); + + it("explains missing snapshot CRDs", async () => { + const f = k8s([ + step(SC, sc("ebs.csi.aws.com")), + step(VSC, { + code: 1, + stderr: 'error: the server doesn\'t have a resource type "volumesnapshotclass"\n', + }), + ]); + await expect(checkStorage(f.k, values)).rejects.toThrow( + new K8sPreflightError( + "volume snapshots are not installed: install the snapshot CRDs and controller (docs/kubernetes/README.md#prerequisites)", + ), + ); + }); + + it("names a missing VolumeSnapshotClass", async () => { + const f = k8s([ + step(SC, sc("ebs.csi.aws.com")), + step(VSC, { + code: 1, + stderr: + 'Error from server (NotFound): volumesnapshotclasses.snapshot.storage.k8s.io "ebs" not found\n', + }), + ]); + await expect(checkStorage(f.k, values)).rejects.toThrow( + new K8sPreflightError( + 'VolumeSnapshotClass "ebs" not found (kubectl get volumesnapshotclass)', + ), + ); + }); + + it("rejects a snapshot class of another driver", async () => { + const f = k8s([step(SC, sc("y")), step(VSC, vsc("x"))]); + await expect(checkStorage(f.k, values)).rejects.toThrow( + new K8sPreflightError( + 'VolumeSnapshotClass "ebs" uses driver "x" but StorageClass "gp3" uses "y": pick a class of the same CSI driver', + ), + ); + }); + + it("passes on other kubectl errors as they are", async () => { + const f = k8s([step(SC, { code: 1, stderr: "Error from server (Forbidden): no\n" })]); + const run = checkStorage(f.k, values); + await expect(run).rejects.toBeInstanceOf(KubeError); + await expect(run).rejects.toThrow( + "kubectl get storageclass gp3 failed: Error from server (Forbidden): no", + ); + }); + + it("requires both classes before asking the cluster", async () => { + const f = k8s([]); + await expect(checkStorage(f.k, { storage: { volumeSnapshotClass: "ebs" } })).rejects.toThrow( + new K8sPreflightError( + "storage.storageClass is required: the StorageClass for task workspaces", + ), + ); + await expect(checkStorage(f.k, { storage: { storageClass: "gp3" } })).rejects.toThrow( + new K8sPreflightError( + "storage.volumeSnapshotClass is required: a VolumeSnapshotClass of the StorageClass's CSI driver", + ), + ); + f.done(); + }); +}); + +describe("requireImageValues", () => { + it("passes when the images are left to the chart's defaults or set", () => { + expect(() => requireImageValues({})).not.toThrow(); + expect(() => + requireImageValues({ images: { controlPlane: "r/cp", harness: "r/h", tag: "1" } }), + ).not.toThrow(); + }); + + it.each([ + ["controlPlane", "images.controlPlane is required: the control-plane image repository"], + ["harness", "images.harness is required: the harness image repository"], + ])("refuses an empty images.%s", (key, message) => { + for (const value of ["", null]) { + expect(() => requireImageValues({ images: { [key]: value } })).toThrow( + new K8sPreflightError(message), + ); + } + }); +}); + +describe("checkNetworkPolicy", () => { + const NS = "devbox-netpol-check-abc"; + const AGNHOST = "registry.k8s.io/e2e-test-images/agnhost:2.52"; + const APPLY = kc("apply", "-f", "-"); + const CREATE_NS = kc("create", "namespace", NS); + const WAIT = kc("wait", "--for=condition=Ready", "pod/server", "-n", NS, "--timeout=120s"); + const IP = kc("get", "pod", "server", "-n", NS, "-o", "jsonpath={.status.podIP}"); + const phase = (pod: string) => kc("get", "pod", pod, "-n", NS, "-o", "jsonpath={.status.phase}"); + const DELETE_NS = kc("delete", "namespace", NS, "--wait=false"); + const pod = (name: string, args: string[]) => ({ + apiVersion: "v1", + kind: "Pod", + metadata: { name, namespace: NS, labels: { "devbox-netpol-check": name } }, + spec: { + restartPolicy: "Never", + securityContext: { + runAsNonRoot: true, + runAsUser: 65534, + runAsGroup: 65534, + seccompProfile: { type: "RuntimeDefault" }, + }, + containers: [ + { + name: "agnhost", + image: AGNHOST, + args, + securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } }, + }, + ], + }, + }); + const policy = { + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + metadata: { name: "deny-server", namespace: NS }, + spec: { + podSelector: { matchLabels: { "devbox-netpol-check": "server" } }, + policyTypes: ["Ingress"], + }, + }; + const deps = () => { + const sleeps: number[] = []; + return { sleeps, deps: { sleep: async (ms: number) => void sleeps.push(ms), suffix: "abc" } }; + }; + const upToBefore = [ + step(CREATE_NS), + step(APPLY), + step(WAIT), + step(IP, { stdout: "10.244.0.7" }), + step(APPLY), + ]; + + it("passes when the policy blocks the second connection, and cleans up", async () => { + const f = k8s([ + ...upToBefore, + step(phase("client-before"), { stdout: "Pending" }), + step(phase("client-before"), { stdout: "Running" }), + step(phase("client-before"), { stdout: "Succeeded" }), + step(APPLY), + step(APPLY), + step(phase("client-after"), { stdout: "Running" }), + step(phase("client-after"), { stdout: "Failed" }), + step(DELETE_NS), + ]); + const d = deps(); + await checkNetworkPolicy(f.k, d.deps); + f.done(); + expect(d.sleeps).toEqual([2000, 2000, 5000, 2000]); + const stdin = f.calls.flatMap((c) => (c.stdin === undefined ? [] : [JSON.parse(c.stdin)])); + expect(stdin).toEqual([ + pod("server", ["netexec", "--http-port=8080"]), + pod("client-before", ["connect", "10.244.0.7:8080", "--timeout=5s"]), + policy, + pod("client-after", ["connect", "10.244.0.7:8080", "--timeout=5s"]), + ]); + }); + + it("brackets an IPv6 server address", async () => { + const f = k8s([ + step(CREATE_NS), + step(APPLY), + step(WAIT), + step(IP, { stdout: "fd00::7" }), + step(APPLY), + step(phase("client-before"), { stdout: "Succeeded" }), + step(APPLY), + step(APPLY), + step(phase("client-after"), { stdout: "Failed" }), + step(DELETE_NS), + ]); + await checkNetworkPolicy(f.k, deps().deps); + f.done(); + expect(JSON.parse(f.calls[4]?.stdin ?? "").spec.containers[0].args).toEqual([ + "connect", + "[fd00::7]:8080", + "--timeout=5s", + ]); + }); + + it("fails when the policy is not enforced", async () => { + const f = k8s([ + ...upToBefore, + step(phase("client-before"), { stdout: "Succeeded" }), + step(APPLY), + step(APPLY), + step(phase("client-after"), { stdout: "Succeeded" }), + step(DELETE_NS), + ]); + await expect(checkNetworkPolicy(f.k, deps().deps)).rejects.toThrow( + new K8sPreflightError( + "NetworkPolicy is not enforced in this cluster, so the sandbox egress lockdown would silently not happen. Use a CNI that enforces it (docs/kubernetes/README.md#prerequisites), or pass --skip-netpol-check to install anyway", + ), + ); + f.done(); + }); + + it("fails when pods cannot talk even without a policy", async () => { + const f = k8s([ + ...upToBefore, + step(phase("client-before"), { stdout: "Failed" }), + step(DELETE_NS), + ]); + await expect(checkNetworkPolicy(f.k, deps().deps)).rejects.toThrow( + new K8sPreflightError( + "could not run the NetworkPolicy check: a test pod could not reach another one without any policy (namespace devbox-netpol-check-abc)", + ), + ); + f.done(); + }); + + it("gives up on the first client after 60 s", async () => { + const polls = Array.from({ length: 30 }, () => + step(phase("client-before"), { stdout: "Running" }), + ); + const f = k8s([...upToBefore, ...polls, step(DELETE_NS)]); + const d = deps(); + await expect(checkNetworkPolicy(f.k, d.deps)).rejects.toThrow( + new K8sPreflightError( + "could not run the NetworkPolicy check: a test pod could not reach another one without any policy (namespace devbox-netpol-check-abc)", + ), + ); + f.done(); + expect(d.sleeps).toEqual(Array(29).fill(2000)); + }); + + it("fails when the second client never finishes", async () => { + const polls = Array.from({ length: 30 }, () => + step(phase("client-after"), { stdout: "Pending" }), + ); + const f = k8s([ + ...upToBefore, + step(phase("client-before"), { stdout: "Succeeded" }), + step(APPLY), + step(APPLY), + ...polls, + step(DELETE_NS), + ]); + await expect(checkNetworkPolicy(f.k, deps().deps)).rejects.toThrow( + new K8sPreflightError( + "could not run the NetworkPolicy check: test pod client-after did not finish within 60 s (namespace devbox-netpol-check-abc)", + ), + ); + f.done(); + }); + + it("deletes the probe namespace when a step fails, and explains the failure", async () => { + const f = k8s([ + step(CREATE_NS), + step(APPLY), + step(WAIT, { + code: 1, + stderr: "error: timed out waiting for the condition on pods/server\n", + }), + step(DELETE_NS), + ]); + const run = checkNetworkPolicy(f.k, deps().deps); + await expect(run).rejects.toBeInstanceOf(K8sPreflightError); + await expect(run).rejects.toThrow( + "could not run the NetworkPolicy check: kubectl wait --for=condition=Ready pod/server failed: error: timed out waiting for the condition on pods/server (pass --skip-netpol-check to install without it)", + ); + f.done(); + }); + + it("explains a probe pod rejected at admission", async () => { + const f = k8s([ + step(CREATE_NS), + step(APPLY, { + code: 1, + stderr: + 'Error from server (Forbidden): pods "server" is forbidden: violates PodSecurity "restricted:latest"\n', + }), + step(DELETE_NS), + ]); + const run = checkNetworkPolicy(f.k, deps().deps); + await expect(run).rejects.toBeInstanceOf(K8sPreflightError); + await expect(run).rejects.toThrow( + 'could not run the NetworkPolicy check: kubectl apply -f - failed: Error from server (Forbidden): pods "server" is forbidden: violates PodSecurity "restricted:latest" (pass --skip-netpol-check to install without it)', + ); + f.done(); + }); +}); + +describe("checkNodeFits", () => { + const NODES = kc("get", "nodes", "-o", "json"); + const node = (cpu: string, memory: string, unschedulable?: boolean) => ({ + spec: unschedulable === undefined ? {} : { unschedulable }, + status: { allocatable: { cpu, memory, pods: "110" } }, + }); + const nodes = (...items: unknown[]) => ({ stdout: JSON.stringify({ items }) }); + + it("passes when one node fits the default sandbox", async () => { + const f = k8s([step(NODES, nodes(node("1900m", "7864320Ki"), node("4", "16303308Ki")))]); + await checkNodeFits(f.k, {}); + f.done(); + }); + + it("uses the configured sandbox size", async () => { + const f = k8s([step(NODES, nodes(node("1900m", "7864320Ki")))]); + await checkNodeFits(f.k, { sandbox: { cpu: "1", memory: "4Gi" } }); + f.done(); + }); + + it("names the largest node when none fits", async () => { + const f = k8s([ + step(NODES, nodes(node("1", "4Gi"), node("1900m", "7864320Ki"), node("8", "64Gi", true))), + ]); + await expect(checkNodeFits(f.k, {})).rejects.toThrow( + new K8sPreflightError( + "no node can fit a sandbox (2 CPU, 8Gi): the largest has 1.9 CPU and 7.5Gi allocatable; lower values.sandbox.cpu/memory in the target file or add bigger nodes", + ), + ); + }); + + it.each([ + ["cpu", { cpu: "two" }, 'values.sandbox.cpu: bad quantity "two"'], + ["memory", { memory: "8 GB" }, 'values.sandbox.memory: bad quantity "8 GB"'], + ])("names a bad sandbox %s before asking the cluster", async (_, sandbox, message) => { + const f = k8s([]); + const run = checkNodeFits(f.k, { sandbox }); + await expect(run).rejects.toBeInstanceOf(K8sPreflightError); + await expect(run).rejects.toThrow(message); + f.done(); + }); + + it("explains a cluster with no schedulable node", async () => { + const f = k8s([step(NODES, nodes(node("8", "64Gi", true)))]); + await expect(checkNodeFits(f.k, { sandbox: { cpu: "500m", memory: "1Gi" } })).rejects.toThrow( + new K8sPreflightError( + "no node can fit a sandbox (500m CPU, 1Gi): no node accepts new pods; lower values.sandbox.cpu/memory in the target file or add bigger nodes", + ), + ); + }); +}); + +describe("checkNoOtherOpenSandbox", () => { + const DEPLOYMENTS = kc( + "get", + "deployments", + "-A", + "-l", + "app.kubernetes.io/name=opensandbox,control-plane=controller-manager", + "-o", + 'jsonpath={range .items[*]}{.metadata.namespace}{"\\n"}{end}', + ); + const CRD = kc( + "get", + "crd", + "batchsandboxes.sandbox.opensandbox.io", + "-o", + "jsonpath={.metadata.annotations.meta\\.helm\\.sh/release-name}", + ); + const noCrd = { + code: 1, + stderr: + 'Error from server (NotFound): customresourcedefinitions.apiextensions.k8s.io "batchsandboxes.sandbox.opensandbox.io" not found\n', + }; + + it("passes on a clean cluster", async () => { + const f = k8s([step(DEPLOYMENTS), step(CRD, noCrd)]); + await checkNoOtherOpenSandbox(f.k); + f.done(); + }); + + it("passes on its own release", async () => { + const f = k8s([step(DEPLOYMENTS, { stdout: "devbox\n" }), step(CRD, { stdout: "devbox" })]); + await checkNoOtherOpenSandbox(f.k); + f.done(); + }); + + it("names another controller's namespace", async () => { + const f = k8s([step(DEPLOYMENTS, { stdout: "devbox\nx\n" })]); + await expect(checkNoOtherOpenSandbox(f.k)).rejects.toThrow( + new K8sPreflightError( + 'another OpenSandbox controller runs in namespace "x": devbox bundles its own; uninstall the other one first', + ), + ); + }); + + it("names the release that owns the CRDs", async () => { + const f = k8s([step(DEPLOYMENTS), step(CRD, { stdout: "y" })]); + await expect(checkNoOtherOpenSandbox(f.k)).rejects.toThrow( + new K8sPreflightError('the OpenSandbox CRDs belong to Helm release "y": uninstall it first'), + ); + }); + + it("rejects CRDs installed outside Helm", async () => { + const f = k8s([step(DEPLOYMENTS), step(CRD, { stdout: "" })]); + await expect(checkNoOtherOpenSandbox(f.k)).rejects.toThrow( + new K8sPreflightError( + "the OpenSandbox CRDs were installed outside Helm: delete them first (kubectl get crd | grep opensandbox.io)", + ), + ); + }); +}); diff --git a/control-plane/scripts/k8s/preflight.ts b/control-plane/scripts/k8s/preflight.ts new file mode 100644 index 0000000..aef7b20 --- /dev/null +++ b/control-plane/scripts/k8s/preflight.ts @@ -0,0 +1,317 @@ +// Checks before `k8s:install` touches the cluster. Each failure says what is +// wrong and how to fix it. None reads a Secret; `k8s:check` validates the +// user's own Secrets. +import { parseCpu, parseMemory } from "./quantity"; +import { type Kube, KubeError, NAMESPACE, RELEASE, type Runner, type RunResult } from "./run"; +import type { Values } from "./values"; + +export class K8sPreflightError extends Error {} + +const AGNHOST = "registry.k8s.io/e2e-test-images/agnhost:2.52"; +const fail = (message: string): never => { + throw new K8sPreflightError(message); +}; +const get = (values: Values, ...path: string[]): unknown => + path.reduce( + (v, k) => (v && typeof v === "object" ? (v as Values)[k] : undefined), + values, + ); + +const isNotFound = (r: RunResult) => /\(NotFound\)/.test(r.stderr); +// The stdout of a successful kubectl call; any failure but the expected ones +// is passed on as it is. +function stdoutOf(args: string[], r: RunResult): string { + if (r.code !== 0) { + throw new KubeError( + `kubectl ${args.slice(0, 3).join(" ")} failed: ${r.stderr.trim() || `exit ${r.code}`}`, + ); + } + return r.stdout; +} + +export async function checkTools(runner: Runner): Promise { + const kubectl = await runner(["kubectl", "version", "--client", "-o", "json"]); + if (kubectl.code !== 0) fail(`kubectl is not working: ${kubectl.stderr.trim()}`); + const helm = await runner(["helm", "version", "--short"]); + if (helm.code !== 0) fail(`helm is not working: ${helm.stderr.trim()}`); + const m = /^v(\d+)\.(\d+)\.(\d+)/.exec(helm.stdout.trim()); + const [major, minor] = [Number(m?.[1]), Number(m?.[2])]; + if (!m || (major === 3 && minor < 14) || major < 3) { + fail(`helm ${helm.stdout.trim().split("+")[0]} is too old: devbox needs Helm 3.14 or newer`); + } +} + +export async function checkCluster(k: Kube): Promise { + const r = await k.kubectl(["version", "-o", "json"]); + if (r.code !== 0) { + fail(`cannot reach the cluster of context "${k.context}": ${r.stderr.trim().split("\n")[0]}`); + } + const server = (JSON.parse(r.stdout) as { serverVersion?: { major: string; minor: string } }) + .serverVersion; + const [major, minor] = [ + Number.parseInt(server?.major ?? "0", 10), + Number.parseInt(server?.minor ?? "0", 10), + ]; + if (major < 1 || (major === 1 && minor < 27)) { + fail(`Kubernetes ${major}.${minor} is too old: devbox needs 1.27 or newer`); + } +} + +// The chart refuses these too (templates/validate.yaml); say so before any cluster call. +export function requireStorageValues(values: Values): { sc: string; vsc: string } { + const sc = String(get(values, "storage", "storageClass") ?? ""); + if (!sc) fail("storage.storageClass is required: the StorageClass for task workspaces"); + const vsc = String(get(values, "storage", "volumeSnapshotClass") ?? ""); + if (!vsc) { + fail( + "storage.volumeSnapshotClass is required: a VolumeSnapshotClass of the StorageClass's CSI driver", + ); + } + return { sc, vsc }; +} + +// The chart defaults both images, so only a value set to empty fails; the chart +// refuses it too (templates/_helpers.tpl), but only after the Secrets step. +export function requireImageValues(values: Values): void { + for (const [key, what] of [ + ["controlPlane", "the control-plane image repository"], + ["harness", "the harness image repository"], + ] as const) { + const image = get(values, "images", key); + if (image !== undefined && !String(image ?? "")) fail(`images.${key} is required: ${what}`); + } +} + +export async function checkStorage( + k: Kube, + values: Values, +): Promise<{ name: string; provisioner: string; bindingMode: string }> { + const { sc, vsc } = requireStorageValues(values); + + const scArgs = ["get", "storageclass", sc, "-o", "json"]; + const scRes = await k.kubectl(scArgs); + if (isNotFound(scRes)) fail(`StorageClass "${sc}" not found (kubectl get storageclass)`); + const { provisioner, volumeBindingMode } = JSON.parse(stdoutOf(scArgs, scRes)) as { + provisioner?: string; + volumeBindingMode?: string; + }; + + const vscArgs = ["get", "volumesnapshotclass", vsc, "-o", "json"]; + const vscRes = await k.kubectl(vscArgs); + if (vscRes.code !== 0 && vscRes.stderr.includes("the server doesn't have a resource type")) { + fail( + "volume snapshots are not installed: install the snapshot CRDs and controller (docs/kubernetes/README.md#prerequisites)", + ); + } + if (isNotFound(vscRes)) + fail(`VolumeSnapshotClass "${vsc}" not found (kubectl get volumesnapshotclass)`); + const { driver } = JSON.parse(stdoutOf(vscArgs, vscRes)) as { driver?: string }; + + if (driver !== provisioner) { + fail( + `VolumeSnapshotClass "${vsc}" uses driver "${driver}" but StorageClass "${sc}" uses "${provisioner}": pick a class of the same CSI driver`, + ); + } + return { + name: sc, + provisioner: provisioner ?? "", + bindingMode: volumeBindingMode ?? "Immediate", + }; +} + +// The NetworkPolicy probe: a server, a client that must reach it, a policy +// denying all ingress to the server, and a client that must not. +export function probePod(namespace: string, name: string, args: string[]): Values { + return { + apiVersion: "v1", + kind: "Pod", + metadata: { name, namespace, labels: { "devbox-netpol-check": name } }, + spec: { + restartPolicy: "Never", + // Admitted under Pod Security "restricted", which some clusters enforce by default. + securityContext: { + runAsNonRoot: true, + runAsUser: 65534, + runAsGroup: 65534, + seccompProfile: { type: "RuntimeDefault" }, + }, + containers: [ + { + name: "agnhost", + image: AGNHOST, + args, + securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } }, + }, + ], + }, + }; +} + +export function denyAllPolicy(namespace: string): Values { + return { + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + metadata: { name: "deny-server", namespace }, + spec: { + podSelector: { matchLabels: { "devbox-netpol-check": "server" } }, + policyTypes: ["Ingress"], + }, + }; +} + +const POLL_MS = 2000; +const POLL_LIMIT_MS = 60_000; + +// The pod's phase once it is Succeeded or Failed, or its last phase after 60 s. +async function waitPhase( + k: Kube, + namespace: string, + pod: string, + sleep: (ms: number) => Promise, +): Promise { + let phase = ""; + for (let i = 0; i < POLL_LIMIT_MS / POLL_MS; i++) { + if (i > 0) await sleep(POLL_MS); + phase = ( + await k.kubectlOk(["get", "pod", pod, "-n", namespace, "-o", "jsonpath={.status.phase}"]) + ).trim(); + if (phase === "Succeeded" || phase === "Failed") return phase; + } + return phase; +} + +export async function checkNetworkPolicy( + k: Kube, + deps: { sleep: (ms: number) => Promise; suffix: string }, +): Promise { + const ns = `devbox-netpol-check-${deps.suffix}`; + const apply = (manifest: Values) => + k.kubectlOk(["apply", "-f", "-"], { stdin: JSON.stringify(manifest) }); + try { + await k.kubectlOk(["create", "namespace", ns]); + await apply(probePod(ns, "server", ["netexec", "--http-port=8080"])); + await k.kubectlOk(["wait", "--for=condition=Ready", "pod/server", "-n", ns, "--timeout=120s"]); + const ip = ( + await k.kubectlOk(["get", "pod", "server", "-n", ns, "-o", "jsonpath={.status.podIP}"]) + ).trim(); + const target = `${ip.includes(":") ? `[${ip}]` : ip}:8080`; + const client = (name: string) => probePod(ns, name, ["connect", target, "--timeout=5s"]); + + await apply(client("client-before")); + if ((await waitPhase(k, ns, "client-before", deps.sleep)) !== "Succeeded") { + fail( + `could not run the NetworkPolicy check: a test pod could not reach another one without any policy (namespace ${ns})`, + ); + } + + await apply(denyAllPolicy(ns)); + await deps.sleep(5000); // policies apply asynchronously + await apply(client("client-after")); + const after = await waitPhase(k, ns, "client-after", deps.sleep); + if (after === "Succeeded") { + fail( + "NetworkPolicy is not enforced in this cluster, so the sandbox egress lockdown would silently not happen. Use a CNI that enforces it (docs/kubernetes/README.md#prerequisites), or pass --skip-netpol-check to install anyway", + ); + } + if (after !== "Failed") { + fail( + `could not run the NetworkPolicy check: test pod client-after did not finish within 60 s (namespace ${ns})`, + ); + } + } catch (err) { + if (err instanceof K8sPreflightError) throw err; + throw new K8sPreflightError( + `could not run the NetworkPolicy check: ${(err as Error).message} (pass --skip-netpol-check to install without it)`, + ); + } finally { + // Not kubectlOk: a failed cleanup must not hide the check's own result. + await k.kubectl(["delete", "namespace", ns, "--wait=false"]); + } +} + +type Node = { + spec?: { unschedulable?: boolean }; + status?: { allocatable?: { cpu?: string; memory?: string } }; +}; + +const userQuantity = (key: string, q: string, parse: (q: string) => number): number => { + try { + return parse(q); + } catch (err) { + return fail(`values.sandbox.${key}: ${(err as Error).message}`); + } +}; + +// Rounded down, so a node just short of the request never reads as enough. +const oneDecimal = (n: number) => (Math.floor(n * 10) / 10).toFixed(1); + +export async function checkNodeFits(k: Kube, values: Values): Promise { + const cpu = String(get(values, "sandbox", "cpu") ?? "2"); + const memory = String(get(values, "sandbox", "memory") ?? "8Gi"); + const [wantCpu, wantMemory] = [ + userQuantity("cpu", cpu, parseCpu), + userQuantity("memory", memory, parseMemory), + ]; + const { items } = JSON.parse(await k.kubectlOk(["get", "nodes", "-o", "json"])) as { + items: Node[]; + }; + const nodes = items + .filter((n) => !n.spec?.unschedulable) + .map((n) => ({ + cpu: parseCpu(n.status?.allocatable?.cpu ?? "0"), + memory: parseMemory(n.status?.allocatable?.memory ?? "0"), + })); + if (nodes.some((n) => n.cpu >= wantCpu && n.memory >= wantMemory)) return; + + const advice = "lower values.sandbox.cpu/memory in the target file or add bigger nodes"; + // "Largest" is the node closest to fitting: the highest share of the scarcer resource. + const fit = (n: { cpu: number; memory: number }) => + Math.min(n.cpu / wantCpu, n.memory / wantMemory); + const largest = nodes.reduce<(typeof nodes)[number] | undefined>( + (best, n) => (!best || fit(n) > fit(best) ? n : best), + undefined, + ); + const found = largest + ? `the largest has ${oneDecimal(largest.cpu)} CPU and ${oneDecimal(largest.memory / 2 ** 30)}Gi allocatable` + : "no node accepts new pods"; + fail(`no node can fit a sandbox (${cpu} CPU, ${memory}): ${found}; ${advice}`); +} + +export async function checkNoOtherOpenSandbox(k: Kube): Promise { + const namespaces = await k.kubectlOk([ + "get", + "deployments", + "-A", + "-l", + "app.kubernetes.io/name=opensandbox,control-plane=controller-manager", + "-o", + 'jsonpath={range .items[*]}{.metadata.namespace}{"\\n"}{end}', + ]); + const other = namespaces + .split("\n") + .map((s) => s.trim()) + .find((ns) => ns && ns !== NAMESPACE); + if (other) { + fail( + `another OpenSandbox controller runs in namespace "${other}": devbox bundles its own; uninstall the other one first`, + ); + } + + const args = [ + "get", + "crd", + "batchsandboxes.sandbox.opensandbox.io", + "-o", + "jsonpath={.metadata.annotations.meta\\.helm\\.sh/release-name}", + ]; + const r = await k.kubectl(args); + if (isNotFound(r)) return; + const release = stdoutOf(args, r).trim(); + if (release === RELEASE) return; + if (!release) { + fail( + "the OpenSandbox CRDs were installed outside Helm: delete them first (kubectl get crd | grep opensandbox.io)", + ); + } + fail(`the OpenSandbox CRDs belong to Helm release "${release}": uninstall it first`); +} diff --git a/control-plane/scripts/k8s/profileName.test.ts b/control-plane/scripts/k8s/profileName.test.ts new file mode 100644 index 0000000..290c7fb --- /dev/null +++ b/control-plane/scripts/k8s/profileName.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "bun:test"; +import { profileNameForContext } from "./profileName"; + +describe("profileNameForContext", () => { + it.each([ + ["kind-devbox", "k8s-kind-devbox"], + ["arn:aws:eks:eu-west-1:123456789012:cluster/dev-cluster", "k8s-dev-cluster"], + ["arn:aws-us-gov:eks:us-gov-west-1:123456789012:cluster/Prod", "k8s-prod"], + ["gke_my-project_europe-west1_dev", "k8s-gke-my-project-europe-west1-dev"], + ["admin@Cluster.Local", "k8s-admin-cluster-local"], + ["--weird--", "k8s-weird"], + ["x".repeat(80), `k8s-${"x".repeat(59)}`], + ])("%s → %s", (context, name) => { + expect(profileNameForContext(context)).toBe(name); + }); +}); diff --git a/control-plane/scripts/k8s/profileName.ts b/control-plane/scripts/k8s/profileName.ts new file mode 100644 index 0000000..f6048c7 --- /dev/null +++ b/control-plane/scripts/k8s/profileName.ts @@ -0,0 +1,20 @@ +// The CLI profile name for a kube context is "k8s-", sanitized to a +// profile name (cli/interface/profiles.ts). An EKS ARN context +// becomes its cluster name, so profiles stay readable. +import { PROFILE_NAME } from "../../../cli/interface/profiles"; + +const EKS_ARN = /^arn:aws[a-z-]*:eks:[^:]+:\d+:cluster\/(.+)$/; + +export function profileNameForContext(context: string): string { + const base = (EKS_ARN.exec(context)?.[1] ?? context) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const name = `k8s-${base}`.slice(0, 63).replace(/-+$/, ""); + if (!PROFILE_NAME.test(name)) { + throw new Error( + `cannot make a profile name from kube context "${context}": set profile in the target file`, + ); + } + return name; +} diff --git a/control-plane/scripts/k8s/quantity.test.ts b/control-plane/scripts/k8s/quantity.test.ts new file mode 100644 index 0000000..848d267 --- /dev/null +++ b/control-plane/scripts/k8s/quantity.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test"; +import { parseCpu, parseMemory } from "./quantity"; + +describe("quantities", () => { + it.each([ + ["2", 2], + ["500m", 0.5], + ["1.5", 1.5], + ])("cpu %s", (q, n) => expect(parseCpu(q)).toBe(n)); + it.each([ + ["8Gi", 8 * 2 ** 30], + ["8G", 8e9], + ["16303308Ki", 16303308 * 1024], + ["512Mi", 512 * 2 ** 20], + ["1073741824", 2 ** 30], + ])("memory %s", (q, n) => expect(parseMemory(q)).toBe(n)); + it("rejects garbage", () => { + expect(() => parseMemory("8 GB")).toThrow('bad quantity "8 GB"'); + }); +}); diff --git a/control-plane/scripts/k8s/quantity.ts b/control-plane/scripts/k8s/quantity.ts new file mode 100644 index 0000000..ed5988c --- /dev/null +++ b/control-plane/scripts/k8s/quantity.ts @@ -0,0 +1,29 @@ +// Parses Kubernetes CPU and memory quantities for the node-fit check. +const UNITS: Record = { + "": 1, + k: 1e3, + M: 1e6, + G: 1e9, + T: 1e12, + Ki: 2 ** 10, + Mi: 2 ** 20, + Gi: 2 ** 30, + Ti: 2 ** 40, +}; + +function parse(q: string): { n: number; unit: string } { + const m = /^(\d+(?:\.\d+)?)(m|k|M|G|T|Ki|Mi|Gi|Ti)?$/.exec(q.trim()); + if (!m?.[1]) throw new Error(`bad quantity "${q}"`); + return { n: Number(m[1]), unit: m[2] ?? "" }; +} + +export function parseCpu(q: string): number { + const { n, unit } = parse(q); + return unit === "m" ? n / 1000 : n * (UNITS[unit] ?? Number.NaN); +} + +export function parseMemory(q: string): number { + const { n, unit } = parse(q); + if (unit === "m") throw new Error(`bad quantity "${q}"`); + return n * (UNITS[unit] ?? Number.NaN); +} diff --git a/control-plane/scripts/k8s/refresh.test.ts b/control-plane/scripts/k8s/refresh.test.ts new file mode 100644 index 0000000..1a21b57 --- /dev/null +++ b/control-plane/scripts/k8s/refresh.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; +import { fakeRunner, type Step } from "./fakeRunner"; +import { refreshSnapshot } from "./refresh"; +import { KubeError, kube, type RunResult } from "./run"; + +const kc = (...args: string[]) => ["kubectl", "--context", "kind-devbox", ...args]; +const step = (cmd: string[], result?: Partial): Step => ({ cmd, result }); +const JOB = "devbox-snapshot-builder-manual-20261014120000"; +const CREATE = kc( + "create", + "job", + JOB, + "--from=cronjob/devbox-snapshot-builder", + "-n", + "devbox-sandboxes", +); +const STATUS = kc( + "get", + "job", + JOB, + "-n", + "devbox-sandboxes", + "-o", + "jsonpath={.status.succeeded}/{.status.failed}", +); +const NEWEST = kc( + "get", + "volumesnapshot", + "-n", + "devbox-sandboxes", + "-l", + "app.kubernetes.io/component=repo-snapshot,app.kubernetes.io/managed-by=devbox-snapshot-builder", + "--sort-by=.metadata.creationTimestamp", + "-o", + "jsonpath={.items[-1:].metadata.name}", +); + +function run(script: Step[]) { + const f = fakeRunner(script); + const sleeps: number[] = []; + const logs: string[] = []; + const result = refreshSnapshot(kube(f.runner, "kind-devbox"), { + sleep: async (ms) => void sleeps.push(ms), + log: (line) => void logs.push(line), + now: () => new Date("2026-10-14T12:00:00Z"), + }); + return { f, sleeps, logs, result }; +} + +describe("refreshSnapshot", () => { + it("runs the builder, waits for it and returns the newest snapshot", async () => { + const script = [ + step(CREATE), + step(STATUS, { stdout: "/" }), + step(STATUS, { stdout: "1/" }), + step(NEWEST, { stdout: "repos-20261014120130\n" }), + ]; + const r = run(script); + expect(await r.result).toBe("repos-20261014120130"); + r.f.done(); + expect(r.f.calls.map((c) => c.cmd.join(" "))).toEqual(script.map((s) => s.cmd.join(" "))); + expect(r.sleeps).toEqual([5000]); + expect(r.logs).toEqual([`running ${JOB}`, "snapshot repos-20261014120130 is ready"]); + }); + + it("shows the builder's logs when it fails", async () => { + const LOGS = kc("logs", `job/${JOB}`, "-n", "devbox-sandboxes", "--tail=50"); + const script = [ + step(CREATE), + step(STATUS, { stdout: "/3" }), + step(LOGS, { stdout: "fatal: repository not found\n" }), + ]; + const r = run(script); + await expect(r.result).rejects.toThrow( + new KubeError("the snapshot builder failed:\nfatal: repository not found"), + ); + r.f.done(); + expect(r.f.calls.map((c) => c.cmd.join(" "))).toEqual(script.map((s) => s.cmd.join(" "))); + }); + + it("gives up after 30 minutes", async () => { + // One poll at 0 s and one after each 5 s sleep, up to 30 minutes. + const polls = 30 * 12 + 1; + const script = [ + step(CREATE), + ...Array.from({ length: polls }, () => step(STATUS, { stdout: "/" })), + ]; + const r = run(script); + await expect(r.result).rejects.toThrow( + new KubeError("the snapshot builder did not finish within 30 minutes"), + ); + r.f.done(); + expect(r.sleeps).toEqual(Array.from({ length: polls - 1 }, () => 5000)); + }); +}); diff --git a/control-plane/scripts/k8s/refresh.ts b/control-plane/scripts/k8s/refresh.ts new file mode 100644 index 0000000..62b6e33 --- /dev/null +++ b/control-plane/scripts/k8s/refresh.ts @@ -0,0 +1,67 @@ +// `k8s:refresh-snapshot` starts a Job from the snapshot builder's CronJob and +// polls until it succeeds or uses up its attempts. +import { type Kube, KubeError, REPO_SNAPSHOTS, SANDBOX_NAMESPACE } from "./run"; + +const POLL_MS = 5000; +const LIMIT_MS = 30 * 60_000; +// The CronJob's backoffLimit is 2: three attempts. +const ATTEMPTS = 3; + +export const stamp = (d: Date) => d.toISOString().replace(/[-:T]|\.\d+Z$/g, ""); + +export async function refreshSnapshot( + k: Kube, + deps: { sleep: (ms: number) => Promise; log: (line: string) => void; now: () => Date }, +): Promise { + const job = `devbox-snapshot-builder-manual-${stamp(deps.now())}`; + await k.kubectlOk([ + "create", + "job", + job, + "--from=cronjob/devbox-snapshot-builder", + "-n", + SANDBOX_NAMESPACE, + ]); + deps.log(`running ${job}`); + for (let waited = 0; ; waited += POLL_MS) { + const status = await k.kubectlOk([ + "get", + "job", + job, + "-n", + SANDBOX_NAMESPACE, + "-o", + "jsonpath={.status.succeeded}/{.status.failed}", + ]); + const [succeeded = 0, failed = 0] = status + .trim() + .split("/") + .map((n) => Number(n || 0)); + if (succeeded > 0) break; + if (failed >= ATTEMPTS) { + const logs = await k.kubectl(["logs", `job/${job}`, "-n", SANDBOX_NAMESPACE, "--tail=50"]); + throw new KubeError( + `the snapshot builder failed:\n${logs.stdout.trim() || logs.stderr.trim()}`, + ); + } + if (waited >= LIMIT_MS) { + throw new KubeError("the snapshot builder did not finish within 30 minutes"); + } + await deps.sleep(POLL_MS); + } + const name = ( + await k.kubectlOk([ + "get", + "volumesnapshot", + "-n", + SANDBOX_NAMESPACE, + "-l", + REPO_SNAPSHOTS, + "--sort-by=.metadata.creationTimestamp", + "-o", + "jsonpath={.items[-1:].metadata.name}", + ]) + ).trim(); + deps.log(`snapshot ${name} is ready`); + return name; +} diff --git a/control-plane/scripts/k8s/run.test.ts b/control-plane/scripts/k8s/run.test.ts new file mode 100644 index 0000000..5373b11 --- /dev/null +++ b/control-plane/scripts/k8s/run.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { fakeRunner } from "./fakeRunner"; +import { KubeError, kube } from "./run"; + +describe("kube", () => { + it("names the context on every call", async () => { + const f = fakeRunner([ + { cmd: ["kubectl", "--context", "c", "get", "ns"], result: { stdout: "ok" } }, + { cmd: ["helm", "--kube-context", "c", "list"], result: { stdout: "fine" } }, + ]); + const k = kube(f.runner, "c"); + expect(await k.kubectlOk(["get", "ns"])).toBe("ok"); + expect(await k.helmOk(["list"])).toBe("fine"); + f.done(); + }); + + it("throws the stderr when kubectlOk fails", async () => { + const f = fakeRunner([ + { + cmd: ["kubectl", "--context", "c", "get", "pods", "-n", "x"], + result: { code: 1, stderr: "boom\n" }, + }, + ]); + await expect(kube(f.runner, "c").kubectlOk(["get", "pods", "-n", "x"])).rejects.toThrow( + new KubeError("kubectl get pods -n failed: boom"), + ); + }); +}); diff --git a/control-plane/scripts/k8s/run.ts b/control-plane/scripts/k8s/run.ts new file mode 100644 index 0000000..3d7d94a --- /dev/null +++ b/control-plane/scripts/k8s/run.ts @@ -0,0 +1,63 @@ +// Runs kubectl and helm for the installer. Every call goes through a +// Runner, so tests swap in a scripted fake and assert the exact commands. +// Every kubectl/helm call names the context, so the installer never acts on +// whatever context happens to be current. + +import { capture } from "../lib/capture"; + +export const RELEASE = "devbox"; +export const NAMESPACE = "devbox"; +export const SANDBOX_NAMESPACE = "devbox-sandboxes"; +export { REPO_SNAPSHOT_SELECTOR as REPO_SNAPSHOTS } from "../../src/clients/repoSnapshots"; + +export type RunResult = { code: number; stdout: string; stderr: string }; +export type RunOptions = { stdin?: string; timeoutMs?: number }; +export type Runner = (cmd: string[], opts?: RunOptions) => Promise; + +export class KubeError extends Error {} + +export const realRunner: Runner = async (cmd, opts = {}) => { + try { + const { code, stdout, stderr } = await capture(cmd, { + stdin: opts.stdin, + timeoutMs: opts.timeoutMs, + }); + return { code, stdout, stderr }; + } catch { + throw new KubeError( + `${cmd[0]} not found: install it (docs/kubernetes/README.md#prerequisites)`, + ); + } +}; + +export type Kube = { + context: string; + kubectl: (args: string[], opts?: RunOptions) => Promise; + helm: (args: string[], opts?: RunOptions) => Promise; + // Like kubectl/helm, but return stdout and throw KubeError on failure. + kubectlOk: (args: string[], opts?: RunOptions) => Promise; + helmOk: (args: string[], opts?: RunOptions) => Promise; +}; + +function ok(tool: string, args: string[], r: RunResult): string { + if (r.code !== 0) { + throw new KubeError( + `${tool} ${args.slice(0, 3).join(" ")} failed: ${r.stderr.trim() || r.stdout.trim() || `exit ${r.code}`}`, + ); + } + return r.stdout; +} + +export function kube(runner: Runner, context: string): Kube { + const kubectl = (args: string[], opts?: RunOptions) => + runner(["kubectl", "--context", context, ...args], opts); + const helm = (args: string[], opts?: RunOptions) => + runner(["helm", "--kube-context", context, ...args], opts); + return { + context, + kubectl, + helm, + kubectlOk: async (args, opts) => ok("kubectl", args, await kubectl(args, opts)), + helmOk: async (args, opts) => ok("helm", args, await helm(args, opts)), + }; +} diff --git a/control-plane/scripts/k8s/secrets.test.ts b/control-plane/scripts/k8s/secrets.test.ts new file mode 100644 index 0000000..8f0e354 --- /dev/null +++ b/control-plane/scripts/k8s/secrets.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "bun:test"; +import { KubeError } from "./run"; +import { secretManifest, userSecrets } from "./secrets"; + +describe("userSecrets", () => { + const tokens = { github: { token: "ghp_x" }, llm: "sk-y", integrations: {} }; + + it("creates the GitHub and LLM Secrets, and no integrations one when it holds nothing", () => { + expect(userSecrets({}, tokens)).toEqual([ + { name: "devbox-github", data: { token: "ghp_x" } }, + { name: "devbox-llm", data: { "api-key": "sk-y" } }, + ]); + }); + + it("devbox-integrations holds exactly the resolved integration secrets", () => { + const integrations = { + LINEAR_CLIENT_SECRET: "lcs", + LINEAR_WEBHOOK_SECRET: "lws", + SLACK_BOT_TOKEN: "xoxb", + GITHUB_WEBHOOK_SECRET: "whs", + }; + expect(userSecrets({}, { ...tokens, integrations })).toEqual([ + { name: "devbox-github", data: { token: "ghp_x" } }, + { name: "devbox-llm", data: { "api-key": "sk-y" } }, + { name: "devbox-integrations", data: integrations }, + ]); + }); + + it("the GitHub App's key Secret and a minted token with its expiry", () => { + const pem = "-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----\n"; + expect( + userSecrets( + {}, + { + ...tokens, + github: { token: "ghs_minted", expiresAt: "2026-10-19T13:00:00Z" }, + githubApp: { pem }, + }, + ), + ).toEqual([ + { + name: "devbox-github", + data: { token: "ghs_minted", "expires-at": "2026-10-19T13:00:00Z" }, + }, + { name: "devbox-github-app", data: { "private-key": pem } }, + { name: "devbox-llm", data: { "api-key": "sk-y" } }, + ]); + }); + + it("no token Secret when the installer has no token to write", () => { + expect(userSecrets({}, { ...tokens, github: undefined })).toEqual([ + { name: "devbox-llm", data: { "api-key": "sk-y" } }, + ]); + }); + + it("skips Secrets the user brings", () => { + const values = { + secrets: { + github: { existingSecret: "mine" }, + llm: { existingSecret: "mine2" }, + integrations: { existingSecret: "mine3" }, + githubApp: { existingSecret: "mine4" }, + }, + }; + expect( + userSecrets(values, { + ...tokens, + githubApp: { pem: "pem" }, + integrations: { SLACK_BOT_TOKEN: "xoxb" }, + }), + ).toEqual([]); + }); + + it("refuses an empty LLM key", () => { + expect(() => userSecrets({}, { ...tokens, llm: "" })).toThrow( + new KubeError( + "Kubernetes installs need a non-empty LLM API key (LLM_API_KEY): iron-proxy cannot serve an empty one", + ), + ); + }); +}); + +describe("secretManifest", () => { + it("base64-encodes values and labels the Secret as the installer's", () => { + expect(JSON.parse(secretManifest({ name: "devbox-github", data: { token: "ghp_x" } }))).toEqual( + { + apiVersion: "v1", + kind: "Secret", + type: "Opaque", + metadata: { + name: "devbox-github", + namespace: "devbox", + labels: { + "app.kubernetes.io/part-of": "devbox", + "app.kubernetes.io/managed-by": "devbox-installer", + }, + }, + data: { token: Buffer.from("ghp_x").toString("base64") }, + }, + ); + }); +}); diff --git a/control-plane/scripts/k8s/secrets.ts b/control-plane/scripts/k8s/secrets.ts new file mode 100644 index 0000000..c0a714c --- /dev/null +++ b/control-plane/scripts/k8s/secrets.ts @@ -0,0 +1,84 @@ +// The user-supplied Secrets, created by the installer from the +// values it resolved itself unless the user names an existingSecret; nothing +// is taken from env here. Sent to kubectl as a manifest on stdin, so values +// never reach argv or disk. The installer label lets `k8s:uninstall --purge` +// delete exactly these, never the user's own. +import { KubeError, NAMESPACE } from "./run"; +import type { Values } from "./values"; + +export type UserSecret = { name: string; data: Record }; + +export const INSTALLER_LABEL = "app.kubernetes.io/managed-by=devbox-installer"; +export const INTEGRATIONS_SECRET = "devbox-integrations"; +export const GITHUB_SECRET = "devbox-github"; +export const GITHUB_APP_SECRET = "devbox-github-app"; + +// The secrets of every integration, as env names, the keys of the +// integrations Secret. The installer resolves only those of enabled integrations. +export type IntegrationSecretName = + | "LINEAR_CLIENT_SECRET" + | "LINEAR_WEBHOOK_SECRET" + | "SLACK_BOT_TOKEN" + | "GITHUB_WEBHOOK_SECRET"; + +// The name of the user's own Secret for `key`, or undefined when the installer owns it. +export const existingSecret = (values: Values, key: string): string | undefined => { + const secrets = values.secrets as Record | undefined; + return secrets?.[key]?.existingSecret || undefined; +}; + +export function userSecrets( + values: Values, + tokens: { + // The token Secret's data; none when the installer has no token to write. + github?: { token: string; expiresAt?: string }; + // The GitHub App's private key, for its devbox-minted tokens. + githubApp?: { pem: string }; + llm: string; + integrations: Partial>; + }, +): UserSecret[] { + const out: UserSecret[] = []; + if (tokens.github && !existingSecret(values, "github")) { + const { token, expiresAt } = tokens.github; + out.push({ + name: GITHUB_SECRET, + data: { token, ...(expiresAt !== undefined ? { "expires-at": expiresAt } : {}) }, + }); + } + if (tokens.githubApp && !existingSecret(values, "githubApp")) { + out.push({ name: GITHUB_APP_SECRET, data: { "private-key": tokens.githubApp.pem } }); + } + if (!existingSecret(values, "llm")) { + if (!tokens.llm) { + throw new KubeError( + "Kubernetes installs need a non-empty LLM API key (LLM_API_KEY): iron-proxy cannot serve an empty one", + ); + } + out.push({ name: "devbox-llm", data: { "api-key": tokens.llm } }); + } + if (!existingSecret(values, "integrations")) { + const data = tokens.integrations as Record; + if (Object.keys(data).length > 0) out.push({ name: INTEGRATIONS_SECRET, data }); + } + return out; +} + +export function secretManifest(s: UserSecret): string { + return JSON.stringify({ + apiVersion: "v1", + kind: "Secret", + type: "Opaque", + metadata: { + name: s.name, + namespace: NAMESPACE, + labels: { + "app.kubernetes.io/part-of": "devbox", + "app.kubernetes.io/managed-by": "devbox-installer", + }, + }, + data: Object.fromEntries( + Object.entries(s.data).map(([k, v]) => [k, Buffer.from(v).toString("base64")]), + ), + }); +} diff --git a/control-plane/scripts/k8s/target.test.ts b/control-plane/scripts/k8s/target.test.ts new file mode 100644 index 0000000..6cc05a9 --- /dev/null +++ b/control-plane/scripts/k8s/target.test.ts @@ -0,0 +1,167 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadTarget, parseTarget, TargetError } from "./target"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-target-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); +const F = "devbox.k8s.json"; + +describe("parseTarget", () => { + it("keeps context, profile and values verbatim", () => { + const raw = { + context: "arn:aws:eks:eu-west-1:123:cluster/dev", + profile: "k8s-dev", + values: { storage: { storageClass: "gp3" }, sandbox: { maxConcurrent: 3 } }, + }; + expect(parseTarget(raw, F)).toEqual(raw); + }); + + it("needs only a context", () => { + expect(parseTarget({ context: "kind-devbox" }, F)).toEqual({ + context: "kind-devbox", + values: {}, + }); + }); + + it("accepts integrations.linear", () => { + const raw = { + context: "c", + values: {}, + integrations: { linear: { enabled: true, clientId: " client-1 " } }, + }; + expect(parseTarget(raw, F)).toEqual({ + context: "c", + values: {}, + integrations: { linear: { enabled: true, clientId: "client-1" } }, + }); + }); + + it("accepts a top-level publicUrl, validated", () => { + expect(parseTarget({ context: "c", publicUrl: "https://hooks.example.com/" }, F)).toEqual({ + context: "c", + publicUrl: "https://hooks.example.com", + values: {}, + }); + expect(() => parseTarget({ context: "c", publicUrl: "http://hooks.example.com" }, F)).toThrow( + new TargetError( + "devbox.k8s.json: publicUrl: Webhooks need a public HTTPS URL: http://hooks.example.com uses http:, not https:", + ), + ); + expect(() => parseTarget({ context: "c", publicUrl: 3 }, F)).toThrow( + new TargetError("devbox.k8s.json: publicUrl: must be a string"), + ); + }); + + it("refuses integrations.linear.publicUrl", () => { + expect(() => + parseTarget( + { + context: "c", + integrations: { + linear: { enabled: true, clientId: "c1", publicUrl: "https://h.example.com" }, + }, + }, + F, + ), + ).toThrow( + new TargetError( + 'devbox.k8s.json: integrations.linear: unknown key "publicUrl" (allowed: enabled, clientId); moved to the top-level publicUrl', + ), + ); + }); + + it("accepts integrations.slack and integrations.githubApp", () => { + const githubApp = { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 456, + botUserId: 789, + tokens: "devbox", + } as const; + const integrations = { slack: { enabled: true, channelId: "C123" }, githubApp }; + expect(parseTarget({ context: "c", integrations }, F)).toEqual({ + context: "c", + values: {}, + integrations: { + slack: integrations.slack, + githubApp: { ...githubApp, tokenScope: "repos", ciResults: true }, + }, + }); + }); + + it("refuses a top-level github: trust lists are chart values", () => { + expect(() => parseTarget({ context: "c", github: { trustedOrgs: ["acme"] } }, F)).toThrow( + new TargetError('devbox.k8s.json: unknown key "github"; chart values go under values'), + ); + }); + + it("refuses integrations.linear with an unknown key", () => { + expect(() => + parseTarget( + { + context: "c", + integrations: { linear: { enabled: true, clientId: "c1", clientSecret: "x" } }, + }, + F, + ), + ).toThrow( + new TargetError( + 'devbox.k8s.json: integrations.linear: unknown key "clientSecret" (allowed: enabled, clientId)', + ), + ); + }); + + it.each([ + ["eks", "devbox.k8s.json: must be a JSON object"], + [{}, "devbox.k8s.json: context: required, the kube context of the cluster"], + [{ context: "" }, "devbox.k8s.json: context: required, the kube context of the cluster"], + [ + { context: "c", storageClass: "gp3" }, + 'devbox.k8s.json: unknown key "storageClass"; chart values go under values', + ], + [ + { context: "c", profile: "My Cluster" }, + 'devbox.k8s.json: profile: use lower-case letters, digits and "-", got "My Cluster"', + ], + [{ context: "c", values: [] }, "devbox.k8s.json: values: must be an object of chart values"], + [ + { context: "c", values: { repos: [] } }, + "devbox.k8s.json: values.repos: set repos in devbox.config.json", + ], + [ + { context: "c", values: { llm: {} } }, + "devbox.k8s.json: values.llm: set llm in devbox.config.json", + ], + [ + { context: "c", values: { integrations: { linear: { clientId: "c1" } } } }, + "devbox.k8s.json: values.integrations: set integrations in the target file's top-level integrations block, not under values", + ], + ])("rejects %j", (raw, message) => { + expect(() => parseTarget(raw, F)).toThrow(new TargetError(message)); + }); +}); + +describe("loadTarget", () => { + it("reads the file and names it in errors", () => { + const path = join(dir, "t.json"); + writeFileSync(path, JSON.stringify({ context: "kind-devbox", values: { a: 1 } })); + expect(loadTarget(path)).toEqual({ context: "kind-devbox", values: { a: 1 } }); + }); + + it("says when the file is missing", () => { + expect(() => loadTarget(join(dir, "none.json"))).toThrow( + new TargetError( + `${join(dir, "none.json")} not found: the target file names the cluster to act on (docs/kubernetes/README.md#1-configure)`, + ), + ); + }); + + it("says when the file is not JSON", () => { + const path = join(dir, "bad.json"); + writeFileSync(path, "{ context: "); + expect(() => loadTarget(path)).toThrow(new TargetError(`${path}: not valid JSON`)); + }); +}); diff --git a/control-plane/scripts/k8s/target.ts b/control-plane/scripts/k8s/target.ts new file mode 100644 index 0000000..c231ea9 --- /dev/null +++ b/control-plane/scripts/k8s/target.ts @@ -0,0 +1,84 @@ +// A deployment target: the cluster to act on, the CLI profile to +// write and the chart values for it. One file per cluster, git-ignored, +// passed to every k8s:* command with --target. devbox.config.json describes +// the setup (repos, llm); this file describes where it runs. +import { readFileSync } from "node:fs"; +import { PROFILE_NAME } from "../../../cli/interface/profiles"; +import { type Integrations, parseIntegrations } from "../lib/integrations"; +import { parsePublicUrl } from "../lib/publicUrl"; +import type { Values } from "./values"; + +// `integrations` is the deployment's own: k8s:install reads +// it here, never from devbox.config.json. +export type Target = { + context: string; + profile?: string; + // The deployment's public URL for webhooks, validated. + publicUrl?: string; + values: Values; + integrations?: Integrations; +}; + +export class TargetError extends Error {} + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +export function parseTarget(raw: unknown, file: string): Target { + const fail = (message: string): never => { + throw new TargetError(`${file}: ${message}`); + }; + if (!isObject(raw)) return fail("must be a JSON object"); + const { context, profile, values = {}, integrations, publicUrl, ...rest } = raw; + const [unknown] = Object.keys(rest); + if (unknown) return fail(`unknown key "${unknown}"; chart values go under values`); + if (typeof context !== "string" || !context) { + return fail("context: required, the kube context of the cluster"); + } + if (profile !== undefined && (typeof profile !== "string" || !PROFILE_NAME.test(profile))) { + return fail(`profile: use lower-case letters, digits and "-", got ${JSON.stringify(profile)}`); + } + if (!isObject(values)) return fail("values: must be an object of chart values"); + // The setup lives in devbox.config.json; the installer checks the token + // against those repos, so a target must not swap them under it. + for (const key of ["repos", "llm"]) { + if (key in values) return fail(`values.${key}: set ${key} in devbox.config.json`); + } + // The chart's integrations values come only from the checked top-level + // block, never set past it. + if ("integrations" in values) { + return fail( + "values.integrations: set integrations in the target file's top-level integrations block, not under values", + ); + } + return { + context, + ...(profile !== undefined ? { profile } : {}), + ...(publicUrl !== undefined ? { publicUrl: parsePublicUrl(publicUrl, "publicUrl", fail) } : {}), + values, + ...(integrations !== undefined + ? { integrations: parseIntegrations(integrations, fail, { platform: "k8s" }) } + : {}), + }; +} + +export function loadTarget(path: string): Target { + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + throw new TargetError( + `${path} not found: the target file names the cluster to act on (docs/kubernetes/README.md#1-configure)`, + ); + } + throw new TargetError(`${path}: cannot read it (${(err as Error).message})`); + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new TargetError(`${path}: not valid JSON`); + } + return parseTarget(raw, path); +} diff --git a/control-plane/scripts/k8s/uninstall.test.ts b/control-plane/scripts/k8s/uninstall.test.ts new file mode 100644 index 0000000..35a3b2f --- /dev/null +++ b/control-plane/scripts/k8s/uninstall.test.ts @@ -0,0 +1,309 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DestructiveRefused } from "../lib/destructive"; +import { fakeTerminal } from "../up/fakeTerminal"; +import { confirm } from "../up/prompt"; +import { fakeRunner, type Step } from "./fakeRunner"; +import { KubeError, type RunResult } from "./run"; +import type { Target } from "./target"; +import { uninstall } from "./uninstall"; + +const root = mkdtempSync(join(tmpdir(), "devbox-uninstall-")); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +const kc = (...args: string[]) => ["kubectl", "--context", "kind-devbox", ...args]; +const hc = (...args: string[]) => ["helm", "--kube-context", "kind-devbox", ...args]; +const step = (cmd: string[], result?: Partial): Step => ({ cmd, result }); + +const target: Target = { context: "kind-devbox", values: {} }; + +const LOCAL = { url: "http://localhost:3000", token: "dbx_local" }; +let n = 0; +function seededHome(): { home: string; path: string } { + const home = join(root, `home-${++n}`); + const path = join(home, ".config", "devbox", "config.json"); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync( + path, + JSON.stringify({ + current: "k8s-kind-devbox", + profiles: { + local: LOCAL, + "k8s-kind-devbox": { url: "http://localhost:13000", token: "dbx_k8s" }, + }, + }), + ); + return { home, path }; +} + +const noConfirm = async (): Promise => { + throw new Error("asked"); +}; +const quiet = { isTTY: false, confirm: noConfirm }; + +async function run(script: Step[], purge: boolean) { + const f = fakeRunner(script); + const { home, path } = seededHome(); + const logs: string[] = []; + await uninstall( + { runner: f.runner, target, env: {}, home, ...quiet, log: (line) => void logs.push(line) }, + { purge, yes: purge }, + ); + f.done(); + expect(f.calls.map((c) => c.cmd.join(" "))).toEqual(script.map((s) => s.cmd.join(" "))); + return { logs, path, file: JSON.parse(readFileSync(path, "utf8")) }; +} + +// A failed install hook Job stays (hook-delete-policy), and its pod keeps the +// scratch PVC in use, so `helm uninstall --wait` would never finish. +const HOOK_JOB = kc( + "delete", + "job", + "devbox-snapshot-builder-install", + "-n", + "devbox-sandboxes", + "--ignore-not-found", + "--cascade=foreground", + "--wait=true", + "--timeout=2m", +); +const UNINSTALL = hc("uninstall", "devbox", "-n", "devbox", "--wait"); +const purgeSteps = (secrets: string): Step[] => [ + step( + kc( + "delete", + "namespace", + "devbox-sandboxes", + "--wait=true", + "--timeout=10m", + "--ignore-not-found", + ), + ), + step( + kc( + "delete", + "secret", + "devbox-opensandbox", + "devbox-postgres", + "devbox-iron-proxy-ca", + "-n", + "devbox", + "--ignore-not-found", + ), + ), + step( + kc("delete", "secret", "-n", "devbox", "-l", "app.kubernetes.io/managed-by=devbox-installer"), + ), + step( + kc( + "delete", + "pvc", + "-n", + "devbox", + "-l", + "app.kubernetes.io/component=postgres", + "--ignore-not-found", + ), + ), + step(kc("get", "secrets", "-n", "devbox", "-o", "name"), { stdout: secrets }), +]; + +describe("uninstall", () => { + it("removes the release and the CLI profile", async () => { + const r = await run([step(HOOK_JOB), step(UNINSTALL)], false); + expect(r.file).toEqual({ profiles: { local: LOCAL } }); + expect(r.logs).toEqual([ + "removed the devbox release from context kind-devbox", + "kept namespaces devbox and devbox-sandboxes, their volumes and Secrets (--purge deletes them)", + `removed CLI profile "k8s-kind-devbox" from ${r.path}`, + ]); + }); + + it("purges everything the install made, then the empty namespace", async () => { + const r = await run( + [ + step(HOOK_JOB), + step(UNINSTALL), + ...purgeSteps(""), + step(kc("delete", "namespace", "devbox", "--wait=false", "--ignore-not-found")), + ], + true, + ); + expect(r.file).toEqual({ profiles: { local: LOCAL } }); + expect(r.logs).toEqual([ + "removed the devbox release from context kind-devbox", + "deleted namespace devbox-sandboxes, the generated and installer-created Secrets and the Postgres volume", + "deleted namespace devbox", + `removed CLI profile "k8s-kind-devbox" from ${r.path}`, + ]); + }); + + it("keeps the namespace while it holds the user's Secrets", async () => { + const r = await run( + [step(HOOK_JOB), step(UNINSTALL), ...purgeSteps("secret/my-github\nsecret/my-llm\n")], + true, + ); + expect(r.logs).toEqual([ + "removed the devbox release from context kind-devbox", + "deleted namespace devbox-sandboxes, the generated and installer-created Secrets and the Postgres volume", + "kept namespace devbox: it still holds Secrets you created (secret/my-github, secret/my-llm)", + `removed CLI profile "k8s-kind-devbox" from ${r.path}`, + ]); + }); + + it("purges even without a release", async () => { + const r = await run( + [ + step(HOOK_JOB), + step(UNINSTALL, { + code: 1, + stderr: "Error: uninstall: Release not loaded: devbox: release: not found\n", + }), + ...purgeSteps(""), + step(kc("delete", "namespace", "devbox", "--wait=false", "--ignore-not-found")), + ], + true, + ); + expect(r.logs).toEqual([ + "no devbox release in context kind-devbox", + "deleted namespace devbox-sandboxes, the generated and installer-created Secrets and the Postgres volume", + "deleted namespace devbox", + `removed CLI profile "k8s-kind-devbox" from ${r.path}`, + ]); + }); + + it("purges a cluster that is already purged", async () => { + // kubectl reports "No resources found" on stderr with exit 0 for an empty + // or missing namespace; the deletes all ignore what is not there. + const r = await run( + [ + step(HOOK_JOB), + step(UNINSTALL, { + code: 1, + stderr: "Error: uninstall: Release not loaded: devbox: release: not found\n", + }), + ...purgeSteps(""), + step(kc("delete", "namespace", "devbox", "--wait=false", "--ignore-not-found")), + ], + true, + ); + expect(r.logs).toEqual([ + "no devbox release in context kind-devbox", + "deleted namespace devbox-sandboxes, the generated and installer-created Secrets and the Postgres volume", + "deleted namespace devbox", + `removed CLI profile "k8s-kind-devbox" from ${r.path}`, + ]); + }); + + it("stops before helm when it cannot delete the install hook Job", async () => { + const f = fakeRunner([ + step(HOOK_JOB, { code: 1, stderr: "Error: Kubernetes cluster unreachable\n" }), + ]); + const { home } = seededHome(); + await expect( + uninstall( + { runner: f.runner, target, env: {}, home, ...quiet, log: () => {} }, + { purge: true, yes: true }, + ), + ).rejects.toThrow( + new KubeError( + "kubectl delete job devbox-snapshot-builder-install failed: Error: Kubernetes cluster unreachable", + ), + ); + f.done(); + }); + + it("stops on any other helm failure", async () => { + const f = fakeRunner([ + step(HOOK_JOB), + step(UNINSTALL, { code: 1, stderr: "Error: Kubernetes cluster unreachable\n" }), + ]); + const { home } = seededHome(); + await expect( + uninstall( + { runner: f.runner, target, env: {}, home, ...quiet, log: () => {} }, + { purge: true, yes: true }, + ), + ).rejects.toThrow( + new KubeError("helm uninstall failed: Error: Kubernetes cluster unreachable"), + ); + f.done(); + }); + + describe("--purge confirmation", () => { + const ask = (keys: string, isTTY: boolean, yes: boolean, script: Step[]) => { + const f = fakeRunner(script); + const { home } = seededHome(); + const logs: string[] = []; + const asked: string[] = []; + const term = fakeTerminal(keys, isTTY).term; + const result = uninstall( + { + runner: f.runner, + target, + env: {}, + home, + isTTY, + confirm: (q, o) => { + asked.push(q); + return confirm(q, o, term); + }, + log: (line) => void logs.push(line), + }, + { purge: true, yes }, + ); + return { f, logs, asked, result }; + }; + + it("--purge lists and asks before anything runs", async () => { + const r = ask("n\r", true, false, []); + await r.result; + expect(r.f.calls).toEqual([]); + expect(r.asked).toEqual(["Delete all of this in context kind-devbox? [y/N] "]); + expect(r.logs).toEqual([ + "This deletes:", + " - namespace devbox-sandboxes, with every task volume and repo snapshot", + " - Secrets devbox-opensandbox, devbox-postgres, devbox-iron-proxy-ca", + " - the Secrets the installer created (label app.kubernetes.io/managed-by=devbox-installer)", + " - the Postgres volume", + " - namespace devbox, if no Secret of yours is left in it", + "Nothing was removed.", + ]); + }); + + it("--purge asks, and on yes runs everything", async () => { + const r = ask("y\r", true, false, [ + step(HOOK_JOB), + step(UNINSTALL), + ...purgeSteps("secret/mine"), + ]); + await r.result; + r.f.done(); + expect(r.asked).toHaveLength(1); + }); + + it("--purge --yes runs as today", async () => { + const r = ask("", false, true, [ + step(HOOK_JOB), + step(UNINSTALL), + ...purgeSteps("secret/mine"), + ]); + await r.result; + r.f.done(); + expect(r.asked).toEqual([]); + expect(r.logs).not.toContain("This deletes:"); + }); + + it("--purge without a terminal refuses", async () => { + const r = ask("", false, false, []); + await expect(r.result).rejects.toThrow( + new DestructiveRefused( + "k8s:uninstall --purge deletes data: pass --yes to confirm without a terminal", + ), + ); + expect(r.f.calls).toEqual([]); + }); + }); +}); diff --git a/control-plane/scripts/k8s/uninstall.ts b/control-plane/scripts/k8s/uninstall.ts new file mode 100644 index 0000000..b757792 --- /dev/null +++ b/control-plane/scripts/k8s/uninstall.ts @@ -0,0 +1,128 @@ +// `bun run k8s:uninstall --target [--purge [--yes]]` removes the release +// and the CLI profile. --purge lists what it deletes and +// asks first (before anything runs); without a terminal it needs --yes. It +// deletes the sandbox namespace +// (task volumes and snapshots), the Secrets the chart generated or the +// installer created, and the Postgres volume. It never deletes a Secret by +// any other name or selector, so Secrets named by existingSecret survive, +// and with them the devbox namespace. +import { confirmDestructive, type DestructiveDeps } from "../lib/destructive"; +import { cliConfigPath, readCliProfile, removeCliProfile } from "../up/cliLogin"; +import { profileFor } from "./connect"; +import { KubeError, kube, NAMESPACE, RELEASE, type Runner, SANDBOX_NAMESPACE } from "./run"; +import { INSTALLER_LABEL } from "./secrets"; +import type { Target } from "./target"; + +// Made by the chart's bootstrap Job; Helm does not own them. +const GENERATED_SECRETS = ["devbox-opensandbox", "devbox-postgres", "devbox-iron-proxy-ca"]; + +export type UninstallDeps = { + runner: Runner; + target: Target; + env: Record; + home: string; + isTTY: boolean; + confirm: DestructiveDeps["confirm"]; + log: (line: string) => void; +}; + +export async function uninstall( + deps: UninstallDeps, + opts: { purge: boolean; yes: boolean }, +): Promise { + const { log } = deps; + const context = deps.target.context; + if (opts.purge) { + const go = await confirmDestructive( + { + command: "k8s:uninstall --purge", + items: [ + `namespace ${SANDBOX_NAMESPACE}, with every task volume and repo snapshot`, + `Secrets ${GENERATED_SECRETS.join(", ")}`, + `the Secrets the installer created (label ${INSTALLER_LABEL})`, + "the Postgres volume", + `namespace ${NAMESPACE}, if no Secret of yours is left in it`, + ], + question: `Delete all of this in context ${context}? [y/N] `, + }, + { yes: opts.yes, isTTY: deps.isTTY, confirm: deps.confirm, log }, + ); + if (!go) { + log("Nothing was removed."); + return; + } + } + const k = kube(deps.runner, context); + + // Helm never deletes hooks; a failed install hook Job may still exist + // (deleted by TTL after ten minutes): remove it so nothing references the + // scratch PVC. + await k.kubectlOk([ + "delete", + "job", + "devbox-snapshot-builder-install", + "-n", + SANDBOX_NAMESPACE, + "--ignore-not-found", + "--cascade=foreground", + "--wait=true", + "--timeout=2m", + ]); + const helm = await k.helm(["uninstall", RELEASE, "-n", NAMESPACE, "--wait"]); + if (helm.code === 0) log(`removed the devbox release from context ${context}`); + else if (/release: not found/.test(helm.stderr)) log(`no devbox release in context ${context}`); + else throw new KubeError(`helm uninstall failed: ${helm.stderr.trim() || helm.stdout.trim()}`); + + if (opts.purge) { + await k.kubectlOk([ + "delete", + "namespace", + SANDBOX_NAMESPACE, + "--wait=true", + "--timeout=10m", + "--ignore-not-found", + ]); + await k.kubectlOk([ + "delete", + "secret", + ...GENERATED_SECRETS, + "-n", + NAMESPACE, + "--ignore-not-found", + ]); + await k.kubectlOk(["delete", "secret", "-n", NAMESPACE, "-l", INSTALLER_LABEL]); + await k.kubectlOk([ + "delete", + "pvc", + "-n", + NAMESPACE, + "-l", + "app.kubernetes.io/component=postgres", + "--ignore-not-found", + ]); + log( + `deleted namespace ${SANDBOX_NAMESPACE}, the generated and installer-created Secrets and the Postgres volume`, + ); + const left = (await k.kubectlOk(["get", "secrets", "-n", NAMESPACE, "-o", "name"])) + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); + if (left.length === 0) { + await k.kubectlOk(["delete", "namespace", NAMESPACE, "--wait=false", "--ignore-not-found"]); + log(`deleted namespace ${NAMESPACE}`); + } else { + log(`kept namespace ${NAMESPACE}: it still holds Secrets you created (${left.join(", ")})`); + } + } else { + log( + `kept namespaces ${NAMESPACE} and ${SANDBOX_NAMESPACE}, their volumes and Secrets (--purge deletes them)`, + ); + } + + const path = cliConfigPath(deps.env, deps.home); + const profile = profileFor(deps.target); + if (readCliProfile(path, profile)) { + removeCliProfile(path, profile); + log(`removed CLI profile "${profile}" from ${path}`); + } +} diff --git a/control-plane/scripts/k8s/values.test.ts b/control-plane/scripts/k8s/values.test.ts new file mode 100644 index 0000000..b12237d --- /dev/null +++ b/control-plane/scripts/k8s/values.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "bun:test"; +import type { DevboxConfig } from "../up/config"; +import { apiIngressUrl, chartValues, deepMerge, userValue, type Values } from "./values"; + +const cfg: DevboxConfig = { + repos: [ + { url: "https://github.com/acme/widgets.git", branch: "main", owner: "acme", name: "widgets" }, + ], + llm: { provider: "openai", baseUrl: "https://llm.example.com/v1", model: "gpt-test" }, + github: { trustedOrgs: [], trustedUsers: [] }, + integrations: {}, +}; +const user: Values = { + storage: { storageClass: "gp3", volumeSnapshotClass: "ebs" }, + sandbox: { maxConcurrent: 3 }, + git: { name: "Bot" }, +}; +const identity = { gitName: "Ada", gitEmail: "1+ada@users.noreply.github.com" }; + +describe("chartValues", () => { + it("adds repos, llm, the cluster IP, the URL and the identity under the user's values", () => { + expect( + chartValues(cfg, user, { + clusterIP: "10.96.0.20", + publicBaseUrl: "http://localhost:13000", + identity, + }), + ).toEqual({ + repos: [{ url: "https://github.com/acme/widgets.git", branch: "main" }], + llm: { provider: "openai", baseUrl: "https://llm.example.com/v1", model: "gpt-test" }, + ironProxy: { clusterIP: "10.96.0.20" }, + controlPlane: { publicBaseUrl: "http://localhost:13000" }, + git: { name: "Bot", email: "1+ada@users.noreply.github.com" }, + storage: { storageClass: "gp3", volumeSnapshotClass: "ebs" }, + sandbox: { maxConcurrent: 3 }, + }); + }); + + it("copies configured LLM overrides and leaves omitted fields out", () => { + expect(chartValues(cfg, {}, {}).llm).toEqual({ + provider: "openai", + baseUrl: "https://llm.example.com/v1", + model: "gpt-test", + }); + expect( + chartValues({ ...cfg, llm: { ...cfg.llm, stages: { review: {} } } }, {}, {}).llm, + ).toEqual({ + provider: "openai", + baseUrl: "https://llm.example.com/v1", + model: "gpt-test", + }); + expect( + chartValues( + { + ...cfg, + llm: { + ...cfg.llm, + reasoningEffort: "high", + stages: { + review: { model: "gpt-review", reasoningEffort: "xhigh" }, + orientation: { reasoningEffort: "minimal" }, + }, + }, + }, + {}, + {}, + ).llm, + ).toEqual({ + provider: "openai", + baseUrl: "https://llm.example.com/v1", + model: "gpt-test", + reasoningEffort: "high", + stages: { + review: { model: "gpt-review", reasoningEffort: "xhigh" }, + orientation: { reasoningEffort: "minimal" }, + }, + }); + }); + + it("user chart values still win over installer LLM values", () => { + expect( + chartValues( + { ...cfg, llm: { ...cfg.llm, reasoningEffort: "high" } }, + { llm: { reasoningEffort: "low", stages: { review: { model: "mine" } } } }, + {}, + ).llm, + ).toEqual({ + provider: "openai", + baseUrl: "https://llm.example.com/v1", + model: "gpt-test", + reasoningEffort: "low", + stages: { review: { model: "mine" } }, + }); + }); + + it("integrations copied, the App slug as botLogin", () => { + expect( + chartValues( + cfg, + {}, + { + integrations: { + linear: { enabled: true, clientId: "client-1" }, + slack: { enabled: false, channelId: "C123" }, + githubApp: { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 456, + botUserId: 789, + tokens: "external", + tokenScope: "installation", + ciResults: false, + org: "example-org", + pending: { appId: 124, slug: "new-devbox" }, + }, + }, + }, + ), + ).toEqual({ + repos: [{ url: "https://github.com/acme/widgets.git", branch: "main" }], + llm: { provider: "openai", baseUrl: "https://llm.example.com/v1", model: "gpt-test" }, + integrations: { + linear: { enabled: true, clientId: "client-1" }, + slack: { enabled: false, channelId: "C123" }, + githubApp: { + enabled: true, + appId: 123, + botLogin: "example-devbox", + installationId: 456, + botUserId: 789, + tokens: "external", + tokenScope: "installation", + ciResults: false, + }, + }, + }); + expect( + chartValues(cfg, {}, { integrations: { linear: { enabled: false } } }).integrations, + ).toEqual({ linear: { enabled: false } }); + expect(chartValues(cfg, {}, { integrations: {} })).not.toHaveProperty("integrations"); + }); + + it("a user value wins over the integrations", () => { + expect( + chartValues( + cfg, + { integrations: { linear: { clientId: "mine" } } }, + { integrations: { linear: { enabled: true, clientId: "client-1" } } }, + ).integrations, + ).toEqual({ linear: { enabled: true, clientId: "mine" } }); + }); + + it("puts an overlay between the installer's values and the user's", () => { + expect( + chartValues( + cfg, + user, + {}, + { sandbox: { cpu: "1", maxConcurrent: 2 }, workspace: { size: "2Gi" } }, + ), + ).toEqual({ + repos: [{ url: "https://github.com/acme/widgets.git", branch: "main" }], + llm: { provider: "openai", baseUrl: "https://llm.example.com/v1", model: "gpt-test" }, + sandbox: { cpu: "1", maxConcurrent: 3 }, + workspace: { size: "2Gi" }, + storage: { storageClass: "gp3", volumeSnapshotClass: "ebs" }, + git: { name: "Bot" }, + }); + }); +}); + +describe("deepMerge", () => { + it("merges objects and replaces arrays", () => { + expect(deepMerge({ a: { b: 1, c: [1] }, d: 1 }, { a: { c: [2] }, e: 2 })).toEqual({ + a: { b: 1, c: [2] }, + d: 1, + e: 2, + }); + }); +}); + +describe("userValue", () => { + it("reads a nested user value", () => { + expect(userValue({ values: user }, ["storage", "storageClass"])).toBe("gp3"); + expect(userValue({ values: user }, ["ironProxy", "clusterIP"])).toBeUndefined(); + }); +}); + +describe("apiIngressUrl", () => { + const tls = [{ hosts: ["devbox.example.com"], secretName: "t" }]; + const api = { enabled: true, host: "devbox.example.com" }; + const rows: [string, Values, string | undefined][] = [ + ["no ingress", {}, undefined], + [ + "only webhooks", + { ingress: { webhooks: { enabled: true, host: "hooks.example.com" } } }, + undefined, + ], + ["api without tls", { ingress: { api } }, "http://devbox.example.com"], + ["api with tls", { ingress: { api: { ...api, tls } } }, "https://devbox.example.com"], + [ + "both roles", + { ingress: { api, webhooks: { enabled: true, host: "hooks.example.com" } } }, + "http://devbox.example.com", + ], + [ + "publicBaseUrl on the api host", + { ingress: { api }, controlPlane: { publicBaseUrl: "https://devbox.example.com/" } }, + "https://devbox.example.com", + ], + [ + "publicBaseUrl on another host", + { ingress: { api }, controlPlane: { publicBaseUrl: "https://links.example.com" } }, + "http://devbox.example.com", + ], + ["api disabled", { ingress: { api: { enabled: false, host: "x" } } }, undefined], + ]; + for (const [name, values, want] of rows) { + it(name, () => expect(apiIngressUrl({ values })).toBe(want)); + } +}); diff --git a/control-plane/scripts/k8s/values.ts b/control-plane/scripts/k8s/values.ts new file mode 100644 index 0000000..cc9cabd --- /dev/null +++ b/control-plane/scripts/k8s/values.ts @@ -0,0 +1,128 @@ +// devbox.config.json and a target file → chart values. The +// target's values are chart values, verbatim. The installer adds only what it +// knows from devbox.config.json and from the cluster; an overlay (kind's +// settings) sits in between; values the user set always win. +import type { Integrations } from "../lib/integrations"; +import type { DevboxConfig } from "../up/config"; +import type { Identity } from "../up/preflight"; +import type { Target } from "./target"; + +export type Values = Record; +export type InstallerValues = { + clusterIP?: string; + publicBaseUrl?: string; + identity?: Identity; + // The target file's integrations block. + integrations?: Integrations; +}; + +const isPlainObject = (v: unknown): v is Values => + typeof v === "object" && v !== null && !Array.isArray(v); + +export function deepMerge(base: Values, top: Values): Values { + const out: Values = { ...base }; + for (const [key, value] of Object.entries(top)) { + const current = out[key]; + out[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value; + } + return out; +} + +// The block as chart values, verbatim; nothing when it is empty. +function integrationValues(integrations: Integrations = {}): Values { + const { linear, slack, githubApp } = integrations; + const out: Values = { + ...(linear + ? { + linear: { + enabled: linear.enabled, + ...(linear.clientId !== undefined ? { clientId: linear.clientId } : {}), + }, + } + : {}), + ...(slack ? { slack } : {}), + // The slug becomes the control plane's botLogin; tokenFile is Compose-only and org is for the CLI's links. + ...(githubApp + ? { + githubApp: { + enabled: githubApp.enabled, + ...(githubApp.appId !== undefined ? { appId: githubApp.appId } : {}), + ...(githubApp.slug !== undefined ? { botLogin: githubApp.slug } : {}), + ...(githubApp.installationId !== undefined + ? { installationId: githubApp.installationId } + : {}), + ...(githubApp.botUserId !== undefined ? { botUserId: githubApp.botUserId } : {}), + ...(githubApp.tokens !== undefined ? { tokens: githubApp.tokens } : {}), + tokenScope: githubApp.tokenScope, + ciResults: githubApp.ciResults, + }, + } + : {}), + }; + return Object.keys(out).length > 0 ? { integrations: out } : {}; +} + +function llmValues(llm: DevboxConfig["llm"]): Values { + const stages: Values = {}; + for (const [stage, setting] of Object.entries(llm.stages ?? {})) { + const fields = { + ...(setting.model !== undefined ? { model: setting.model } : {}), + ...(setting.reasoningEffort !== undefined + ? { reasoningEffort: setting.reasoningEffort } + : {}), + }; + if (Object.keys(fields).length > 0) stages[stage] = fields; + } + return { + provider: llm.provider, + baseUrl: llm.baseUrl, + model: llm.model, + ...(llm.reasoningEffort !== undefined ? { reasoningEffort: llm.reasoningEffort } : {}), + ...(Object.keys(stages).length > 0 ? { stages } : {}), + }; +} + +export function chartValues( + cfg: DevboxConfig, + user: Values, + extra: InstallerValues, + overlay: Values = {}, +): Values { + const ours: Values = { + repos: cfg.repos.map(({ url, branch }) => ({ url, branch })), + llm: llmValues(cfg.llm), + ...(extra.clusterIP ? { ironProxy: { clusterIP: extra.clusterIP } } : {}), + ...(extra.publicBaseUrl ? { controlPlane: { publicBaseUrl: extra.publicBaseUrl } } : {}), + ...(extra.identity + ? { git: { name: extra.identity.gitName, email: extra.identity.gitEmail } } + : {}), + ...integrationValues(extra.integrations), + }; + return deepMerge(deepMerge(ours, overlay), user); +} + +export function userValue(target: Pick, path: string[]): unknown { + let v: unknown = target.values; + for (const key of path) v = isPlainObject(v) ? v[key] : undefined; + return v; +} + +// The URL of the api Ingress (the one that routes /, the API included), or +// undefined when there is none. The scheme follows the chart: https when the +// Ingress has TLS. When controlPlane.publicBaseUrl names the same host (TLS +// terminated at a load balancer, so `tls` is empty), its origin wins. +export function apiIngressUrl(target: Pick): string | undefined { + const api = userValue(target, ["ingress", "api"]); + if (!isPlainObject(api) || api.enabled !== true) return undefined; + if (typeof api.host !== "string" || api.host === "") return undefined; + const base = userValue(target, ["controlPlane", "publicBaseUrl"]); + if (typeof base === "string") { + try { + const u = new URL(base); + if (u.hostname === api.host) return u.origin; + } catch { + // not a URL: fall through to the Ingress rule + } + } + return `${Array.isArray(api.tls) && api.tls.length > 0 ? "https" : "http"}://${api.host}`; +} diff --git a/control-plane/scripts/lib/capture.ts b/control-plane/scripts/lib/capture.ts new file mode 100644 index 0000000..6284e00 --- /dev/null +++ b/control-plane/scripts/lib/capture.ts @@ -0,0 +1,40 @@ +// Runs a command and captures its output. Throws only if it can't start. +// With timeoutMs, kills the command when it runs longer and returns without +// waiting for its output (a child may still hold the pipes open), which is +// why `expired` races `done`. +export type Captured = { code: number; stdout: string; stderr: string; timedOut: boolean }; + +export async function capture( + cmd: string[], + opts: { env?: Record; stdin?: string; timeoutMs?: number } = {}, +): Promise { + const proc = Bun.spawn(cmd, { + env: opts.env ?? process.env, + stdin: opts.stdin === undefined ? "ignore" : new Blob([opts.stdin]), + stdout: "pipe", + stderr: "pipe", + }); + const done = Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]).then(([stdout, stderr, code]): Captured => ({ code, stdout, stderr, timedOut: false })); + if (opts.timeoutMs === undefined) return done; + let timer: Timer | undefined; + const expired = new Promise((resolve) => { + timer = setTimeout(() => { + proc.kill(); + resolve({ + code: -1, + stdout: "", + stderr: `timed out after ${opts.timeoutMs} ms`, + timedOut: true, + }); + }, opts.timeoutMs); + }); + try { + return await Promise.race([done, expired]); + } finally { + clearTimeout(timer); + } +} diff --git a/control-plane/scripts/lib/configFile.test.ts b/control-plane/scripts/lib/configFile.test.ts new file mode 100644 index 0000000..c3d9df3 --- /dev/null +++ b/control-plane/scripts/lib/configFile.test.ts @@ -0,0 +1,94 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeConfigKeys } from "./configFile"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-config-file-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +let n = 0; +function file(content: unknown): string { + const path = join(dir, `c${n++}.json`); + writeFileSync(path, JSON.stringify(content)); + return path; +} +const read = (path: string): unknown => JSON.parse(readFileSync(path, "utf8")); +const LINEAR = { enabled: true, clientId: "client-1" }; +const APP = { enabled: true, appId: 7, slug: "example-devbox" }; + +describe("writeConfigKeys", () => { + it("adds a nested key and keeps the rest", () => { + const path = file({ + context: "kind-devbox", + values: { ingress: { webhooks: { enabled: true } } }, + }); + writeConfigKeys(path, { "integrations.linear": LINEAR }); + expect(read(path)).toEqual({ + context: "kind-devbox", + values: { ingress: { webhooks: { enabled: true } } }, + integrations: { linear: LINEAR }, + }); + }); + + it("sets publicUrl and integrations.githubApp in one call, other keys unchanged", () => { + const path = file({ + repos: [{ url: "https://github.com/example-org/example-repo.git" }], + publicUrl: "https://old.example.com", + integrations: { linear: LINEAR, slack: { enabled: true } }, + }); + writeConfigKeys(path, { + publicUrl: "https://hooks.example.com", + "integrations.githubApp": APP, + }); + expect(read(path)).toEqual({ + repos: [{ url: "https://github.com/example-org/example-repo.git" }], + publicUrl: "https://hooks.example.com", + integrations: { linear: LINEAR, slack: { enabled: true }, githubApp: APP }, + }); + }); + + it("writes 2-space JSON and a newline", () => { + const path = file({ context: "c" }); + writeConfigKeys(path, { publicUrl: "https://hooks.example.com" }); + const text = readFileSync(path, "utf8"); + expect(JSON.parse(text)).toEqual({ context: "c", publicUrl: "https://hooks.example.com" }); + expect(text.endsWith("}\n")).toBe(true); + expect( + text + .split("\n") + .filter((l) => l.startsWith(" ")) + .every((l) => /^ {2}\S/.test(l)), + ).toBe(true); + }); + + it("replaces an existing value", () => { + const path = file({ context: "c", integrations: { linear: { clientId: "old" } } }); + writeConfigKeys(path, { "integrations.linear": LINEAR }); + expect(read(path)).toEqual({ context: "c", integrations: { linear: LINEAR } }); + }); + + it("undefined deletes a key", () => { + const path = file({ context: "c", publicUrl: "https://hooks.example.com" }); + writeConfigKeys(path, { publicUrl: undefined }); + expect(read(path)).toEqual({ context: "c" }); + }); + + it("deleting the last key under integrations removes integrations", () => { + const path = file({ context: "c", integrations: { linear: LINEAR } }); + writeConfigKeys(path, { "integrations.linear": undefined }); + expect(read(path)).toEqual({ context: "c" }); + }); + + it("keeps other integrations when deleting one", () => { + const path = file({ context: "c", integrations: { linear: LINEAR, githubApp: APP } }); + writeConfigKeys(path, { "integrations.linear": undefined }); + expect(read(path)).toEqual({ context: "c", integrations: { githubApp: APP } }); + }); + + it("deleting a key that is not there changes nothing", () => { + const path = file({ context: "c" }); + writeConfigKeys(path, { "integrations.linear": undefined }); + expect(read(path)).toEqual({ context: "c" }); + }); +}); diff --git a/control-plane/scripts/lib/configFile.ts b/control-plane/scripts/lib/configFile.ts new file mode 100644 index 0000000..86b3e9b --- /dev/null +++ b/control-plane/scripts/lib/configFile.ts @@ -0,0 +1,33 @@ +// Writes keys of a deployment's config file: +// devbox.config.json for Compose, the target file for k8s. Every other key is +// kept as it was; only formatting is normalised (2-space JSON). +import { readFileSync, writeFileSync } from "node:fs"; + +type Json = Record; + +const isObject = (v: unknown): v is Json => + typeof v === "object" && v !== null && !Array.isArray(v); + +// Sets the value at a dotted path, or deletes it when `value` is undefined; +// a parent object left empty by a delete is removed too. +function setPath(obj: Json, path: string[], value: unknown): Json { + const [head, ...rest] = path; + if (head === undefined) return obj; + const out: Json = { ...obj }; + if (rest.length === 0) { + if (value === undefined) delete out[head]; + else out[head] = value; + return out; + } + const child = isObject(out[head]) ? out[head] : {}; + const next = setPath(child, rest, value); + if (Object.keys(next).length === 0 && value === undefined) delete out[head]; + else out[head] = next; + return out; +} + +export function writeConfigKeys(path: string, keys: Record): void { + let file = JSON.parse(readFileSync(path, "utf8")) as Json; + for (const [key, value] of Object.entries(keys)) file = setPath(file, key.split("."), value); + writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`); +} diff --git a/control-plane/scripts/lib/destructive.test.ts b/control-plane/scripts/lib/destructive.test.ts new file mode 100644 index 0000000..c77c370 --- /dev/null +++ b/control-plane/scripts/lib/destructive.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import { fakeTerminal } from "../up/fakeTerminal"; +import { confirm } from "../up/prompt"; +import { confirmDestructive, DestructiveRefused } from "./destructive"; + +const P = { command: "thing:wipe", items: ["a", "b"], question: "Sure? [y/N] " }; + +function setup(keys: string, o: { yes?: boolean; isTTY?: boolean } = {}) { + const logs: string[] = []; + const asked: string[] = []; + const term = fakeTerminal(keys, o.isTTY ?? true).term; + const deps = { + yes: o.yes ?? false, + isTTY: o.isTTY ?? true, + confirm: (q: string, opts: { defaultYes: boolean }) => { + asked.push(q); + return confirm(q, opts, term); + }, + log: (line: string) => void logs.push(line), + }; + return { deps, logs, asked }; +} + +describe("confirmDestructive", () => { + it("terminal yes", async () => { + const s = setup("y\r"); + expect(await confirmDestructive(P, s.deps)).toBe(true); + expect(s.logs).toEqual(["This deletes:", " - a", " - b"]); + expect(s.asked).toEqual(["Sure? [y/N] "]); + }); + + it("terminal no", async () => { + expect(await confirmDestructive(P, setup("n\r").deps)).toBe(false); + }); + + it("Enter answers no", async () => { + expect(await confirmDestructive(P, setup("\r").deps)).toBe(false); + }); + + it("Ctrl-D answers no", async () => { + expect(await confirmDestructive(P, setup("\u0004").deps)).toBe(false); + }); + + it("no terminal refuses without --yes", async () => { + const s = setup("y\r", { isTTY: false }); + await expect(confirmDestructive(P, s.deps)).rejects.toThrow( + new DestructiveRefused("thing:wipe deletes data: pass --yes to confirm without a terminal"), + ); + expect(s.logs).toEqual([]); + expect(s.asked).toEqual([]); + }); + + it("--yes without a terminal", async () => { + const s = setup("", { yes: true, isTTY: false }); + expect(await confirmDestructive(P, s.deps)).toBe(true); + expect(s.logs).toEqual([]); + expect(s.asked).toEqual([]); + }); + + it("--yes with a terminal asks nothing", async () => { + const s = setup("", { yes: true }); + expect(await confirmDestructive(P, s.deps)).toBe(true); + expect(s.logs).toEqual([]); + expect(s.asked).toEqual([]); + }); +}); diff --git a/control-plane/scripts/lib/destructive.ts b/control-plane/scripts/lib/destructive.ts new file mode 100644 index 0000000..b1129f2 --- /dev/null +++ b/control-plane/scripts/lib/destructive.ts @@ -0,0 +1,29 @@ +// Destructive commands share one confirmation: list what goes, then ask with a +// default of no. Without a terminal nothing can answer, so +// the command refuses unless --yes was given. +import type { ResolverDeps } from "./secrets"; + +export class DestructiveRefused extends Error {} + +export type DestructiveDeps = { + yes: boolean; + isTTY: boolean; + confirm: ResolverDeps["confirm"]; + log: (line: string) => void; +}; + +// True to go ahead. On false the caller prints "Nothing was removed." and exits 0. +export async function confirmDestructive( + p: { command: string; items: string[]; question: string }, + deps: DestructiveDeps, +): Promise { + if (deps.yes) return true; + if (!deps.isTTY) { + throw new DestructiveRefused( + `${p.command} deletes data: pass --yes to confirm without a terminal`, + ); + } + deps.log("This deletes:"); + for (const item of p.items) deps.log(` - ${item}`); + return deps.confirm(p.question, { defaultYes: false }); +} diff --git a/control-plane/scripts/lib/githubApp.test.ts b/control-plane/scripts/lib/githubApp.test.ts new file mode 100644 index 0000000..ec18d38 --- /dev/null +++ b/control-plane/scripts/lib/githubApp.test.ts @@ -0,0 +1,468 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { GithubAppError } from "../../src/githubToken/mint"; +import { parseConfig } from "../up/config"; +import { + appIdentity, + appSettingsUrl, + callWithAppKey, + checkCoverage, + coverageMessage, + GithubAppSetupError, + installationSettingsUrl, + mintAppToken, + missingKeyMessage, + readTokenFile, + savedKeyFix, + verifyApp, +} from "./githubApp"; +import { PrivateKeyError } from "./githubAppKey"; + +const { privateKey: pem } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); + +const app = { + enabled: true as const, + appId: 123, + slug: "example-devbox", + installationId: 456, + botUserId: 789, + tokens: "devbox" as const, + tokenScope: "repos" as const, + ciResults: true, +}; + +const repos = parseConfig({ + repos: [ + { url: "https://github.com/example-org/a.git" }, + { url: "https://github.com/example-org/b.git" }, + { url: "https://github.com/example-org/c.git" }, + ], + llm: { baseUrl: "https://llm.example.com/v1", model: "m1" }, +}).repos; + +type Req = { method: string; url: string; authorization: string | null }; + +function fakeFetch(routes: Record) { + const requests: Req[] = []; + const fetch = async (url: string, init?: RequestInit) => { + const method = init?.method ?? "GET"; + requests.push({ + method, + url, + authorization: new Headers(init?.headers).get("authorization"), + }); + const route = routes[`${method} ${url}`]; + if (!route) return new Response("not routed", { status: 599 }); + return Response.json(route[1], { status: route[0] }); + }; + return { fetch, requests }; +} + +describe("appIdentity", () => { + it("is the App's bot, with its noreply address", () => { + expect(appIdentity("example-devbox", 789)).toEqual({ + gitName: "example-devbox[bot]", + gitEmail: "789+example-devbox[bot]@users.noreply.github.com", + }); + }); +}); + +describe("installationSettingsUrl", () => { + it("is the organization's installation page for an organization account", () => { + expect(installationSettingsUrl(app, { login: "example-org", type: "Organization" })).toBe( + "https://github.com/organizations/example-org/settings/installations/456", + ); + }); + + it("is the user's installation page for a user account", () => { + expect(installationSettingsUrl(app, { login: "octo", type: "User" })).toBe( + "https://github.com/settings/installations/456", + ); + }); + + it("without the account, is the App's page listing where it is installed", () => { + expect(installationSettingsUrl(app)).toBe( + "https://github.com/apps/example-devbox/installations/new", + ); + }); +}); + +describe("checkCoverage", () => { + const api = "https://api.github.com"; + + it("returns the repos the installation does not cover: 404, or another installation", async () => { + const f = fakeFetch({ + [`GET ${api}/repos/example-org/a/installation`]: [200, { id: 456 }], + [`GET ${api}/repos/example-org/b/installation`]: [404, { message: "Not Found" }], + [`GET ${api}/repos/example-org/c/installation`]: [200, { id: 999 }], + }); + expect(await checkCoverage(app, pem, repos, f.fetch)).toEqual([ + "example-org/b", + "example-org/c", + ]); + expect(f.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + `GET ${api}/repos/example-org/a/installation`, + `GET ${api}/repos/example-org/b/installation`, + `GET ${api}/repos/example-org/c/installation`, + ]); + // Each call carries a freshly signed App JWT, never an installation token. + for (const r of f.requests) + expect(r.authorization).toMatch(/^Bearer eyJ[\w-]+\.[\w-]+\.[\w-]+$/); + }); + + it("one covered repo and one 404", async () => { + const f = fakeFetch({ + [`GET ${api}/repos/example-org/a/installation`]: [200, { id: 456 }], + [`GET ${api}/repos/example-org/b/installation`]: [404, { message: "Not Found" }], + }); + expect(await checkCoverage(app, pem, repos.slice(0, 2), f.fetch)).toEqual(["example-org/b"]); + }); + + it("an empty list when every repo is covered", async () => { + const f = fakeFetch({ + [`GET ${api}/repos/example-org/a/installation`]: [200, { id: 456 }], + }); + expect(await checkCoverage(app, pem, repos.slice(0, 1), f.fetch)).toEqual([]); + }); + + it("throws GitHub's other refusals", async () => { + const f = fakeFetch({ + [`GET ${api}/repos/example-org/a/installation`]: [ + 401, + { message: "A JSON web token could not be decoded" }, + ], + }); + const err = await checkCoverage(app, pem, repos.slice(0, 1), f.fetch).catch((e) => e); + expect(err).toBeInstanceOf(GithubAppError); + expect((err as GithubAppError).status).toBe(401); + }); +}); + +describe("coverageMessage", () => { + it("names the repos and where to add them", () => { + expect(coverageMessage(["example-org/b", "example-org/c"], "https://github.com/x")).toBe( + 'These repos are not in the GitHub App\'s installation: example-org/b, example-org/c. Add them at https://github.com/x, or run with tokenScope "installation"', + ); + }); +}); + +describe("callWithAppKey", () => { + const rejected = new GithubAppError( + "GET", + "/repos/example-org/a/installation", + 401, + JSON.stringify({ message: "'Expiration time' claim ('exp') is too far in the future" }), + ); + + it("returns the call's result", async () => { + expect(await callWithAppKey(async () => 7)).toBe(7); + }); + + it("a 401 stops the run naming the clock, the App id and how to replace the key", async () => { + const err = await callWithAppKey(async () => { + throw rejected; + }).catch((e) => e); + expect(err).toEqual( + new PrivateKeyError( + "GitHub rejected the App's credentials: 'Expiration time' claim ('exp') is too far in the future. Check the system clock and integrations.githubApp.appId; if the key itself is wrong, run bun run secrets rm GITHUB_APP_PRIVATE_KEY, then bun run github:setup.", + ), + ); + }); + + it("names the target file for a Kubernetes deployment", async () => { + const err = await callWithAppKey(async () => { + throw rejected; + }, savedKeyFix("devbox.k8s.json")).catch((e) => e); + expect(err).toEqual( + new PrivateKeyError( + "GitHub rejected the App's credentials: 'Expiration time' claim ('exp') is too far in the future. Check the system clock and integrations.githubApp.appId; if the key itself is wrong, run bun run secrets rm GITHUB_APP_PRIVATE_KEY --target devbox.k8s.json, then bun run github:setup --target devbox.k8s.json.", + ), + ); + }); + + it("says how to fix the key where it came from", async () => { + const err = await callWithAppKey(async () => { + throw rejected; + }, 'put the right key in Secret "my-app-key" (key private-key).').catch((e) => e); + expect(err).toEqual( + new PrivateKeyError( + "GitHub rejected the App's credentials: 'Expiration time' claim ('exp') is too far in the future. Check the system clock and integrations.githubApp.appId; if the key itself is wrong, put the right key in Secret \"my-app-key\" (key private-key).", + ), + ); + }); + + it("other errors pass through", async () => { + const notFound = new GithubAppError("POST", "/app/installations/456/access_tokens", 404, "{}"); + const err = await callWithAppKey(async () => { + throw notFound; + }).catch((e) => e); + expect(err).toBe(notFound); + }); +}); + +describe("missingKeyMessage", () => { + it("names the key's env forms and github:setup, with the target file on Kubernetes", () => { + expect(missingKeyMessage()).toBe( + "No private key for the GitHub App: set GITHUB_APP_PRIVATE_KEY_FILE to its path, or run bun run github:setup", + ); + expect(missingKeyMessage("devbox.k8s.json")).toBe( + "No private key for the GitHub App: set GITHUB_APP_PRIVATE_KEY_FILE to its path, or run bun run github:setup --target devbox.k8s.json", + ); + }); +}); + +describe("verifyApp", () => { + const APP = "GET https://api.github.com/app"; + + it("passes when the App id names the configured slug, signed with the App's JWT", async () => { + const f = fakeFetch({ [APP]: [200, { id: 123, slug: "example-devbox" }] }); + await verifyApp(app, pem, f.fetch); + expect(f.requests.map((r) => `${r.method} ${r.url}`)).toEqual([APP]); + expect(f.requests[0]?.authorization).toMatch(/^Bearer eyJ[\w-]+\.[\w-]+\.[\w-]+$/); + }); + + it("a 401 is the rejected-credentials message, naming the target file on Kubernetes", async () => { + const f = fakeFetch({ [APP]: [401, { message: "Bad credentials" }] }); + expect( + await verifyApp(app, pem, f.fetch, { target: "devbox.k8s.json" }).catch((e) => e), + ).toEqual( + new PrivateKeyError( + "GitHub rejected the App's credentials: Bad credentials. Check the system clock and integrations.githubApp.appId; if the key itself is wrong, run bun run secrets rm GITHUB_APP_PRIVATE_KEY --target devbox.k8s.json, then bun run github:setup --target devbox.k8s.json.", + ), + ); + }); + + it("a 404 means no App has that id", async () => { + const f = fakeFetch({ [APP]: [404, { message: "Integration not found" }] }); + expect(await verifyApp(app, pem, f.fetch).catch((e) => e)).toEqual( + new GithubAppSetupError( + "GitHub has no App with id 123: check integrations.githubApp.appId (the App's settings page shows its App ID)", + ), + ); + }); + + it("another App's slug means the settings belong to another App", async () => { + const f = fakeFetch({ [APP]: [200, { id: 123, slug: "other-devbox" }] }); + expect(await verifyApp(app, pem, f.fetch).catch((e) => e)).toEqual( + new GithubAppSetupError( + 'integrations.githubApp.slug is "example-devbox" but App 123 is "other-devbox": run bun run github:setup', + ), + ); + expect( + await verifyApp(app, pem, f.fetch, { target: "devbox.k8s.json" }).catch((e) => e), + ).toEqual( + new GithubAppSetupError( + 'integrations.githubApp.slug is "example-devbox" but App 123 is "other-devbox": run bun run github:setup --target devbox.k8s.json', + ), + ); + }); + + it("returns the App's slug, page and owner", async () => { + const f = fakeFetch({ + [APP]: [ + 200, + { + id: 123, + slug: "example-devbox", + html_url: "https://github.com/apps/example-devbox", + owner: { login: "example-org", type: "Organization", id: 9 }, + }, + ], + }); + expect(await verifyApp(app, pem, f.fetch)).toEqual({ + slug: "example-devbox", + htmlUrl: "https://github.com/apps/example-devbox", + owner: { login: "example-org", type: "Organization" }, + }); + }); + + it("without a configured slug (adopting an App): takes GitHub's", async () => { + const f = fakeFetch({ [APP]: [200, { id: 123, slug: "example-devbox" }] }); + expect(await verifyApp({ appId: 123 }, pem, f.fetch)).toEqual({ + slug: "example-devbox", + htmlUrl: "https://github.com/apps/example-devbox", + }); + const none = fakeFetch({ [APP]: [200, { id: 123 }] }); + expect(await verifyApp({ appId: 123 }, pem, none.fetch).catch((e) => e)).toEqual( + new GithubAppSetupError("GitHub's answer for App 123 names no slug"), + ); + }); + + it("a 404 with a message of the caller's own", async () => { + const f = fakeFetch({ [APP]: [404, { message: "Integration not found" }] }); + expect( + await verifyApp({ appId: 123 }, pem, f.fetch, { notFound: "no such App" }).catch((e) => e), + ).toEqual(new GithubAppSetupError("no such App")); + }); + + it("a 401 with a message of the caller's own", async () => { + const f = fakeFetch({ [APP]: [401, { message: "Bad credentials" }] }); + expect( + await verifyApp({ appId: 123 }, pem, f.fetch, { + rejected: (reason) => `GitHub rejected the key for App 123: ${reason}`, + }).catch((e) => e), + ).toEqual(new PrivateKeyError("GitHub rejected the key for App 123: Bad credentials")); + }); + + it("an answer without a slug says so", async () => { + const f = fakeFetch({ [APP]: [200, { id: 123 }] }); + expect(await verifyApp(app, pem, f.fetch).catch((e) => e)).toEqual( + new GithubAppSetupError( + 'integrations.githubApp.slug is "example-devbox" but GitHub\'s answer for App 123 names no slug: run bun run github:setup', + ), + ); + }); +}); + +describe("appSettingsUrl", () => { + it("a user's App and an organization's", () => { + expect(appSettingsUrl("example-devbox")).toBe( + "https://github.com/settings/apps/example-devbox", + ); + expect(appSettingsUrl("example-devbox", { login: "example-user", type: "User" })).toBe( + "https://github.com/settings/apps/example-devbox", + ); + expect(appSettingsUrl("example-devbox", { login: "example-org", type: "Organization" })).toBe( + "https://github.com/organizations/example-org/settings/apps/example-devbox", + ); + }); +}); + +describe("mintAppToken", () => { + const api = "https://api.github.com"; + const GET_APP = `GET ${api}/app`; + const APP_OK: [number, unknown] = [200, { id: 123, slug: "example-devbox" }]; + const COVER_A = `GET ${api}/repos/example-org/a/installation`; + const MINT = `POST ${api}/app/installations/456/access_tokens`; + const minted = { token: "ghs_minted", expires_at: "2026-10-19T13:00:00Z" }; + const one = repos.slice(0, 1); + const calls = (f: ReturnType) => f.requests.map((r) => `${r.method} ${r.url}`); + + it("checks coverage, then mints a token narrowed to the repos' names", async () => { + const f = fakeFetch({ + [GET_APP]: APP_OK, + [COVER_A]: [200, { id: 456 }], + [MINT]: [201, minted], + }); + expect(await mintAppToken(app, pem, one, { fetch: f.fetch, checkCoverage: true })).toEqual({ + token: "ghs_minted", + expiresAt: "2026-10-19T13:00:00Z", + }); + expect(calls(f)).toEqual([GET_APP, COVER_A, MINT]); + }); + + it("without the coverage check only mints", async () => { + const f = fakeFetch({ [GET_APP]: APP_OK, [MINT]: [201, minted] }); + await mintAppToken(app, pem, one, { fetch: f.fetch, checkCoverage: false }); + expect(calls(f)).toEqual([GET_APP, MINT]); + }); + + it("refuses repos outside the installation before minting", async () => { + const f = fakeFetch({ + [GET_APP]: APP_OK, + [COVER_A]: [404, { message: "Not Found" }], + [`GET ${api}/app/installations/456`]: [200, { account: { login: "octo", type: "User" } }], + }); + const err = await mintAppToken(app, pem, one, { fetch: f.fetch, checkCoverage: true }).catch( + (e) => e, + ); + expect(err).toEqual( + new GithubAppSetupError( + 'These repos are not in the GitHub App\'s installation: example-org/a. Add them at https://github.com/settings/installations/456, or run with tokenScope "installation"', + ), + ); + expect(calls(f)).toEqual([GET_APP, COVER_A, `GET ${api}/app/installations/456`]); + }); + + it("a 401 names the target file; other refusals name the call", async () => { + const rejected = fakeFetch({ + [GET_APP]: APP_OK, + [MINT]: [401, { message: "Bad credentials" }], + }); + const err = await mintAppToken(app, pem, one, { + fetch: rejected.fetch, + checkCoverage: false, + target: "devbox.k8s.json", + }).catch((e) => e); + expect(err).toEqual( + new PrivateKeyError( + "GitHub rejected the App's credentials: Bad credentials. Check the system clock and integrations.githubApp.appId; if the key itself is wrong, run bun run secrets rm GITHUB_APP_PRIVATE_KEY --target devbox.k8s.json, then bun run github:setup --target devbox.k8s.json.", + ), + ); + const gone = fakeFetch({ [GET_APP]: APP_OK, [MINT]: [404, { message: "Not Found" }] }); + const err2 = await mintAppToken(app, pem, one, { + fetch: gone.fetch, + checkCoverage: false, + }).catch((e) => e); + expect(err2).toEqual( + new GithubAppSetupError( + "minting a GitHub App token failed: GitHub POST /app/installations/456/access_tokens: HTTP 404: Not Found", + ), + ); + }); + + it("a wrong App id stops before the coverage check and the mint", async () => { + const f = fakeFetch({ [GET_APP]: [404, { message: "Integration not found" }] }); + const err = await mintAppToken(app, pem, one, { fetch: f.fetch, checkCoverage: true }).catch( + (e) => e, + ); + expect(err).toEqual( + new GithubAppSetupError( + "GitHub has no App with id 123: check integrations.githubApp.appId (the App's settings page shows its App ID)", + ), + ); + expect(calls(f)).toEqual([GET_APP]); + }); + + it("tokenScope installation does not narrow", async () => { + const f = fakeFetch({ [GET_APP]: APP_OK, [MINT]: [201, minted] }); + let body: unknown; + const fetch = async (url: string, init?: RequestInit) => { + if (init?.body) body = JSON.parse(String(init.body)); + return f.fetch(url, init); + }; + await mintAppToken({ ...app, tokenScope: "installation" }, pem, one, { + fetch, + checkCoverage: false, + }); + expect(body).toEqual({}); + await mintAppToken(app, pem, one, { fetch, checkCoverage: false }); + expect(body).toEqual({ repositories: ["a"] }); + }); +}); + +describe("readTokenFile", () => { + const dir = mkdtempSync(join(tmpdir(), "devbox-token-")); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + it("returns the token, trimmed", () => { + const p = join(dir, "token"); + writeFileSync(p, "ghs_abc\n"); + expect(readTokenFile(p)).toBe("ghs_abc"); + }); + + it("names the setting and the path when the file is missing, a directory or empty", () => { + const sub = join(dir, "sub"); + mkdirSync(sub); + const empty = join(dir, "empty"); + writeFileSync(empty, " \n"); + expect(() => readTokenFile(join(dir, "missing"))).toThrow( + new GithubAppSetupError( + `integrations.githubApp.tokenFile ${join(dir, "missing")}: not found`, + ), + ); + expect(() => readTokenFile(sub)).toThrow( + new GithubAppSetupError(`integrations.githubApp.tokenFile ${sub}: is a directory`), + ); + expect(() => readTokenFile(empty)).toThrow( + new GithubAppSetupError(`integrations.githubApp.tokenFile ${empty}: is empty`), + ); + }); +}); diff --git a/control-plane/scripts/lib/githubApp.ts b/control-plane/scripts/lib/githubApp.ts new file mode 100644 index 0000000..28416bf --- /dev/null +++ b/control-plane/scripts/lib/githubApp.ts @@ -0,0 +1,280 @@ +// What the scripts need around a configured GitHub App: the commit identity +// of its bot, whether its installation covers the configured repos, where the +// installation's settings are, a check that the App id names it, a guard +// for a private key GitHub rejects, a token minted for checks, and +// the external mode's token file. Never prints a key, JWT or token. +import { readFileSync } from "node:fs"; +import { appJwt } from "../../src/githubToken/jwt"; +import { + type Fetch, + GithubAppError, + githubApiBaseUrl, + githubAppFetch, + mintInstallationToken, +} from "../../src/githubToken/mint"; +import type { RepoConfig } from "../up/config"; +import type { Identity } from "../up/preflight"; +import { PrivateKeyError } from "./githubAppKey"; +import type { EnabledGithubApp } from "./integrations"; + +// The ` --target ` a Kubernetes deployment's commands take; empty on Compose. +const targetFlag = (target?: string): string => (target ? ` --target ${target}` : ""); + +export const missingKeyMessage = (target?: string): string => + `No private key for the GitHub App: set GITHUB_APP_PRIVATE_KEY_FILE to its path, or run bun run github:setup${targetFlag(target)}`; + +export class GithubAppSetupError extends Error {} + +// Commits link to the App's bot on GitHub: its login is `[bot]`, and the +// noreply address carries the bot's user id (not the App's id). +export function appIdentity(slug: string, botUserId: number): Identity { + return { + gitName: `${slug}[bot]`, + gitEmail: `${botUserId}+${slug}[bot]@users.noreply.github.com`, + }; +} + +export type InstallationAccount = { login: string; type: string }; + +// The page where repos are added to the installation. It depends on the +// account the App is installed on; without it, the App's own page lists its +// installations, each with a Configure link. +export function installationSettingsUrl( + app: Pick, + account?: InstallationAccount, +): string { + if (!account) return `https://github.com/apps/${app.slug}/installations/new`; + return account.type === "Organization" + ? `https://github.com/organizations/${account.login}/settings/installations/${app.installationId}` + : `https://github.com/settings/installations/${app.installationId}`; +} + +// The account the installation belongs to, for installationSettingsUrl. +// Undefined when GitHub doesn't say. +export async function installationAccount( + app: Pick, + pem: string, + fetch: Fetch, +): Promise { + try { + const out = (await githubAppFetch( + `/app/installations/${app.installationId}`, + { method: "GET", jwt: () => appJwt(app.appId, pem, new Date()) }, + { fetch, apiBase: githubApiBaseUrl() }, + )) as { account?: { login?: unknown; type?: unknown } } | null; + const { login, type } = out?.account ?? {}; + return typeof login === "string" && typeof type === "string" ? { login, type } : undefined; + } catch { + return undefined; + } +} + +// The configured repos ("owner/name") the App's installation does not cover: +// GitHub answers 404, or names another installation of the App. Other +// refusals are thrown (a 401 means GitHub rejected the key). +export async function checkCoverage( + app: Pick, + pem: string, + repos: RepoConfig[], + fetch: Fetch, +): Promise { + const uncovered: string[] = []; + for (const repo of repos) { + const slug = `${repo.owner}/${repo.name}`; + try { + const out = (await githubAppFetch( + `/repos/${slug}/installation`, + { method: "GET", jwt: () => appJwt(app.appId, pem, new Date()) }, + { fetch, apiBase: githubApiBaseUrl() }, + )) as { id?: unknown } | null; + if (out?.id !== app.installationId) uncovered.push(slug); + } catch (err) { + if (err instanceof GithubAppError && err.status === 404) uncovered.push(slug); + else throw err; + } + } + return uncovered; +} + +export const coverageMessage = (uncovered: string[], settingsUrl: string): string => + `These repos are not in the GitHub App's installation: ${uncovered.join(", ")}. Add them at ${settingsUrl}, or run with tokenScope "installation"`; + +// How to replace a wrong key that came from the keychain or env, for the +// rejected-credentials message. `target` is a Kubernetes deployment's target file. +export const savedKeyFix = (target?: string): string => { + const flag = targetFlag(target); + return `run bun run secrets rm GITHUB_APP_PRIVATE_KEY${flag}, then bun run github:setup${flag}.`; +}; + +// Runs a call signed with the App's key. A 401 can mean a skewed host clock +// (the JWT's times), a wrong App id or a wrong key, and the saved key may be +// the only copy, so it is never removed or asked for again: the run stops +// naming what to check. `keyFix` says how to replace the key where it came from. +export async function callWithAppKey( + call: () => Promise, + keyFix: string = savedKeyFix(), +): Promise { + try { + return await call(); + } catch (err) { + if (!(err instanceof GithubAppError) || err.status !== 401) throw err; + throw new PrivateKeyError( + `GitHub rejected the App's credentials: ${err.reason || "HTTP 401"}. Check the system clock and integrations.githubApp.appId; if the key itself is wrong, ${keyFix}`, + ); + } +} + +// Where the key came from, for the messages: a Kubernetes deployment's target +// file, and how to replace a wrong key (savedKeyFix by default). `rejected` +// replaces the whole message for credentials GitHub rejects, `notFound` the +// one for an App id GitHub doesn't know. +export type KeyOrigin = { + target?: string; + keyFix?: string; + rejected?: (reason: string) => string; + notFound?: string; +}; + +// The App's settings page, which holds its webhook URL and private keys. +export const appSettingsUrl = (slug: string, owner?: InstallationAccount): string => + owner?.type === "Organization" + ? `https://github.com/organizations/${owner.login}/settings/apps/${slug}` + : `https://github.com/settings/apps/${slug}`; + +export type AppInfo = { slug: string; htmlUrl: string; owner?: InstallationAccount }; + +// That the App id names the configured App, before any call that depends on +// it: for a JWT signed for an id no App has, GitHub answers 404, which the +// coverage check would read as an uncovered repo and the mint as a missing +// installation. Without a configured slug (an App being adopted), GitHub's +// is taken. Returns what GitHub says about the App. +export async function verifyApp( + app: { appId: number; slug?: string }, + pem: string, + fetch: Fetch, + origin: KeyOrigin = {}, +): Promise { + const { target, rejected } = origin; + const call = () => + githubAppFetch( + "/app", + { method: "GET", jwt: () => appJwt(app.appId, pem, new Date()) }, + { fetch, apiBase: githubApiBaseUrl() }, + ); + let out: { slug?: unknown; html_url?: unknown; owner?: unknown } | null; + try { + out = (await (rejected + ? call().catch((err: unknown) => { + if (err instanceof GithubAppError && err.status === 401) { + throw new PrivateKeyError(rejected(err.reason || "HTTP 401")); + } + throw err; + }) + : callWithAppKey(call, origin.keyFix ?? savedKeyFix(target)))) as typeof out; + } catch (err) { + if (!(err instanceof GithubAppError)) throw err; + if (err.status === 404) { + throw new GithubAppSetupError( + origin.notFound ?? + `GitHub has no App with id ${app.appId}: check integrations.githubApp.appId (the App's settings page shows its App ID)`, + ); + } + throw new GithubAppSetupError(`checking the GitHub App failed: ${err.message}`); + } + const slug = typeof out?.slug === "string" && out.slug ? out.slug : undefined; + if (app.slug === undefined && slug === undefined) { + throw new GithubAppSetupError(`GitHub's answer for App ${app.appId} names no slug`); + } + if (app.slug !== undefined && slug !== app.slug) { + const actual = slug + ? `App ${app.appId} is "${slug}"` + : `GitHub's answer for App ${app.appId} names no slug`; + throw new GithubAppSetupError( + `integrations.githubApp.slug is "${app.slug}" but ${actual}: run bun run github:setup${targetFlag(target)}`, + ); + } + const found = slug as string; + const o = out?.owner as { login?: unknown; type?: unknown } | undefined; + const owner = + typeof o?.login === "string" && typeof o.type === "string" + ? { login: o.login, type: o.type } + : undefined; + return { + slug: found, + htmlUrl: typeof out?.html_url === "string" ? out.html_url : `https://github.com/apps/${found}`, + ...(owner ? { owner } : {}), + }; +} + +// The repos' names, the list a token is narrowed to with tokenScope "repos". +export const appRepositories = (repos: RepoConfig[]): string[] => repos.map((r) => r.name); + +// One token minted with the App's key, for the scripts' own checks. First, +// that the App id and slug match (verifyApp); then, when asked, that the +// installation covers every repo: a token narrowed to a +// repo outside it is refused as a whole. Credentials GitHub rejects stop the +// run (callWithAppKey); its other refusals name the call. +export async function mintAppToken( + app: EnabledGithubApp, + pem: string, + repos: RepoConfig[], + opts: { fetch: Fetch; checkCoverage: boolean } & KeyOrigin, +): Promise<{ token: string; expiresAt: string }> { + const { fetch } = opts; + const keyFix = opts.keyFix ?? savedKeyFix(opts.target); + const withKey = async (what: string, call: () => Promise): Promise => { + try { + return await callWithAppKey(call, keyFix); + } catch (err) { + if (err instanceof GithubAppError) { + throw new GithubAppSetupError(`${what} failed: ${err.message}`); + } + throw err; + } + }; + await verifyApp(app, pem, fetch, { target: opts.target, keyFix }); + if (opts.checkCoverage) { + const uncovered = await withKey("checking the GitHub App's installation", () => + checkCoverage(app, pem, repos, fetch), + ); + if (uncovered.length > 0) { + const account = await installationAccount(app, pem, fetch); + throw new GithubAppSetupError( + coverageMessage(uncovered, installationSettingsUrl(app, account)), + ); + } + } + return withKey("minting a GitHub App token", () => + mintInstallationToken({ + appId: app.appId, + installationId: app.installationId, + privateKeyPem: pem, + repositories: app.tokenScope === "repos" ? appRepositories(repos) : [], + fetch, + }), + ); +} + +// The external mode's token, read once for the checks and the repos volume. +export function readTokenFile(path: string): string { + const fail = (why: string) => + new GithubAppSetupError(`integrations.githubApp.tokenFile ${path}: ${why}`); + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + throw fail( + code === "EISDIR" + ? "is a directory" + : code === "ENOENT" + ? "not found" + : code === "EACCES" + ? "permission denied" + : "cannot read it", + ); + } + const token = text.trim(); + if (!token) throw fail("is empty"); + return token; +} diff --git a/control-plane/scripts/lib/githubAppKey.test.ts b/control-plane/scripts/lib/githubAppKey.test.ts new file mode 100644 index 0000000..f9ecb70 --- /dev/null +++ b/control-plane/scripts/lib/githubAppKey.test.ts @@ -0,0 +1,189 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + isPem, + PrivateKeyError, + peekPrivateKey, + readPrivateKeyFile, + resolvePrivateKey, +} from "./githubAppKey"; +import { type Deployment, MemoryKeychain } from "./keychain"; +import type { ResolverDeps } from "./secrets"; + +const K8S: Deployment = { kind: "k8s", context: "ctx", namespace: "devbox" }; +const PKCS1 = + "-----BEGIN RSA PRIVATE KEY-----\nQUJDREVGRw==\nSElKS0xNTg==\n-----END RSA PRIVATE KEY-----\n"; +const PKCS8 = "-----BEGIN PRIVATE KEY-----\nQUJDREVGRw==\n-----END PRIVATE KEY-----\n"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-key-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); +const file = (name: string, content: string): string => { + const p = join(dir, name); + writeFileSync(p, content); + return p; +}; + +function setup(opts: { + env?: Record; + entries?: Record; + deployment?: Deployment; + noKeychain?: boolean; +}) { + const log: string[] = []; + const prompts: string[] = []; + const keychain = new MemoryKeychain(opts.entries); + const deps: ResolverDeps = { + env: opts.env ?? {}, + keychain: opts.noKeychain ? undefined : keychain, + deployment: opts.deployment ?? { kind: "compose" }, + isTTY: true, + prompt: async (q) => { + prompts.push(q); + return ""; + }, + confirm: async () => false, + run: async () => ({ code: 1, stdout: "" }), + log: (l) => void log.push(l), + }; + return { deps, log, prompts }; +} + +describe("isPem", () => { + it("accepts PKCS#1 and PKCS#8 blocks, with surrounding text", () => { + expect(isPem(PKCS1)).toBe(true); + expect(isPem(PKCS8)).toBe(true); + expect(isPem(` \n${PKCS8}\n`)).toBe(true); + }); + + it("rejects other text, mismatched or public-key blocks", () => { + expect(isPem("")).toBe(false); + expect(isPem("not a key")).toBe(false); + expect(isPem("-----BEGIN PUBLIC KEY-----\nQUJD\n-----END PUBLIC KEY-----\n")).toBe(false); + expect(isPem("-----BEGIN PRIVATE KEY-----\nQUJD\n")).toBe(false); + expect(isPem("-----BEGIN RSA PRIVATE KEY-----\nQUJD\n-----END PRIVATE KEY-----\n")).toBe(false); + }); +}); + +describe("resolvePrivateKey", () => { + it("the key file wins over the env var and the keychain", async () => { + const p = file("a.pem", PKCS1); + const s = setup({ + env: { GITHUB_APP_PRIVATE_KEY_FILE: p, GITHUB_APP_PRIVATE_KEY: PKCS8 }, + entries: { "compose/GITHUB_APP_PRIVATE_KEY": "kc" }, + }); + expect(await resolvePrivateKey(s.deps)).toEqual({ pem: PKCS1, source: "env", path: p }); + expect(s.log).toEqual([`GITHUB_APP_PRIVATE_KEY: from ${p} (GITHUB_APP_PRIVATE_KEY_FILE)`]); + }); + + it("the env var wins over the keychain", async () => { + const s = setup({ + env: { GITHUB_APP_PRIVATE_KEY: PKCS8 }, + entries: { "compose/GITHUB_APP_PRIVATE_KEY": PKCS1 }, + }); + expect(await resolvePrivateKey(s.deps)).toEqual({ pem: PKCS8, source: "env" }); + expect(s.log).toEqual(["GITHUB_APP_PRIVATE_KEY: from your environment"]); + }); + + it("an empty file path or env var counts as unset", async () => { + const s = setup({ + env: { GITHUB_APP_PRIVATE_KEY_FILE: "", GITHUB_APP_PRIVATE_KEY: "" }, + entries: { "compose/GITHUB_APP_PRIVATE_KEY": PKCS1 }, + }); + expect((await resolvePrivateKey(s.deps))?.source).toBe("keychain"); + }); + + it("falls back to the deployment's keychain entry", async () => { + const s = setup({ + deployment: K8S, + entries: { "k8s:ctx/devbox/GITHUB_APP_PRIVATE_KEY": PKCS1 }, + }); + expect(await resolvePrivateKey(s.deps)).toEqual({ pem: PKCS1, source: "keychain" }); + expect(s.log).toEqual(["GITHUB_APP_PRIVATE_KEY: from the system keychain (k8s:ctx/devbox)"]); + }); + + it("returns undefined, without prompting, when there is no key", async () => { + const s = setup({}); + expect(await resolvePrivateKey(s.deps)).toBeUndefined(); + expect(await resolvePrivateKey(setup({ noKeychain: true }).deps)).toBeUndefined(); + expect(s.prompts).toEqual([]); + expect(s.log).toEqual([]); + }); + + it("a key file that cannot be used fails without falling through", async () => { + const missing = join(dir, "missing.pem"); + const asDir = join(dir, "adir"); + mkdirSync(asDir); + const notPem = file("notpem.txt", "hello, I am not a key\n"); + const cases: Array<[string, string]> = [ + [missing, "cannot read it"], + [asDir, "is a directory"], + [notPem, "does not hold a PEM private key"], + ]; + for (const [path, why] of cases) { + const s = setup({ + env: { GITHUB_APP_PRIVATE_KEY_FILE: path, GITHUB_APP_PRIVATE_KEY: PKCS8 }, + entries: { "compose/GITHUB_APP_PRIVATE_KEY": PKCS1 }, + }); + const err = await resolvePrivateKey(s.deps).catch((e) => e); + expect(err).toBeInstanceOf(PrivateKeyError); + expect(err.message).toBe(`GITHUB_APP_PRIVATE_KEY_FILE=${path}: ${why}`); + expect(s.log).toEqual([]); + } + }); + + it("an env var that is not a PEM fails without falling through", async () => { + const s = setup({ + env: { GITHUB_APP_PRIVATE_KEY: "just some text" }, + entries: { "compose/GITHUB_APP_PRIVATE_KEY": PKCS1 }, + }); + const err = await resolvePrivateKey(s.deps).catch((e) => e); + expect(err).toBeInstanceOf(PrivateKeyError); + expect(err.message).toBe("GITHUB_APP_PRIVATE_KEY does not hold a PEM private key"); + }); + + it("the key's content never appears in a message", async () => { + const body = "SECRETBODYSECRETBODY"; + const bad = `-----BEGIN RSA PRIVATE KEY-----\n${body}\n`; // no END line + const p = file("trunc.pem", bad); + const s = setup({ env: { GITHUB_APP_PRIVATE_KEY_FILE: p } }); + const err = await resolvePrivateKey(s.deps).catch((e) => e); + expect(err.message).not.toContain(body); + const e2 = setup({ env: { GITHUB_APP_PRIVATE_KEY: bad } }); + const err2 = await resolvePrivateKey(e2.deps).catch((e) => e); + expect(err2.message).not.toContain(body); + const ok = setup({ env: { GITHUB_APP_PRIVATE_KEY: PKCS1.replace("QUJDREVGRw==", body) } }); + await resolvePrivateKey(ok.deps); + expect(ok.log.join("\n")).not.toContain(body); + }); +}); + +describe("peekPrivateKey", () => { + it("reports env for a file path or the env var, keychain, or missing", async () => { + expect(await peekPrivateKey(setup({ env: { GITHUB_APP_PRIVATE_KEY_FILE: "/x" } }).deps)).toBe( + "env", + ); + expect(await peekPrivateKey(setup({ env: { GITHUB_APP_PRIVATE_KEY: PKCS1 } }).deps)).toBe( + "env", + ); + expect( + await peekPrivateKey(setup({ entries: { "compose/GITHUB_APP_PRIVATE_KEY": PKCS1 } }).deps), + ).toBe("keychain"); + expect(await peekPrivateKey(setup({}).deps)).toBe("missing"); + }); +}); + +describe("readPrivateKeyFile", () => { + it("reads a PEM, naming the file by the flag that gave it", () => { + expect(readPrivateKeyFile(file("flag.pem", PKCS1), "--private-key-file")).toBe(PKCS1); + const missing = join(dir, "nope.pem"); + expect(() => readPrivateKeyFile(missing, "--private-key-file")).toThrow( + new PrivateKeyError(`--private-key-file ${missing}: cannot read it`), + ); + const notPem = file("flag.pub", "ssh-rsa AAAA"); + expect(() => readPrivateKeyFile(notPem, "--private-key-file")).toThrow( + new PrivateKeyError(`--private-key-file ${notPem}: does not hold a PEM private key`), + ); + }); +}); diff --git a/control-plane/scripts/lib/githubAppKey.ts b/control-plane/scripts/lib/githubAppKey.ts new file mode 100644 index 0000000..3d4d9df --- /dev/null +++ b/control-plane/scripts/lib/githubAppKey.ts @@ -0,0 +1,64 @@ +// Finds the GitHub App's private key (a multi-line PEM) for the scripts: +// the file named by GITHUB_APP_PRIVATE_KEY_FILE, then GITHUB_APP_PRIVATE_KEY, +// then the deployment's keychain entry. It never prompts (a prompt takes one +// line), and neither messages nor logs ever carry the key itself. +import { readFileSync } from "node:fs"; +import { deploymentScope, entryName } from "./keychain"; +import type { PeekDeps, ResolverDeps } from "./secrets"; + +export class PrivateKeyError extends Error {} + +const KEY = "GITHUB_APP_PRIVATE_KEY"; +const KEY_FILE = "GITHUB_APP_PRIVATE_KEY_FILE"; + +// A PKCS#1 (`RSA PRIVATE KEY`, what GitHub issues) or PKCS#8 (`PRIVATE KEY`) block. +const PEM = /-----BEGIN (RSA )?PRIVATE KEY-----[\s\S]+?-----END \1PRIVATE KEY-----/; + +export const isPem = (text: string): boolean => PEM.test(text); + +// A key file, named in messages the way it was given: `GITHUB_APP_PRIVATE_KEY_FILE=` +// by default, or ` `. +export function readPrivateKeyFile(path: string, flag?: string): string { + const fail = (why: string) => + new PrivateKeyError(`${flag ? `${flag} ${path}` : `${KEY_FILE}=${path}`}: ${why}`); + let text: string; + try { + text = readFileSync(path, "utf8"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + throw fail(code === "EISDIR" ? "is a directory" : "cannot read it"); + } + if (!isPem(text)) throw fail("does not hold a PEM private key"); + return text; +} + +export type PrivateKey = { pem: string; source: "env" | "keychain"; path?: string }; + +export async function resolvePrivateKey(deps: ResolverDeps): Promise { + const path = deps.env[KEY_FILE]; + if (path) { + const pem = readPrivateKeyFile(path); + deps.log(`${KEY}: from ${path} (${KEY_FILE})`); + return { pem, source: "env", path }; + } + const inline = deps.env[KEY]; + if (inline) { + if (!isPem(inline)) throw new PrivateKeyError(`${KEY} does not hold a PEM private key`); + deps.log(`${KEY}: from your environment`); + return { pem: inline, source: "env" }; + } + const saved = await deps.keychain?.get(entryName(deps.deployment, KEY)); + if (saved) { + deps.log(`${KEY}: from the system keychain (${deploymentScope(deps.deployment)})`); + return { pem: saved, source: "keychain" }; + } + return undefined; +} + +// Where the key would come from, for status; nothing is read or logged. +export async function peekPrivateKey( + deps: Pick, +): Promise<"env" | "keychain" | "missing"> { + if (deps.env[KEY_FILE] || deps.env[KEY]) return "env"; + return (await deps.keychain?.get(entryName(deps.deployment, KEY))) ? "keychain" : "missing"; +} diff --git a/control-plane/scripts/lib/integrations.test.ts b/control-plane/scripts/lib/integrations.test.ts new file mode 100644 index 0000000..9eac497 --- /dev/null +++ b/control-plane/scripts/lib/integrations.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, it } from "bun:test"; +import { homedir } from "node:os"; +import { enabledGithubApp, enabledLinear, parseIntegrations } from "./integrations"; + +class Failed extends Error {} +const fail = (msg: string): never => { + throw new Failed(msg); +}; +const K8S = { platform: "k8s" } as const; +const COMPOSE = { platform: "compose" } as const; + +function failure(raw: unknown, opts: { platform: "compose" | "k8s" } = K8S): string { + try { + parseIntegrations(raw, fail, opts); + } catch (err) { + if (err instanceof Failed) return err.message; + throw err; + } + throw new Error("expected a failure"); +} + +describe("parseIntegrations", () => { + it("accepts enabled and clientId", () => { + expect( + parseIntegrations({ linear: { enabled: true, clientId: " abc123 " } }, fail, COMPOSE), + ).toEqual({ linear: { enabled: true, clientId: "abc123" } }); + }); + + it("refuses integrations.linear.publicUrl: it moved to the top level", () => { + expect( + failure({ linear: { enabled: true, clientId: "a", publicUrl: "https://x.example.com" } }), + ).toBe( + 'integrations.linear: unknown key "publicUrl" (allowed: enabled, clientId); moved to the top-level publicUrl', + ); + }); + + it("is empty when absent", () => { + expect(parseIntegrations(undefined, fail, COMPOSE)).toEqual({}); + expect(parseIntegrations({}, fail, K8S)).toEqual({}); + }); + + it("enabled is required", () => { + for (const name of ["linear", "slack", "githubApp"]) { + expect(failure({ [name]: {} })).toBe(`integrations.${name}.enabled: required, true or false`); + expect(failure({ [name]: { enabled: "true" } })).toBe( + `integrations.${name}.enabled: required, true or false`, + ); + } + }); + + it("enabled false keeps the other keys", () => { + expect( + parseIntegrations( + { + linear: { enabled: false, clientId: "abc123" }, + slack: { enabled: false, channelId: "C123" }, + githubApp: { enabled: false, slug: "example-devbox", botUserId: 42 }, + }, + fail, + K8S, + ), + ).toEqual({ + linear: { enabled: false, clientId: "abc123" }, + slack: { enabled: false, channelId: "C123" }, + githubApp: { + enabled: false, + slug: "example-devbox", + botUserId: 42, + tokenScope: "repos", + ciResults: true, + }, + }); + // Off needs no settings, but present ones are still checked. + expect(parseIntegrations({ linear: { enabled: false } }, fail, COMPOSE)).toEqual({ + linear: { enabled: false }, + }); + expect(failure({ linear: { enabled: false, clientId: " " } })).toBe( + "integrations.linear.clientId: must be a non-empty string", + ); + }); + + it("clientId required when enabled", () => { + expect(failure({ linear: { enabled: true } })).toBe( + "integrations.linear.clientId: required when enabled is true", + ); + expect(failure({ linear: { enabled: true, clientId: " " } })).toBe( + "integrations.linear.clientId: must be a non-empty string", + ); + expect(failure({ linear: { enabled: true, clientId: 7 } })).toBe( + "integrations.linear.clientId: must be a non-empty string", + ); + }); + + it("slack parsed on k8s", () => { + expect(parseIntegrations({ slack: { enabled: true, channelId: " C123 " } }, fail, K8S)).toEqual( + { slack: { enabled: true, channelId: "C123" } }, + ); + expect(failure({ slack: { enabled: true } })).toBe( + "integrations.slack.channelId: required when enabled is true", + ); + }); + + it("slack refused on Compose", () => { + expect(failure({ slack: { enabled: true, channelId: "C123" } }, COMPOSE)).toBe( + "integrations.slack: supported on Kubernetes only (the target file)", + ); + }); + + it("a leftover githubWebhooks is refused on both platforms", () => { + const msg = + "integrations.githubWebhooks: replaced by integrations.githubApp; run bun run github:setup"; + expect(failure({ githubWebhooks: { enabled: false } }, COMPOSE)).toBe(msg); + expect(failure({ githubWebhooks: { enabled: true, botLogin: "b", botUserId: 1 } })).toBe(msg); + }); + + it("refuses a non-object", () => { + expect(failure("linear")).toBe("integrations: must be an object"); + expect(failure({ linear: [] })).toBe("integrations.linear: must be an object"); + expect(failure({ slack: "on" })).toBe("integrations.slack: must be an object"); + }); + + it("refuses an unknown key in integrations", () => { + expect(failure({ jira: {} })).toBe( + 'integrations: unknown key "jira" (allowed: linear, slack, githubApp)', + ); + expect(failure({ jira: {} }, COMPOSE)).toBe( + 'integrations: unknown key "jira" (allowed: linear, githubApp)', + ); + }); + + it("unknown keys refused in each block", () => { + expect(failure({ linear: { enabled: true, clientId: "a", clientSecret: "s" } })).toBe( + 'integrations.linear: unknown key "clientSecret" (allowed: enabled, clientId)', + ); + expect(failure({ slack: { enabled: true, channelId: "C1", botToken: "t" } })).toBe( + 'integrations.slack: unknown key "botToken" (allowed: enabled, channelId)', + ); + expect(failure({ githubApp: { enabled: false, privateKey: "s" } })).toBe( + 'integrations.githubApp: unknown key "privateKey" (allowed: enabled, appId, slug, installationId, botUserId, tokens, tokenScope, ciResults, tokenFile, org, pending)', + ); + }); +}); + +describe("enabledLinear", () => { + it("is the block only when enabled", () => { + const on = { enabled: true as const, clientId: "abc123" }; + expect(enabledLinear({ linear: on })).toEqual(on); + expect(enabledLinear({ linear: { enabled: false, clientId: "abc123" } })).toBeUndefined(); + expect(enabledLinear({})).toBeUndefined(); + }); +}); + +const APP = { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 456, + botUserId: 789, + tokens: "devbox", +} as const; + +describe("integrations.githubApp", () => { + it("fills the defaults", () => { + expect(parseIntegrations({ githubApp: APP }, fail, COMPOSE)).toEqual({ + githubApp: { ...APP, tokenScope: "repos", ciResults: true }, + }); + expect(parseIntegrations({ githubApp: APP }, fail, K8S)).toEqual({ + githubApp: { ...APP, tokenScope: "repos", ciResults: true }, + }); + }); + + it("refuses an explicit null tokenScope or ciResults", () => { + expect(failure({ githubApp: { ...APP, tokenScope: null } })).toBe( + 'integrations.githubApp.tokenScope: must be "repos" or "installation"', + ); + expect(failure({ githubApp: { ...APP, ciResults: null } })).toBe( + "integrations.githubApp.ciResults: must be true or false", + ); + }); + + it("keeps explicit tokenScope and ciResults", () => { + expect( + parseIntegrations( + { githubApp: { ...APP, tokenScope: "installation", ciResults: false } }, + fail, + K8S, + ), + ).toEqual({ githubApp: { ...APP, tokenScope: "installation", ciResults: false } }); + expect(failure({ githubApp: { ...APP, tokenScope: "all" } })).toBe( + 'integrations.githubApp.tokenScope: must be "repos" or "installation"', + ); + expect(failure({ githubApp: { ...APP, ciResults: "yes" } })).toBe( + "integrations.githubApp.ciResults: must be true or false", + ); + }); + + it("each required key is required when enabled", () => { + for (const key of ["appId", "slug", "installationId", "botUserId", "tokens"]) { + const { [key]: _omitted, ...rest } = APP as Record; + expect(failure({ githubApp: rest })).toBe( + `integrations.githubApp.${key}: required when enabled is true`, + ); + } + }); + + it("tokens must be devbox or external", () => { + expect(failure({ githubApp: { ...APP, tokens: "other" } })).toBe( + 'integrations.githubApp.tokens: must be "devbox" or "external"', + ); + }); + + it("ids must be positive integers, slug non-empty", () => { + for (const key of ["appId", "installationId", "botUserId"]) { + for (const v of [0, -1, 1.5, "1", null]) { + expect(failure({ githubApp: { ...APP, [key]: v } })).toBe( + `integrations.githubApp.${key}: must be a positive integer`, + ); + } + } + expect(failure({ githubApp: { ...APP, slug: " " } })).toBe( + "integrations.githubApp.slug: must be a non-empty string", + ); + }); + + it("a disabled block may hold any subset, and present keys are still checked", () => { + expect(parseIntegrations({ githubApp: { enabled: false, appId: 5 } }, fail, COMPOSE)).toEqual({ + githubApp: { enabled: false, appId: 5, tokenScope: "repos", ciResults: true }, + }); + expect(failure({ githubApp: { enabled: false, appId: 0 } })).toBe( + "integrations.githubApp.appId: must be a positive integer", + ); + }); + + it("tokenFile is required with external tokens on Compose, when enabled", () => { + expect(failure({ githubApp: { ...APP, tokens: "external" } }, COMPOSE)).toBe( + 'integrations.githubApp.tokenFile: required with tokens "external" on Compose', + ); + expect( + parseIntegrations( + { githubApp: { ...APP, tokens: "external", tokenFile: "/run/gh/token" } }, + fail, + COMPOSE, + ), + ).toEqual({ + githubApp: { + ...APP, + tokens: "external", + tokenFile: "/run/gh/token", + tokenScope: "repos", + ciResults: true, + }, + }); + }); + + it("org is an optional non-empty string, on both platforms", () => { + for (const platform of [COMPOSE, K8S]) { + expect( + parseIntegrations({ githubApp: { ...APP, org: "example-org" } }, fail, platform), + ).toEqual({ + githubApp: { ...APP, tokenScope: "repos", ciResults: true, org: "example-org" }, + }); + } + expect(failure({ githubApp: { ...APP, org: "" } })).toBe( + "integrations.githubApp.org: must be a non-empty string", + ); + expect(failure({ githubApp: { ...APP, org: 5 } })).toBe( + "integrations.githubApp.org: must be a non-empty string", + ); + }); + + it("pending.publicUrl: the URL the new App was registered with, validated", () => { + expect( + parseIntegrations( + { + githubApp: { + ...APP, + pending: { appId: 124, slug: "n", publicUrl: "https://new.example.com/" }, + }, + }, + fail, + COMPOSE, + ).githubApp?.pending, + ).toEqual({ appId: 124, slug: "n", publicUrl: "https://new.example.com" }); + expect( + failure({ + githubApp: { + ...APP, + pending: { appId: 1, slug: "n", publicUrl: "http://new.example.com" }, + }, + }), + ).toBe( + "integrations.githubApp.pending.publicUrl: Webhooks need a public HTTPS URL: http://new.example.com uses http:, not https:", + ); + }); + + it("pending: a new App waiting for its install, on both platforms", () => { + for (const platform of [COMPOSE, K8S]) { + expect( + parseIntegrations( + { + githubApp: { ...APP, pending: { appId: 124, slug: "new-devbox", org: "example-org" } }, + }, + fail, + platform, + ), + ).toEqual({ + githubApp: { + ...APP, + tokenScope: "repos", + ciResults: true, + pending: { appId: 124, slug: "new-devbox", org: "example-org" }, + }, + }); + } + expect( + parseIntegrations( + { githubApp: { ...APP, pending: { appId: 124, slug: "n" } } }, + fail, + COMPOSE, + ).githubApp?.pending, + ).toEqual({ appId: 124, slug: "n" }); + expect(failure({ githubApp: { ...APP, pending: "x" } })).toBe( + "integrations.githubApp.pending: must be an object", + ); + expect(failure({ githubApp: { ...APP, pending: { slug: "n" } } })).toBe( + "integrations.githubApp.pending.appId: must be a positive integer", + ); + expect(failure({ githubApp: { ...APP, pending: { appId: 1, slug: "" } } })).toBe( + "integrations.githubApp.pending.slug: must be a non-empty string", + ); + expect(failure({ githubApp: { ...APP, pending: { appId: 1, slug: "n", org: "" } } })).toBe( + "integrations.githubApp.pending.org: must be a non-empty string", + ); + expect(failure({ githubApp: { ...APP, pending: { appId: 1, slug: "n", key: "k" } } })).toBe( + 'integrations.githubApp.pending: unknown key "key" (allowed: appId, slug, org, publicUrl)', + ); + }); + + it("a disabled external block without tokenFile parses", () => { + expect( + parseIntegrations({ githubApp: { enabled: false, tokens: "external" } }, fail, COMPOSE), + ).toEqual({ + githubApp: { enabled: false, tokens: "external", tokenScope: "repos", ciResults: true }, + }); + }); + + it("tokenFile must be absolute", () => { + expect( + failure({ githubApp: { ...APP, tokens: "external", tokenFile: "rel/token" } }, COMPOSE), + ).toBe("integrations.githubApp.tokenFile: must be an absolute path"); + }); + + it("tokenFile must sit in a directory of its own: not / or the home directory", () => { + const own = + "keep the token file in a directory of its own (its whole directory is mounted into the containers)"; + for (const tokenFile of ["/token", `${homedir()}/token`, `${homedir()}/sub/../token`]) { + expect(failure({ githubApp: { ...APP, tokens: "external", tokenFile } }, COMPOSE)).toBe( + `integrations.githubApp.tokenFile ${tokenFile}: ${own}`, + ); + } + expect( + parseIntegrations( + { githubApp: { ...APP, tokens: "external", tokenFile: `${homedir()}/gh/token` } }, + fail, + COMPOSE, + ).githubApp?.tokenFile, + ).toBe(`${homedir()}/gh/token`); + }); + + it('tokenFile can\'t contain ":" or ","', () => { + for (const tokenFile of ["/run/a:b/token", "/run/gh/a,b"]) { + expect(failure({ githubApp: { ...APP, tokens: "external", tokenFile } }, COMPOSE)).toBe( + `integrations.githubApp.tokenFile ${tokenFile}: the path can't contain ":" or ","`, + ); + } + }); + + it("tokenFile is refused on Kubernetes", () => { + expect(failure({ githubApp: { ...APP, tokenFile: "/run/gh/token" } })).toBe( + "integrations.githubApp.tokenFile: Compose only; on Kubernetes use secrets.github.existingSecret", + ); + }); +}); + +describe("enabledGithubApp", () => { + it("is the block only when enabled", () => { + const on = { ...APP, tokenScope: "repos", ciResults: true } as const; + expect(enabledGithubApp({ githubApp: on })).toEqual(on); + expect(enabledGithubApp({ githubApp: { ...on, enabled: false } })).toBeUndefined(); + expect(enabledGithubApp({})).toBeUndefined(); + }); +}); diff --git a/control-plane/scripts/lib/integrations.ts b/control-plane/scripts/lib/integrations.ts new file mode 100644 index 0000000..ff12b44 --- /dev/null +++ b/control-plane/scripts/lib/integrations.ts @@ -0,0 +1,269 @@ +// The `integrations` block of a deployment's config: each +// integration's `enabled` switch and its non-secret settings. Linear on both +// platforms; the GitHub App on both; Slack on Kubernetes only. Secrets +// never live here, and a secret never turns an integration on: only +// `enabled: true` does. Unknown keys are refused so a typo (or a pasted +// secret) fails loudly. + +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { parsePublicUrl } from "./publicUrl"; + +export type LinearIntegration = { enabled: boolean; clientId?: string }; +export type SlackIntegration = { enabled: boolean; channelId?: string }; +// The GitHub App and its webhooks, one switch. tokenScope and ciResults +// are always filled with their defaults; the other keys are required only when +// enabled is true. +export type GithubAppIntegration = { + enabled: boolean; + appId?: number; + slug?: string; + installationId?: number; + botUserId?: number; + tokens?: "devbox" | "external"; + tokenScope: "repos" | "installation"; + ciResults: boolean; + // Compose, tokens "external": the file the user's own process keeps fresh. + tokenFile?: string; + // The owning organization's login; absent for an App owned by a user. + org?: string; + // A new App registered to replace this one, waiting for its install; + // only github:setup reads it. + pending?: PendingGithubApp; +}; +// publicUrl: the public URL the new App was set up with, when it differs +// from the saved one; it becomes the deployment's once the App is installed. +export type PendingGithubApp = { appId: number; slug: string; org?: string; publicUrl?: string }; +export type Integrations = { + linear?: LinearIntegration; + slack?: SlackIntegration; + githubApp?: GithubAppIntegration; +}; + +export type EnabledLinear = LinearIntegration & { enabled: true; clientId: string }; + +// The Linear block when Linear is on; parseIntegrations guarantees its clientId. +export function enabledLinear(i: Integrations): EnabledLinear | undefined { + const l = i.linear; + return l?.enabled && l.clientId ? (l as EnabledLinear) : undefined; +} + +export type EnabledGithubApp = GithubAppIntegration & { + enabled: true; + appId: number; + slug: string; + installationId: number; + botUserId: number; + tokens: "devbox" | "external"; +}; + +// The GitHub App block when it is on; parseIntegrations guarantees its keys. +export function enabledGithubApp(i: Integrations): EnabledGithubApp | undefined { + const g = i.githubApp; + return g?.enabled ? (g as EnabledGithubApp) : undefined; +} + +type Fail = (msg: string) => never; + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +function onlyKeys( + obj: Record, + allowed: readonly string[], + field: string, + fail: Fail, +): void { + const extra = Object.keys(obj).find((k) => !allowed.includes(k)); + if (extra !== undefined) { + fail(`${field}: unknown key ${JSON.stringify(extra)} (allowed: ${allowed.join(", ")})`); + } +} + +// The block as an object with only `keys` and `enabled`, a boolean. +function block( + raw: unknown, + name: string, + keys: readonly string[], + fail: Fail, +): { obj: Record; enabled: boolean } { + const field = `integrations.${name}`; + if (!isObject(raw)) return fail(`${field}: must be an object`); + onlyKeys(raw, ["enabled", ...keys], field, fail); + if (typeof raw.enabled !== "boolean") return fail(`${field}.enabled: required, true or false`); + return { obj: raw, enabled: raw.enabled }; +} + +// A setting: validated when present (even with the integration off), and +// required when it is on. +function setting( + b: { obj: Record; enabled: boolean }, + field: string, + key: string, + parse: (v: unknown, field: string) => T, + fail: Fail, +): T | undefined { + const f = `${field}.${key}`; + if (b.obj[key] === undefined) { + return b.enabled ? fail(`${f}: required when enabled is true`) : undefined; + } + return parse(b.obj[key], f); +} + +const nonEmpty = + (fail: Fail) => + (v: unknown, field: string): string => + typeof v === "string" && v.trim() ? v.trim() : fail(`${field}: must be a non-empty string`); + +const positiveInt = + (fail: Fail) => + (v: unknown, field: string): number => + typeof v === "number" && Number.isInteger(v) && v > 0 + ? v + : fail(`${field}: must be a positive integer`); + +function parseLinear(raw: unknown, fail: Fail): LinearIntegration { + const f = "integrations.linear"; + // The public URL is the deployment's, not Linear's. + if (isObject(raw) && "publicUrl" in raw) { + return fail( + `${f}: unknown key "publicUrl" (allowed: enabled, clientId); moved to the top-level publicUrl`, + ); + } + const b = block(raw, "linear", ["clientId"], fail); + const out: LinearIntegration = { enabled: b.enabled }; + const clientId = setting(b, f, "clientId", nonEmpty(fail), fail); + if (clientId !== undefined) out.clientId = clientId; + return out; +} + +function parseSlack(raw: unknown, fail: Fail): SlackIntegration { + const b = block(raw, "slack", ["channelId"], fail); + const channelId = setting(b, "integrations.slack", "channelId", nonEmpty(fail), fail); + return { enabled: b.enabled, ...(channelId !== undefined ? { channelId } : {}) }; +} + +function parseGithubApp( + raw: unknown, + fail: Fail, + platform: "compose" | "k8s", +): GithubAppIntegration { + const f = "integrations.githubApp"; + const b = block( + raw, + "githubApp", + [ + "appId", + "slug", + "installationId", + "botUserId", + "tokens", + "tokenScope", + "ciResults", + "tokenFile", + "org", + "pending", + ], + fail, + ); + const appId = setting(b, f, "appId", positiveInt(fail), fail); + const slug = setting(b, f, "slug", nonEmpty(fail), fail); + const installationId = setting(b, f, "installationId", positiveInt(fail), fail); + const botUserId = setting(b, f, "botUserId", positiveInt(fail), fail); + const tokens = setting( + b, + f, + "tokens", + (v, field) => + v === "devbox" || v === "external" ? v : fail(`${field}: must be "devbox" or "external"`), + fail, + ); + const tokenScope = b.obj.tokenScope === undefined ? "repos" : b.obj.tokenScope; + if (tokenScope !== "repos" && tokenScope !== "installation") { + return fail(`${f}.tokenScope: must be "repos" or "installation"`); + } + const ciResults = b.obj.ciResults === undefined ? true : b.obj.ciResults; + if (typeof ciResults !== "boolean") return fail(`${f}.ciResults: must be true or false`); + const org = b.obj.org === undefined ? undefined : nonEmpty(fail)(b.obj.org, `${f}.org`); + const pending = b.obj.pending === undefined ? undefined : parsePending(b.obj.pending, fail); + let tokenFile: string | undefined; + if (b.obj.tokenFile !== undefined) { + if (platform === "k8s") { + return fail(`${f}.tokenFile: Compose only; on Kubernetes use secrets.github.existingSecret`); + } + tokenFile = nonEmpty(fail)(b.obj.tokenFile, `${f}.tokenFile`); + if (!tokenFile.startsWith("/")) return fail(`${f}.tokenFile: must be an absolute path`); + const bad = (why: string) => fail(`${f}.tokenFile ${tokenFile}: ${why}`); + // Compose's short volume syntax splits on these. + if (/[:,]/.test(tokenFile)) return bad(`the path can't contain ":" or ","`); + const dir = dirname(resolve(tokenFile)); + if (dir === "/" || dir === resolve(homedir())) { + return bad( + "keep the token file in a directory of its own (its whole directory is mounted into the containers)", + ); + } + } else if (b.enabled && tokens === "external" && platform === "compose") { + return fail(`${f}.tokenFile: required with tokens "external" on Compose`); + } + return { + enabled: b.enabled, + ...(appId !== undefined ? { appId } : {}), + ...(slug !== undefined ? { slug } : {}), + ...(installationId !== undefined ? { installationId } : {}), + ...(botUserId !== undefined ? { botUserId } : {}), + ...(tokens !== undefined ? { tokens } : {}), + tokenScope, + ciResults, + ...(tokenFile !== undefined ? { tokenFile } : {}), + ...(org !== undefined ? { org } : {}), + ...(pending !== undefined ? { pending } : {}), + }; +} + +function parsePending(raw: unknown, fail: Fail): PendingGithubApp { + const f = "integrations.githubApp.pending"; + if (!isObject(raw)) return fail(`${f}: must be an object`); + onlyKeys(raw, ["appId", "slug", "org", "publicUrl"], f, fail); + const appId = positiveInt(fail)(raw.appId, `${f}.appId`); + const slug = nonEmpty(fail)(raw.slug, `${f}.slug`); + const org = raw.org === undefined ? undefined : nonEmpty(fail)(raw.org, `${f}.org`); + const publicUrl = + raw.publicUrl === undefined ? undefined : parsePublicUrl(raw.publicUrl, `${f}.publicUrl`, fail); + return { + appId, + slug, + ...(org !== undefined ? { org } : {}), + ...(publicUrl !== undefined ? { publicUrl } : {}), + }; +} + +const K8S_ONLY = ["slack"] as const; + +export function parseIntegrations( + raw: unknown, + fail: Fail, + opts: { platform: "compose" | "k8s" }, +): Integrations { + if (raw === undefined) return {}; + if (!isObject(raw)) return fail("integrations: must be an object"); + if ("githubWebhooks" in raw) { + return fail( + "integrations.githubWebhooks: replaced by integrations.githubApp; run bun run github:setup", + ); + } + if (opts.platform === "compose") { + const k8sOnly = K8S_ONLY.find((name) => name in raw); + if (k8sOnly) + return fail(`integrations.${k8sOnly}: supported on Kubernetes only (the target file)`); + onlyKeys(raw, ["linear", "githubApp"], "integrations", fail); + } else { + onlyKeys(raw, ["linear", ...K8S_ONLY, "githubApp"], "integrations", fail); + } + const out: Integrations = {}; + if (raw.linear !== undefined) out.linear = parseLinear(raw.linear, fail); + if (raw.slack !== undefined) out.slack = parseSlack(raw.slack, fail); + if (raw.githubApp !== undefined) { + out.githubApp = parseGithubApp(raw.githubApp, fail, opts.platform); + } + return out; +} diff --git a/control-plane/scripts/lib/keychain.test.ts b/control-plane/scripts/lib/keychain.test.ts new file mode 100644 index 0000000..3b73e04 --- /dev/null +++ b/control-plane/scripts/lib/keychain.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import { + deploymentScope, + entryName, + guarded, + type Keychain, + keychainUsable, + MemoryKeychain, +} from "./keychain"; + +const platformError = () => + Object.assign(new Error("libsecret not available"), { code: "ERR_SECRETS_PLATFORM_ERROR" }); + +// An inner keychain that counts calls and throws `err` from every method. +function throwing(err: Error): { k: Keychain; calls: string[] } { + const calls: string[] = []; + return { + calls, + k: { + get: async (name) => { + calls.push(`get ${name}`); + throw err; + }, + set: async (name) => { + calls.push(`set ${name}`); + throw err; + }, + delete: async (name) => { + calls.push(`delete ${name}`); + throw err; + }, + }, + }; +} + +describe("guarded", () => { + it("guarded disables itself on a platform error, silently", async () => { + // guarded takes no logger; it must not write anywhere else either. + const spies = [ + spyOn(process.stdout, "write"), + spyOn(process.stderr, "write"), + spyOn(console, "log"), + spyOn(console, "warn"), + spyOn(console, "error"), + ]; + const inner = throwing(platformError()); + const k = guarded(inner.k); + expect(await k.get("compose/GITHUB_TOKEN")).toBeUndefined(); + expect(await k.get("compose/LLM_API_KEY")).toBeUndefined(); + expect(await k.set("compose/LLM_API_KEY", "sk-new")).toBeUndefined(); + expect(await k.delete("compose/LLM_API_KEY")).toBe(false); + expect(inner.calls).toEqual(["get compose/GITHUB_TOKEN"]); + const written = spies.map((s) => s.mock.calls.length); + for (const s of spies) s.mockRestore(); + expect(written).toEqual([0, 0, 0, 0, 0]); + expect(k.disabled).toBe(true); + }); + + it("guarded rethrows other errors", async () => { + const err = Object.assign(new Error("user denied access"), { code: "ERR_SECRETS_ACCESS" }); + const inner = throwing(err); + const k = guarded(inner.k); + await expect(k.get("compose/GITHUB_TOKEN")).rejects.toBe(err); + await expect(k.set("compose/GITHUB_TOKEN", "v")).rejects.toBe(err); + await expect(k.delete("compose/GITHUB_TOKEN")).rejects.toBe(err); + expect(inner.calls).toEqual([ + "get compose/GITHUB_TOKEN", + "set compose/GITHUB_TOKEN", + "delete compose/GITHUB_TOKEN", + ]); + expect(k.disabled).toBe(false); + }); + + it("passes calls through while the keychain works", async () => { + const inner = new MemoryKeychain({ "compose/GITHUB_TOKEN": "ghp_saved" }); + const k = guarded(inner); + expect(await k.get("compose/GITHUB_TOKEN")).toBe("ghp_saved"); + await k.set("compose/LLM_API_KEY", "sk-new"); + expect(await k.delete("compose/GITHUB_TOKEN")).toBe(true); + expect(await k.delete("compose/GITHUB_TOKEN")).toBe(false); + expect(inner.entries()).toEqual({ "compose/LLM_API_KEY": "sk-new" }); + }); +}); + +describe("keychainUsable", () => { + it("keychainUsable is false after a platform error", async () => { + const inner = throwing(platformError()); + expect(await keychainUsable(guarded(inner.k), "compose/LINEAR_CLIENT_SECRET")).toBe(false); + expect(inner.calls).toEqual(["get compose/LINEAR_CLIENT_SECRET"]); + }); + + it("is true for a working keychain and false for none", async () => { + const k = guarded(new MemoryKeychain({ "compose/LINEAR_CLIENT_SECRET": "saved" })); + expect(await keychainUsable(k, "compose/LINEAR_CLIENT_SECRET")).toBe(true); + expect(await keychainUsable(undefined, "compose/LINEAR_CLIENT_SECRET")).toBe(false); + }); +}); + +describe("entry names", () => { + it("scopes Compose and each k8s context and namespace apart", () => { + expect(deploymentScope({ kind: "compose" })).toBe("compose"); + expect(deploymentScope({ kind: "k8s", context: "kind-x", namespace: "devbox" })).toBe( + "k8s:kind-x/devbox", + ); + expect(entryName({ kind: "compose" }, "GITHUB_TOKEN")).toBe("compose/GITHUB_TOKEN"); + expect( + entryName({ kind: "k8s", context: "prod", namespace: "devbox" }, "LINEAR_CLIENT_SECRET"), + ).toBe("k8s:prod/devbox/LINEAR_CLIENT_SECRET"); + }); +}); diff --git a/control-plane/scripts/lib/keychain.ts b/control-plane/scripts/lib/keychain.ts new file mode 100644 index 0000000..d207b44 --- /dev/null +++ b/control-plane/scripts/lib/keychain.ts @@ -0,0 +1,95 @@ +// The OS keychain for secrets uses macOS Keychain, the Secret Service on Linux +// (libsecret), or Windows Credential Manager through Bun.secrets. Every +// entry lives under the service "devbox"; its name carries the deployment, so +// a local stack and each cluster keep their own credentials. + +export interface Keychain { + get(name: string): Promise; + set(name: string, value: string): Promise; + delete(name: string): Promise; + // True once the keychain switched itself off for this run (the platform has + // none); it then acts as an empty one. + readonly disabled?: boolean; +} + +export const KEYCHAIN_SERVICE = "devbox"; + +export type Deployment = { kind: "compose" } | { kind: "k8s"; context: string; namespace: string }; + +export const deploymentScope = (d: Deployment): string => + d.kind === "compose" ? "compose" : `k8s:${d.context}/${d.namespace}`; + +export const entryName = (d: Deployment, name: string): string => `${deploymentScope(d)}/${name}`; + +export function bunKeychain(): Keychain { + const service = KEYCHAIN_SERVICE; + return { + get: async (name) => (await Bun.secrets.get({ service, name })) ?? undefined, + set: (name, value) => Bun.secrets.set({ service, name, value }), + delete: (name) => Bun.secrets.delete({ service, name }), + }; +} + +// A keychain that, once the platform has none (headless Linux without +// libsecret), acts as an empty one for the rest of the run, without a word. +export type GuardedKeychain = Keychain & { readonly disabled: boolean }; + +export function guarded(k: Keychain): GuardedKeychain { + let disabled = false; + const call = async (f: () => Promise, off: T): Promise => { + if (disabled) return off; + try { + return await f(); + } catch (err) { + if ((err as { code?: unknown } | null)?.code !== "ERR_SECRETS_PLATFORM_ERROR") throw err; + disabled = true; + return off; + } + }; + return { + get disabled() { + return disabled; + }, + get: (name) => call(() => k.get(name), undefined), + set: (name, value) => call(() => k.set(name, value), undefined), + delete: (name) => call(() => k.delete(name), false), + }; +} + +// The keychain `up` and `k8s:install` use: none with --no-keychain. +export const keychainFor = (noKeychain: boolean | undefined): GuardedKeychain | undefined => + noKeychain ? undefined : guarded(bunKeychain()); + +// Whether a keychain can be offered: a guarded one learns that its platform +// has none only from a first call, so one read of `probeName` (the value is +// dropped) settles it. +export async function keychainUsable(k: Keychain | undefined, probeName: string): Promise { + if (!k) return false; + await k.get(probeName); + return !k.disabled; +} + +// For tests. +export class MemoryKeychain implements Keychain { + private readonly map: Map; + + constructor(entries: Record = {}) { + this.map = new Map(Object.entries(entries)); + } + + async get(name: string): Promise { + return this.map.get(name); + } + + async set(name: string, value: string): Promise { + this.map.set(name, value); + } + + async delete(name: string): Promise { + return this.map.delete(name); + } + + entries(): Record { + return Object.fromEntries(this.map); + } +} diff --git a/control-plane/scripts/lib/knownError.test.ts b/control-plane/scripts/lib/knownError.test.ts new file mode 100644 index 0000000..ff361bf --- /dev/null +++ b/control-plane/scripts/lib/knownError.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "bun:test"; +import { LinearCheckError } from "../linear/check"; +import { ConfigError } from "../up/config"; +import { GithubAppSetupError } from "./githubApp"; +import { PrivateKeyError } from "./githubAppKey"; +import { errorMessage, isKnownError } from "./knownError"; +import { PublicUrlError } from "./publicUrl"; +import { SecretError } from "./secrets"; + +// Bun.secrets throws plain Errors with a `code`. +const bunSecretsError = (message: string, code: string) => + Object.assign(new Error(message), { code }); + +describe("known errors", () => { + it("a keychain error is known and points at --no-keychain", () => { + for (const code of ["ERR_SECRETS_ACCESS_DENIED", "ERR_SECRETS_INTERNAL"]) { + const err = bunSecretsError("Cannot autolaunch D-Bus without X11 $DISPLAY", code); + expect(isKnownError(err)).toBe(true); + expect(errorMessage(err)).toBe( + "Cannot autolaunch D-Bus without X11 $DISPLAY (use --no-keychain to skip the system keychain)", + ); + } + }); + + it("other errors keep their message; only listed classes are known", () => { + const other = bunSecretsError("boom", "ERR_SOMETHING_ELSE"); + expect(isKnownError(other)).toBe(false); + expect(errorMessage(other)).toBe("boom"); + expect(isKnownError(new Error("plain"))).toBe(false); + expect(errorMessage(new SecretError("no LLM API key: set LLM_API_KEY"))).toBe( + "no LLM API key: set LLM_API_KEY", + ); + expect(isKnownError(new SecretError("x"))).toBe(true); + expect(errorMessage("a string")).toBe("a string"); + }); + + it("the Linear check, public URL, config and GitHub App errors are known", () => { + expect(isKnownError(new LinearCheckError("x"))).toBe(true); + expect(isKnownError(new PublicUrlError("x"))).toBe(true); + expect(isKnownError(new ConfigError("x"))).toBe(true); + expect(isKnownError(new PrivateKeyError("x"))).toBe(true); + expect(isKnownError(new GithubAppSetupError("x"))).toBe(true); + }); +}); diff --git a/control-plane/scripts/lib/knownError.ts b/control-plane/scripts/lib/knownError.ts new file mode 100644 index 0000000..dafd751 --- /dev/null +++ b/control-plane/scripts/lib/knownError.ts @@ -0,0 +1,48 @@ +// Errors that explain themselves: printed on one line, no stack. +import { TargetError } from "../k8s/target"; +import { CliConfigError } from "../up/cliLogin"; +import { ConfigError } from "../up/config"; +import { PreflightError } from "../up/preflight"; +import { PromptInterrupted } from "../up/prompt"; +import { DestructiveRefused } from "./destructive"; +import { GithubAppSetupError } from "./githubApp"; +import { PrivateKeyError } from "./githubAppKey"; +import { PublicUrlError } from "./publicUrl"; +import { SecretError } from "./secrets"; + +const SHARED = [ + ConfigError, + SecretError, + PreflightError, + PromptInterrupted, + CliConfigError, + TargetError, + PublicUrlError, + DestructiveRefused, + PrivateKeyError, + GithubAppSetupError, +]; + +const codeOf = (err: unknown): string => String((err as { code?: unknown } | null)?.code ?? ""); + +// An error from Bun.secrets other than "no keychain on this platform", which +// the guarded keychain swallows: a locked keychain, a refused access, a Secret +// Service that is installed but not running. +const isKeychainError = (err: unknown): boolean => + err instanceof Error && codeOf(err).startsWith("ERR_SECRETS_"); + +export function isKnownError(err: unknown, ...more: Array Error>): boolean { + return ( + [...SHARED, ...more].some((cls) => err instanceof cls) || + (err instanceof TypeError && /^ERR_PARSE_ARGS_/.test(codeOf(err))) || + isKeychainError(err) + ); +} + +// The one line printed for an error. +export function errorMessage(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + return isKeychainError(err) + ? `${message} (use --no-keychain to skip the system keychain)` + : message; +} diff --git a/control-plane/scripts/lib/openUrl.ts b/control-plane/scripts/lib/openUrl.ts new file mode 100644 index 0000000..e72ccd4 --- /dev/null +++ b/control-plane/scripts/lib/openUrl.ts @@ -0,0 +1,12 @@ +// Opens a page in the user's browser, best effort: the scripts always print +// the link as well, for a machine with no browser. +export function openUrl(url: string): void { + const cmd = process.platform === "darwin" ? "open" : "xdg-open"; + try { + Bun.spawn([cmd, url], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).exited.catch( + () => {}, + ); + } catch { + // no opener on this machine + } +} diff --git a/control-plane/scripts/lib/probe.test.ts b/control-plane/scripts/lib/probe.test.ts new file mode 100644 index 0000000..67428bc --- /dev/null +++ b/control-plane/scripts/lib/probe.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "bun:test"; +import type { Fetch } from "../up/preflight"; +import { type ProbeResult, probeWebhook } from "./probe"; + +const PUBLIC = "https://hooks.example.com"; +const HOOK = "https://hooks.example.com/webhook/linear"; +const GITHUB_HOOK = "https://hooks.example.com/webhook/github"; +const NO_ANSWER = + "the public URL does not reach devbox. Is the tunnel down, or the Ingress or DNS not ready yet?"; + +type Req = { url: string; init: RequestInit | undefined }; + +function fakeFetch(answer: (() => Response) | Error): { fetch: Fetch; requests: Req[] } { + const requests: Req[] = []; + const fetch: Fetch = async (url, init) => { + requests.push({ url, init }); + if (answer instanceof Error) throw answer; + return answer(); + }; + return { fetch, requests }; +} + +const probe = ( + answer: (() => Response) | Error, + opts: { fromApiIngress: boolean } = { fromApiIngress: false }, +): Promise => + probeWebhook(PUBLIC, "/webhook/linear", fakeFetch(answer).fetch, { + ...opts, + integration: "Linear", + }); + +describe("probeWebhook", () => { + it("401 invalid signature is ok", async () => { + expect( + await probe(() => Response.json({ error: "invalid signature" }, { status: 401 })), + ).toEqual({ ok: true }); + }); + + it("401 with another body is not ok", async () => { + expect(await probe(() => Response.json({ error: "denied" }, { status: 401 }))).toEqual({ + ok: false, + message: `the webhook URL ${HOOK} got 401: {"error":"denied"}`, + }); + }); + + it("401 with a non-JSON body is not ok", async () => { + expect(await probe(() => new Response("Unauthorized", { status: 401 }))).toEqual({ + ok: false, + message: `the webhook URL ${HOOK} got 401: Unauthorized`, + }); + }); + + it("404", async () => { + expect(await probe(() => new Response("404 Not Found", { status: 404 }))).toEqual({ + ok: false, + message: `the webhook URL ${HOOK} got 404: something answered, but not devbox with Linear on. Check that the tunnel or proxy forwards to the control plane (port 3000 on Compose), that integrations.linear.enabled is true and applied, or restart the control plane`, + }); + }); + + it("GitHub: probes /webhook/github and names integrations.githubApp.enabled on 404", async () => { + const ok = fakeFetch(() => Response.json({ error: "invalid signature" }, { status: 401 })); + const opts = { fromApiIngress: false, integration: "GitHub" } as const; + expect(await probeWebhook(PUBLIC, "/webhook/github", ok.fetch, opts)).toEqual({ ok: true }); + expect(ok.requests.map((r) => r.url)).toEqual([GITHUB_HOOK]); + const missing = fakeFetch(() => new Response("404 Not Found", { status: 404 })); + expect(await probeWebhook(PUBLIC, "/webhook/github", missing.fetch, opts)).toEqual({ + ok: false, + message: `the webhook URL ${GITHUB_HOOK} got 404: something answered, but not devbox with GitHub on. Check that the tunnel or proxy forwards to the control plane (port 3000 on Compose), that integrations.githubApp.enabled is true and applied, or restart the control plane`, + }); + }); + + it("no answer", async () => { + expect(await probe(new Error("getaddrinfo ENOTFOUND hooks.example.com"))).toEqual({ + ok: false, + message: `the webhook URL ${HOOK} got no answer (getaddrinfo ENOTFOUND hooks.example.com): ${NO_ANSWER}`, + }); + }); + + it("no answer from an api-Ingress URL adds the VPN hint", async () => { + expect(await probe(new Error("connect ETIMEDOUT"), { fromApiIngress: true })).toEqual({ + ok: false, + message: `the webhook URL ${HOOK} got no answer (connect ETIMEDOUT): ${NO_ANSWER} The URL comes from ingress.api, which may be reachable only over a VPN: enable a public ingress.webhooks in the target file`, + }); + }); + + it("timeout", async () => { + expect(await probe(new DOMException("The operation timed out.", "TimeoutError"))).toEqual({ + ok: false, + message: `the webhook URL ${HOOK} got no answer (no answer within 10s): ${NO_ANSWER}`, + }); + }); + + it("500 with a 600-character body is truncated", async () => { + expect(await probe(() => new Response("x".repeat(600), { status: 500 }))).toEqual({ + ok: false, + message: `the webhook URL ${HOOK} got 500: ${"x".repeat(300)}…`, + }); + }); + + it("sends no signature", async () => { + const { fetch, requests } = fakeFetch(() => + Response.json({ error: "invalid signature" }, { status: 401 }), + ); + await probeWebhook(PUBLIC, "/webhook/linear", fetch, { + fromApiIngress: false, + integration: "Linear", + }); + expect(requests).toHaveLength(1); + const [req] = requests; + expect(req?.url).toBe(HOOK); + expect(req?.init?.method).toBe("POST"); + expect(req?.init?.body).toBe("{}"); + expect(Object.fromEntries(new Headers(req?.init?.headers).entries())).toEqual({ + "content-type": "application/json", + }); + expect(req?.init?.signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/control-plane/scripts/lib/probe.ts b/control-plane/scripts/lib/probe.ts new file mode 100644 index 0000000..5232119 --- /dev/null +++ b/control-plane/scripts/lib/probe.ts @@ -0,0 +1,69 @@ +// The unsigned probe of the webhook URL (Linear and GitHub): an empty POST without a +// signature header. devbox with the integration on answers it 401 "invalid signature" before parsing, and +// records nothing. No secret is used. +import type { Fetch } from "../up/preflight"; + +export type ProbeResult = { ok: true } | { ok: false; message: string }; + +const ENABLED = { + Linear: "integrations.linear.enabled", + GitHub: "integrations.githubApp.enabled", +} as const; + +const TIMEOUT_MS = 10_000; +const BODY_LIMIT = 300; + +const truncate = (text: string): string => + text.length > BODY_LIMIT ? `${text.slice(0, BODY_LIMIT)}…` : text; + +export async function probeWebhook( + publicUrl: string, + path: "/webhook/linear" | "/webhook/github", + fetch: Fetch, + opts: { fromApiIngress: boolean; integration: "Linear" | "GitHub" }, +): Promise { + const url = `${publicUrl}${path}`; + const failed = (what: string): ProbeResult => ({ + ok: false, + message: `the webhook URL ${url} got ${what}`, + }); + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (err) { + const cause = + (err as { name?: unknown } | null)?.name === "TimeoutError" + ? `no answer within ${TIMEOUT_MS / 1000}s` + : err instanceof Error + ? err.message + : String(err); + const vpn = opts.fromApiIngress + ? " The URL comes from ingress.api, which may be reachable only over a VPN: enable a public ingress.webhooks in the target file" + : ""; + return failed( + `no answer (${cause}): the public URL does not reach devbox. Is the tunnel down, or the Ingress or DNS not ready yet?${vpn}`, + ); + } + if (res.status === 404) { + await res.body?.cancel(); + return failed( + `404: something answered, but not devbox with ${opts.integration} on. Check that the tunnel or proxy forwards to the control plane (port 3000 on Compose), that ${ENABLED[opts.integration]} is true and applied, or restart the control plane`, + ); + } + const text = (await res.text().catch(() => "")).trim(); + if (res.status === 401 && isInvalidSignature(text)) return { ok: true }; + return failed(`${res.status}: ${truncate(text)}`); +} + +function isInvalidSignature(text: string): boolean { + try { + return (JSON.parse(text) as { error?: unknown } | null)?.error === "invalid signature"; + } catch { + return false; + } +} diff --git a/control-plane/scripts/lib/publicUrl.test.ts b/control-plane/scripts/lib/publicUrl.test.ts new file mode 100644 index 0000000..c0c9dba --- /dev/null +++ b/control-plane/scripts/lib/publicUrl.test.ts @@ -0,0 +1,418 @@ +import { describe, expect, it } from "bun:test"; +import type { Deployment } from "./keychain"; +import { + type Configured, + ingressPublicUrl, + ingressSource, + PublicUrlError, + publicUrlChangeLines, + resolvePublicUrl, + validatePublicUrl, +} from "./publicUrl"; + +const P = "Webhooks need a public HTTPS URL: "; + +describe("validatePublicUrl", () => { + it("accepts a public DNS name", () => { + expect(validatePublicUrl("https://devbox.example.com")).toBe("https://devbox.example.com"); + }); + + it("trims a trailing slash", () => { + expect(validatePublicUrl("https://devbox.example.com/")).toBe("https://devbox.example.com"); + expect(validatePublicUrl("https://devbox.example.com//")).toBe("https://devbox.example.com"); + }); + + it("keeps a path prefix", () => { + expect(validatePublicUrl("https://proxy.example.com/devbox/")).toBe( + "https://proxy.example.com/devbox", + ); + }); + + it("keeps a port", () => { + expect(validatePublicUrl("https://devbox.example.com:8443")).toBe( + "https://devbox.example.com:8443", + ); + }); + + const refused: Array<[string, string, string]> = [ + [ + "a non-URL", + "devbox.example.com", + `${P}the value is not an absolute URL (start it with https://)`, + ], + [ + "a non-URL with a query, without echoing it", + "h.example.com/?token=s3cret", + `${P}the value is not an absolute URL (start it with https://)`, + ], + [ + "http with a query, without echoing it", + "http://h.example.com/?token=s3cret", + `${P}http://h.example.com uses http:, not https:`, + ], + [ + "ws with a fragment, without echoing it", + "ws://h.example.com/hooks#access_token=s3cret", + `${P}ws://h.example.com/hooks uses ws:, not https:`, + ], + [ + "a non-https URL", + "http://devbox.example.com", + `${P}http://devbox.example.com uses http:, not https:`, + ], + ["localhost", "https://localhost:3000", `${P}localhost is not reachable from the internet`], + [ + "a *.localhost name", + "https://devbox.localhost", + `${P}devbox.localhost is not reachable from the internet`, + ], + ["an IPv4 loopback", "https://127.0.0.1", `${P}127.0.0.1 is a loopback address`], + ["an IPv4 loopback in 127/8", "https://127.8.9.10", `${P}127.8.9.10 is a loopback address`], + ["an IPv4 link-local", "https://169.254.1.2", `${P}169.254.1.2 is a link-local address`], + ["an RFC 1918 10/8", "https://10.1.2.3", `${P}10.1.2.3 is a private (RFC 1918) address`], + [ + "an RFC 1918 172.16/12", + "https://172.31.0.1", + `${P}172.31.0.1 is a private (RFC 1918) address`, + ], + [ + "an RFC 1918 192.168/16", + "https://192.168.1.1", + `${P}192.168.1.1 is a private (RFC 1918) address`, + ], + ["an IPv6 loopback", "https://[::1]", `${P}[::1] is a loopback address`], + ["an IPv6 link-local", "https://[fe80::1]", `${P}[fe80::1] is a link-local address`], + [ + "an IPv6 link-local at the top of fe80::/10", + "https://[febf::1]", + `${P}[febf::1] is a link-local address`, + ], + ["an IPv6 ULA fc00::/7 (fc)", "https://[fc00::1]", `${P}[fc00::1] is a private (ULA) address`], + [ + "an IPv6 ULA fc00::/7 (fd)", + "https://[fd12:3456::1]", + `${P}[fd12:3456::1] is a private (ULA) address`, + ], + [ + "a query", + "https://devbox.example.com/?token=s3cret", + `${P}https://devbox.example.com/ must not have a query`, + ], + [ + "a fragment", + "https://devbox.example.com/hooks#access_token=s3cret", + `${P}https://devbox.example.com/hooks must not have a fragment`, + ], + [ + "a bare query mark", + "https://devbox.example.com?", + `${P}https://devbox.example.com/ must not have a query`, + ], + [ + "another scheme, without echoing the rest (it may hold a password)", + "devbox:pw@example.com", + `${P}the URL uses devbox:, not https:`, + ], + ["0.0.0.0", "https://0.0.0.0", `${P}0.0.0.0 is an unspecified address`], + ["[::]", "https://[::]", `${P}[::] is an unspecified address`], + [ + "an IPv4-mapped loopback, dotted", + "https://[::ffff:127.0.0.1]", + `${P}[::ffff:7f00:1] is a loopback address`, + ], + [ + "an IPv4-mapped loopback, hex", + "https://[::ffff:7f00:1]", + `${P}[::ffff:7f00:1] is a loopback address`, + ], + [ + "an IPv4-mapped link-local", + "https://[::ffff:169.254.0.1]", + `${P}[::ffff:a9fe:1] is a link-local address`, + ], + [ + "an IPv4-mapped private address", + "https://[::ffff:192.168.0.1]", + `${P}[::ffff:c0a8:1] is a private (RFC 1918) address`, + ], + [ + "credentials", + "https://user:pass@devbox.example.com", + `${P}the URL must not contain credentials`, + ], + ]; + for (const [what, raw, message] of refused) { + it(`refuses ${what}`, () => { + let err: unknown; + try { + validatePublicUrl(raw); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(PublicUrlError); + expect((err as Error).message).toBe(message); + expect((err as Error).message).not.toContain("s3cret"); + }); + } + + it("accepts public addresses next to the private ranges", () => { + expect(validatePublicUrl("https://172.32.0.1")).toBe("https://172.32.0.1"); + expect(validatePublicUrl("https://[fec0::1]")).toBe("https://[fec0::1]"); + expect(validatePublicUrl("https://[2001:db8::1]")).toBe("https://[2001:db8::1]"); + expect(validatePublicUrl("https://[::ffff:8.8.8.8]")).toBe("https://[::ffff:808:808]"); + }); +}); + +describe("ingressPublicUrl", () => { + const webhooks = { enabled: true, host: "hooks.example.com", tls: [{ hosts: ["x"] }] }; + const api = { enabled: true, host: "devbox.example.com", tls: [{ hosts: ["y"] }] }; + + it("webhooks Ingress wins", () => { + expect(ingressPublicUrl({ values: { ingress: { webhooks, api } } })).toBe( + "https://hooks.example.com", + ); + }); + + it("falls back to the api Ingress", () => { + expect( + ingressPublicUrl({ + values: { ingress: { webhooks: { enabled: false, host: "hooks.example.com" }, api } }, + }), + ).toBe("https://devbox.example.com"); + expect( + ingressPublicUrl({ values: { ingress: { webhooks: { enabled: true, host: "" }, api } } }), + ).toBe("https://devbox.example.com"); + }); + + it("https only with tls", () => { + expect(ingressPublicUrl({ values: { ingress: { webhooks: { ...webhooks, tls: [] } } } })).toBe( + "http://hooks.example.com", + ); + expect( + ingressPublicUrl({ + values: { ingress: { api: { enabled: true, host: "devbox.example.com" } } }, + }), + ).toBe("http://devbox.example.com"); + }); + + it("none without an Ingress", () => { + expect(ingressPublicUrl({ values: {} })).toBeUndefined(); + expect( + ingressPublicUrl({ + values: { ingress: { api: { enabled: false, host: "devbox.example.com" } } }, + }), + ).toBeUndefined(); + }); + + it("says which Ingress it used", () => { + expect(ingressSource({ values: { ingress: { webhooks, api } } })).toBe("webhooks"); + expect(ingressSource({ values: { ingress: { api } } })).toBe("api"); + expect(ingressSource({ values: {} })).toBeUndefined(); + }); +}); + +const COMPOSE: Deployment = { kind: "compose" }; +const K8S: Deployment = { kind: "k8s", context: "kind-devbox", namespace: "devbox" }; +const NEXT = "https://hooks.example.com"; + +describe("resolvePublicUrl", () => { + const deps = (o: { isTTY?: boolean; answer?: string } = {}) => { + const asked: string[] = []; + return { + asked, + deps: { + resolver: { isTTY: o.isTTY ?? true }, + promptLine: async (q: string) => { + asked.push(q); + return o.answer ?? ""; + }, + }, + }; + }; + + it("--public-url wins, validated and trimmed", async () => { + const x = deps(); + expect( + await resolvePublicUrl( + { deployment: COMPOSE, publicUrl: "https://saved.example.com" }, + { publicUrl: `${NEXT}/` }, + x.deps, + ), + ).toBe(NEXT); + await expect( + resolvePublicUrl({ deployment: COMPOSE }, { publicUrl: "http://x.example.com" }, x.deps), + ).rejects.toBeInstanceOf(PublicUrlError); + expect(x.asked).toEqual([]); + }); + + it("then the saved publicUrl", async () => { + expect( + await resolvePublicUrl( + { deployment: K8S, publicUrl: NEXT, ingressUrl: "https://i.example.com" }, + {}, + deps().deps, + ), + ).toBe(NEXT); + }); + + it("k8s: the Ingress URL", async () => { + expect( + await resolvePublicUrl( + { deployment: K8S, ingressUrl: "https://i.example.com" }, + {}, + deps().deps, + ), + ).toBe("https://i.example.com"); + }); + + it("k8s: an http Ingress gets the TLS hint", async () => { + await expect( + resolvePublicUrl({ deployment: K8S, ingressUrl: "http://i.example.com" }, {}, deps().deps), + ).rejects.toThrow( + new PublicUrlError( + `${P}http://i.example.com uses http:, not https:. If TLS ends at a load balancer in front of the Ingress, pass --public-url https://i.example.com`, + ), + ); + }); + + it("k8s: an Ingress host with a space gets no hint", async () => { + await expect( + resolvePublicUrl({ deployment: K8S, ingressUrl: "http://i example.com" }, {}, deps().deps), + ).rejects.toThrow( + new PublicUrlError(`${P}the value is not an absolute URL (start it with https://)`), + ); + }); + + it("k8s: no Ingress", async () => { + await expect(resolvePublicUrl({ deployment: K8S }, {}, deps().deps)).rejects.toThrow( + new PublicUrlError(`${P}enable ingress.webhooks, or pass --public-url.`), + ); + }); + + it("Compose: a prompt with a terminal", async () => { + const x = deps({ answer: ` ${NEXT}/ ` }); + expect(await resolvePublicUrl({ deployment: COMPOSE }, {}, x.deps)).toBe(NEXT); + expect(x.asked).toEqual([ + "Public HTTPS URL that forwards to port 3000 on this machine (a tunnel or reverse proxy): ", + ]); + }); + + it("Compose: no terminal or an empty answer fails naming --public-url", async () => { + const none = new PublicUrlError( + `${P}pass --public-url `, + ); + const x = deps({ isTTY: false }); + await expect(resolvePublicUrl({ deployment: COMPOSE }, {}, x.deps)).rejects.toThrow(none); + expect(x.asked).toEqual([]); + await expect(resolvePublicUrl({ deployment: COMPOSE }, {}, deps().deps)).rejects.toThrow(none); + }); +}); + +describe("publicUrlChangeLines", () => { + const OLD = "https://old.example.com"; + const LINEAR_LINE = `Update the webhook URL in the Linear app to ${NEXT}/webhook/linear`; + const EXTERNAL_LINE = `Update the webhook URL in the GitHub App to ${NEXT}/webhook/github: https://github.com/settings/apps/example-devbox`; + const UPDATED = `Updated the GitHub App's webhook URL to ${NEXT}/webhook/github`; + const RUN_GITHUB = "Run bun run github:setup to update the GitHub App's webhook URL"; + const app = (tokens: "devbox" | "external", hookUpdated?: boolean) => ({ + tokens, + slug: "example-devbox", + ...(hookUpdated !== undefined ? { hookUpdated } : {}), + }); + + const table: Array<[string, Configured, "linear" | "github", string[]]> = [ + ["nothing configured", { linear: false }, "linear", []], + ["Linear, from linear", { linear: true }, "linear", [LINEAR_LINE]], + ["Linear, from github", { linear: true }, "github", [LINEAR_LINE]], + [ + "devbox App, from linear: points at github:setup", + { linear: false, githubApp: app("devbox") }, + "linear", + [RUN_GITHUB], + ], + [ + "devbox App, from github, hook updated", + { linear: false, githubApp: app("devbox", true) }, + "github", + [UPDATED], + ], + [ + "devbox App, from github, hook not updated (no key): by hand", + { linear: false, githubApp: app("devbox", false) }, + "github", + [EXTERNAL_LINE], + ], + [ + "devbox App, from github, hookUpdated unset: by hand", + { linear: false, githubApp: app("devbox") }, + "github", + [EXTERNAL_LINE], + ], + [ + "devbox App, from linear ignores hookUpdated", + { linear: false, githubApp: app("devbox", true) }, + "linear", + [RUN_GITHUB], + ], + [ + "external App, from linear", + { linear: false, githubApp: app("external") }, + "linear", + [EXTERNAL_LINE], + ], + [ + "external App, from github", + { linear: false, githubApp: app("external") }, + "github", + [EXTERNAL_LINE], + ], + [ + "Linear and devbox App, from linear: Linear first", + { linear: true, githubApp: app("devbox") }, + "linear", + [LINEAR_LINE, RUN_GITHUB], + ], + [ + "Linear and devbox App, from github, hook updated", + { linear: true, githubApp: app("devbox", true) }, + "github", + [LINEAR_LINE, UPDATED], + ], + [ + "Linear and external App, from linear", + { linear: true, githubApp: app("external") }, + "linear", + [LINEAR_LINE, EXTERNAL_LINE], + ], + [ + "Linear and external App, from github", + { linear: true, githubApp: app("external") }, + "github", + [LINEAR_LINE, EXTERNAL_LINE], + ], + ]; + for (const [what, configured, from, lines] of table) { + it(what, () => { + expect(publicUrlChangeLines(OLD, NEXT, configured, from)).toEqual(lines); + }); + } + + it("an organization's App: its settings link is the organization's", () => { + expect( + publicUrlChangeLines( + OLD, + NEXT, + { linear: false, githubApp: { ...app("external"), org: "example-org" } }, + "github", + ), + ).toEqual([ + `Update the webhook URL in the GitHub App to ${NEXT}/webhook/github: https://github.com/organizations/example-org/settings/apps/example-devbox`, + ]); + }); + + it("no lines when the URL is new or unchanged", () => { + const all: Configured = { linear: true, githubApp: app("external") }; + expect(publicUrlChangeLines(undefined, NEXT, all, "linear")).toEqual([]); + expect(publicUrlChangeLines(NEXT, NEXT, all, "github")).toEqual([]); + }); +}); diff --git a/control-plane/scripts/lib/publicUrl.ts b/control-plane/scripts/lib/publicUrl.ts new file mode 100644 index 0000000..f4f4faf --- /dev/null +++ b/control-plane/scripts/lib/publicUrl.ts @@ -0,0 +1,209 @@ +// The deployment's one public URL, where Linear and +// GitHub deliver webhooks. Both refuse anything but a public HTTPS URL, so a +// bad one is caught here, before a manifest or a test webhook is built from +// it. A path prefix (a reverse proxy under /devbox) is kept; trailing slashes +// are trimmed. +import type { Target } from "../k8s/target"; +import { userValue } from "../k8s/values"; +import type { Deployment } from "./keychain"; + +export class PublicUrlError extends Error {} + +const refuse = (why: string): never => { + throw new PublicUrlError(`Webhooks need a public HTTPS URL: ${why}`); +}; + +const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + +// URL has already normalised an IPv4 literal to four decimal octets. +function ipv4Kind(host: string): string | undefined { + const m = IPV4.exec(host); + if (!m) return undefined; + const [a, b] = [Number(m[1]), Number(m[2])]; + if (a === 0) return "an unspecified address"; + if (a === 127) return "a loopback address"; + if (a === 169 && b === 254) return "a link-local address"; + if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) { + return "a private (RFC 1918) address"; + } + return undefined; +} + +// URL has already compressed an IPv6 literal and lower-cased it, in brackets. +function ipv6Kind(host: string): string | undefined { + if (!host.startsWith("[")) return undefined; + const ip = host.slice(1, -1); + if (ip === "::") return "an unspecified address"; + if (ip === "::1") return "a loopback address"; + // An IPv4-mapped address (URL writes ::ffff:127.0.0.1 as ::ffff:7f00:1) is + // the IPv4 address it maps. + const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(ip); + if (mapped) { + const [hi, lo] = [Number.parseInt(mapped[1] ?? "", 16), Number.parseInt(mapped[2] ?? "", 16)]; + return ipv4Kind(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`); + } + const first = ip.startsWith("::") ? 0 : Number.parseInt(ip.split(":")[0] ?? "", 16); + if (first >= 0xfe80 && first <= 0xfebf) return "a link-local address"; + if (first >= 0xfc00 && first <= 0xfdff) return "a private (ULA) address"; + return undefined; +} + +export function validatePublicUrl(raw: string): string { + const text = raw.trim(); + // The input is never echoed whole: a query, fragment or credentials in it + // may hold a token. + if (!URL.canParse(text)) + return refuse("the value is not an absolute URL (start it with https://)"); + const url = new URL(text); + if (url.username || url.password) return refuse("the URL must not contain credentials"); + if (url.protocol !== "https:") { + // Only a URL with a host (http:, ws:, …) had its credentials refused + // above; in any other ("devbox:pw@example.com") the rest may be one. + const what = url.host + ? `${url.protocol}//${url.host}${url.pathname}`.replace(/\/+$/, "") + : "the URL"; + return refuse(`${what} uses ${url.protocol}, not https:`); + } + const host = url.hostname; + if (host === "localhost" || host.endsWith(".localhost")) { + return refuse(`${host} is not reachable from the internet`); + } + const kind = ipv4Kind(host) ?? ipv6Kind(host); + if (kind) return refuse(`${host} is ${kind}`); + // `search` and `hash` are empty for a bare "?" or "#"; the href keeps them. + const bare = `${url.origin}${url.pathname}`; + if (url.search || url.href.includes("?")) return refuse(`${bare} must not have a query`); + if (url.hash || url.href.includes("#")) return refuse(`${bare} must not have a fragment`); + return url.href.replace(/\/+$/, ""); +} + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +type IngressValues = { enabled?: unknown; host?: unknown; tls?: unknown }; + +function ingress(target: Pick, name: string): IngressValues | undefined { + const v = userValue(target, ["ingress", name]); + return isObject(v) && v.enabled === true && typeof v.host === "string" && v.host !== "" + ? v + : undefined; +} + +// The Ingress k8s derives the public URL from: `webhooks` when it +// is enabled with a host, else `api`. +export const ingressSource = (target: Pick): "webhooks" | "api" | undefined => + ingress(target, "webhooks") ? "webhooks" : ingress(target, "api") ? "api" : undefined; + +// That Ingress's URL: https:// when it has TLS. Without TLS (or with TLS ended +// at a load balancer) it is http://, which validatePublicUrl refuses. +export function ingressPublicUrl(target: Pick): string | undefined { + const source = ingressSource(target); + const v = source && ingress(target, source); + if (!v) return undefined; + return `${Array.isArray(v.tls) && v.tls.length > 0 ? "https" : "http"}://${v.host}`; +} + +// A `publicUrl` setting in a config file: a string, validated, with the +// field's name in front of the message. +export function parsePublicUrl(raw: unknown, field: string, fail: (msg: string) => never): string { + if (typeof raw !== "string") return fail(`${field}: must be a string`); + try { + return validatePublicUrl(raw); + } catch (err) { + if (err instanceof PublicUrlError) return fail(`${field}: ${err.message}`); + throw err; + } +} + +export type PublicUrlDeps = { + resolver: { isTTY: boolean }; + // Asks with echo; empty at EOF. + promptLine: (q: string) => Promise; +}; + +const URL_QUESTION = + "Public HTTPS URL that forwards to port 3000 on this machine (a tunnel or reverse proxy): "; + +// The public URL to use: --public-url, the saved +// `publicUrl`, the Ingress (k8s), a prompt (Compose, with a terminal). +export async function resolvePublicUrl( + d: { publicUrl?: string; deployment: Deployment; ingressUrl?: string }, + opts: { publicUrl?: string }, + deps: PublicUrlDeps, +): Promise { + if (opts.publicUrl !== undefined) return validatePublicUrl(opts.publicUrl); + if (d.publicUrl) return d.publicUrl; + if (d.deployment.kind === "k8s") { + if (!d.ingressUrl) { + throw new PublicUrlError( + "Webhooks need a public HTTPS URL: enable ingress.webhooks, or pass --public-url.", + ); + } + try { + return validatePublicUrl(d.ingressUrl); + } catch (err) { + const url = d.ingressUrl; + if (!(err instanceof PublicUrlError) || !url.startsWith("http://") || !URL.canParse(url)) { + throw err; + } + // An Ingress without `tls` may still be HTTPS at a load balancer. + throw new PublicUrlError( + `${err.message}. If TLS ends at a load balancer in front of the Ingress, pass --public-url https://${new URL(url).host}`, + ); + } + } + const none = new PublicUrlError( + "Webhooks need a public HTTPS URL: pass --public-url ", + ); + if (!deps.resolver.isTTY) throw none; + const answer = (await deps.promptLine(URL_QUESTION)).trim(); + if (!answer) throw none; + return validatePublicUrl(answer); +} + +export type Configured = { + linear: boolean; + // hookUpdated: github:setup updated the App's webhook URL itself. org: the + // organization that owns the App, when known (its settings page is there). + githubApp?: { + tokens: "devbox" | "external"; + slug: string; + hookUpdated?: boolean; + org?: string; + }; +}; + +// What each configured integration needs when the public URL changes, in +// the order Linear, then GitHub. `from` is the command that changed it: the +// App's webhook URL is updated by github:setup, which holds the key. `flag` +// is the deployment's ` --target ` (empty on Compose). +export function publicUrlChangeLines( + old: string | undefined, + next: string, + configured: Configured, + from: "linear" | "github", + flag = "", +): string[] { + if (old === undefined || old === next) return []; + const lines: string[] = []; + if (configured.linear) { + lines.push(`Update the webhook URL in the Linear app to ${next}/webhook/linear`); + } + const app = configured.githubApp; + if (app) { + if (app.tokens === "devbox" && from === "github" && app.hookUpdated) { + lines.push(`Updated the GitHub App's webhook URL to ${next}/webhook/github`); + } else if (app.tokens === "devbox" && from === "linear") { + lines.push(`Run bun run github:setup${flag} to update the GitHub App's webhook URL`); + } else { + // External tokens, or github:setup without the key: by hand. + const settings = app.org + ? `https://github.com/organizations/${app.org}/settings/apps` + : "https://github.com/settings/apps"; + lines.push( + `Update the webhook URL in the GitHub App to ${next}/webhook/github: ${settings}/${app.slug}`, + ); + } + } + return lines; +} diff --git a/control-plane/scripts/lib/secrets.test.ts b/control-plane/scripts/lib/secrets.test.ts new file mode 100644 index 0000000..df3acf1 --- /dev/null +++ b/control-plane/scripts/lib/secrets.test.ts @@ -0,0 +1,632 @@ +import { describe, expect, it } from "bun:test"; +import { fakeTerminal } from "../up/fakeTerminal"; +import { CredentialRejected, PreflightError } from "../up/preflight"; +import { confirm } from "../up/prompt"; +import { type Deployment, guarded, type Keychain, MemoryKeychain } from "./keychain"; +import { + checkWithRetry, + offerSave, + peekSecret, + type Resolved, + type ResolverDeps, + resolveSecret, + SecretError, + secretNamesFor, +} from "./secrets"; + +const COMPOSE: Deployment = { kind: "compose" }; +const K8S: Deployment = { kind: "k8s", context: "ctx", namespace: "devbox" }; + +type Calls = { run: string[][]; prompt: string[]; confirm: string[]; log: string[] }; + +function deps( + opts: { + env?: Record; + keychain?: Keychain | null; // null = --no-keychain + deployment?: Deployment; + isTTY?: boolean; + gh?: { code: number; stdout: string } | "missing"; + answers?: string[]; + // Keystrokes at a yes/no question, answered by the real confirm(). + keys?: string; + targetPath?: string; + } = {}, +): { deps: ResolverDeps; calls: Calls; keychain: MemoryKeychain } { + const calls: Calls = { run: [], prompt: [], confirm: [], log: [] }; + const memory = opts.keychain instanceof MemoryKeychain ? opts.keychain : new MemoryKeychain(); + const answers = [...(opts.answers ?? [])]; + return { + calls, + keychain: memory, + deps: { + env: opts.env ?? {}, + keychain: opts.keychain === null ? undefined : (opts.keychain ?? memory), + deployment: opts.deployment ?? COMPOSE, + ...(opts.targetPath === undefined ? {} : { targetPath: opts.targetPath }), + isTTY: opts.isTTY ?? true, + prompt: async (q) => { + calls.prompt.push(q); + return answers.shift() ?? ""; + }, + confirm: (q, o) => { + calls.confirm.push(q); + return confirm(q, o, fakeTerminal(opts.keys ?? "\r").term); + }, + run: async (cmd) => { + calls.run.push(cmd); + if (opts.gh === "missing" || opts.gh === undefined) throw new Error("ENOENT: gh"); + return opts.gh; + }, + log: (line) => void calls.log.push(line), + }, + }; +} + +const none = { run: [], prompt: [], confirm: [], log: [] }; +const GH = ["gh", "auth", "token"]; +const GH_OK = { code: 0, stdout: "gho_gh\n" }; + +describe("resolveSecret", () => { + it("env wins over keychain", async () => { + const kc = new MemoryKeychain({ "compose/LLM_API_KEY": "sk-saved" }); + const d = deps({ env: { LLM_API_KEY: "sk-env" }, keychain: kc }); + expect(await resolveSecret("LLM_API_KEY", d.deps)).toEqual({ + name: "LLM_API_KEY", + value: "sk-env", + source: "env", + }); + expect(d.calls).toEqual({ ...none, log: ["LLM_API_KEY: from your environment"] }); + }); + + it("empty GITHUB_TOKEN in env is kept", async () => { + const kc = new MemoryKeychain({ "compose/GITHUB_TOKEN": "ghp_saved" }); + const d = deps({ env: { GITHUB_TOKEN: "", LLM_API_KEY: "" }, keychain: kc, gh: GH_OK }); + expect(await resolveSecret("GITHUB_TOKEN", d.deps)).toEqual({ + name: "GITHUB_TOKEN", + value: "", + source: "env", + }); + expect(await resolveSecret("LLM_API_KEY", d.deps)).toEqual({ + name: "LLM_API_KEY", + value: "", + source: "env", + }); + expect(d.calls).toEqual({ + ...none, + log: ["GITHUB_TOKEN: from your environment", "LLM_API_KEY: from your environment"], + }); + }); + + it("empty LINEAR_CLIENT_SECRET in env falls through", async () => { + const kc = new MemoryKeychain({ "compose/LINEAR_CLIENT_SECRET": "lin_saved" }); + const d = deps({ env: { LINEAR_CLIENT_SECRET: "", LINEAR_WEBHOOK_SECRET: "" }, keychain: kc }); + expect(await resolveSecret("LINEAR_CLIENT_SECRET", d.deps)).toEqual({ + name: "LINEAR_CLIENT_SECRET", + value: "lin_saved", + source: "keychain", + }); + const p = deps({ env: { LINEAR_WEBHOOK_SECRET: "" }, answers: ["whsec"] }); + expect(await resolveSecret("LINEAR_WEBHOOK_SECRET", p.deps)).toEqual({ + name: "LINEAR_WEBHOOK_SECRET", + value: "whsec", + source: "prompt", + }); + expect(p.calls).toEqual({ + ...none, + prompt: ["Linear webhook signing secret (input hidden): "], + }); + }); + + it("keychain entry is per deployment", async () => { + const kc = new MemoryKeychain({ + "compose/LLM_API_KEY": "sk-local", + "k8s:ctx/devbox/LLM_API_KEY": "sk-cluster", + "k8s:other/devbox/LLM_API_KEY": "sk-other", + }); + const compose = deps({ keychain: kc, deployment: COMPOSE }); + const k8s = deps({ keychain: kc, deployment: K8S }); + expect((await resolveSecret("LLM_API_KEY", compose.deps)).value).toBe("sk-local"); + expect(await resolveSecret("LLM_API_KEY", k8s.deps)).toEqual({ + name: "LLM_API_KEY", + value: "sk-cluster", + source: "keychain", + }); + expect(k8s.calls).toEqual({ + ...none, + log: ["LLM_API_KEY: from the system keychain (k8s:ctx/devbox)"], + }); + }); + + it("gh only for GITHUB_TOKEN", async () => { + const d = deps({ gh: { code: 0, stdout: " gho_gh\n" }, answers: ["sk-typed"] }); + expect(await resolveSecret("GITHUB_TOKEN", d.deps)).toEqual({ + name: "GITHUB_TOKEN", + value: "gho_gh", + source: "gh", + }); + expect(await resolveSecret("LLM_API_KEY", d.deps)).toEqual({ + name: "LLM_API_KEY", + value: "sk-typed", + source: "prompt", + }); + expect(d.calls).toEqual({ + ...none, + run: [GH], + prompt: ["LLM API key (input hidden): "], + log: ["GITHUB_TOKEN: from gh"], + }); + }); + + it("prompts for GITHUB_TOKEN when gh is missing, logged out or prints nothing", async () => { + for (const gh of ["missing", { code: 1, stdout: "" }, { code: 0, stdout: "\n" }] as const) { + const d = deps({ gh, answers: [" ghp_typed \n"] }); + expect(await resolveSecret("GITHUB_TOKEN", d.deps)).toEqual({ + name: "GITHUB_TOKEN", + value: "ghp_typed", + source: "prompt", + }); + expect(d.calls).toEqual({ ...none, run: [GH], prompt: ["GitHub token (input hidden): "] }); + } + }); + + it("uses each secret's own prompt label", async () => { + const d = deps({ answers: ["a", "b"] }); + await resolveSecret("LINEAR_CLIENT_SECRET", d.deps); + await resolveSecret("LINEAR_WEBHOOK_SECRET", d.deps); + expect(d.calls.prompt).toEqual([ + "Linear client secret (input hidden): ", + "Linear webhook signing secret (input hidden): ", + ]); + }); + + it("throws on an empty answer at the prompt", async () => { + const gh = deps({ answers: [" "] }); + await expect(resolveSecret("GITHUB_TOKEN", gh.deps)).rejects.toThrow( + new SecretError( + "no GitHub token: set GITHUB_TOKEN, log in with `gh auth login`, or enter one at the prompt", + ), + ); + const llm = deps({ answers: [""] }); + await expect(resolveSecret("LLM_API_KEY", llm.deps)).rejects.toThrow( + new SecretError("no LLM API key: set LLM_API_KEY or enter one at the prompt"), + ); + }); + + it("missing without a terminal names the variable and how to save it", async () => { + const UP = "run `bun run up` once in a terminal to save it in the keychain"; + const cases: Array<[Parameters[0], string]> = [ + [ + "GITHUB_TOKEN", + `no GitHub token: set GITHUB_TOKEN, log in with \`gh auth login\`, or ${UP}`, + ], + ["LLM_API_KEY", `no LLM API key: set LLM_API_KEY or ${UP}`], + ["LINEAR_CLIENT_SECRET", `no Linear client secret: set LINEAR_CLIENT_SECRET or ${UP}`], + [ + "LINEAR_WEBHOOK_SECRET", + `no Linear webhook signing secret: set LINEAR_WEBHOOK_SECRET or ${UP}`, + ], + ]; + for (const [name, message] of cases) { + const d = deps({ isTTY: false, gh: "missing" }); + const err = await resolveSecret(name, d.deps).catch((e) => e); + expect(err).toBeInstanceOf(SecretError); + expect(err.message).toBe(message); + expect(d.calls.prompt).toEqual([]); + } + // --noPrompt behaves the same with a terminal. + const np = deps({ isTTY: true }); + await expect(resolveSecret("LLM_API_KEY", np.deps, { noPrompt: true })).rejects.toThrow( + new SecretError(`no LLM API key: set LLM_API_KEY or ${UP}`), + ); + expect(np.calls.prompt).toEqual([]); + // With --no-keychain the keychain is no way out. + const nk = deps({ isTTY: false, keychain: null }); + await expect(resolveSecret("LLM_API_KEY", nk.deps)).rejects.toThrow( + new SecretError("no LLM API key: set LLM_API_KEY"), + ); + }); + + it("on k8s the hint names the installer and its target file", async () => { + const withPath = deps({ isTTY: false, deployment: K8S, targetPath: "devbox.k8s.json" }); + await expect(resolveSecret("LLM_API_KEY", withPath.deps)).rejects.toThrow( + new SecretError( + "no LLM API key: set LLM_API_KEY or run `bun run k8s:install --target devbox.k8s.json` once in a terminal to save it in the keychain", + ), + ); + const noPath = deps({ isTTY: false, deployment: K8S, gh: "missing" }); + await expect(resolveSecret("GITHUB_TOKEN", noPath.deps)).rejects.toThrow( + new SecretError( + "no GitHub token: set GITHUB_TOKEN, log in with `gh auth login`, or run `bun run k8s:install --target ` once in a terminal to save it in the keychain", + ), + ); + const nk = deps({ isTTY: false, deployment: K8S, targetPath: "t.json", keychain: null }); + await expect(resolveSecret("LLM_API_KEY", nk.deps)).rejects.toThrow( + new SecretError("no LLM API key: set LLM_API_KEY"), + ); + }); + + it("on kind the hint names k8s:kind, which installs from its own target file", async () => { + const kind: Deployment = { kind: "k8s", context: "kind-devbox", namespace: "devbox" }; + const d = deps({ isTTY: false, deployment: kind, targetPath: "devbox.kind.json" }); + await expect(resolveSecret("LLM_API_KEY", d.deps)).rejects.toThrow( + new SecretError( + "no LLM API key: set LLM_API_KEY or run `bun run k8s:kind` once in a terminal to save it in the keychain", + ), + ); + }); + + it("a keychain that switched itself off is no way out either", async () => { + const off: Keychain = { + disabled: true, + get: async () => undefined, + set: async () => {}, + delete: async () => false, + }; + const d = deps({ isTTY: false, keychain: off }); + await expect(resolveSecret("LLM_API_KEY", d.deps)).rejects.toThrow( + new SecretError("no LLM API key: set LLM_API_KEY"), + ); + }); + + it("--no-keychain never reads or writes", async () => { + const kc = new MemoryKeychain({ "compose/LLM_API_KEY": "sk-saved" }); + // The entry exists, but --no-keychain hands the resolver no keychain at all. + const d = deps({ keychain: null, answers: ["sk-typed"] }); + const r = await resolveSecret("LLM_API_KEY", d.deps); + expect(r).toEqual({ name: "LLM_API_KEY", value: "sk-typed", source: "prompt" }); + await offerSave(r, d.deps); + expect(await peekSecret("LLM_API_KEY", d.deps)).toBe("missing"); + expect(d.calls).toEqual({ ...none, prompt: ["LLM API key (input hidden): "] }); + expect(kc.entries()).toEqual({ "compose/LLM_API_KEY": "sk-saved" }); + }); +}); + +describe("offerSave", () => { + it("prompt then save on yes", async () => { + const d = deps({ deployment: K8S, answers: ["sk-typed"], keys: "y\r" }); + const r = await resolveSecret("LLM_API_KEY", d.deps); + await offerSave(r, d.deps); + expect(d.calls.confirm).toEqual([ + "Save LLM_API_KEY to the system keychain for k8s:ctx/devbox? [Y/n] ", + ]); + expect(d.keychain.entries()).toEqual({ "k8s:ctx/devbox/LLM_API_KEY": "sk-typed" }); + }); + + it("Enter alone saves", async () => { + const d = deps({ answers: ["sk-typed"], keys: "\r" }); + await offerSave(await resolveSecret("LLM_API_KEY", d.deps), d.deps); + expect(d.calls.confirm).toEqual([ + "Save LLM_API_KEY to the system keychain for compose? [Y/n] ", + ]); + expect(d.keychain.entries()).toEqual({ "compose/LLM_API_KEY": "sk-typed" }); + }); + + it("Ctrl-D saves nothing", async () => { + const d = deps({ answers: ["sk-typed"], keys: "\u0004" }); + await offerSave(await resolveSecret("LLM_API_KEY", d.deps), d.deps); + expect(d.calls.confirm).toEqual([ + "Save LLM_API_KEY to the system keychain for compose? [Y/n] ", + ]); + expect(d.keychain.entries()).toEqual({}); + }); + + it("saves nothing on no", async () => { + const d = deps({ answers: ["sk-typed"], keys: "n\r" }); + await offerSave(await resolveSecret("LLM_API_KEY", d.deps), d.deps); + expect(d.calls.confirm).toEqual([ + "Save LLM_API_KEY to the system keychain for compose? [Y/n] ", + ]); + expect(d.keychain.entries()).toEqual({}); + }); + + it("gh value is never offered for saving", async () => { + const d = deps({ gh: GH_OK }); + const r = await resolveSecret("GITHUB_TOKEN", d.deps); + expect(r.source).toBe("gh"); + await offerSave(r, d.deps); + const e = deps({ env: { LLM_API_KEY: "sk-env" } }); + await offerSave(await resolveSecret("LLM_API_KEY", e.deps), e.deps); + const k = deps({ keychain: new MemoryKeychain({ "compose/LLM_API_KEY": "sk-saved" }) }); + await offerSave(await resolveSecret("LLM_API_KEY", k.deps), k.deps); + expect([d.calls.confirm, e.calls.confirm, k.calls.confirm]).toEqual([[], [], []]); + expect(d.keychain.entries()).toEqual({}); + expect(e.keychain.entries()).toEqual({}); + }); + + it("no save offer without a terminal", async () => { + const d = deps({ isTTY: false }); + const r: Resolved = { name: "LLM_API_KEY", value: "sk-typed", source: "prompt" }; + await offerSave(r, d.deps); + expect(d.calls).toEqual(none); + expect(d.keychain.entries()).toEqual({}); + }); + + it("no save offer once the keychain turned out to be unavailable", async () => { + const broken: Keychain = { + get: async () => { + throw Object.assign(new Error("libsecret not available"), { + code: "ERR_SECRETS_PLATFORM_ERROR", + }); + }, + set: async () => {}, + delete: async () => false, + }; + const d = deps({ keychain: guarded(broken), answers: ["sk-typed"] }); + const r = await resolveSecret("LLM_API_KEY", d.deps); + await offerSave(r, d.deps); + expect(d.calls).toEqual({ ...none, prompt: ["LLM API key (input hidden): "] }); + }); +}); + +describe("source lines", () => { + it("announces env, keychain and gh sources", async () => { + const kc = new MemoryKeychain({ "k8s:ctx/devbox/LLM_API_KEY": "sk-saved" }); + const d = deps({ + keychain: kc, + deployment: K8S, + env: { LINEAR_CLIENT_SECRET: "lc" }, + gh: GH_OK, + }); + await resolveSecret("GITHUB_TOKEN", d.deps); + await resolveSecret("LLM_API_KEY", d.deps); + await resolveSecret("LINEAR_CLIENT_SECRET", d.deps); + expect(d.calls.log).toEqual([ + "GITHUB_TOKEN: from gh", + "LLM_API_KEY: from the system keychain (k8s:ctx/devbox)", + "LINEAR_CLIENT_SECRET: from your environment", + ]); + }); + + it("no source line before a prompt", async () => { + const d = deps({ answers: ["xoxb-typed"], deployment: K8S }); + expect(await resolveSecret("SLACK_BOT_TOKEN", d.deps)).toEqual({ + name: "SLACK_BOT_TOKEN", + value: "xoxb-typed", + source: "prompt", + }); + expect(d.calls).toEqual({ ...none, prompt: ["Slack bot token (input hidden): "] }); + }); + + it("an empty env value counts as missing for the Slack and webhook secrets", async () => { + const d = deps({ + deployment: K8S, + env: { SLACK_BOT_TOKEN: "", GITHUB_WEBHOOK_SECRET: "" }, + answers: ["xoxb", "whsec"], + }); + expect((await resolveSecret("SLACK_BOT_TOKEN", d.deps)).source).toBe("prompt"); + expect((await resolveSecret("GITHUB_WEBHOOK_SECRET", d.deps)).source).toBe("prompt"); + expect(d.calls.prompt).toEqual([ + "Slack bot token (input hidden): ", + "GitHub webhook secret (input hidden): ", + ]); + }); +}); + +describe("secretNamesFor", () => { + it("Compose gets six, k8s seven", () => { + expect(secretNamesFor(COMPOSE)).toEqual([ + "GITHUB_TOKEN", + "LLM_API_KEY", + "LINEAR_CLIENT_SECRET", + "LINEAR_WEBHOOK_SECRET", + "GITHUB_APP_PRIVATE_KEY", + "GITHUB_WEBHOOK_SECRET", + ]); + expect(secretNamesFor(K8S)).toEqual([ + "GITHUB_TOKEN", + "LLM_API_KEY", + "LINEAR_CLIENT_SECRET", + "LINEAR_WEBHOOK_SECRET", + "GITHUB_APP_PRIVATE_KEY", + "GITHUB_WEBHOOK_SECRET", + "SLACK_BOT_TOKEN", + ]); + }); +}); + +describe("checkWithRetry", () => { + const rejected = () => + new CredentialRejected("GitHub rejected GITHUB_TOKEN (GET /user returned 401)"); + + it("returns the check's result for an accepted value", async () => { + const d = deps(); + const r: Resolved = { name: "GITHUB_TOKEN", value: "ghp_x", source: "keychain" }; + expect(await checkWithRetry(r, d.deps, async (v) => `ok ${v}`)).toEqual({ + resolved: r, + result: "ok ghp_x", + }); + expect(d.calls).toEqual(none); + }); + + it("a rejected keychain value is removed, then asked again until accepted", async () => { + const kc = new MemoryKeychain({ + "k8s:ctx/devbox/GITHUB_TOKEN": "ghp_old", + "compose/GITHUB_TOKEN": "ghp_local", + }); + const d = deps({ keychain: kc, deployment: K8S, gh: GH_OK, answers: ["ghp_new"] }); + const r = await resolveSecret("GITHUB_TOKEN", d.deps); + const checked: string[] = []; + const out = await checkWithRetry(r, d.deps, async (v) => { + checked.push(v); + if (v === "ghp_old") throw rejected(); + return "identity"; + }); + expect(out).toEqual({ + resolved: { name: "GITHUB_TOKEN", value: "ghp_new", source: "prompt" }, + result: "identity", + }); + expect(checked).toEqual(["ghp_old", "ghp_new"]); + expect(kc.entries()).toEqual({ "compose/GITHUB_TOKEN": "ghp_local" }); + expect(d.calls).toEqual({ + ...none, + prompt: ["GitHub token (input hidden): "], + log: [ + "GITHUB_TOKEN: from the system keychain (k8s:ctx/devbox)", + "The saved GITHUB_TOKEN for k8s:ctx/devbox was rejected; removing it", + "GitHub rejected GITHUB_TOKEN (GET /user returned 401)", + "Enter GITHUB_TOKEN again, or press Enter on an empty value to exit.", + ], + }); + // The new value can then be saved in its place. + await offerSave(out.resolved, d.deps); + expect(kc.entries()).toEqual({ + "compose/GITHUB_TOKEN": "ghp_local", + "k8s:ctx/devbox/GITHUB_TOKEN": "ghp_new", + }); + }); + + it("a rejected typed value is asked again until accepted", async () => { + const d = deps({ answers: ["sk-bad-1", "sk-bad-2", "sk-good"], gh: "missing" }); + const typed = await resolveSecret("LLM_API_KEY", d.deps); + expect(typed.value).toBe("sk-bad-1"); + const checked: string[] = []; + const out = await checkWithRetry(typed, d.deps, async (v) => { + checked.push(v); + if (v !== "sk-good") throw new CredentialRejected(`LLM check failed for ${v}`); + return "ok"; + }); + expect(out).toEqual({ + resolved: { name: "LLM_API_KEY", value: "sk-good", source: "prompt" }, + result: "ok", + }); + expect(checked).toEqual(["sk-bad-1", "sk-bad-2", "sk-good"]); + expect(d.calls).toEqual({ + ...none, + prompt: [ + "LLM API key (input hidden): ", + "LLM API key (input hidden): ", + "LLM API key (input hidden): ", + ], + log: [ + "LLM check failed for sk-bad-1", + "Enter LLM_API_KEY again, or press Enter on an empty value to exit.", + "LLM check failed for sk-bad-2", + "Enter LLM_API_KEY again, or press Enter on an empty value to exit.", + ], + }); + }); + + it("an empty answer after a rejection exits naming the secret", async () => { + const kc = new MemoryKeychain({ "compose/LLM_API_KEY": "sk-old" }); + const d = deps({ keychain: kc, answers: [""] }); + const r = await resolveSecret("LLM_API_KEY", d.deps); + const run = checkWithRetry(r, d.deps, async () => { + throw new CredentialRejected("LLM check failed: 401"); + }); + await expect(run).rejects.toThrow(SecretError); + await expect(run).rejects.toThrow("no LLM API key: set LLM_API_KEY or enter one at the prompt"); + expect(kc.entries()).toEqual({}); + expect(d.calls).toEqual({ + ...none, + prompt: ["LLM API key (input hidden): "], + log: [ + "LLM_API_KEY: from the system keychain (compose)", + "The saved LLM_API_KEY for compose was rejected; removing it", + "LLM check failed: 401", + "Enter LLM_API_KEY again, or press Enter on an empty value to exit.", + ], + }); + }); + + it("no terminal stops after removing a rejected keychain value", async () => { + const kc = new MemoryKeychain({ "compose/LLM_API_KEY": "sk-old" }); + const d = deps({ keychain: kc, isTTY: false }); + const r = await resolveSecret("LLM_API_KEY", d.deps); + const err = new CredentialRejected( + "LLM check failed: POST https://llm.example.com/v1/responses returned 401 (check llm.baseUrl, llm.model and LLM_API_KEY)", + ); + await expect( + checkWithRetry(r, d.deps, async () => { + throw err; + }), + ).rejects.toBe(err); + expect(kc.entries()).toEqual({}); + expect(d.calls).toEqual({ + ...none, + log: [ + "LLM_API_KEY: from the system keychain (compose)", + "The saved LLM_API_KEY for compose was rejected; removing it", + ], + }); + }); + + it("a rejected env value stops", async () => { + const kc = new MemoryKeychain({ "compose/GITHUB_TOKEN": "ghp_saved" }); + const d = deps({ keychain: kc, env: { GITHUB_TOKEN: "ghp_env" }, answers: ["x"] }); + const r = await resolveSecret("GITHUB_TOKEN", d.deps); + const err = rejected(); + await expect( + checkWithRetry(r, d.deps, async () => { + throw err; + }), + ).rejects.toBe(err); + const p = deps({ gh: GH_OK }); + const fromGh = await resolveSecret("GITHUB_TOKEN", p.deps); + await expect( + checkWithRetry(fromGh, p.deps, async () => { + throw err; + }), + ).rejects.toBe(err); + expect(kc.entries()).toEqual({ "compose/GITHUB_TOKEN": "ghp_saved" }); + expect(d.calls).toEqual({ ...none, log: ["GITHUB_TOKEN: from your environment"] }); + expect(p.calls).toEqual({ ...none, run: [GH], log: ["GITHUB_TOKEN: from gh"] }); + }); + + it("other failures of a keychain value are not rejections", async () => { + const kc = new MemoryKeychain({ "compose/GITHUB_TOKEN": "ghp_saved" }); + const d = deps({ keychain: kc }); + const r = await resolveSecret("GITHUB_TOKEN", d.deps); + const err = new PreflightError("GitHub rate limit hit"); + await expect( + checkWithRetry(r, d.deps, async () => { + throw err; + }), + ).rejects.toBe(err); + expect(kc.entries()).toEqual({ "compose/GITHUB_TOKEN": "ghp_saved" }); + expect(d.calls).toEqual({ + ...none, + log: ["GITHUB_TOKEN: from the system keychain (compose)"], + }); + }); +}); + +describe("peekSecret", () => { + it("peek reports env/keychain/missing and never prompts", async () => { + const kc = new MemoryKeychain({ "compose/LLM_API_KEY": "sk-saved" }); + const d = deps({ + keychain: kc, + env: { GITHUB_TOKEN: "", LINEAR_CLIENT_SECRET: "" }, + gh: GH_OK, + }); + expect(await peekSecret("GITHUB_TOKEN", d.deps)).toBe("env"); + expect(await peekSecret("LLM_API_KEY", d.deps)).toBe("keychain"); + expect(await peekSecret("LINEAR_CLIENT_SECRET", d.deps)).toBe("missing"); + expect(await peekSecret("LINEAR_WEBHOOK_SECRET", d.deps)).toBe("missing"); + expect(d.calls).toEqual(none); + }); + + it("reports gh for GITHUB_TOKEN when only `gh auth token` has one", async () => { + const ok = deps({ gh: GH_OK }); + expect(await peekSecret("GITHUB_TOKEN", ok.deps)).toBe("gh"); + expect(await peekSecret("LLM_API_KEY", ok.deps)).toBe("missing"); + expect(ok.calls).toEqual({ ...none, run: [GH] }); + const saved = deps({ + gh: GH_OK, + keychain: new MemoryKeychain({ "compose/GITHUB_TOKEN": "ghp_saved" }), + }); + expect(await peekSecret("GITHUB_TOKEN", saved.deps)).toBe("keychain"); + expect(saved.calls).toEqual(none); + for (const gh of ["missing", { code: 1, stdout: "" }, { code: 0, stdout: "\n" }] as const) { + const d = deps({ gh }); + expect(await peekSecret("GITHUB_TOKEN", d.deps)).toBe("missing"); + expect(d.calls).toEqual({ ...none, run: [GH] }); + } + }); + + it("needs only env, keychain, deployment and run", async () => { + const run = async () => GH_OK; + expect( + await peekSecret("GITHUB_TOKEN", { env: {}, keychain: undefined, deployment: COMPOSE, run }), + ).toBe("gh"); + }); +}); diff --git a/control-plane/scripts/lib/secrets.ts b/control-plane/scripts/lib/secrets.ts new file mode 100644 index 0000000..382cdb6 --- /dev/null +++ b/control-plane/scripts/lib/secrets.ts @@ -0,0 +1,199 @@ +// Where `bun run up` and `bun run k8s:install` find the real secrets: +// env var → the deployment's keychain entry → `gh auth token` (for +// GITHUB_TOKEN only) → a hidden prompt (terminal only) → an offer to save the +// typed value. Values never reach argv, logs, files or error messages. +// +// A GITHUB_TOKEN or LLM_API_KEY that is set, even to the empty string, wins: +// an empty GITHUB_TOKEN means "no token" (anonymous clones; the checks before +// startup then refuse it), an empty LLM_API_KEY an endpoint that needs no key. +// An empty Linear, Slack or webhook secret counts as missing. +import { installCommand } from "../k8s/kind"; +import { CredentialRejected } from "../up/preflight"; +import { type Deployment, deploymentScope, entryName, type Keychain } from "./keychain"; + +export type SecretName = + | "GITHUB_TOKEN" + | "LLM_API_KEY" + | "LINEAR_CLIENT_SECRET" + | "LINEAR_WEBHOOK_SECRET" + | "SLACK_BOT_TOKEN" + | "GITHUB_WEBHOOK_SECRET" + | "GITHUB_APP_PRIVATE_KEY"; + +const COMPOSE_SECRET_NAMES: readonly SecretName[] = [ + "GITHUB_TOKEN", + "LLM_API_KEY", + "LINEAR_CLIENT_SECRET", + "LINEAR_WEBHOOK_SECRET", + "GITHUB_APP_PRIVATE_KEY", + "GITHUB_WEBHOOK_SECRET", +]; + +// Slack is only wired up on Kubernetes. +const K8S_SECRET_NAMES: readonly SecretName[] = [...COMPOSE_SECRET_NAMES, "SLACK_BOT_TOKEN"]; + +export const secretNamesFor = (d: Deployment): readonly SecretName[] => + d.kind === "compose" ? COMPOSE_SECRET_NAMES : K8S_SECRET_NAMES; + +export type SecretSource = "env" | "keychain" | "gh" | "prompt"; +export type Resolved = { name: SecretName; value: string; source: SecretSource }; + +export type ResolverDeps = { + env: Record; + keychain: Keychain | undefined; // undefined = --no-keychain + deployment: Deployment; + // On k8s, the target file, for the hint in messages. + targetPath?: string; + isTTY: boolean; + // Asks with hidden input and returns the answer (empty at EOF). + prompt: (q: string) => Promise; + // A yes/no question; Enter alone answers `defaultYes`, Ctrl-D no. + confirm: (q: string, opts: { defaultYes: boolean }) => Promise; + // Runs a command and returns its exit code and stdout. Throws if it can't start. + run: (cmd: string[]) => Promise<{ code: number; stdout: string }>; + log: (line: string) => void; +}; + +export class SecretError extends Error {} + +const LABELS: Record = { + GITHUB_TOKEN: "GitHub token", + LLM_API_KEY: "LLM API key", + LINEAR_CLIENT_SECRET: "Linear client secret", + LINEAR_WEBHOOK_SECRET: "Linear webhook signing secret", + SLACK_BOT_TOKEN: "Slack bot token", + GITHUB_WEBHOOK_SECRET: "GitHub webhook secret", + GITHUB_APP_PRIVATE_KEY: "GitHub App private key", +}; + +const EMPTY_IS_SET: ReadonlySet = new Set(["GITHUB_TOKEN", "LLM_API_KEY"]); + +// "a", "a or b", "a, b, or c". +const either = (xs: string[]): string => + xs.length < 3 ? xs.join(" or ") : `${xs.slice(0, -1).join(", ")}, or ${xs.at(-1)}`; + +// The interactive run that offers to save a typed secret: there is no +// `secrets set`. +const savingCommand = (deps: ResolverDeps): string => + deps.deployment.kind === "compose" + ? "bun run up" + : installCommand(deps.deployment.context, deps.targetPath ?? ""); + +function missing(name: SecretName, deps: ResolverDeps, prompted: boolean): SecretError { + const ways = [`set ${name}`]; + if (name === "GITHUB_TOKEN") ways.push("log in with `gh auth login`"); + if (prompted) ways.push("enter one at the prompt"); + else if (deps.keychain && !deps.keychain.disabled) { + ways.push(`run \`${savingCommand(deps)}\` once in a terminal to save it in the keychain`); + } + return new SecretError(`no ${LABELS[name]}: ${either(ways)}`); +} + +// What `secrets ls` needs to tell where a secret would come from. +export type PeekDeps = Pick; + +// The env var or the deployment's keychain entry. +async function stored(name: SecretName, deps: PeekDeps): Promise { + const env = deps.env[name]; + if (env !== undefined && (env !== "" || EMPTY_IS_SET.has(name))) { + return { name, value: env, source: "env" }; + } + const saved = await deps.keychain?.get(entryName(deps.deployment, name)); + return saved ? { name, value: saved, source: "keychain" } : undefined; +} + +async function ghToken(deps: Pick): Promise { + try { + const { code, stdout } = await deps.run(["gh", "auth", "token"]); + return code === 0 ? stdout.trim() : ""; + } catch { + return ""; // gh is not installed + } +} + +async function promptFor(name: SecretName, deps: ResolverDeps): Promise { + const value = (await deps.prompt(`${LABELS[name]} (input hidden): `)).trim(); + if (!value) throw missing(name, deps, true); + return { name, value, source: "prompt" }; +} + +export async function resolveSecret( + name: SecretName, + deps: ResolverDeps, + opts: { noPrompt?: boolean } = {}, +): Promise { + const found = await stored(name, deps); + if (found) { + deps.log( + found.source === "env" + ? `${name}: from your environment` + : `${name}: from the system keychain (${deploymentScope(deps.deployment)})`, + ); + return found; + } + if (name === "GITHUB_TOKEN") { + const value = await ghToken(deps); + if (value) { + deps.log("GITHUB_TOKEN: from gh"); + return { name, value, source: "gh" }; + } + } + if (!deps.isTTY || opts.noPrompt) throw missing(name, deps, false); + return promptFor(name, deps); +} + +// Where a secret would come from, without prompting. For GITHUB_TOKEN it may +// run `gh auth token` (read-only); the value is dropped. +export async function peekSecret( + name: SecretName, + deps: PeekDeps, +): Promise { + const found = await stored(name, deps); + if (found) return found.source; + if (name === "GITHUB_TOKEN" && (await ghToken(deps))) return "gh"; + return "missing"; +} + +// After a prompt and that value's own check (or right away when its check is +// skipped), with a terminal and a working keychain. Values from env or `gh` +// are never saved. Not destructive, so Enter alone saves. +export async function offerSave(r: Resolved, deps: ResolverDeps): Promise { + const k = deps.keychain; + if (r.source !== "prompt" || !deps.isTTY || !k || k.disabled) return; + const scope = deploymentScope(deps.deployment); + if ( + await deps.confirm(`Save ${r.name} to the system keychain for ${scope}? [Y/n] `, { + defaultYes: true, + }) + ) { + await k.set(entryName(deps.deployment, r.name), r.value); + } +} + +// Runs `check` on the value. A saved value the service rejects is +// removed. A rejected saved or typed value, with a terminal, is asked for again +// until one is accepted; an empty answer exits. A rejected value from env or +// `gh`, or any rejection without a terminal, stops the run. +export async function checkWithRetry( + r: Resolved, + deps: ResolverDeps, + check: (value: string) => Promise, +): Promise<{ resolved: Resolved; result: T }> { + let current = r; + for (;;) { + try { + return { resolved: current, result: await check(current.value) }; + } catch (err) { + if (!(err instanceof CredentialRejected)) throw err; + if (current.source === "keychain" && deps.keychain) { + const scope = deploymentScope(deps.deployment); + deps.log(`The saved ${current.name} for ${scope} was rejected; removing it`); + await deps.keychain.delete(entryName(deps.deployment, current.name)); + } + if (!deps.isTTY || (current.source !== "keychain" && current.source !== "prompt")) throw err; + deps.log(err.message); + deps.log(`Enter ${current.name} again, or press Enter on an empty value to exit.`); + current = await promptFor(current.name, deps); + } + } +} diff --git a/control-plane/scripts/linear.ts b/control-plane/scripts/linear.ts new file mode 100644 index 0000000..0a1d7d0 --- /dev/null +++ b/control-plane/scripts/linear.ts @@ -0,0 +1,166 @@ +// The Linear integration: +// bun run linear:setup [--target ] [--public-url ] [--name ] +// [--reregister] [--no-keychain] +// bun run linear:status [--target ] [--no-keychain] +// bun run linear:disable [--target ] [--no-keychain] [--forget [--yes]] +// Without --target it acts on the Compose deployment (devbox.config.json), +// with it on the k8s deployment that target file describes. Secret values are +// never printed. +import { parseArgs } from "node:util"; +import { K8sPreflightError } from "./k8s/preflight"; +import { KubeError } from "./k8s/run"; +import { capture } from "./lib/capture"; +import { DestructiveRefused } from "./lib/destructive"; +import { keychainFor } from "./lib/keychain"; +import { errorMessage, isKnownError } from "./lib/knownError"; +import { openUrl } from "./lib/openUrl"; +import type { ResolverDeps } from "./lib/secrets"; +import { loadDeployment } from "./linear/deployment"; +import { disable } from "./linear/disable"; +import { SetupError, setup } from "./linear/setup"; +import { status } from "./linear/status"; +import { confirm, promptHidden, promptLine } from "./up/prompt"; + +const COMMANDS = ["setup", "status", "disable"]; + +const USAGE = `Usage: + bun run linear:setup [--target ] [--public-url ] [--name ] + [--reregister] [--no-keychain] + +Registers a Linear app for devbox, saves its client ID and public URL in the +deployment's config and, if you choose, its secrets in the system keychain. +It applies nothing: it names the command that does (bun run up, +k8s:install with --target, or k8s:kind on kind). + + bun run linear:status [--target ] [--no-keychain] + bun run linear:disable [--target ] [--no-keychain] [--forget [--yes]] + +status shows the configuration, where the secrets are found, what the control +plane reports and whether the webhook URL reaches devbox. disable switches +Linear off (enabled: false), keeping the app's settings and saved secrets so +that linear:setup turns it back on; it names the command that applies it. +--forget removes integrations.linear and the saved Linear secrets instead, +after you confirm (--yes confirms without a terminal); the app in Linear is +left for you to delete. + + --target the k8s deployment's target file (default: Compose) + --public-url the public HTTPS URL Linear sends webhooks to + --name the app's name in Linear, when registering (default: devbox) + --reregister register a new app even if a client ID is configured + --no-keychain never read or write the system keychain + --forget disable: delete the configuration and saved secrets + --yes disable --forget: confirm without asking +`; + +class UsageError extends Error {} + +const log = (line: string) => process.stdout.write(`${line}\n`); + +const SETUP_FLAGS = { + target: { type: "string" }, + "public-url": { type: "string" }, + name: { type: "string" }, + reregister: { type: "boolean" }, + "no-keychain": { type: "boolean" }, + help: { type: "boolean", short: "h" }, +} as const; + +const OTHER_FLAGS = { + target: { type: "string" }, + "no-keychain": { type: "boolean" }, + forget: { type: "boolean" }, + yes: { type: "boolean" }, + help: { type: "boolean", short: "h" }, +} as const; + +function resolverFor(d: ReturnType, noKeychain: boolean): ResolverDeps { + return { + env: process.env, + keychain: keychainFor(noKeychain), + deployment: d.deployment, + ...(d.deployment.kind === "k8s" ? { targetPath: d.configPath } : {}), + isTTY: process.stdin.isTTY === true, + prompt: promptHidden, + confirm, + run: (cmd) => capture(cmd), + log, + }; +} + +async function main(command: string | undefined, argv: string[]): Promise { + if (command === "--help" || command === "-h" || command === "help") { + process.stdout.write(USAGE); + return 0; + } + switch (command) { + case "setup": { + const { values } = parseArgs({ args: argv, options: SETUP_FLAGS }); + if (values.help) { + process.stdout.write(USAGE); + return 0; + } + const noKeychain = values["no-keychain"] === true; + const d = loadDeployment({ target: values.target, noKeychain }); + await setup( + d, + { + publicUrl: values["public-url"], + name: values.name ?? "devbox", + reregister: values.reregister === true, + }, + { resolver: resolverFor(d, noKeychain), promptLine, openUrl, fetch, log }, + ); + return 0; + } + case "status": + case "disable": { + const { values } = parseArgs({ args: argv, options: OTHER_FLAGS }); + if (values.help) { + process.stdout.write(USAGE); + return 0; + } + if (command === "status" && (values.forget || values.yes)) { + throw new UsageError("--forget and --yes belong to linear:disable"); + } + if (values.yes && !values.forget) throw new UsageError("--yes goes with --forget"); + const noKeychain = values["no-keychain"] === true; + const d = loadDeployment({ target: values.target, noKeychain }); + const resolver = resolverFor(d, noKeychain); + if (command === "status") await status(d, { resolver, fetch, log }); + else { + await disable( + d, + { forget: values.forget === true, yes: values.yes === true }, + { resolver, log }, + ); + } + return 0; + } + default: + throw new UsageError(`unknown command "${command ?? ""}": use one of ${COMMANDS.join(", ")}`); + } +} + +if (import.meta.main) { + const [command, ...argv] = process.argv.slice(2); + try { + process.exit(await main(command, argv)); + } catch (err) { + process.stderr.write(`\nbun run linear:${command ?? ""}: ${errorMessage(err)}\n`); + if ( + !isKnownError( + err, + UsageError, + SetupError, + KubeError, + K8sPreflightError, + DestructiveRefused, + ) && + err instanceof Error && + err.stack + ) { + process.stderr.write(`${err.stack}\n`); + } + process.exit(1); + } +} diff --git a/control-plane/scripts/linear/check.test.ts b/control-plane/scripts/linear/check.test.ts new file mode 100644 index 0000000..3e9f60c --- /dev/null +++ b/control-plane/scripts/linear/check.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "bun:test"; +import { CredentialRejected, type Fetch } from "../up/preflight"; +import { checkLinear, LinearCheckError } from "./check"; + +const TOKEN_URL = "https://api.linear.app/oauth/token"; +const GRAPHQL_URL = "https://api.linear.app/graphql"; +const SECRET = "s3cret-value"; + +type Req = { method: string; url: string; headers: Record; body: string }; +type Answer = [number, unknown] | Error; + +function fakeFetch(routes: Record): { fetch: Fetch; requests: Req[] } { + const requests: Req[] = []; + const fetch: Fetch = async (url, init) => { + requests.push({ + method: init?.method ?? "GET", + url: String(url), + headers: Object.fromEntries(new Headers(init?.headers).entries()), + body: String(init?.body ?? ""), + }); + const route = routes[String(url)]; + if (route instanceof Error) throw route; + if (!route) return new Response("not routed", { status: 599 }); + return Response.json(route[1], { status: route[0] }); + }; + return { fetch, requests }; +} + +const tokenOk: Answer = [ + 200, + { access_token: "lin_oauth_tok", token_type: "Bearer", expires_in: 2592000, scope: "read" }, +]; + +async function failure(routes: Record): Promise { + const { fetch } = fakeFetch(routes); + try { + await checkLinear("client-1", SECRET, fetch); + } catch (err) { + expect((err as Error).message).not.toContain(SECRET); + return err as Error; + } + throw new Error("expected checkLinear to fail"); +} + +describe("checkLinear", () => { + it("returns the identity", async () => { + const { fetch, requests } = fakeFetch({ + [TOKEN_URL]: tokenOk, + [GRAPHQL_URL]: [ + 200, + { + data: { + viewer: { name: "devbox" }, + organization: { name: "Example", urlKey: "example" }, + }, + }, + ], + }); + expect(await checkLinear("client-1", SECRET, fetch)).toEqual({ + viewerName: "devbox", + workspaceName: "Example", + urlKey: "example", + }); + expect(requests).toHaveLength(2); + expect(requests[0]?.url).toBe(TOKEN_URL); + expect(Object.fromEntries(new URLSearchParams(requests[0]?.body))).toEqual({ + grant_type: "client_credentials", + client_id: "client-1", + client_secret: SECRET, + scope: "read,write,app:assignable,app:mentionable", + }); + const gql = requests[1]; + expect(gql?.method).toBe("POST"); + expect(gql?.url).toBe(GRAPHQL_URL); + expect(gql?.headers.authorization).toBe("Bearer lin_oauth_tok"); + expect(gql?.headers["content-type"]).toBe("application/json"); + expect(JSON.parse(gql?.body ?? "")).toEqual({ + query: "{ viewer { name } organization { name urlKey } }", + }); + }); + + it("invalid_client is a rejected credential", async () => { + const err = await failure({ + [TOKEN_URL]: [401, { error: "invalid_client", error_description: "Invalid client" }], + }); + expect(err).toBeInstanceOf(CredentialRejected); + expect(err.message).toBe( + "Linear rejected the client ID or LINEAR_CLIENT_SECRET (invalid_client): copy them again from the app's page in Linear (Settings → API → Applications)", + ); + }); + + it("an app without client credentials is told to turn them on", async () => { + const err = await failure({ + [TOKEN_URL]: [ + 400, + { + error: "unauthorized_client", + error_description: "Client does not support the client_credentials grant type", + }, + ], + }); + expect(err).toBeInstanceOf(LinearCheckError); + expect(err).not.toBeInstanceOf(CredentialRejected); + expect(err.message).toBe( + "the Linear app does not allow client credentials: turn it on in Linear (Settings → API → Applications → the app)", + ); + }); + + it("a network error on the token request names the egress", async () => { + const err = await failure({ [TOKEN_URL]: new TypeError("Unable to connect") }); + expect(err).toBeInstanceOf(LinearCheckError); + expect(err.message).toBe("can't reach api.linear.app: check this machine's egress or proxy"); + }); + + it("a network error on the GraphQL query names the egress", async () => { + const err = await failure({ + [TOKEN_URL]: tokenOk, + [GRAPHQL_URL]: new TypeError("Unable to connect"), + }); + expect(err).toBeInstanceOf(LinearCheckError); + expect(err.message).toBe("can't reach api.linear.app: check this machine's egress or proxy"); + }); + + it("another token failure keeps Linear's code and description", async () => { + const err = await failure({ + [TOKEN_URL]: [500, { error: "server_error", error_description: "try later" }], + }); + expect(err).toBeInstanceOf(LinearCheckError); + expect(err.message).toBe( + "Linear check failed: Linear token request failed with status 500: server_error (try later)", + ); + }); + + it("a failed GraphQL query gives the status", async () => { + const err = await failure({ [TOKEN_URL]: tokenOk, [GRAPHQL_URL]: [500, { oops: 1 }] }); + expect(err).toBeInstanceOf(LinearCheckError); + expect(err.message).toBe("Linear check failed: the identity query returned 500"); + }); + + it("GraphQL errors are reported", async () => { + const err = await failure({ + [TOKEN_URL]: tokenOk, + [GRAPHQL_URL]: [200, { errors: [{ message: "forbidden" }, { message: "nope" }] }], + }); + expect(err).toBeInstanceOf(LinearCheckError); + expect(err.message).toBe("Linear check failed: the identity query failed: forbidden; nope"); + }); + + describe("a new app", () => { + const notReady: Answer = [200, { errors: [{ message: "Entity not found: Organization" }] }]; + const identity: Answer = [ + 200, + { + data: { viewer: { name: "devbox" }, organization: { name: "Example", urlKey: "example" } }, + }, + ]; + + // The GraphQL answers in order; the token request always succeeds. + function sequence(answers: Answer[]): { fetch: Fetch; tokens: () => number } { + let tokens = 0; + const fetch: Fetch = async (url) => { + if (String(url) === TOKEN_URL) { + tokens++; + return Response.json((tokenOk as [number, unknown])[1]); + } + const a = answers.shift() ?? notReady; + if (a instanceof Error) throw a; + return Response.json(a[1], { status: a[0] }); + }; + return { fetch, tokens: () => tokens }; + } + + it("is asked again, with a new token, until Linear knows its workspace", async () => { + const { fetch, tokens } = sequence([notReady, notReady, identity]); + const slept: number[] = []; + const log: string[] = []; + const id = await checkLinear("client-1", SECRET, fetch, { + sleep: async (ms) => void slept.push(ms), + log: (l) => void log.push(l), + }); + expect(id.workspaceName).toBe("Example"); + expect(slept).toEqual([5000, 5000]); + expect(tokens()).toBe(3); + expect(log).toEqual([ + "Linear does not know the app's workspace yet, as for a new app: trying again for up to 60 seconds", + ]); + }); + + it("gives up after 60 seconds and says to run the command again", async () => { + const { fetch } = sequence([]); + const slept: number[] = []; + const run = checkLinear("client-1", SECRET, fetch, { + sleep: async (ms) => void slept.push(ms), + }); + await expect(run).rejects.toThrow( + "Linear check failed: the identity query failed: Entity not found: Organization. A new Linear app answers this for about a minute: run the command again", + ); + await expect(run).rejects.toBeInstanceOf(LinearCheckError); + expect(slept.reduce((a, b) => a + b, 0)).toBe(60_000); + }); + + it("other GraphQL errors are not retried", async () => { + const { fetch } = sequence([[200, { errors: [{ message: "Entity not found: Team" }] }]]); + const slept: number[] = []; + await expect( + checkLinear("client-1", SECRET, fetch, { sleep: async (ms) => void slept.push(ms) }), + ).rejects.toThrow("Linear check failed: the identity query failed: Entity not found: Team"); + expect(slept).toEqual([]); + }); + }); + + it("an answer without the identity is refused", async () => { + const err = await failure({ + [TOKEN_URL]: tokenOk, + [GRAPHQL_URL]: [200, { data: { viewer: { name: "devbox" } } }], + }); + expect(err).toBeInstanceOf(LinearCheckError); + expect(err.message).toBe( + "Linear check failed: the identity query's answer has no viewer or organization", + ); + }); +}); diff --git a/control-plane/scripts/linear/check.ts b/control-plane/scripts/linear/check.ts new file mode 100644 index 0000000..95d3b99 --- /dev/null +++ b/control-plane/scripts/linear/check.ts @@ -0,0 +1,132 @@ +// The Linear check before startup requests a client-credentials token for the +// configured app, then fetches the identity it acts as. Each failure +// names the cause and the next action; the client secret never appears in a +// message. +// +// A Linear app answers "Entity not found: Organization" for a while after it +// is created (about a minute, seen on a new app): the check retries that one +// answer, with a fresh token each time, before it fails. +import { LinearTokenError, requestClientCredentialsToken } from "../../src/clients/linearOauth"; +import { CredentialRejected, type Fetch, PreflightError } from "../up/preflight"; + +export type LinearIdentity = { viewerName: string; workspaceName: string; urlKey: string }; + +export class LinearCheckError extends PreflightError {} + +const GRAPHQL_URL = "https://api.linear.app/graphql"; +const QUERY = "{ viewer { name } organization { name urlKey } }"; +const UNREACHABLE = "can't reach api.linear.app: check this machine's egress or proxy"; +const NEW_APP = /Entity not found: Organization/; +const RETRY_MS = 5_000; +const RETRY_FOR_MS = 60_000; + +export type CheckLinearOptions = { + sleep?: (ms: number) => Promise; + // Told once, before the first retry. + log?: (line: string) => void; +}; + +// Linear's answer while it is still setting up a new app. +class AppNotReady extends LinearCheckError {} + +function tokenFailure(err: unknown): Error { + if (!(err instanceof LinearTokenError)) return err as Error; + if (err.code === "invalid_client") { + return new CredentialRejected( + "Linear rejected the client ID or LINEAR_CLIENT_SECRET (invalid_client): copy them again from the app's page in Linear (Settings → API → Applications)", + ); + } + if (err.description?.includes("client_credentials")) { + return new LinearCheckError( + "the Linear app does not allow client credentials: turn it on in Linear (Settings → API → Applications → the app)", + ); + } + // No status: the request never got an answer. + if (err.status === undefined) return new LinearCheckError(UNREACHABLE); + return new LinearCheckError(`Linear check failed: ${err.message}`); +} + +type GraphqlAnswer = { + data?: { + viewer?: { name?: unknown }; + organization?: { name?: unknown; urlKey?: unknown }; + }; + errors?: Array<{ message?: unknown }>; +}; + +export async function checkLinear( + clientId: string, + clientSecret: string, + fetch: Fetch, + opts: CheckLinearOptions = {}, +): Promise { + const sleep = opts.sleep ?? Bun.sleep; + for (let waited = 0; ; waited += RETRY_MS) { + try { + return await checkOnce(clientId, clientSecret, fetch); + } catch (err) { + if (!(err instanceof AppNotReady) || waited >= RETRY_FOR_MS) throw err; + if (waited === 0) { + opts.log?.( + `Linear does not know the app's workspace yet, as for a new app: trying again for up to ${RETRY_FOR_MS / 1000} seconds`, + ); + } + await sleep(RETRY_MS); + } + } +} + +async function checkOnce( + clientId: string, + clientSecret: string, + fetch: Fetch, +): Promise { + let accessToken: string; + try { + ({ accessToken } = await requestClientCredentialsToken( + { clientId, clientSecret }, + fetch as typeof globalThis.fetch, + )); + } catch (err) { + throw tokenFailure(err); + } + + let res: Response; + try { + res = await fetch(GRAPHQL_URL, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ query: QUERY }), + }); + } catch { + throw new LinearCheckError(UNREACHABLE); + } + if (!res.ok) { + await res.body?.cancel(); + throw new LinearCheckError(`Linear check failed: the identity query returned ${res.status}`); + } + const body = (await res.json().catch(() => ({}))) as GraphqlAnswer; + if (body.errors?.length) { + const messages = body.errors.map((e) => String(e.message ?? "unknown error")).join("; "); + const failed = `Linear check failed: the identity query failed: ${messages}`; + if (NEW_APP.test(messages)) { + throw new AppNotReady( + `${failed}. A new Linear app answers this for about a minute: run the command again`, + ); + } + throw new LinearCheckError(failed); + } + const viewerName = body.data?.viewer?.name; + const workspaceName = body.data?.organization?.name; + const urlKey = body.data?.organization?.urlKey; + if ( + typeof viewerName !== "string" || + typeof workspaceName !== "string" || + typeof urlKey !== "string" + ) { + throw new LinearCheckError( + "Linear check failed: the identity query's answer has no viewer or organization", + ); + } + return { viewerName, workspaceName, urlKey }; +} diff --git a/control-plane/scripts/linear/deployment.test.ts b/control-plane/scripts/linear/deployment.test.ts new file mode 100644 index 0000000..5a75f20 --- /dev/null +++ b/control-plane/scripts/linear/deployment.test.ts @@ -0,0 +1,102 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CliConfigError } from "../up/cliLogin"; +import { loadDeployment } from "./deployment"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-linear-deployment-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +// No CLI profiles: an empty config directory. +let savedXdg: string | undefined; +beforeEach(() => { + savedXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = mkdtempSync(join(dir, "config-")); +}); +afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; +}); + +const LINEAR = { enabled: true, clientId: "client-1" }; +const PUBLIC = "https://hooks.example.com"; + +let n = 0; +function targetFile(content: unknown): string { + const path = join(dir, `t${n++}.json`); + writeFileSync(path, JSON.stringify(content)); + return path; +} + +describe("loadDeployment on k8s", () => { + it("kind's target file: applied with k8s:kind", async () => { + const path = targetFile({ + context: "kind-devbox", + profile: "k8s-kind-devbox", + publicUrl: PUBLIC, + values: {}, + integrations: { linear: LINEAR }, + }); + const { api, ...d } = loadDeployment({ target: path, noKeychain: true }); + expect(d).toEqual({ + deployment: { kind: "k8s", context: "kind-devbox", namespace: "devbox" }, + configPath: path, + linear: LINEAR, + publicUrl: PUBLIC, + fromApiIngress: false, + applyCommand: "bun run k8s:kind", + statusCommand: `bun run linear:status --target ${path}`, + }); + await expect(api("/api/integrations/linear")).rejects.toThrow( + new CliConfigError('no CLI profile "k8s-kind-devbox": run `bun run k8s:kind` first'), + ); + }); + + it("an enabled GitHub App carries its slug, token mode and owning organization", () => { + const path = targetFile({ + context: "kind-devbox", + profile: "k8s-kind-devbox", + publicUrl: PUBLIC, + values: {}, + integrations: { + githubApp: { + enabled: true, + appId: 123, + slug: "example-devbox", + installationId: 7, + botUserId: 9, + tokens: "devbox", + org: "example-org", + }, + }, + }); + const d = loadDeployment({ target: path, noKeychain: true }); + expect(d.githubApp).toEqual({ tokens: "devbox", slug: "example-devbox", org: "example-org" }); + }); + + it("any other target file: applied with k8s:install --target", async () => { + const path = targetFile({ + context: "prod", + profile: "k8s-prod", + publicUrl: PUBLIC, + values: {}, + integrations: { linear: LINEAR }, + }); + const { api, ...d } = loadDeployment({ target: path, noKeychain: true }); + expect(d).toEqual({ + deployment: { kind: "k8s", context: "prod", namespace: "devbox" }, + configPath: path, + linear: LINEAR, + publicUrl: PUBLIC, + fromApiIngress: false, + applyCommand: `bun run k8s:install --target ${path}`, + statusCommand: `bun run linear:status --target ${path}`, + }); + await expect(api("/api/integrations/linear")).rejects.toThrow( + new CliConfigError( + `no CLI profile "k8s-prod": run \`bun run k8s:install --target ${path}\` first`, + ), + ); + }); +}); diff --git a/control-plane/scripts/linear/deployment.ts b/control-plane/scripts/linear/deployment.ts new file mode 100644 index 0000000..38ab980 --- /dev/null +++ b/control-plane/scripts/linear/deployment.ts @@ -0,0 +1,129 @@ +// The deployment `linear:*` acts on: Compose without --target, the +// k8s deployment a target file describes with it. Each knows where its +// `integrations.linear` lives, the commands that apply and check it, and how +// to call its API through its CLI profile. Nothing here applies anything. +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { backgroundPortForward, isPortFree, profileFor } from "../k8s/connect"; +import { installCommand } from "../k8s/kind"; +import { kube, NAMESPACE, realRunner } from "../k8s/run"; +import { existingSecret } from "../k8s/secrets"; +import { loadTarget, type Target } from "../k8s/target"; +import { enabledGithubApp, type Integrations, type LinearIntegration } from "../lib/integrations"; +import type { Deployment } from "../lib/keychain"; +import { ingressPublicUrl, ingressSource } from "../lib/publicUrl"; +import { CliConfigError, cliConfigPath, readCliProfile } from "../up/cliLogin"; +import { loadConfig } from "../up/config"; + +export type LinearDeployment = { + deployment: Deployment; + // The file holding integrations.linear: devbox.config.json or the target file. + configPath: string; + linear: LinearIntegration | undefined; + // The deployment's top-level publicUrl, shared with GitHub. + publicUrl?: string; + // k8s: the URL derived from the target's Ingress values, and whether it is + // the api Ingress's (which may be VPN-only). + ingressUrl?: string; + fromApiIngress?: boolean; + // The commands that apply the config (`bun run up`, `bun run k8s:install + // --target `, or `bun run k8s:kind`) and check Linear. + applyCommand: string; + statusCommand: string; + // k8s: the user's own integrations Secret (secrets.integrations.existingSecret). + byoIntegrationsSecret?: string; + // The enabled GitHub App (slug and who mints its tokens): used for the public + // URL change lines and, as `[bot]`, as the bot's GitHub username. + githubApp?: { tokens: "devbox" | "external"; slug: string; org?: string }; + // GET through the deployment's CLI profile; the body is read in full. + api: (path: string) => Promise; +}; + +const ROOT = join(import.meta.dir, "..", "..", ".."); +const CONFIG_PATH = join(ROOT, "devbox.config.json"); +const API_TIMEOUT_MS = 10_000; + +function profileUrl(profile: string, hint: string): { url: string; token: string } { + const saved = readCliProfile(cliConfigPath(process.env, homedir()), profile); + if (!saved) throw new CliConfigError(`no CLI profile "${profile}": run \`${hint}\` first`); + return saved; +} + +// The body is read before returning, so a port-forward can stop after it. +async function apiGet(url: string, token: string, path: string): Promise { + const res = await fetch(`${url}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); + return new Response(await res.arrayBuffer(), { status: res.status, headers: res.headers }); +} + +function githubAppOf(i: Integrations): LinearDeployment["githubApp"] { + const g = enabledGithubApp(i); + return g + ? { tokens: g.tokens, slug: g.slug, ...(g.org !== undefined ? { org: g.org } : {}) } + : undefined; +} + +// The Compose deployment's API, through the "local" CLI profile. +export const composeApi = async (path: string): Promise => { + const p = profileUrl("local", "bun run up"); + return apiGet(p.url, p.token, path); +}; + +// A k8s deployment's API, through its CLI profile; `apply` is named when the +// profile is missing. +export function k8sApi(target: Target, apply: string): (path: string) => Promise { + const k = kube(realRunner, target.context); + return async (apiPath) => { + const p = profileUrl(profileFor(target), apply); + const url = new URL(p.url); + const port = Number(url.port); + // A running `k8s:connect` already forwards the profile's port. + const forward = + url.hostname === "localhost" && (await isPortFree(port)) + ? await backgroundPortForward(k, port) + : undefined; + try { + return await apiGet(p.url, p.token, apiPath); + } finally { + forward?.stop(); + } + }; +} + +export function loadDeployment(opts: { target?: string; noKeychain: boolean }): LinearDeployment { + if (opts.target === undefined) { + const cfg = loadConfig(CONFIG_PATH); + const app = githubAppOf(cfg.integrations); + return { + deployment: { kind: "compose" }, + configPath: CONFIG_PATH, + linear: cfg.integrations.linear, + ...(app ? { githubApp: app } : {}), + ...(cfg.publicUrl !== undefined ? { publicUrl: cfg.publicUrl } : {}), + applyCommand: "bun run up", + statusCommand: "bun run linear:status", + api: composeApi, + }; + } + + const path = resolve(opts.target); + const target = loadTarget(path); + const byo = existingSecret(target.values, "integrations"); + const app = githubAppOf(target.integrations ?? {}); + const apply = installCommand(target.context, path); + return { + deployment: { kind: "k8s", context: target.context, namespace: NAMESPACE }, + configPath: path, + linear: target.integrations?.linear, + ...(app ? { githubApp: app } : {}), + ...(target.publicUrl !== undefined ? { publicUrl: target.publicUrl } : {}), + ingressUrl: ingressPublicUrl(target), + fromApiIngress: ingressSource(target) === "api", + applyCommand: apply, + statusCommand: `bun run linear:status --target ${path}`, + ...(byo !== undefined ? { byoIntegrationsSecret: byo } : {}), + api: k8sApi(target, apply), + }; +} diff --git a/control-plane/scripts/linear/disable.test.ts b/control-plane/scripts/linear/disable.test.ts new file mode 100644 index 0000000..d6ce9f1 --- /dev/null +++ b/control-plane/scripts/linear/disable.test.ts @@ -0,0 +1,265 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DestructiveRefused } from "../lib/destructive"; +import { type Deployment, MemoryKeychain } from "../lib/keychain"; +import type { ResolverDeps } from "../lib/secrets"; +import { type LinearDeployment, loadDeployment } from "./deployment"; +import { disable } from "./disable"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-linear-disable-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const COMPOSE: Deployment = { kind: "compose" }; +const LINEAR = { enabled: true, clientId: "lin-client-id" }; +const OFF = { ...LINEAR, enabled: false }; +let n = 0; + +async function run(o: { + linear?: typeof LINEAR; + forget?: boolean; + yes?: boolean; + isTTY?: boolean; + answer?: boolean; + saved?: Record; + noKeychain?: boolean; + down?: boolean; +}) { + const path = join(dir, `c${n++}.json`); + const original = JSON.stringify({ + port: 1, + ...(o.linear ? { integrations: { linear: o.linear } } : {}), + }); + writeFileSync(path, original); + const keychain = new MemoryKeychain({ + "compose/GITHUB_TOKEN": "c", + ...(o.saved ?? { + "compose/LINEAR_CLIENT_SECRET": "a", + "compose/LINEAR_WEBHOOK_SECRET": "b", + }), + }); + const log: string[] = []; + const asked: string[] = []; + const applied: string[] = []; + const d: LinearDeployment = { + deployment: COMPOSE, + configPath: path, + linear: o.linear, + applyCommand: "bun run up", + statusCommand: "bun run linear:status", + api: async () => { + if (o.down) throw new Error("down"); + return Response.json({ configured: true, appUser: { id: "u", name: "devbox" } }); + }, + }; + const resolver = { + env: {}, + keychain: o.noKeychain ? undefined : keychain, + deployment: COMPOSE, + isTTY: o.isTTY ?? true, + confirm: async (q: string) => { + asked.push(q); + return o.answer ?? false; + }, + run: async (cmd: string[]) => { + applied.push(cmd.join(" ")); + return { code: 0, stdout: "" }; + }, + } as unknown as ResolverDeps; + let error: unknown; + try { + await disable( + d, + { forget: o.forget === true, yes: o.yes === true }, + { resolver, log: (l) => log.push(l) }, + ); + } catch (err) { + error = err; + } + return { + log, + asked, + applied, + error, + path, + keychain, + original: JSON.parse(original), + file: JSON.parse(readFileSync(path, "utf8")), + }; +} + +const ALL_ENTRIES = { + "compose/GITHUB_TOKEN": "c", + "compose/LINEAR_CLIENT_SECRET": "a", + "compose/LINEAR_WEBHOOK_SECRET": "b", +}; + +describe("linear disable", () => { + it("switches off, keeps the block's keys and the keychain", async () => { + const r = await run({ linear: LINEAR }); + expect(r.file).toEqual({ port: 1, integrations: { linear: OFF } }); + expect(r.keychain.entries()).toEqual(ALL_ENTRIES); + expect(r.asked).toEqual([]); + expect(r.log).toEqual([`Disabled Linear in ${r.path}.`, "Next: bun run up"]); + }); + + it("already disabled", async () => { + const r = await run({ linear: OFF }); + expect(r.file).toEqual(r.original); + expect(r.log).toEqual(["Linear is already disabled for compose."]); + }); + + it("not configured", async () => { + for (const forget of [false, true]) { + const r = await run({ forget }); + expect(r.file).toEqual({ port: 1 }); + expect(r.keychain.entries()).toEqual(ALL_ENTRIES); + expect(r.log).toEqual(["Linear is not configured for compose. Nothing to disable."]); + } + }); + + it("--forget lists and removes on yes", async () => { + const r = await run({ linear: OFF, forget: true, answer: true }); + expect(r.asked).toEqual(["Remove these? [y/N] "]); + expect(r.file).toEqual({ port: 1 }); + expect(r.keychain.entries()).toEqual({ "compose/GITHUB_TOKEN": "c" }); + expect(r.log).toEqual([ + "This deletes:", + ` - integrations.linear in ${r.path}`, + " - LINEAR_CLIENT_SECRET for compose in the system keychain", + " - LINEAR_WEBHOOK_SECRET for compose in the system keychain", + "Next: bun run up", + "Delete the app in Linear: Settings → API → Applications → devbox", + ]); + }); + + it("--forget falls back to a generic app name", async () => { + const r = await run({ linear: LINEAR, forget: true, yes: true, down: true }); + expect(r.log).toEqual([ + "Next: bun run up", + "Delete the app in Linear: Settings → API → Applications → the devbox app", + ]); + }); + + it("--forget on no removes nothing", async () => { + const r = await run({ linear: LINEAR, forget: true, answer: false }); + expect(r.file).toEqual(r.original); + expect(r.keychain.entries()).toEqual(ALL_ENTRIES); + expect(r.log).toEqual([ + "This deletes:", + ` - integrations.linear in ${r.path}`, + " - LINEAR_CLIENT_SECRET for compose in the system keychain", + " - LINEAR_WEBHOOK_SECRET for compose in the system keychain", + "Nothing was removed.", + ]); + expect(r.asked).toEqual(["Remove these? [y/N] "]); + }); + + it("--forget without a terminal refuses; --forget --yes removes", async () => { + const refused = await run({ linear: LINEAR, forget: true, isTTY: false }); + expect(refused.error).toBeInstanceOf(DestructiveRefused); + expect((refused.error as Error).message).toBe( + "linear:disable --forget deletes data: pass --yes to confirm without a terminal", + ); + expect(refused.file).toEqual(refused.original); + expect(refused.keychain.entries()).toEqual(ALL_ENTRIES); + + const yes = await run({ linear: LINEAR, forget: true, yes: true, isTTY: false }); + expect(yes.error).toBeUndefined(); + expect(yes.asked).toEqual([]); + expect(yes.file).toEqual({ port: 1 }); + expect(yes.keychain.entries()).toEqual({ "compose/GITHUB_TOKEN": "c" }); + }); + + it("--forget lists only saved entries", async () => { + const some = await run({ + linear: LINEAR, + forget: true, + answer: true, + saved: { "compose/LINEAR_WEBHOOK_SECRET": "b" }, + }); + expect(some.log).toEqual([ + "This deletes:", + ` - integrations.linear in ${some.path}`, + " - LINEAR_WEBHOOK_SECRET for compose in the system keychain", + "Next: bun run up", + "Delete the app in Linear: Settings → API → Applications → devbox", + ]); + expect(some.keychain.entries()).toEqual({ "compose/GITHUB_TOKEN": "c" }); + + const none = await run({ linear: LINEAR, forget: true, answer: true, noKeychain: true }); + expect(none.log).toEqual([ + "This deletes:", + ` - integrations.linear in ${none.path}`, + "Next: bun run up", + "Delete the app in Linear: Settings → API → Applications → devbox", + ]); + expect(none.keychain.entries()).toEqual(ALL_ENTRIES); + }); + + it("never applies", async () => { + for (const forget of [false, true]) { + const r = await run({ linear: LINEAR, forget, yes: true }); + expect(r.applied).toEqual([]); + expect(r.log).toContain("Next: bun run up"); + } + }); +}); + +describe("linear disable on kind", () => { + const KIND: Deployment = { kind: "k8s", context: "kind-devbox", namespace: "devbox" }; + // The public URL is the deployment's, shared with GitHub: --forget keeps it. + const KIND_TARGET = { + context: "kind-devbox", + profile: "k8s-kind-devbox", + publicUrl: "https://hooks.example.com", + values: {}, + }; + + function kindDeployment(linear: typeof LINEAR) { + const path = join(dir, `kind${n++}.json`); + writeFileSync(path, JSON.stringify({ ...KIND_TARGET, integrations: { linear } })); + const d = loadDeployment({ target: path, noKeychain: false }); + const log: string[] = []; + const keychain = new MemoryKeychain({ + "k8s:kind-devbox/devbox/LINEAR_CLIENT_SECRET": "a", + "k8s:kind-devbox/devbox/LINEAR_WEBHOOK_SECRET": "b", + }); + const resolver = { + env: {}, + keychain, + deployment: KIND, + isTTY: true, + confirm: async () => true, + } as unknown as ResolverDeps; + return { path, d, log, keychain, deps: { resolver, log: (l: string) => log.push(l) } }; + } + + it("the next step is k8s:kind", async () => { + const off = kindDeployment(LINEAR); + await disable(off.d, { forget: false, yes: false }, off.deps); + expect(JSON.parse(readFileSync(off.path, "utf8"))).toEqual({ + ...KIND_TARGET, + integrations: { linear: OFF }, + }); + expect(off.log).toEqual([`Disabled Linear in ${off.path}.`, "Next: bun run k8s:kind"]); + + const forget = kindDeployment(OFF); + // The control plane is down: the generic app name. + forget.d.api = async () => { + throw new Error("down"); + }; + await disable(forget.d, { forget: true, yes: false }, forget.deps); + expect(JSON.parse(readFileSync(forget.path, "utf8"))).toEqual(KIND_TARGET); + expect(forget.keychain.entries()).toEqual({}); + expect(forget.log).toEqual([ + "This deletes:", + ` - integrations.linear in ${forget.path}`, + " - LINEAR_CLIENT_SECRET for k8s:kind-devbox/devbox in the system keychain", + " - LINEAR_WEBHOOK_SECRET for k8s:kind-devbox/devbox in the system keychain", + "Next: bun run k8s:kind", + "Delete the app in Linear: Settings → API → Applications → the devbox app", + ]); + }); +}); diff --git a/control-plane/scripts/linear/disable.ts b/control-plane/scripts/linear/disable.ts new file mode 100644 index 0000000..67350f8 --- /dev/null +++ b/control-plane/scripts/linear/disable.ts @@ -0,0 +1,71 @@ +// `bun run linear:disable [--forget]`. Plain, it switches Linear +// off and keeps everything needed to switch it back on. --forget removes the +// block and the saved Linear secrets, after the one destructive confirmation. +// Neither applies anything: both name the command that does. + +import { writeConfigKeys } from "../lib/configFile"; +import { confirmDestructive } from "../lib/destructive"; +import { deploymentScope, entryName } from "../lib/keychain"; +import type { ResolverDeps } from "../lib/secrets"; +import type { LinearDeployment } from "./deployment"; +import { readStatus } from "./status"; + +export type DisableDeps = { resolver: ResolverDeps; log: (line: string) => void }; +export type DisableOpts = { forget: boolean; yes: boolean }; + +const LINEAR_SECRETS = ["LINEAR_CLIENT_SECRET", "LINEAR_WEBHOOK_SECRET"] as const; + +export async function disable( + d: LinearDeployment, + opts: DisableOpts, + deps: DisableDeps, +): Promise { + const { log, resolver } = deps; + const scope = deploymentScope(d.deployment); + if (!d.linear) { + log(`Linear is not configured for ${scope}. Nothing to disable.`); + return; + } + + if (!opts.forget) { + if (!d.linear.enabled) { + log(`Linear is already disabled for ${scope}.`); + return; + } + writeConfigKeys(d.configPath, { "integrations.linear": { ...d.linear, enabled: false } }); + log(`Disabled Linear in ${d.configPath}.`); + log(`Next: ${d.applyCommand}`); + return; + } + + const k = resolver.keychain && !resolver.keychain.disabled ? resolver.keychain : undefined; + const saved: (typeof LINEAR_SECRETS)[number][] = []; + for (const name of LINEAR_SECRETS) { + if (k && (await k.get(entryName(d.deployment, name))) !== undefined) saved.push(name); + } + const proceed = await confirmDestructive( + { + command: "linear:disable --forget", + items: [ + `integrations.linear in ${d.configPath}`, + ...saved.map((name) => `${name} for ${scope} in the system keychain`), + ], + question: "Remove these? [y/N] ", + }, + { yes: opts.yes, isTTY: resolver.isTTY, confirm: resolver.confirm, log }, + ); + if (!proceed) { + log("Nothing was removed."); + return; + } + + // While the control plane still knows the name. + const read = await readStatus(d); + const appName = ("body" in read && read.body.appUser?.name) || "the devbox app"; + + // Keychain first: if a delete fails, the block is still there to list on a retry. + for (const name of saved) await k?.delete(entryName(d.deployment, name)); + writeConfigKeys(d.configPath, { "integrations.linear": undefined }); + log(`Next: ${d.applyCommand}`); + log(`Delete the app in Linear: Settings → API → Applications → ${appName}`); +} diff --git a/control-plane/scripts/linear/manifest.test.ts b/control-plane/scripts/linear/manifest.test.ts new file mode 100644 index 0000000..b7feb8f --- /dev/null +++ b/control-plane/scripts/linear/manifest.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test"; +import { linearManifest, manifestLink } from "./manifest"; + +const PREFIX = "https://linear.app/settings/api/applications/new?manifest="; + +describe("linearManifest", () => { + it("the manifest in full", () => { + expect(linearManifest("https://hooks.example.com", "devbox")).toEqual({ + schemaVersion: "1.0.0", + distribution: "private", + display: { description: "devbox coding agent" }, + developer: { name: "devbox" }, + oauth: { + client_name: "devbox", + client_uri: "https://github.com/rudderlabs/devbox", + redirect_uris: ["https://hooks.example.com/oauth/callback"], + grant_types: ["authorization_code", "client_credentials"], + }, + webhook: { + enabled: true, + url: "https://hooks.example.com/webhook/linear", + resourceTypes: ["AgentSessionEvent"], + }, + }); + }); + + it("names the app after --name", () => { + const m = linearManifest("https://hooks.example.com/devbox", "my agent") as { + oauth: { client_name: string; redirect_uris: string[] }; + webhook: { url: string }; + }; + expect(m.oauth.client_name).toBe("my agent"); + expect(m.oauth.redirect_uris).toEqual(["https://hooks.example.com/devbox/oauth/callback"]); + expect(m.webhook.url).toBe("https://hooks.example.com/devbox/webhook/linear"); + }); +}); + +describe("manifestLink", () => { + it("the link decodes back to the manifest", () => { + const m = linearManifest("https://hooks.example.com", "devbox & co"); + const link = manifestLink(m); + expect(link.startsWith(PREFIX)).toBe(true); + expect(JSON.parse(decodeURIComponent(link.slice(PREFIX.length)))).toEqual(m); + expect(new URL(link).searchParams.get("manifest")).toBe(JSON.stringify(m)); + }); +}); diff --git a/control-plane/scripts/linear/manifest.ts b/control-plane/scripts/linear/manifest.ts new file mode 100644 index 0000000..6a57920 --- /dev/null +++ b/control-plane/scripts/linear/manifest.ts @@ -0,0 +1,28 @@ +// `linear:setup` opens Linear's pre-filled "create app" page with this manifest, +// so the webhook URL and event type can't be mistyped. Linear requires the +// authorization_code grant and a redirect URI; devbox never uses either because +// it gets its token with client credentials. +const CREATE_APP = "https://linear.app/settings/api/applications/new?manifest="; + +export function linearManifest(publicUrl: string, name: string): object { + return { + schemaVersion: "1.0.0", + distribution: "private", + display: { description: "devbox coding agent" }, + developer: { name: "devbox" }, + oauth: { + client_name: name, + client_uri: "https://github.com/rudderlabs/devbox", + redirect_uris: [`${publicUrl}/oauth/callback`], + grant_types: ["authorization_code", "client_credentials"], + }, + webhook: { + enabled: true, + url: `${publicUrl}/webhook/linear`, + resourceTypes: ["AgentSessionEvent"], + }, + }; +} + +export const manifestLink = (m: object): string => + `${CREATE_APP}${encodeURIComponent(JSON.stringify(m))}`; diff --git a/control-plane/scripts/linear/setup.test.ts b/control-plane/scripts/linear/setup.test.ts new file mode 100644 index 0000000..6140a19 --- /dev/null +++ b/control-plane/scripts/linear/setup.test.ts @@ -0,0 +1,828 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { LinearIntegration } from "../lib/integrations"; +import { type Deployment, guarded, type Keychain, MemoryKeychain } from "../lib/keychain"; +import { PublicUrlError } from "../lib/publicUrl"; +import type { ResolverDeps } from "../lib/secrets"; +import type { Fetch } from "../up/preflight"; +import { type LinearDeployment, loadDeployment } from "./deployment"; +import { linearManifest, manifestLink } from "./manifest"; +import { type SetupDeps, SetupError, type SetupOptions, setup } from "./setup"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-linear-setup-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const TOKEN_URL = "https://api.linear.app/oauth/token"; +const GRAPHQL_URL = "https://api.linear.app/graphql"; +const PUBLIC = "https://hooks.example.com"; +const COMPOSE: Deployment = { kind: "compose" }; +const K8S: Deployment = { kind: "k8s", context: "kind-devbox", namespace: "devbox" }; +const LINEAR: LinearIntegration = { enabled: true, clientId: "client-1" }; + +const URL_QUESTION = + "Public HTTPS URL that forwards to port 3000 on this machine (a tunnel or reverse proxy): "; +const SAVE_COMPOSE = "Save the Linear secrets to the system keychain for compose? [Y/n] "; +const CLIENT_SECRET_Q = "Linear client secret (input hidden): "; +const WEBHOOK_SECRET_Q = "Linear webhook signing secret (input hidden): "; +const REGISTER = "Register the Linear app: open this link, review the app and click Create:"; +const COPY = + "Then copy the client ID, the client secret and the webhook signing secret from the app's page."; +const GITHUB_HINT = + 'set "GitHub username" to the GitHub account devbox works as (the owner of GITHUB_TOKEN)'; +const LINK = ` ${manifestLink(linearManifest(PUBLIC, "devbox"))}`; +const ACT_AS = "devbox will act as devbox in workspace Example"; +const NEXT_COMPOSE = ["Next: bun run up", "Then check Linear with: bun run linear:status"]; +const SET_ENV = [ + "Set LINEAR_CLIENT_SECRET in your environment before bun run up, or it asks for them", + "Set LINEAR_WEBHOOK_SECRET in your environment before bun run up, or it asks for them", +]; +const REJECTED = + "Linear rejected the client ID or LINEAR_CLIENT_SECRET (invalid_client): copy them again from the app's page in Linear (Settings → API → Applications)"; +const AGAIN = "Enter LINEAR_CLIENT_SECRET again, or press Enter on an empty value to exit."; + +let n = 0; +function configFile(content: unknown): string { + const path = join(dir, `c${n++}.json`); + writeFileSync(path, JSON.stringify(content)); + return path; +} +const readJson = (path: string): unknown => JSON.parse(readFileSync(path, "utf8")); + +const platformError = () => + Object.assign(new Error("libsecret not available"), { code: "ERR_SECRETS_PLATFORM_ERROR" }); + +// A keychain whose platform has none: every call fails as Bun.secrets does. +const noPlatform: Keychain = { + get: async () => { + throw platformError(); + }, + set: async () => { + throw platformError(); + }, + delete: async () => { + throw platformError(); + }, +}; + +type World = { + log: string[]; + // Every question asked, in order: line prompts, hidden prompts, confirms. + asked: string[]; + opened: string[]; + fetched: string[]; + keychain: MemoryKeychain; +}; + +function harness(o: { + deployment?: Deployment; + linear?: LinearIntegration; + // The saved top-level publicUrl; PUBLIC when `linear` is, as setup saves both. + publicUrl?: string; + ingressUrl?: string; + byoIntegrationsSecret?: string; + githubApp?: { tokens: "devbox" | "external"; slug: string; org?: string }; + env?: Record; + keychain?: Keychain | "none"; + // Entries already in the default (in-memory) keychain. + saved?: Record; + lines?: string[]; + hidden?: string[]; + save?: boolean; + validSecrets?: string[]; + isTTY?: boolean; +}): { d: LinearDeployment; deps: SetupDeps; w: World } { + const deployment = o.deployment ?? COMPOSE; + const w: World = { + log: [], + asked: [], + opened: [], + fetched: [], + keychain: new MemoryKeychain(o.saved), + }; + const lines = [...(o.lines ?? [])]; + const hidden = [...(o.hidden ?? [])]; + const valid = new Set(o.validSecrets ?? ["client-secret"]); + const fetch: Fetch = async (url, init) => { + w.fetched.push(String(url)); + if (url === TOKEN_URL) { + const secret = new URLSearchParams(String(init?.body)).get("client_secret") ?? ""; + return valid.has(secret) + ? Response.json({ access_token: "tok", token_type: "Bearer", expires_in: 2592000 }) + : Response.json( + { error: "invalid_client", error_description: "Invalid client" }, + { status: 401 }, + ); + } + if (url === GRAPHQL_URL) { + return Response.json({ + data: { viewer: { name: "devbox" }, organization: { name: "Example", urlKey: "example" } }, + }); + } + return new Response("not routed", { status: 599 }); + }; + const savedUrl = o.publicUrl ?? (o.linear ? PUBLIC : undefined); + const configPath = configFile({ + ...(o.linear ? { integrations: { linear: o.linear } } : { port: 1 }), + ...(savedUrl !== undefined ? { publicUrl: savedUrl } : {}), + }); + const resolver: ResolverDeps = { + env: o.env ?? {}, + keychain: o.keychain === "none" ? undefined : (o.keychain ?? w.keychain), + deployment, + isTTY: o.isTTY ?? true, + prompt: async (q) => { + w.asked.push(q); + return hidden.shift() ?? ""; + }, + confirm: async (q) => { + w.asked.push(q); + return o.save ?? true; + }, + run: async () => { + throw new Error("setup runs no command"); + }, + log: (line) => w.log.push(line), + }; + const target = deployment.kind === "k8s" ? ` --target ${configPath}` : ""; + // No apply and no api: setup has neither. + const d: LinearDeployment = { + deployment, + configPath, + linear: o.linear, + ...(savedUrl !== undefined ? { publicUrl: savedUrl } : {}), + ...(o.ingressUrl !== undefined ? { ingressUrl: o.ingressUrl } : {}), + ...(o.byoIntegrationsSecret !== undefined + ? { byoIntegrationsSecret: o.byoIntegrationsSecret } + : {}), + ...(o.githubApp ? { githubApp: o.githubApp } : {}), + applyCommand: deployment.kind === "k8s" ? `bun run k8s:install${target}` : "bun run up", + statusCommand: `bun run linear:status${target}`, + api: async () => { + throw new Error("setup calls no API"); + }, + }; + const deps: SetupDeps = { + resolver, + promptLine: async (q) => { + w.asked.push(q); + return lines.shift() ?? ""; + }, + openUrl: (url) => w.opened.push(url), + fetch, + log: (line) => w.log.push(line), + }; + return { d, deps, w }; +} + +const opts = (o: Partial = {}): SetupOptions => ({ + name: "devbox", + reregister: false, + ...o, +}); + +describe("setup", () => { + it("first run on Compose: URL prompt, register, client ID, write, save yes, next step", async () => { + const { d, deps, w } = harness({ + lines: [PUBLIC, "client-1"], + hidden: ["client-secret", "whsec"], + }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([ + URL_QUESTION, + "Client ID: ", + SAVE_COMPOSE, + CLIENT_SECRET_Q, + WEBHOOK_SECRET_Q, + ]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + REGISTER, + LINK, + COPY, + GITHUB_HINT, + `Wrote integrations.linear to ${d.configPath}`, + ACT_AS, + "Next: bun run up", + "Then check Linear with: bun run linear:status", + ]); + expect(w.opened).toEqual([LINK.trim()]); + expect(readJson(d.configPath)).toEqual({ + port: 1, + integrations: { linear: { enabled: true, clientId: "client-1" } }, + publicUrl: PUBLIC, + }); + expect(w.keychain.entries()).toEqual({ + "compose/LINEAR_CLIENT_SECRET": "client-secret", + "compose/LINEAR_WEBHOOK_SECRET": "whsec", + }); + expect(w.fetched).toEqual([TOKEN_URL, GRAPHQL_URL]); + }); + + it("k8s: next step includes the GitOps values line", async () => { + const { d, deps, w } = harness({ + deployment: K8S, + ingressUrl: PUBLIC, + lines: ["client-1"], + save: false, + }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([ + "Client ID: ", + "Save the Linear secrets to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + ]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + REGISTER, + LINK, + COPY, + GITHUB_HINT, + `Wrote integrations.linear to ${d.configPath}`, + `Set LINEAR_CLIENT_SECRET in your environment before bun run k8s:install --target ${d.configPath}, or it asks for them`, + `Set LINEAR_WEBHOOK_SECRET in your environment before bun run k8s:install --target ${d.configPath}, or it asks for them`, + `Next: bun run k8s:install --target ${d.configPath}`, + "With GitOps, set these chart values instead: integrations.linear.enabled: true, integrations.linear.clientId: client-1", + `Then check Linear with: bun run linear:status --target ${d.configPath}`, + ]); + expect(w.keychain.entries()).toEqual({}); + expect(w.fetched).toEqual([]); + }); + + it("save no: nothing asked, nothing saved; not-saved env lines", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + env: { LINEAR_WEBHOOK_SECRET: "whsec-env", LINEAR_CLIENT_SECRET: "" }, + save: false, + }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([SAVE_COMPOSE]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + "Set LINEAR_CLIENT_SECRET in your environment before bun run up, or it asks for them", + "bun run up will use LINEAR_WEBHOOK_SECRET from your environment", + "Next: bun run up", + "Then check Linear with: bun run linear:status", + ]); + expect(w.keychain.entries()).toEqual({}); + expect(w.fetched).toEqual([]); + }); + + it("no usable keychain with a terminal: no save question", async () => { + for (const keychain of [guarded(noPlatform), "none" as const]) { + const { d, deps, w } = harness({ linear: LINEAR, keychain }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + "Set LINEAR_CLIENT_SECRET in your environment before bun run up, or it asks for them", + "Set LINEAR_WEBHOOK_SECRET in your environment before bun run up, or it asks for them", + "Next: bun run up", + "Then check Linear with: bun run linear:status", + ]); + } + }); + + it("no terminal: no save question", async () => { + const { d, deps, w } = harness({ linear: LINEAR, isTTY: false }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + ...SET_ENV, + ...NEXT_COMPOSE, + ]); + expect(w.keychain.entries()).toEqual({}); + }); + + it("the user's integrations Secret: asks nothing, names the Secret", async () => { + const { d, deps, w } = harness({ + deployment: K8S, + linear: LINEAR, + byoIntegrationsSecret: "my-integrations", + env: { LINEAR_CLIENT_SECRET: "x", LINEAR_WEBHOOK_SECRET: "y" }, + }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + 'Put LINEAR_CLIENT_SECRET and LINEAR_WEBHOOK_SECRET in Secret "my-integrations" in namespace devbox.', + `Next: bun run k8s:install --target ${d.configPath}`, + "With GitOps, set these chart values instead: integrations.linear.enabled: true, integrations.linear.clientId: client-1", + `Then check Linear with: bun run linear:status --target ${d.configPath}`, + ]); + expect(w.keychain.entries()).toEqual({}); + expect(w.fetched).toEqual([]); + }); + + it("a rejected client secret is asked again; an empty answer exits and saves nothing", async () => { + const { d, deps, w } = harness({ linear: LINEAR, hidden: ["wrong", "client-secret", "whsec"] }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([SAVE_COMPOSE, CLIENT_SECRET_Q, CLIENT_SECRET_Q, WEBHOOK_SECRET_Q]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + REJECTED, + AGAIN, + ACT_AS, + "Next: bun run up", + "Then check Linear with: bun run linear:status", + ]); + expect(w.keychain.entries()).toEqual({ + "compose/LINEAR_CLIENT_SECRET": "client-secret", + "compose/LINEAR_WEBHOOK_SECRET": "whsec", + }); + + const empty = harness({ linear: LINEAR, hidden: ["wrong", ""] }); + await expect(setup(empty.d, opts(), empty.deps)).rejects.toThrow( + new SetupError( + "No Linear client secret entered: nothing was saved. Run `bun run linear:setup` again when you have it.", + ), + ); + expect(empty.w.asked).toEqual([SAVE_COMPOSE, CLIENT_SECRET_Q, CLIENT_SECRET_Q]); + expect(empty.w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${empty.d.configPath}`, + REJECTED, + AGAIN, + ]); + expect(empty.w.keychain.entries()).toEqual({}); + }); + + it("an empty first answer exits and saves nothing", async () => { + const client = harness({ linear: LINEAR, hidden: [""] }); + await expect(setup(client.d, opts(), client.deps)).rejects.toThrow( + new SetupError( + "No Linear client secret entered: nothing was saved. Run `bun run linear:setup` again when you have it.", + ), + ); + expect(client.w.asked).toEqual([SAVE_COMPOSE, CLIENT_SECRET_Q]); + expect(client.w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${client.d.configPath}`, + ]); + expect(client.w.fetched).toEqual([]); + + const webhook = harness({ linear: LINEAR, hidden: ["client-secret", ""] }); + await expect(setup(webhook.d, opts(), webhook.deps)).rejects.toThrow( + new SetupError( + "No Linear webhook signing secret entered: nothing was saved. Run `bun run linear:setup` again when you have it.", + ), + ); + expect(webhook.w.asked).toEqual([SAVE_COMPOSE, CLIENT_SECRET_Q, WEBHOOK_SECRET_Q]); + expect(webhook.w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${webhook.d.configPath}`, + ACT_AS, + ]); + expect(webhook.w.keychain.entries()).toEqual({}); + }); + + it("a keychain that fails while saving: the not-saved lines", async () => { + // The platform's keychain answers reads but fails on the first write. + const inner: Keychain = { + get: async () => undefined, + set: async () => { + throw platformError(); + }, + delete: async () => false, + }; + const { d, deps, w } = harness({ + linear: LINEAR, + keychain: guarded(inner), + hidden: ["client-secret", "whsec"], + }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([SAVE_COMPOSE, CLIENT_SECRET_Q, WEBHOOK_SECRET_Q]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + ACT_AS, + ...SET_ENV, + ...NEXT_COMPOSE, + ]); + }); + + it("registering with LINEAR_* in env warns they predate the app", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + env: { LINEAR_CLIENT_SECRET: "old", LINEAR_WEBHOOK_SECRET: "old-whsec" }, + lines: ["client-2"], + hidden: ["client-secret", "whsec"], + }); + await setup(d, opts({ reregister: true }), deps); + expect(w.asked).toEqual(["Client ID: ", SAVE_COMPOSE, CLIENT_SECRET_Q, WEBHOOK_SECRET_Q]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + REGISTER, + LINK, + COPY, + GITHUB_HINT, + `Wrote integrations.linear to ${d.configPath}`, + ACT_AS, + "LINEAR_CLIENT_SECRET is set in your environment from before this app was created: set it to the new app's value, or unset it, before bun run up", + "LINEAR_WEBHOOK_SECRET is set in your environment from before this app was created: set it to the new app's value, or unset it, before bun run up", + "Next: bun run up", + "Then check Linear with: bun run linear:status", + ]); + }); + + it("no registration: saved and env differs warns; env equal is silent", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + env: { LINEAR_CLIENT_SECRET: "client-secret", LINEAR_WEBHOOK_SECRET: "other" }, + hidden: ["client-secret", "whsec"], + }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([SAVE_COMPOSE, CLIENT_SECRET_Q, WEBHOOK_SECRET_Q]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + ACT_AS, + "LINEAR_WEBHOOK_SECRET in your environment overrides the saved value at install: unset it, or set it to the new value", + "Next: bun run up", + "Then check Linear with: bun run linear:status", + ]); + }); + + it("empty client ID with a terminal", async () => { + const { d, deps, w } = harness({ lines: [""] }); + const before = readFileSync(d.configPath, "utf8"); + await expect(setup(d, opts({ publicUrl: PUBLIC }), deps)).rejects.toThrow( + new SetupError( + "No client ID entered: nothing was written. Run `bun run linear:setup` again when you have it.", + ), + ); + expect(w.asked).toEqual(["Client ID: "]); + expect(w.log).toEqual([`Public URL: ${PUBLIC}`, REGISTER, LINK, COPY, GITHUB_HINT]); + expect(readFileSync(d.configPath, "utf8")).toBe(before); + }); + + it("empty client ID without a terminal", async () => { + const { d, deps, w } = harness({ isTTY: false }); + const before = readFileSync(d.configPath, "utf8"); + await expect(setup(d, opts({ publicUrl: PUBLIC }), deps)).rejects.toThrow( + new SetupError( + `no Linear client ID: run \`bun run linear:setup\` in a terminal, or set integrations.linear.clientId in ${d.configPath}`, + ), + ); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([`Public URL: ${PUBLIC}`, REGISTER, LINK, COPY, GITHUB_HINT]); + expect(readFileSync(d.configPath, "utf8")).toBe(before); + }); + + it("GitHub username on Compose: the App's bot login when an App is enabled", async () => { + const { d, deps, w } = harness({ + githubApp: { tokens: "devbox", slug: "devbox-bot" }, + lines: [PUBLIC, "client-1"], + hidden: ["client-secret", "whsec"], + }); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + REGISTER, + LINK, + COPY, + 'set "GitHub username" to devbox-bot[bot]', + `Wrote integrations.linear to ${d.configPath}`, + ACT_AS, + ...NEXT_COMPOSE, + ]); + }); + + it("GitHub username suggestion from githubApp.slug as the bot login", async () => { + const { d, deps, w } = harness({ + deployment: K8S, + ingressUrl: PUBLIC, + githubApp: { tokens: "devbox", slug: "devbox-bot" }, + lines: ["client-1"], + save: false, + }); + await setup(d, opts(), deps); + const target = ` --target ${d.configPath}`; + expect(w.asked).toEqual([ + "Client ID: ", + "Save the Linear secrets to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + ]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + REGISTER, + LINK, + COPY, + 'set "GitHub username" to devbox-bot[bot]', + `Wrote integrations.linear to ${d.configPath}`, + `Set LINEAR_CLIENT_SECRET in your environment before bun run k8s:install${target}, or it asks for them`, + `Set LINEAR_WEBHOOK_SECRET in your environment before bun run k8s:install${target}, or it asks for them`, + `Next: bun run k8s:install${target}`, + "With GitOps, set these chart values instead: integrations.linear.enabled: true, integrations.linear.clientId: client-1", + `Then check Linear with: bun run linear:status${target}`, + ]); + }); + + it("http Ingress URL: the TLS hint", async () => { + const { d, deps, w } = harness({ deployment: K8S, ingressUrl: "http://hooks.example.com" }); + await expect(setup(d, opts(), deps)).rejects.toThrow( + new PublicUrlError( + "Webhooks need a public HTTPS URL: http://hooks.example.com uses http:, not https:. If TLS ends at a load balancer in front of the Ingress, pass --public-url https://hooks.example.com", + ), + ); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([]); + }); + + it("an Ingress host with a space: no TLS hint, no TypeError", async () => { + const { d, deps, w } = harness({ deployment: K8S, ingressUrl: "http://hooks example.com" }); + await expect(setup(d, opts(), deps)).rejects.toThrow( + new PublicUrlError( + "Webhooks need a public HTTPS URL: the value is not an absolute URL (start it with https://)", + ), + ); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([]); + }); + + it("k8s: no Ingress and no --public-url", async () => { + const { d, deps, w } = harness({ deployment: K8S }); + await expect(setup(d, opts(), deps)).rejects.toThrow( + new PublicUrlError( + "Webhooks need a public HTTPS URL: enable ingress.webhooks, or pass --public-url.", + ), + ); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([]); + }); + + it("Compose without a terminal and no URL fails naming --public-url", async () => { + const { d, deps, w } = harness({ isTTY: false }); + await expect(setup(d, opts(), deps)).rejects.toThrow( + new PublicUrlError( + "Webhooks need a public HTTPS URL: pass --public-url ", + ), + ); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([]); + }); + + it("re-run reuses config and skips register", async () => { + const { d, deps, w } = harness({ + linear: { ...LINEAR, enabled: false }, + publicUrl: PUBLIC, + save: false, + }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([SAVE_COMPOSE]); + expect(w.opened).toEqual([]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + "Set LINEAR_CLIENT_SECRET in your environment before bun run up, or it asks for them", + "Set LINEAR_WEBHOOK_SECRET in your environment before bun run up, or it asks for them", + "Next: bun run up", + "Then check Linear with: bun run linear:status", + ]); + expect(readJson(d.configPath)).toEqual({ + integrations: { linear: LINEAR }, + publicUrl: PUBLIC, + }); + }); + + it("--reregister registers again", async () => { + const { d, deps, w } = harness({ linear: LINEAR, lines: ["client-2"], save: false }); + await setup(d, opts({ reregister: true, name: "devbox-local" }), deps); + const link = ` ${manifestLink(linearManifest(PUBLIC, "devbox-local"))}`; + expect(w.asked).toEqual(["Client ID: ", SAVE_COMPOSE]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + REGISTER, + link, + COPY, + GITHUB_HINT, + `Wrote integrations.linear to ${d.configPath}`, + ...SET_ENV, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual({ + integrations: { linear: { ...LINEAR, clientId: "client-2" } }, + publicUrl: PUBLIC, + }); + }); + + it("--reregister with the save declined names the earlier app's saved secrets", async () => { + for (const deployment of [COMPOSE, K8S]) { + const scope = deployment.kind === "k8s" ? "k8s:kind-devbox/devbox" : "compose"; + const saved = { + [`${scope}/LINEAR_CLIENT_SECRET`]: "old-secret", + [`${scope}/LINEAR_WEBHOOK_SECRET`]: "old-whsec", + }; + const { d, deps, w } = harness({ + deployment, + linear: LINEAR, + saved, + lines: ["client-2"], + save: false, + }); + await setup(d, opts({ reregister: true }), deps); + const target = deployment.kind === "k8s" ? ` --target ${d.configPath}` : ""; + const apply = deployment.kind === "k8s" ? `bun run k8s:install${target}` : "bun run up"; + expect(w.asked).toEqual([ + "Client ID: ", + `Save the Linear secrets to the system keychain for ${scope}? [Y/n] `, + ]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + REGISTER, + LINK, + COPY, + GITHUB_HINT, + `Wrote integrations.linear to ${d.configPath}`, + `Set LINEAR_CLIENT_SECRET in your environment before ${apply}, or it asks for them`, + `The saved LINEAR_CLIENT_SECRET for ${scope} belongs to an earlier app: remove it with bun run secrets rm LINEAR_CLIENT_SECRET${target}`, + `Set LINEAR_WEBHOOK_SECRET in your environment before ${apply}, or it asks for them`, + `The saved LINEAR_WEBHOOK_SECRET for ${scope} belongs to an earlier app: remove it with bun run secrets rm LINEAR_WEBHOOK_SECRET${target}`, + `Next: ${apply}`, + ...(deployment.kind === "k8s" + ? [ + "With GitOps, set these chart values instead: integrations.linear.enabled: true, integrations.linear.clientId: client-2", + ] + : []), + `Then check Linear with: bun run linear:status${target}`, + ]); + // Nothing is removed for the user. + expect(w.keychain.entries()).toEqual(saved); + } + }); + + it("a changed public URL prints the update line", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + publicUrl: "https://old.example.com", + save: false, + }); + await setup(d, opts({ publicUrl: `${PUBLIC}/` }), deps); + expect(w.asked).toEqual([SAVE_COMPOSE]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + `Update the webhook URL in the Linear app to ${PUBLIC}/webhook/linear`, + ...SET_ENV, + ...NEXT_COMPOSE, + ]); + expect(readJson(d.configPath)).toEqual({ + integrations: { linear: LINEAR }, + publicUrl: PUBLIC, + }); + }); + + it("a changed public URL also prints the GitHub App line (devbox tokens)", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + publicUrl: "https://old.example.com", + githubApp: { tokens: "devbox", slug: "example-devbox" }, + save: false, + }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + `Update the webhook URL in the Linear app to ${PUBLIC}/webhook/linear`, + "Run bun run github:setup to update the GitHub App's webhook URL", + ...SET_ENV, + ...NEXT_COMPOSE, + ]); + }); + + it("without a terminal, secrets saved in the keychain need no env line", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + isTTY: false, + saved: { "compose/LINEAR_CLIENT_SECRET": "c", "compose/LINEAR_WEBHOOK_SECRET": "w" }, + }); + await setup(d, opts(), deps); + expect(w.asked).toEqual([]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + ...NEXT_COMPOSE, + ]); + }); + + it("on k8s, the GitHub App line names the target file", async () => { + const { d, deps, w } = harness({ + deployment: K8S, + linear: LINEAR, + publicUrl: "https://old.example.com", + githubApp: { tokens: "devbox", slug: "example-devbox" }, + save: false, + }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + `Update the webhook URL in the Linear app to ${PUBLIC}/webhook/linear`, + `Run bun run github:setup --target ${d.configPath} to update the GitHub App's webhook URL`, + `Set LINEAR_CLIENT_SECRET in your environment before bun run k8s:install --target ${d.configPath}, or it asks for them`, + `Set LINEAR_WEBHOOK_SECRET in your environment before bun run k8s:install --target ${d.configPath}, or it asks for them`, + `Next: bun run k8s:install --target ${d.configPath}`, + "With GitOps, set these chart values instead: integrations.linear.enabled: true, integrations.linear.clientId: client-1", + `Then check Linear with: bun run linear:status --target ${d.configPath}`, + ]); + }); + + it("a changed public URL prints the manual GitHub App line (external tokens)", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + publicUrl: "https://old.example.com", + githubApp: { tokens: "external", slug: "example-devbox" }, + save: false, + }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.log).toContain( + `Update the webhook URL in the GitHub App to ${PUBLIC}/webhook/github: https://github.com/settings/apps/example-devbox`, + ); + }); + + it("an organization's App: the manual line links the organization's settings", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + publicUrl: "https://old.example.com", + githubApp: { tokens: "external", slug: "example-devbox", org: "example-org" }, + save: false, + }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.log).toContain( + `Update the webhook URL in the GitHub App to ${PUBLIC}/webhook/github: https://github.com/organizations/example-org/settings/apps/example-devbox`, + ); + }); + + it("an unchanged public URL prints no update line", async () => { + const { d, deps, w } = harness({ linear: LINEAR, publicUrl: PUBLIC, save: false }); + await setup(d, opts(), deps); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + `Wrote integrations.linear to ${d.configPath}`, + ...SET_ENV, + ...NEXT_COMPOSE, + ]); + }); + + it("re-registering with a new URL prints no update line: the new app has it", async () => { + const { d, deps, w } = harness({ + linear: LINEAR, + publicUrl: "https://old.example.com", + lines: ["client-2"], + save: false, + }); + await setup(d, opts({ publicUrl: PUBLIC, reregister: true }), deps); + expect(w.log.some((l) => l.startsWith("Update the webhook URL"))).toBe(false); + }); + + it("never applies", () => { + // Nothing under scripts/linear runs `up` or the installer. + const sources = readdirSync(import.meta.dir).filter( + (f) => f.endsWith(".ts") && !f.endsWith(".test.ts"), + ); + const offending = sources.filter((f) => { + const text = readFileSync(join(import.meta.dir, f), "utf8"); + return /from "\.\.\/up"|from "\.\.\/k8s"|from "\.\.\/k8s\/install"|install\(/.test(text); + }); + expect(offending).toEqual([]); + }); +}); + +describe("setup on kind", () => { + it("the env lines and the next step name k8s:kind", async () => { + const kind: Deployment = { kind: "k8s", context: "kind-devbox", namespace: "devbox" }; + const path = configFile({ context: "kind-devbox", profile: "k8s-kind-devbox", values: {} }); + const d = loadDeployment({ target: path, noKeychain: false }); + const { deps, w } = harness({ deployment: kind, lines: ["client-1"], save: false }); + await setup(d, opts({ publicUrl: PUBLIC }), deps); + expect(w.asked).toEqual([ + "Client ID: ", + "Save the Linear secrets to the system keychain for k8s:kind-devbox/devbox? [Y/n] ", + ]); + expect(w.log).toEqual([ + `Public URL: ${PUBLIC}`, + REGISTER, + LINK, + COPY, + GITHUB_HINT, + `Wrote integrations.linear to ${path}`, + "Set LINEAR_CLIENT_SECRET in your environment before bun run k8s:kind, or it asks for them", + "Set LINEAR_WEBHOOK_SECRET in your environment before bun run k8s:kind, or it asks for them", + "Next: bun run k8s:kind", + "With GitOps, set these chart values instead: integrations.linear.enabled: true, integrations.linear.clientId: client-1", + `Then check Linear with: bun run linear:status --target ${path}`, + ]); + expect(readJson(path)).toEqual({ + context: "kind-devbox", + profile: "k8s-kind-devbox", + values: {}, + integrations: { linear: { enabled: true, clientId: "client-1" } }, + publicUrl: PUBLIC, + }); + }); +}); diff --git a/control-plane/scripts/linear/setup.ts b/control-plane/scripts/linear/setup.ts new file mode 100644 index 0000000..7686641 --- /dev/null +++ b/control-plane/scripts/linear/setup.ts @@ -0,0 +1,210 @@ +// `bun run linear:setup`: resolve the public URL, register the +// Linear app from a manifest link, record its client ID and public URL in the +// deployment's config, optionally save its secrets in the system keychain, and +// name the command that applies it. setup never applies, never touches the +// cluster, sends no webhook and never takes a secret from env. +// Re-running is safe: each step reuses what is already configured. + +import { writeConfigKeys } from "../lib/configFile"; +import { deploymentScope, entryName, keychainUsable } from "../lib/keychain"; +import { publicUrlChangeLines, resolvePublicUrl } from "../lib/publicUrl"; +import { checkWithRetry, type Resolved, type ResolverDeps, SecretError } from "../lib/secrets"; +import type { Fetch } from "../up/preflight"; +import { checkLinear, type LinearIdentity } from "./check"; +import type { LinearDeployment } from "./deployment"; +import { linearManifest, manifestLink } from "./manifest"; + +export class SetupError extends Error {} + +export type SetupOptions = { + publicUrl?: string; // --public-url + name: string; // --name, the app's name in Linear + reregister: boolean; +}; + +export type SetupDeps = { + resolver: ResolverDeps; + // Asks with echo; empty at EOF. + promptLine: (q: string) => Promise; + // Opens the link in a browser, best effort: it is always printed too. + openUrl: (url: string) => void; + fetch: Fetch; + log: (line: string) => void; +}; + +const LINEAR_SECRETS = ["LINEAR_CLIENT_SECRET", "LINEAR_WEBHOOK_SECRET"] as const; +const AGAIN = "Run `bun run linear:setup` again when you have it."; + +// 3. Asked only when registering, or when the config has none. +async function askClientId(d: LinearDeployment, deps: SetupDeps): Promise { + if (!deps.resolver.isTTY) { + throw new SetupError( + `no Linear client ID: run \`bun run linear:setup\` in a terminal, or set integrations.linear.clientId in ${d.configPath}`, + ); + } + const id = (await deps.promptLine("Client ID: ")).trim(); + if (!id) throw new SetupError(`No client ID entered: nothing was written. ${AGAIN}`); + return id; +} + +const noSecret = (label: string) => + new SetupError(`No ${label} entered: nothing was saved. ${AGAIN}`); + +async function askSecret(deps: SetupDeps, label: string): Promise { + const value = (await deps.resolver.prompt(`${label} (input hidden): `)).trim(); + if (!value) throw noSecret(label); + return value; +} + +type Saved = Record<(typeof LINEAR_SECRETS)[number], string>; + +// 5. With a terminal and a keychain that works, offer to save both secrets; +// the client secret is checked against Linear first. Returns what was saved, +// and whether the keychain can be read (for the earlier app's entries). +async function saveSecrets( + d: LinearDeployment, + clientId: string, + deps: SetupDeps, +): Promise<{ saved?: Saved; usable: boolean }> { + const { resolver, log } = deps; + const k = resolver.keychain; + const usable = await keychainUsable(k, entryName(d.deployment, "LINEAR_CLIENT_SECRET")); + if (!resolver.isTTY || !k || !usable) return { usable }; + const scope = deploymentScope(d.deployment); + const yes = await resolver.confirm( + `Save the Linear secrets to the system keychain for ${scope}? [Y/n] `, + { defaultYes: true }, + ); + if (!yes) return { usable }; + + const typed = await askSecret(deps, "Linear client secret"); + let client: Resolved; + let identity: LinearIdentity; + try { + ({ resolved: client, result: identity } = await checkWithRetry( + { name: "LINEAR_CLIENT_SECRET", value: typed, source: "prompt" }, + resolver, + (s) => checkLinear(clientId, s, deps.fetch, { log }), + )); + } catch (err) { + // An empty answer at the re-ask: setup's own message, not up's env hint. + if (err instanceof SecretError) throw noSecret("Linear client secret"); + throw err; + } + log(`devbox will act as ${identity.viewerName} in workspace ${identity.workspaceName}`); + const webhook = await askSecret(deps, "Linear webhook signing secret"); + + const saved = { LINEAR_CLIENT_SECRET: client.value, LINEAR_WEBHOOK_SECRET: webhook }; + for (const name of LINEAR_SECRETS) await k.set(entryName(d.deployment, name), saved[name]); + // A keychain that switched itself off during `set` saved nothing. + return k.disabled ? { usable: false } : { saved, usable: true }; +} + +// What env will do to each secret at the next apply, and, after a +// registration, the earlier app's entries left in the keychain. Compared in +// memory, never printed. An empty value counts as unset. +async function secretLines( + d: LinearDeployment, + deps: SetupDeps, + registered: boolean, + outcome: { saved?: Saved; usable: boolean }, +): Promise { + const { env, keychain } = deps.resolver; + const { saved, usable } = outcome; + const apply = d.applyCommand; + const scope = deploymentScope(d.deployment); + const target = d.deployment.kind === "k8s" ? ` --target ${d.configPath}` : ""; + const lines: string[] = []; + for (const name of LINEAR_SECRETS) { + const value = env[name] || undefined; + if (registered && value !== undefined) { + lines.push( + `${name} is set in your environment from before this app was created: set it to the new app's value, or unset it, before ${apply}`, + ); + } else if (saved) { + if (!registered && value !== undefined && value !== saved[name]) { + lines.push( + `${name} in your environment overrides the saved value at install: unset it, or set it to the new value`, + ); + } + } else if (value === undefined) { + // Not asked this run (no terminal): the next apply reads the value saved + // for this app from the keychain. After a registration it is an earlier + // app's, named below. + const kept = !registered && usable && (await keychain?.get(entryName(d.deployment, name))); + if (!kept) lines.push(`Set ${name} in your environment before ${apply}, or it asks for them`); + } else { + lines.push(`${apply} will use ${name} from your environment`); + } + if (registered && !saved && usable && (await keychain?.get(entryName(d.deployment, name)))) { + lines.push( + `The saved ${name} for ${scope} belongs to an earlier app: remove it with bun run secrets rm ${name}${target}`, + ); + } + } + return lines; +} + +export async function setup( + d: LinearDeployment, + opts: SetupOptions, + deps: SetupDeps, +): Promise { + const { log } = deps; + const publicUrl = await resolvePublicUrl(d, opts, deps); + log(`Public URL: ${publicUrl}`); + + // 2. Register. + const register = !d.linear?.clientId || opts.reregister; + if (register) { + const link = manifestLink(linearManifest(publicUrl, opts.name)); + log("Register the Linear app: open this link, review the app and click Create:"); + log(` ${link}`); + deps.openUrl(link); + log( + "Then copy the client ID, the client secret and the webhook signing secret from the app's page.", + ); + log( + `set "GitHub username" to ${d.githubApp ? `${d.githubApp.slug}[bot]` : "the GitHub account devbox works as (the owner of GITHUB_TOKEN)"}`, + ); + } + + // 3. Client ID. + const clientId = register ? await askClientId(d, deps) : (d.linear?.clientId ?? ""); + + // 4. Write. + writeConfigKeys(d.configPath, { + "integrations.linear": { enabled: true, clientId }, + publicUrl, + }); + log(`Wrote integrations.linear to ${d.configPath}`); + // A new app was just registered with this URL: nothing to update in Linear. + for (const line of publicUrlChangeLines( + d.publicUrl, + publicUrl, + { linear: !register, ...(d.githubApp ? { githubApp: d.githubApp } : {}) }, + "linear", + d.deployment.kind === "k8s" ? ` --target ${d.configPath}` : "", + )) { + log(line); + } + + // 5. Secrets. The user's own integrations Secret holds them; env is moot. + if (d.deployment.kind === "k8s" && d.byoIntegrationsSecret) { + log( + `Put LINEAR_CLIENT_SECRET and LINEAR_WEBHOOK_SECRET in Secret "${d.byoIntegrationsSecret}" in namespace ${d.deployment.namespace}.`, + ); + } else { + const outcome = await saveSecrets(d, clientId, deps); + for (const line of await secretLines(d, deps, register, outcome)) log(line); + } + + // 6. Next step. + log(`Next: ${d.applyCommand}`); + if (d.deployment.kind === "k8s") { + log( + `With GitOps, set these chart values instead: integrations.linear.enabled: true, integrations.linear.clientId: ${clientId}`, + ); + } + log(`Then check Linear with: ${d.statusCommand}`); +} diff --git a/control-plane/scripts/linear/status.test.ts b/control-plane/scripts/linear/status.test.ts new file mode 100644 index 0000000..2a31755 --- /dev/null +++ b/control-plane/scripts/linear/status.test.ts @@ -0,0 +1,265 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type Deployment, MemoryKeychain } from "../lib/keychain"; +import type { PeekDeps } from "../lib/secrets"; +import { type LinearDeployment, loadDeployment } from "./deployment"; +import { status } from "./status"; + +const COMPOSE: Deployment = { kind: "compose" }; +const LINEAR = { enabled: true, clientId: "lin-client-id" }; +const PUBLIC = "https://hooks.example.com"; +const BODY = { + configured: true, + workspace: { id: "o1", name: "Example", urlKey: "example" }, + appUser: { id: "u1", name: "devbox" }, + lastWebhookAt: "2026-10-16T07:55:25.031Z", + lastTokenError: null, +}; +const PROBE_URL = "https://hooks.example.com/webhook/linear"; +const unsigned = () => Response.json({ error: "invalid signature" }, { status: 401 }); + +async function run(o: { + linear?: Partial & { enabled: boolean }; + // The top-level publicUrl; PUBLIC unless null. + publicUrl?: string | null; + api?: () => Promise; + fetch?: (url: string, init?: RequestInit) => Promise; + byo?: string; + fromApiIngress?: boolean; +}): Promise<{ log: string[]; probed: string[] }> { + const log: string[] = []; + const probed: string[] = []; + const d: LinearDeployment = { + deployment: COMPOSE, + configPath: "devbox.config.json", + linear: o.linear, + ...(o.publicUrl === null ? {} : { publicUrl: o.publicUrl ?? PUBLIC }), + applyCommand: "bun run up", + statusCommand: "bun run linear:status", + ...(o.byo ? { byoIntegrationsSecret: o.byo } : {}), + ...(o.fromApiIngress ? { fromApiIngress: true } : {}), + api: o.api ?? (async () => Response.json(BODY)), + }; + const resolver = { + env: { LINEAR_WEBHOOK_SECRET: "whsec" }, + keychain: new MemoryKeychain({ "compose/LINEAR_CLIENT_SECRET": "csecret" }), + deployment: COMPOSE, + run: async () => ({ code: 1, stdout: "" }), + } as unknown as PeekDeps; + await status(d, { + resolver, + fetch: async (url, init) => { + probed.push(url); + return (o.fetch ?? unsigned)(url, init); + }, + log: (l) => log.push(l), + }); + return { log, probed }; +} + +const HEAD = [ + "Linear (compose)", + " clientId lin-client-id", + " publicUrl https://hooks.example.com", + " LINEAR_CLIENT_SECRET keychain", + " LINEAR_WEBHOOK_SECRET env", +]; +const OK_LINE = `Webhook URL ${PROBE_URL}: reaches devbox with Linear on`; +const CP_LINE = + "Control plane: workspace Example (example), app user devbox, last webhook from Linear 2026-10-16T07:55:25.031Z"; + +describe("linear status", () => { + it("not configured", async () => { + const r = await run({}); + expect(r.log).toEqual(["Linear is not configured for compose. Run: bun run linear:setup"]); + expect(r.probed).toEqual([]); + }); + + it("disabled", async () => { + const r = await run({ linear: { ...LINEAR, enabled: false } }); + expect(r.log).toEqual([ + "Linear is disabled for compose (enabled: false in devbox.config.json). Turn it on again: bun run linear:setup, or set enabled to true, then bun run up.", + ]); + expect(r.probed).toEqual([]); + }); + + it("configured: block, sources, control plane, probe ok", async () => { + const r = await run({ linear: LINEAR }); + expect(r.log).toEqual([...HEAD, CP_LINE, OK_LINE]); + expect(r.probed).toEqual([PROBE_URL]); + }); + + it("the user's Secret: sources say from Secret (not read)", async () => { + const r = await run({ linear: LINEAR, byo: "my-integrations" }); + expect(r.log).toEqual([ + "Linear (compose)", + " clientId lin-client-id", + " publicUrl https://hooks.example.com", + ' LINEAR_CLIENT_SECRET from Secret "my-integrations" (not read)', + ' LINEAR_WEBHOOK_SECRET from Secret "my-integrations" (not read)', + CP_LINE, + OK_LINE, + ]); + }); + + it("control plane unreachable: the probe still runs", async () => { + const r = await run({ + linear: LINEAR, + api: async () => { + throw new Error("connect ECONNREFUSED"); + }, + }); + expect(r.log).toEqual([ + ...HEAD, + "Control plane: not reachable (connect ECONNREFUSED)", + OK_LINE, + ]); + }); + + it("configured false: the apply-command hint", async () => { + const r = await run({ + linear: LINEAR, + api: async () => Response.json({ configured: false }), + }); + expect(r.log).toEqual([ + ...HEAD, + "Control plane: Linear is not on there yet. Run: bun run up", + OK_LINE, + ]); + }); + + it("last webhook from Linear: none yet", async () => { + const r = await run({ + linear: LINEAR, + api: async () => Response.json({ ...BODY, lastWebhookAt: null }), + }); + expect(r.log).toEqual([ + ...HEAD, + "Control plane: workspace Example (example), app user devbox, last webhook from Linear none yet", + OK_LINE, + ]); + }); + + it("probe 404 / no answer / VPN hint", async () => { + const notFound = await run({ + linear: LINEAR, + fetch: async () => new Response("nope", { status: 404 }), + }); + expect(notFound.log).toEqual([ + ...HEAD, + CP_LINE, + `the webhook URL ${PROBE_URL} got 404: something answered, but not devbox with Linear on. Check that the tunnel or proxy forwards to the control plane (port 3000 on Compose), that integrations.linear.enabled is true and applied, or restart the control plane`, + ]); + + const down = async () => { + throw new Error("ECONNREFUSED"); + }; + const noAnswer = await run({ linear: LINEAR, fetch: down }); + expect(noAnswer.log).toEqual([ + ...HEAD, + CP_LINE, + `the webhook URL ${PROBE_URL} got no answer (ECONNREFUSED): the public URL does not reach devbox. Is the tunnel down, or the Ingress or DNS not ready yet?`, + ]); + + const vpn = await run({ linear: LINEAR, fetch: down, fromApiIngress: true }); + expect(vpn.log).toEqual([ + ...HEAD, + CP_LINE, + `the webhook URL ${PROBE_URL} got no answer (ECONNREFUSED): the public URL does not reach devbox. Is the tunnel down, or the Ingress or DNS not ready yet? The URL comes from ingress.api, which may be reachable only over a VPN: enable a public ingress.webhooks in the target file`, + ]); + }); + + it("no publicUrl: no probe", async () => { + const r = await run({ linear: LINEAR, publicUrl: null }); + expect(r.log).toEqual([ + "Linear (compose)", + " clientId lin-client-id", + " publicUrl not set", + " LINEAR_CLIENT_SECRET keychain", + " LINEAR_WEBHOOK_SECRET env", + CP_LINE, + ]); + expect(r.probed).toEqual([]); + }); + + it("lastTokenError", async () => { + const r = await run({ + linear: LINEAR, + api: async () => Response.json({ ...BODY, lastTokenError: "invalid_client" }), + }); + expect(r.log).toEqual([...HEAD, CP_LINE, "Last Linear token error: invalid_client", OK_LINE]); + }); +}); + +describe("linear status on kind", () => { + const dir = mkdtempSync(join(tmpdir(), "devbox-linear-status-")); + let savedXdg: string | undefined; + beforeEach(() => { + savedXdg = process.env.XDG_CONFIG_HOME; + // No CLI profiles. + process.env.XDG_CONFIG_HOME = mkdtempSync(join(dir, "config-")); + }); + afterEach(() => { + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + }); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + const KIND: Deployment = { kind: "k8s", context: "kind-devbox", namespace: "devbox" }; + let n = 0; + + async function onKind( + linear: typeof LINEAR, + api?: () => Promise, + ): Promise<{ path: string; log: string[] }> { + const path = join(dir, `t${n++}.json`); + writeFileSync( + path, + JSON.stringify({ + context: "kind-devbox", + profile: "k8s-kind-devbox", + publicUrl: PUBLIC, + integrations: { linear }, + }), + ); + const loaded = loadDeployment({ target: path, noKeychain: true }); + const d = api ? { ...loaded, api } : loaded; + const log: string[] = []; + const resolver = { + env: { LINEAR_WEBHOOK_SECRET: "whsec" }, + keychain: new MemoryKeychain({ "k8s:kind-devbox/devbox/LINEAR_CLIENT_SECRET": "csecret" }), + deployment: KIND, + run: async () => ({ code: 1, stdout: "" }), + } as unknown as PeekDeps; + await status(d, { resolver, fetch: async () => unsigned(), log: (l) => log.push(l) }); + return { path, log }; + } + + const KIND_HEAD = [ + "Linear (k8s:kind-devbox/devbox)", + " clientId lin-client-id", + " publicUrl https://hooks.example.com", + " LINEAR_CLIENT_SECRET keychain", + " LINEAR_WEBHOOK_SECRET env", + ]; + + it("the apply and profile hints name k8s:kind", async () => { + expect((await onKind(LINEAR)).log).toEqual([ + ...KIND_HEAD, + 'Control plane: not reachable (no CLI profile "k8s-kind-devbox": run `bun run k8s:kind` first)', + OK_LINE, + ]); + const off = await onKind(LINEAR, async () => Response.json({ configured: false })); + expect(off.log).toEqual([ + ...KIND_HEAD, + "Control plane: Linear is not on there yet. Run: bun run k8s:kind", + OK_LINE, + ]); + const disabled = await onKind({ ...LINEAR, enabled: false }); + expect(disabled.log).toEqual([ + `Linear is disabled for k8s:kind-devbox/devbox (enabled: false in ${disabled.path}). Turn it on again: bun run linear:setup, or set enabled to true, then bun run k8s:kind.`, + ]); + }); +}); diff --git a/control-plane/scripts/linear/status.ts b/control-plane/scripts/linear/status.ts new file mode 100644 index 0000000..4943194 --- /dev/null +++ b/control-plane/scripts/linear/status.ts @@ -0,0 +1,88 @@ +// `bun run linear:status`: what is configured, where each secret +// would come from, what the running control plane reports and whether the +// webhook URL reaches it. Read-only, never prompts, never prints a secret +// value; a control plane or URL that cannot be reached is reported, not a +// failure. +import { deploymentScope } from "../lib/keychain"; +import { errorMessage } from "../lib/knownError"; +import { probeWebhook } from "../lib/probe"; +import { type PeekDeps, peekSecret } from "../lib/secrets"; +import type { Fetch } from "../up/preflight"; +import type { LinearDeployment } from "./deployment"; + +export const STATUS_PATH = "/api/integrations/linear"; + +export type LinearStatusBody = { + configured?: boolean; + workspace?: { name?: string; urlKey?: string } | null; + appUser?: { name?: string } | null; + lastWebhookAt?: string | null; + lastTokenError?: string | null; +}; + +export type StatusDeps = { resolver: PeekDeps; fetch: Fetch; log: (line: string) => void }; + +// The status endpoint's body, or undefined when it cannot be read. +export async function readStatus( + d: LinearDeployment, +): Promise<{ body: LinearStatusBody } | { error: string }> { + try { + const res = await d.api(STATUS_PATH); + if (!res.ok) { + await res.body?.cancel(); + return { error: `HTTP ${res.status}` }; + } + return { body: (await res.json()) as LinearStatusBody }; + } catch (err) { + return { error: errorMessage(err) }; + } +} + +export async function status(d: LinearDeployment, deps: StatusDeps): Promise { + const { log } = deps; + const scope = deploymentScope(d.deployment); + if (!d.linear) { + log(`Linear is not configured for ${scope}. Run: bun run linear:setup`); + return; + } + if (!d.linear.enabled) { + log( + `Linear is disabled for ${scope} (enabled: false in ${d.configPath}). Turn it on again: bun run linear:setup, or set enabled to true, then ${d.applyCommand}.`, + ); + return; + } + log(`Linear (${scope})`); + log(` clientId ${d.linear.clientId}`); + log(` publicUrl ${d.publicUrl ?? "not set"}`); + for (const name of ["LINEAR_CLIENT_SECRET", "LINEAR_WEBHOOK_SECRET"] as const) { + const where = d.byoIntegrationsSecret + ? `from Secret "${d.byoIntegrationsSecret}" (not read)` + : await peekSecret(name, deps.resolver); + log(` ${name.padEnd(21)} ${where}`); + } + + const r = await readStatus(d); + if ("error" in r) { + log(`Control plane: not reachable (${r.error})`); + } else if (!r.body.configured) { + log(`Control plane: Linear is not on there yet. Run: ${d.applyCommand}`); + } else { + const s = r.body; + const workspace = s.workspace ? `${s.workspace.name} (${s.workspace.urlKey})` : "unknown"; + log( + `Control plane: workspace ${workspace}, app user ${s.appUser?.name ?? "unknown"}, last webhook from Linear ${s.lastWebhookAt ?? "none yet"}`, + ); + if (s.lastTokenError) log(`Last Linear token error: ${s.lastTokenError}`); + } + + if (!d.publicUrl) return; + const probe = await probeWebhook(d.publicUrl, "/webhook/linear", deps.fetch, { + fromApiIngress: d.fromApiIngress === true, + integration: "Linear", + }); + log( + probe.ok + ? `Webhook URL ${d.publicUrl}/webhook/linear: reaches devbox with Linear on` + : probe.message, + ); +} diff --git a/control-plane/scripts/local-down.sh b/control-plane/scripts/local-down.sh new file mode 100755 index 0000000..45b792d --- /dev/null +++ b/control-plane/scripts/local-down.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Stops the local stack started by `bun run up` (`bun run down`). Sandboxes are +# OpenSandbox-created containers (not compose services) on the `devbox` +# network, so they go first or compose can't remove the network. Volumes are +# kept: postgres data, the devbox-repos base and per-task workspaces +# (`bun run reset` removes them). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +sandboxes="$(docker ps -aq --filter label=opensandbox.io/id; docker ps -aq --filter label=opensandbox.io/egress-sidecar-for)" +if [ -n "$sandboxes" ]; then + # shellcheck disable=SC2086 + docker rm -f $sandboxes >/dev/null + echo "Removed $(echo "$sandboxes" | wc -l | tr -d ' ') sandbox container(s)" +fi +# The profile also stops the GitHub App token minter, which a plain `down` +# leaves running (and the network with it). +docker compose -f "$ROOT/docker-compose.yml" --profile github-app down diff --git a/control-plane/scripts/quickstart.test.ts b/control-plane/scripts/quickstart.test.ts new file mode 100644 index 0000000..cc46d38 --- /dev/null +++ b/control-plane/scripts/quickstart.test.ts @@ -0,0 +1,253 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { type QuickstartDeps, QuickstartError, quickstart } from "./quickstart"; +import { ChoiceError } from "./quickstart/choices"; +import { ConfigMissing } from "./quickstart/configReport"; +import type { Ran } from "./quickstart/prerequisites"; + +const dirs: string[] = []; +afterAll(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); +}); + +const CONFIG = { + repos: [{ url: "https://github.com/frodo/ring.git" }], + llm: { provider: "anthropic", baseUrl: "https://api.anthropic.com", model: "claude-opus-5-5" }, +}; +const CONFIG_WRITTEN = { ...CONFIG, repos: [{ ...CONFIG.repos[0], branch: "main" }] }; + +// A checkout with devbox.config.json (and devbox.kind.json if given); every +// tool present; each step exits with the next code in `codes` (default 0). +function world( + opts: { + config?: unknown; + kindTarget?: unknown; + answers?: (string | boolean)[]; + isTTY?: boolean; + codes?: number[]; + tools?: Record; + } = {}, +) { + const root = mkdtempSync(join(tmpdir(), "devbox-quickstart-")); + dirs.push(root); + if (opts.config !== null) { + writeFileSync(join(root, "devbox.config.json"), JSON.stringify(opts.config ?? CONFIG)); + } + if (opts.kindTarget) { + writeFileSync(join(root, "devbox.kind.json"), JSON.stringify(opts.kindTarget)); + } + const answers = [...(opts.answers ?? [])]; + const codes = [...(opts.codes ?? [])]; + const asked: string[] = []; + const log: string[] = []; + const steps: string[] = []; + const next = (q: string) => { + asked.push(q); + const a = answers.shift(); + if (a === undefined) throw new Error(`unexpected question: ${q}`); + return a; + }; + const deps: QuickstartDeps = { + root, + isTTY: opts.isTTY ?? true, + promptLine: async (q) => String(next(q)), + confirm: async (q, { defaultYes }) => { + const a = next(q); + return a === "" ? defaultYes : a === true; + }, + log: (l) => void log.push(l), + run: async (cmd) => + opts.tools?.[cmd.slice(0, 2).join(" ")] ?? { + code: 0, + stdout: cmd[1] === "info" ? `28.0.1 ${16 * 1024 ** 3}` : "v3.17.1 kind v0.27.0 2.29.1", + stderr: "", + }, + exec: async (cmd, cwd) => { + expect(cwd).toBe(root); + steps.push(cmd.slice(1).join(" ")); + return codes.shift() ?? 0; + }, + }; + return { deps, asked, log, steps }; +} + +const ALL_NO = ["--compose", "--no-github-app", "--no-linear"]; + +describe("quickstart", () => { + it("with every flag, asks nothing and runs up once", async () => { + const w = world({ isTTY: false }); + await quickstart(ALL_NO, w.deps); + expect(w.asked).toEqual([]); + expect(w.steps).toEqual(["control-plane/scripts/up.ts"]); + expect(w.log).toContain(" frodo/ring (branch main)"); + expect(w.log).toContain("platform compose; GitHub App setup: no; Linear setup: no"); + }); + + it("without a terminal, names every flag still needed before doing anything", async () => { + const w = world({ isTTY: false }); + await expect(quickstart(["--kind"], w.deps)).rejects.toThrow(ChoiceError); + await expect(quickstart(["--kind"], w.deps)).rejects.toThrow( + "pass --github-app or --no-github-app, --linear or --no-linear", + ); + expect(w.steps).toEqual([]); + expect(w.log).toEqual([]); + }); + + it("asks for what the flags leave open", async () => { + const w = world({ answers: ["kind", "", ""] }); + await quickstart([], w.deps); + expect(w.asked).toEqual([ + "Run devbox with Docker Compose or on a local kind cluster? [compose/kind] ", + "Set up the GitHub App? [y/N] ", + "Set up Linear? [y/N] ", + ]); + expect(w.steps).toEqual(["control-plane/scripts/k8s.ts kind"]); + }); + + it("starts the stack, sets up the integrations, then applies them", async () => { + const w = world(); + await quickstart( + ["--compose", "--github-app", "--linear", "--public-url", "https://hooks.example.com"], + w.deps, + ); + expect(w.steps).toEqual([ + "control-plane/scripts/up.ts", + "control-plane/scripts/github.ts setup --public-url https://hooks.example.com", + "control-plane/scripts/linear.ts setup --public-url https://hooks.example.com", + "control-plane/scripts/up.ts", + ]); + expect(w.log.join("\n")).toContain("ngrok http 3000"); + expect(w.log).toContain( + "Check the integrations with: bun run github:status, bun run linear:status", + ); + }); + + it("on kind, asks for the tunnel's URL after k8s:kind and passes it to the setups", async () => { + const w = world({ answers: ["http://localhost:13000", "https://hooks.example.com/"] }); + await quickstart(["--kind", "--github-app", "--linear", "--no-keychain"], w.deps); + expect(w.asked).toEqual([ + "Public HTTPS URL of the tunnel: ", + "Public HTTPS URL of the tunnel: ", + ]); + expect(w.log).toContain( + "Webhooks need a public HTTPS URL: http://localhost:13000 uses http:, not https:", + ); + expect(w.steps).toEqual([ + "control-plane/scripts/k8s.ts kind --no-keychain", + "control-plane/scripts/github.ts setup --target devbox.kind.json --public-url https://hooks.example.com --no-keychain", + "control-plane/scripts/linear.ts setup --target devbox.kind.json --public-url https://hooks.example.com --no-keychain", + "control-plane/scripts/k8s.ts kind --no-keychain", + ]); + expect(w.log).toContain(" bun run k8s:connect --target devbox.kind.json"); + }); + + it("on kind, takes --public-url without asking", async () => { + const w = world({ isTTY: false }); + await quickstart( + ["--kind", "--no-github-app", "--linear", "--public-url", "https://hooks.example.com"], + w.deps, + ); + expect(w.asked).toEqual([]); + expect(w.steps[1]).toBe( + "control-plane/scripts/linear.ts setup --target devbox.kind.json --public-url https://hooks.example.com", + ); + }); + + it("on kind without a terminal or a URL, stops before installing anything", async () => { + const w = world({ isTTY: false }); + await expect(quickstart(["--kind", "--github-app", "--no-linear"], w.deps)).rejects.toThrow( + "pass --public-url ", + ); + expect(w.steps).toEqual([]); + }); + + it("on kind, an empty answer stops before the setups", async () => { + const w = world({ answers: [""] }); + await expect(quickstart(["--kind", "--no-github-app", "--linear"], w.deps)).rejects.toThrow( + "no public URL entered", + ); + expect(w.steps).toEqual(["control-plane/scripts/k8s.ts kind"]); + }); + + it("refuses a --public-url that is not a public HTTPS URL, before anything runs", async () => { + const w = world(); + await expect( + quickstart([...ALL_NO, "--public-url", "https://192.168.1.10"], w.deps), + ).rejects.toThrow("--public-url: Webhooks need a public HTTPS URL: 192.168.1.10 is a private"); + expect(w.steps).toEqual([]); + }); + + it("on kind, reads which integrations are on from devbox.kind.json", async () => { + const w = world({ + kindTarget: { + context: "kind-devbox", + profile: "k8s-kind-devbox", + integrations: { linear: { enabled: true, clientId: "client-1" } }, + publicUrl: "https://hooks.example.com", + }, + // The saved public URL is used, not asked for. + answers: ["", ""], + }); + await quickstart(["--kind"], w.deps); + expect(w.asked).toEqual([ + "Set up the GitHub App? [y/N] ", + "Linear is already set up. Run its setup again? [Y/n] ", + ]); + expect(w.steps).toHaveLength(3); + }); + + it("without a terminal, stops at a missing config and says what to write", async () => { + const missing = world({ config: null, isTTY: false }); + await expect(quickstart(ALL_NO, missing.deps)).rejects.toThrow( + "devbox.config.json not found. List the repos", + ); + expect(existsSync(join(missing.deps.root, "devbox.config.json"))).toBe(false); + }); + + it("with a terminal, writes the missing config from the answers, then goes on", async () => { + const w = world({ config: null, answers: ["", "frodo/ring", "", "", "anthropic", "", ""] }); + await quickstart(ALL_NO, w.deps); + expect(JSON.parse(readFileSync(join(w.deps.root, "devbox.config.json"), "utf8"))).toEqual( + CONFIG_WRITTEN, + ); + expect(w.log).toContain(" frodo/ring (branch main)"); + expect(w.steps).toEqual(["control-plane/scripts/up.ts"]); + }); + + it("stops when the user would rather write the config", async () => { + const w = world({ config: null, answers: [false] }); + await expect(quickstart(ALL_NO, w.deps)).rejects.toThrow(ConfigMissing); + expect(w.steps).toEqual([]); + }); + + it("never rewrites an existing config; reports the example's placeholder", async () => { + const example = world({ + config: { ...CONFIG, repos: [{ url: "https://github.com/your-org/your-repo.git" }] }, + }); + await expect(quickstart([], example.deps)).rejects.toThrow("your-org/your-repo"); + expect(example.asked).toEqual([]); + }); + + it("stops before starting anything when a tool is missing", async () => { + const w = world({ + tools: { "openssl version": { code: 127, stdout: "", stderr: "", missing: true } }, + }); + await expect(quickstart(ALL_NO, w.deps)).rejects.toThrow( + "1 prerequisite(s) missing: fix them, then run bun run quickstart again", + ); + expect(w.log).toContain("✗ openssl not found: install it"); + expect(w.steps).toEqual([]); + }); + + it("stops at a failed step and says to run it again", async () => { + const w = world({ codes: [0, 1] }); + const run = quickstart(["--compose", "--github-app", "--no-linear"], w.deps); + await expect(run).rejects.toThrow(QuickstartError); + await expect(run).rejects.toThrow( + "bun run github:setup failed (exit 1). Fix what it reported, then run bun run quickstart again", + ); + expect(w.steps).toHaveLength(2); + }); +}); diff --git a/control-plane/scripts/quickstart.ts b/control-plane/scripts/quickstart.ts new file mode 100644 index 0000000..e630e8c --- /dev/null +++ b/control-plane/scripts/quickstart.ts @@ -0,0 +1,272 @@ +// `bun run quickstart`: from devbox.config.json to a running local devbox, on +// Docker Compose or a kind cluster, with the GitHub App and Linear if chosen. +// Without the file it asks for the repos and the LLM and writes it +// (quickstart/createConfig.ts); an existing one is only reported. Then it asks +// what the flags leave open, checks the tools the platform needs, then runs the +// existing commands: the start command (`up` or `k8s:kind`), each chosen +// integration's setup, and the start command again to apply them +// (quickstart/steps.ts). Each of those is safe to re-run, so after a failure +// the fix is to run the quickstart again. +// +// bun run quickstart [--compose | --kind] [--[no-]github-app] [--[no-]linear] +// [--public-url ] [--no-keychain] +// +// An omitted choice is asked for, never taken as "no"; without a terminal the +// quickstart stops and names the flags to pass. +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { KIND_TARGET_FILE } from "./k8s/kind"; +import { loadTarget } from "./k8s/target"; +import { capture } from "./lib/capture"; +import { enabledGithubApp, enabledLinear, type Integrations } from "./lib/integrations"; +import { errorMessage, isKnownError } from "./lib/knownError"; +import { PublicUrlError, validatePublicUrl } from "./lib/publicUrl"; +import { + ChoiceError, + type ChoiceFlags, + chooseIntegration, + choosePlatform, + type Enabled, + type Platform, + requireDecidable, +} from "./quickstart/choices"; +import { ConfigMissing, describeConfig, loadForQuickstart } from "./quickstart/configReport"; +import { createConfig } from "./quickstart/createConfig"; +import { checkPrerequisites, DOCKER_INFO_TIMEOUT_MS, type Run } from "./quickstart/prerequisites"; +import { + integrationSteps, + type Step, + startStep, + statusCommands, + tunnelHint, +} from "./quickstart/steps"; +import { + bad, + bold, + cmd, + colorWanted, + dim, + good, + heading, + setColor, + warn, +} from "./quickstart/style"; +import { confirm, promptLine } from "./up/prompt"; + +const ROOT = join(import.meta.dir, "..", ".."); + +export const USAGE = `Usage: + bun run quickstart [--compose | --kind] [--[no-]github-app] [--[no-]linear] + [--public-url ] [--no-keychain] + +Starts devbox on this machine from devbox.config.json, which it helps you +write when there is none: checks the config and the tools, starts devbox +(bun run up, or bun run k8s:kind), sets up the chosen integrations and +applies them. A choice you leave out is asked for. + + --compose, --kind run with Docker Compose, or on a local kind cluster + --github-app set up the GitHub App (--no-github-app: don't) + --linear set up Linear (--no-linear: don't) + --public-url the public HTTPS URL for webhooks, for the setups + --no-keychain never read or write the system keychain +`; + +export class QuickstartError extends Error {} +class UsageError extends Error {} + +export type QuickstartDeps = { + root: string; + isTTY: boolean; + promptLine: (q: string) => Promise; + confirm: (q: string, opts: { defaultYes: boolean }) => Promise; + log: (line: string) => void; + // For the prerequisite checks: captures output. + run: Run; + // For the steps: output goes to the terminal; resolves with the exit code. + exec: (cmd: string[], cwd: string) => Promise; +}; + +// Which integrations the platform's config already has on: devbox.config.json +// on Compose, devbox.kind.json (once k8s:kind wrote it) on kind. +function enabledIn(platform: Platform, composeIntegrations: Integrations, root: string): Enabled { + let integrations = composeIntegrations; + if (platform === "kind") { + const path = join(root, KIND_TARGET_FILE); + integrations = existsSync(path) ? (loadTarget(path).integrations ?? {}) : {}; + } + return { + githubApp: enabledGithubApp(integrations) !== undefined, + linear: enabledLinear(integrations) !== undefined, + }; +} + +// --public-url, checked before anything runs. +function flagPublicUrl(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + try { + return validatePublicUrl(raw); + } catch (err) { + if (err instanceof PublicUrlError) throw new UsageError(`--public-url: ${err.message}`); + throw err; + } +} + +// The public URL devbox.kind.json already has (a setup saved it). +function savedKindPublicUrl(root: string): string | undefined { + const path = join(root, KIND_TARGET_FILE); + return existsSync(path) ? loadTarget(path).publicUrl : undefined; +} + +// Asked until it is a public HTTPS URL; empty stops the quickstart. +async function askPublicUrl(deps: QuickstartDeps): Promise { + for (;;) { + const answer = (await deps.promptLine("Public HTTPS URL of the tunnel: ")).trim(); + if (!answer) { + throw new QuickstartError( + "no public URL entered: start the tunnel, then run bun run quickstart again (or pass --public-url)", + ); + } + try { + return validatePublicUrl(answer); + } catch (err) { + if (!(err instanceof PublicUrlError)) throw err; + deps.log(bad(err.message)); + } + } +} + +async function runStep(s: Step, deps: QuickstartDeps): Promise { + deps.log(`\n${heading(`==> ${s.title}`)}`); + const code = await deps.exec(s.cmd, deps.root); + if (code !== 0) { + throw new QuickstartError( + `${s.title} failed (exit ${code}). Fix what it reported, then run bun run quickstart again: what is already done is reused.`, + ); + } +} + +export async function quickstart(argv: string[], deps: QuickstartDeps): Promise { + const { values } = parseArgs({ + args: argv, + options: { + compose: { type: "boolean" }, + kind: { type: "boolean" }, + "github-app": { type: "boolean" }, + linear: { type: "boolean" }, + "public-url": { type: "string" }, + keychain: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + allowNegative: true, + allowPositionals: false, + }); + if (values.help) { + deps.log(USAGE); + return; + } + const flags: ChoiceFlags = values; + // Every flag a missing terminal can't ask for, before any work. + requireDecidable(flags, deps.isTTY); + const log = deps.log; + const configPath = join(deps.root, "devbox.config.json"); + + log(heading("==> Config")); + // Without a terminal, loadForQuickstart says what to write instead. + if (deps.isTTY && !existsSync(configPath) && !(await createConfig(configPath, deps))) { + throw new ConfigMissing( + "devbox.config.json not created: copy devbox.config.example.json and fill in repos and llm, then run bun run quickstart again", + ); + } + // repos and llm first, as both platforms read them. + for (const line of describeConfig(loadForQuickstart(configPath, "kind"))) log(line); + + log(`\n${heading("==> Choices")}`); + const platform = await choosePlatform(flags, deps); + const cfg = loadForQuickstart(configPath, platform); + const enabled = enabledIn(platform, cfg.integrations, deps.root); + const choices = { + platform, + githubApp: await chooseIntegration("githubApp", flags, enabled, deps), + linear: await chooseIntegration("linear", flags, enabled, deps), + }; + const yesNo = (b: boolean) => (b ? good("yes") : dim("no")); + log( + `platform ${bold(platform)}; GitHub App setup: ${yesNo(choices.githubApp)}; Linear setup: ${yesNo(choices.linear)}`, + ); + // The setups ask for the public URL only on Compose: on kind it comes from + // here, so without a terminal it must be known before the long install. + let publicUrl = flagPublicUrl(values["public-url"]); + const askUrl = + platform === "kind" && + (choices.githubApp || choices.linear) && + publicUrl === undefined && + savedKindPublicUrl(deps.root) === undefined; + if (askUrl && !deps.isTTY) { + throw new QuickstartError( + "on kind, the GitHub App and Linear need a public URL and there is no terminal to ask in: pass --public-url ", + ); + } + + log(`\n${heading("==> Prerequisites")}`); + const found = await checkPrerequisites(platform, deps.run); + for (const w of found.warnings) log(`${warn("!")} ${w}`); + for (const p of found.problems) log(`${bad("✗")} ${p}`); + if (found.problems.length > 0) { + throw new QuickstartError( + `${found.problems.length} prerequisite(s) missing: fix them, then run bun run quickstart again`, + ); + } + log(`${good("✓")} all found`); + + const noKeychain = values.keychain === false; + const start = startStep(choices, { noKeychain }); + await runStep(start, deps); + if (choices.githubApp || choices.linear) { + log(""); + for (const line of tunnelHint(choices)) log(warn(line)); + if (askUrl) publicUrl = await askPublicUrl(deps); + for (const s of integrationSteps(choices, { noKeychain, publicUrl })) await runStep(s, deps); + await runStep(start, deps); + } + + log(`\n${heading("==> Done")}`); + log(good(`✓ devbox is running (${platform === "compose" ? "Docker Compose" : "kind"}).`)); + const status = statusCommands(choices, enabled); + if (status.length > 0) log(`Check the integrations with: ${status.map(cmd).join(", ")}`); + log(dim("Run bun run quickstart again at any time: what is already set up is reused.")); +} + +async function exec(cmd: string[], cwd: string): Promise { + const proc = Bun.spawn(cmd, { cwd, stdin: "inherit", stdout: "inherit", stderr: "inherit" }); + return proc.exited; +} + +const run: Run = async (cmd) => { + try { + return await capture(cmd, { timeoutMs: DOCKER_INFO_TIMEOUT_MS }); + } catch { + return { code: 127, stdout: "", stderr: "", missing: true }; + } +}; + +if (import.meta.main) { + setColor(colorWanted(process.stdout.isTTY === true, process.env)); + try { + await quickstart(process.argv.slice(2), { + root: ROOT, + isTTY: process.stdin.isTTY === true, + promptLine: (q) => promptLine(q), + confirm: (q, o) => confirm(q, o), + log: (line) => process.stdout.write(`${line}\n`), + run, + exec, + }); + } catch (err) { + const known = isKnownError(err, QuickstartError, ChoiceError, ConfigMissing, UsageError); + process.stderr.write(`\n${bad(bold("bun run quickstart:"))} ${errorMessage(err)}\n`); + if (err instanceof UsageError || err instanceof ChoiceError) process.stderr.write(`\n${USAGE}`); + if (!known && err instanceof Error && err.stack) process.stderr.write(`${err.stack}\n`); + process.exit(1); + } +} diff --git a/control-plane/scripts/quickstart/choices.test.ts b/control-plane/scripts/quickstart/choices.test.ts new file mode 100644 index 0000000..31b2220 --- /dev/null +++ b/control-plane/scripts/quickstart/choices.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "bun:test"; +import { + type ChoiceDeps, + ChoiceError, + chooseIntegration, + choosePlatform, + platformFlag, + requireDecidable, + undecidedFlags, +} from "./choices"; + +// Answers the questions in order; records them and what was logged. +function world(answers: (string | boolean)[]) { + const asked: string[] = []; + const log: string[] = []; + const next = (q: string) => { + asked.push(q); + const a = answers.shift(); + if (a === undefined) throw new Error(`unexpected question: ${q}`); + return a; + }; + const deps: ChoiceDeps = { + isTTY: true, + promptLine: async (q) => String(next(q)), + confirm: async (q, { defaultYes }) => { + const a = next(q); + return a === "" ? defaultYes : a === true; + }, + log: (l) => void log.push(l), + }; + return { deps, asked, log }; +} + +const OFF = { githubApp: false, linear: false }; + +describe("platformFlag", () => { + it("reads --compose and --kind", () => { + expect(platformFlag({ compose: true })).toBe("compose"); + expect(platformFlag({ kind: true })).toBe("kind"); + expect(platformFlag({})).toBeUndefined(); + }); + + it("refuses both, and the negated forms", () => { + expect(() => platformFlag({ compose: true, kind: true })).toThrow( + "--compose and --kind exclude each other", + ); + expect(() => platformFlag({ kind: false })).toThrow(ChoiceError); + expect(() => platformFlag({ compose: false })).toThrow(ChoiceError); + }); +}); + +describe("requireDecidable", () => { + it("names every open choice when there is no terminal", () => { + expect(undecidedFlags({ kind: true })).toEqual([ + "--github-app or --no-github-app", + "--linear or --no-linear", + ]); + expect(() => requireDecidable({ "github-app": false }, false)).toThrow( + "no terminal to ask in: pass --compose or --kind, --linear or --no-linear", + ); + }); + + it("passes with every flag, or with a terminal", () => { + requireDecidable({ compose: true, "github-app": false, linear: true }, false); + requireDecidable({}, true); + }); +}); + +describe("choosePlatform", () => { + it("takes the flag without asking", async () => { + const w = world([]); + expect(await choosePlatform({ kind: true }, w.deps)).toBe("kind"); + expect(w.asked).toEqual([]); + }); + + it("asks until the answer is compose or kind", async () => { + const w = world(["docker", " Kind "]); + expect(await choosePlatform({}, w.deps)).toBe("kind"); + expect(w.asked).toHaveLength(2); + expect(w.log).toEqual(['Answer "compose" or "kind".']); + }); +}); + +describe("chooseIntegration", () => { + it("takes --github-app and --no-github-app without asking", async () => { + const w = world([]); + expect(await chooseIntegration("githubApp", { "github-app": true }, OFF, w.deps)).toBe(true); + expect(await chooseIntegration("githubApp", { "github-app": false }, OFF, w.deps)).toBe(false); + expect(w.asked).toEqual([]); + }); + + it("asks when the flag is omitted; Enter means no", async () => { + const w = world([""]); + expect(await chooseIntegration("linear", {}, OFF, w.deps)).toBe(false); + expect(w.asked).toEqual(["Set up Linear? [y/N] "]); + expect(w.log[0]).toContain("It needs a public HTTPS URL"); + }); + + it("asks to run the setup again when already on; Enter means yes", async () => { + const w = world([""]); + const on = { githubApp: true, linear: false }; + expect(await chooseIntegration("githubApp", {}, on, w.deps)).toBe(true); + expect(w.asked).toEqual(["The GitHub App is already set up. Run its setup again? [Y/n] "]); + }); +}); diff --git a/control-plane/scripts/quickstart/choices.ts b/control-plane/scripts/quickstart/choices.ts new file mode 100644 index 0000000..d5c9361 --- /dev/null +++ b/control-plane/scripts/quickstart/choices.ts @@ -0,0 +1,102 @@ +// The quickstart's three choices: where devbox runs, and whether to set up +// the GitHub App and Linear. A flag decides; an omitted flag is asked, never +// taken as "no". Without a terminal every undecided choice fails at once, +// naming the flags to pass. + +import { dim } from "./style"; + +export type Platform = "compose" | "kind"; +export type Choices = { platform: Platform; githubApp: boolean; linear: boolean }; + +// The parsed flags; parseArgs with allowNegative gives false for --no-. +export type ChoiceFlags = { + compose?: boolean; + kind?: boolean; + "github-app"?: boolean; + linear?: boolean; +}; + +// Which integrations the deployment already has on. +export type Enabled = { githubApp: boolean; linear: boolean }; + +export type ChoiceDeps = { + isTTY: boolean; + // Asks with echo; empty at EOF. + promptLine: (q: string) => Promise; + confirm: (q: string, opts: { defaultYes: boolean }) => Promise; + log: (line: string) => void; +}; + +export class ChoiceError extends Error {} + +const platformQuestion = () => + `Run devbox with Docker Compose or on a local kind cluster? ${dim("[compose/kind]")} `; + +// The platform from the flags, or undefined when they leave it open. +export function platformFlag(flags: ChoiceFlags): Platform | undefined { + if (flags.compose === false || flags.kind === false) { + throw new ChoiceError("--no-compose and --no-kind are not flags: pass --compose or --kind"); + } + if (flags.compose && flags.kind) throw new ChoiceError("--compose and --kind exclude each other"); + return flags.compose ? "compose" : flags.kind ? "kind" : undefined; +} + +// Without a terminal, the flags that would have to be asked for. +export function undecidedFlags(flags: ChoiceFlags): string[] { + const missing: string[] = []; + if (platformFlag(flags) === undefined) missing.push("--compose or --kind"); + if (flags["github-app"] === undefined) missing.push("--github-app or --no-github-app"); + if (flags.linear === undefined) missing.push("--linear or --no-linear"); + return missing; +} + +export function requireDecidable(flags: ChoiceFlags, isTTY: boolean): void { + const missing = undecidedFlags(flags); + if (isTTY || missing.length === 0) return; + throw new ChoiceError(`no terminal to ask in: pass ${missing.join(", ")}`); +} + +export async function choosePlatform(flags: ChoiceFlags, deps: ChoiceDeps): Promise { + const flagged = platformFlag(flags); + if (flagged) return flagged; + for (;;) { + const a = (await deps.promptLine(platformQuestion())).trim().toLowerCase(); + if (a === "compose" || a === "kind") return a; + deps.log('Answer "compose" or "kind".'); + } +} + +const WHAT = { + githubApp: { + flag: "github-app", + name: "the GitHub App", + gives: + "Pull requests then come from the App's bot, and @mentions, reviews and failed CI runs reach the agent.", + }, + linear: { + flag: "linear", + name: "Linear", + gives: "Delegating a Linear issue to the agent, or @mentioning it, then starts a task.", + }, +} as const; + +// One integration: the flag, else a question. Already on, Enter re-runs its +// setup (safe: it reuses what is configured); off, Enter skips it. +export async function chooseIntegration( + which: keyof typeof WHAT, + flags: ChoiceFlags, + enabled: Enabled, + deps: ChoiceDeps, +): Promise { + const w = WHAT[which]; + const flagged = flags[w.flag]; + if (flagged !== undefined) return flagged; + if (enabled[which]) { + const name = w.name[0]?.toUpperCase() + w.name.slice(1); + return deps.confirm(`${name} is already set up. Run its setup again? ${dim("[Y/n]")} `, { + defaultYes: true, + }); + } + deps.log(`\n${w.gives} It needs a public HTTPS URL, such as a tunnel.`); + return deps.confirm(`Set up ${w.name}? ${dim("[y/N]")} `, { defaultYes: false }); +} diff --git a/control-plane/scripts/quickstart/configReport.test.ts b/control-plane/scripts/quickstart/configReport.test.ts new file mode 100644 index 0000000..dcb3f1a --- /dev/null +++ b/control-plane/scripts/quickstart/configReport.test.ts @@ -0,0 +1,69 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ConfigMissing, describeConfig, loadForQuickstart } from "./configReport"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-quickstart-config-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const LLM = { provider: "openai", baseUrl: "https://api.openai.com/v1", model: "gpt-5.6-sol" }; +const EXAMPLE = join(import.meta.dir, "..", "..", "..", "devbox.config.example.json"); + +let n = 0; +function configFile(content: unknown): string { + const path = join(dir, `c${n++}.json`); + writeFileSync(path, typeof content === "string" ? content : JSON.stringify(content)); + return path; +} + +describe("loadForQuickstart", () => { + it("says what to fill when the file is missing", () => { + expect(() => loadForQuickstart(join(dir, "none.json"), "compose")).toThrow( + "devbox.config.json not found. List the repos the agent may work on under `repos`", + ); + }); + + it("passes the config error on, with where to read about it", () => { + const path = configFile({ repos: [] }); + expect(() => loadForQuickstart(path, "compose")).toThrow(ConfigMissing); + expect(() => loadForQuickstart(path, "compose")).toThrow( + "devbox.config.json: repos: must be a non-empty list. List the repos", + ); + }); + + it("on kind, ignores the Compose-only blocks", () => { + const path = configFile({ + repos: [{ url: "https://github.com/frodo/ring.git" }], + llm: LLM, + sandbox: { cpu: "not a quantity" }, + }); + expect(() => loadForQuickstart(path, "compose")).toThrow("sandbox.cpu"); + expect(loadForQuickstart(path, "kind").repos[0]?.name).toBe("ring"); + }); +}); + +describe("describeConfig", () => { + it("lists the repos and the LLM", () => { + const path = configFile({ + repos: [ + { url: "https://github.com/frodo/ring.git" }, + { url: "https://github.com/frodo/shire.git", branch: "develop" }, + ], + llm: LLM, + }); + expect(describeConfig(loadForQuickstart(path, "compose"))).toEqual([ + "devbox.config.json: 2 repo(s)", + " frodo/ring (branch main)", + " frodo/shire (branch develop)", + " LLM: provider openai, model gpt-5.6-sol at https://api.openai.com/v1", + ]); + }); + + it("refuses the example's placeholder repo", () => { + const path = configFile(readFileSync(EXAMPLE, "utf8")); + expect(() => describeConfig(loadForQuickstart(path, "compose"))).toThrow( + "repos still has the example's your-org/your-repo: put your own repo there.", + ); + }); +}); diff --git a/control-plane/scripts/quickstart/configReport.ts b/control-plane/scripts/quickstart/configReport.ts new file mode 100644 index 0000000..5ec1793 --- /dev/null +++ b/control-plane/scripts/quickstart/configReport.ts @@ -0,0 +1,49 @@ +// What devbox.config.json holds, for the quickstart. It never changes the +// file (quickstart/createConfig.ts writes one only where there is none). It +// says what is there, or what is missing and where to read about it. +import { existsSync } from "node:fs"; +import { llmSummary } from "../up"; +import { ConfigError, type DevboxConfig, loadConfig } from "../up/config"; +import type { Platform } from "./choices"; +import { bold, dim } from "./style"; + +export class ConfigMissing extends Error {} + +const HOW = + "List the repos the agent may work on under `repos` and your LLM endpoint under `llm`; " + + "devbox.config.example.json is a start and docs/configuration.md describes every field."; + +// The example's placeholders, which pass validation but name no real repo. +const PLACEHOLDER_OWNER = "your-org"; +const PLACEHOLDER_REPO = "your-repo"; + +// Loads the config as the platform reads it: kind skips the Compose-only +// blocks (its integrations are in devbox.kind.json). +export function loadForQuickstart(path: string, platform: Platform): DevboxConfig { + if (!existsSync(path)) { + throw new ConfigMissing(`devbox.config.json not found. ${HOW}`); + } + try { + return platform === "kind" ? loadConfig(path, { integrations: false }) : loadConfig(path); + } catch (err) { + if (err instanceof ConfigError) throw new ConfigMissing(`${err.message}. ${HOW}`); + throw err; + } +} + +// The lines to print; throws ConfigMissing when a repo is still the example's. +export function describeConfig(cfg: DevboxConfig): string[] { + const placeholder = cfg.repos.find( + (r) => r.owner === PLACEHOLDER_OWNER || r.name === PLACEHOLDER_REPO, + ); + if (placeholder) { + throw new ConfigMissing( + `devbox.config.json: repos still has the example's ${placeholder.owner}/${placeholder.name}: put your own repo there. ${HOW}`, + ); + } + return [ + `devbox.config.json: ${cfg.repos.length} repo(s)`, + ...cfg.repos.map((r) => ` ${bold(`${r.owner}/${r.name}`)} ${dim(`(branch ${r.branch})`)}`), + ` ${dim("LLM:")} ${llmSummary(cfg.llm)}`, + ]; +} diff --git a/control-plane/scripts/quickstart/createConfig.test.ts b/control-plane/scripts/quickstart/createConfig.test.ts new file mode 100644 index 0000000..79652e2 --- /dev/null +++ b/control-plane/scripts/quickstart/createConfig.test.ts @@ -0,0 +1,141 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig } from "../up/config"; +import { type CreateDeps, createConfig, repoUrl } from "./createConfig"; + +const dir = mkdtempSync(join(tmpdir(), "devbox-quickstart-create-")); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +let n = 0; +const nextPath = () => join(dir, `c${n++}.json`); + +// Answers in order: strings for promptLine, booleans (or "" for Enter) for +// confirm. +function world(answers: (string | boolean)[]) { + const asked: string[] = []; + const log: string[] = []; + const next = (q: string) => { + asked.push(q); + const a = answers.shift(); + if (a === undefined) throw new Error(`unexpected question: ${q}`); + return a; + }; + const deps: CreateDeps = { + promptLine: async (q) => String(next(q)), + confirm: async (q, { defaultYes }) => { + const a = next(q); + return a === "" ? defaultYes : a === true; + }, + log: (l) => void log.push(l), + }; + return { deps, asked, log }; +} + +const readJson = (path: string): unknown => JSON.parse(readFileSync(path, "utf8")); + +describe("repoUrl", () => { + it("takes owner/name and GitHub URLs", () => { + const url = "https://github.com/frodo/ring.git"; + expect(repoUrl("frodo/ring")).toBe(url); + expect(repoUrl(" https://github.com/frodo/ring ")).toBe(url); + expect(repoUrl("https://github.com/frodo/ring.git")).toBe(url); + expect(repoUrl("https://github.com/frodo/ring/")).toBe(url); + expect(repoUrl("frodo/ring.js")).toBe("https://github.com/frodo/ring.js.git"); + }); + + it("refuses anything else", () => { + expect(repoUrl("ring")).toBeUndefined(); + expect(repoUrl("https://gitlab.com/frodo/ring")).toBeUndefined(); + expect(repoUrl("git@github.com:frodo/ring.git")).toBeUndefined(); + expect(repoUrl("frodo/ring/tree/main")).toBeUndefined(); + }); +}); + +describe("createConfig", () => { + it("writes the defaults with Enter on every question but the repo", async () => { + const path = nextPath(); + const w = world(["", "frodo/ring", "", "", "", "", ""]); + expect(await createConfig(path, w.deps)).toBe(true); + expect(w.asked).toEqual([ + "Create it now? [Y/n] ", + "Repo the agent may work on (owner/name): ", + "Branch of frodo/ring [main]: ", + "Another repo (owner/name, Enter when done): ", + "LLM provider [openai/anthropic] (openai): ", + "Model [gpt-5.6-sol]: ", + "Base URL [https://api.openai.com/v1]: ", + ]); + expect(readJson(path)).toEqual({ + repos: [{ url: "https://github.com/frodo/ring.git", branch: "main" }], + llm: { provider: "openai", baseUrl: "https://api.openai.com/v1", model: "gpt-5.6-sol" }, + }); + // What it writes, `up` accepts. + expect(loadConfig(path).repos).toHaveLength(1); + }); + + it("takes several repos and Anthropic's defaults", async () => { + const path = nextPath(); + const w = world([ + true, + "frodo/ring", + "develop", + "https://github.com/frodo/shire", + "", + "", + "anthropic", + "", + "", + ]); + await createConfig(path, w.deps); + expect(readJson(path)).toEqual({ + repos: [ + { url: "https://github.com/frodo/ring.git", branch: "develop" }, + { url: "https://github.com/frodo/shire.git", branch: "main" }, + ], + llm: { + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + model: "claude-opus-5-5", + }, + }); + }); + + it("asks again after an answer the parser refuses", async () => { + const path = nextPath(); + const w = world([ + "", + "", // no repo yet + "ring", // not owner/name + "frodo/ring", + "", + "samwise/ring", // same name as frodo/ring + "", + "", + "gemini", // unknown provider + "anthropic", + "", + "https://api.anthropic.com/v1", // anthropic takes no /v1 + "", + "", + ]); + await createConfig(path, w.deps); + expect(w.log).toEqual([ + "devbox.config.json not found. It lists the repos the agent may work on and your LLM.", + "At least one repo is needed.", + '"ring" is not owner/name or https://github.com/owner/name', + 'devbox.config.json: repos[1].url: another repo is also named "ring" (each repo is cloned to /workspace/)', + 'Answer "openai" or "anthropic".', + 'devbox.config.json: llm.baseUrl: for provider "anthropic" use the API root without /v1, for example https://api.anthropic.com', + `✓ Wrote ${path}. Edit it later for per-stage models and sandbox sizing (docs/configuration.md).`, + ]); + expect((readJson(path) as { repos: unknown[] }).repos).toHaveLength(1); + }); + + it("writes nothing when the user says no", async () => { + const path = nextPath(); + expect(await createConfig(path, world([false]).deps)).toBe(false); + expect(existsSync(path)).toBe(false); + }); +}); diff --git a/control-plane/scripts/quickstart/createConfig.ts b/control-plane/scripts/quickstart/createConfig.ts new file mode 100644 index 0000000..d7189c3 --- /dev/null +++ b/control-plane/scripts/quickstart/createConfig.ts @@ -0,0 +1,111 @@ +// Writes devbox.config.json from the user's answers when there is none: the +// repos (owner/name or a GitHub URL, each with its branch) and the LLM +// (provider, model, base URL). Every answer is checked with the parser `up` +// uses, and a refused one is asked again. No secret is asked here: `up` finds +// the GitHub token and the LLM key itself. An existing file is never touched. +import { writeFileSync } from "node:fs"; +import { ConfigError, type Provider, parseConfig } from "../up/config"; +import { bad, dim, good } from "./style"; + +export const LLM_DEFAULTS: Record = { + openai: { baseUrl: "https://api.openai.com/v1", model: "gpt-5.6-sol" }, + anthropic: { baseUrl: "https://api.anthropic.com", model: "claude-opus-5-5" }, +}; + +export type CreateDeps = { + // Asks with echo; empty at EOF. + promptLine: (q: string) => Promise; + confirm: (q: string, opts: { defaultYes: boolean }) => Promise; + log: (line: string) => void; +}; + +type RepoEntry = { url: string; branch: string }; +type LlmEntry = { provider: Provider; baseUrl: string; model: string }; + +// owner/name, or https://github.com/owner/name with or without .git or a +// trailing slash, as the URL the config takes; undefined for anything else. +export function repoUrl(answer: string): string | undefined { + const m = /^(?:https:\/\/github\.com\/)?([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec( + answer.trim(), + ); + return m ? `https://github.com/${m[1]}/${m[2]}.git` : undefined; +} + +// The parser's message for a config, or undefined when it is accepted. +function refusal(repos: RepoEntry[], llm: LlmEntry): string | undefined { + try { + parseConfig({ repos, llm }); + return undefined; + } catch (err) { + if (err instanceof ConfigError) return err.message; + throw err; + } +} + +async function askRepos(deps: CreateDeps): Promise { + const repos: RepoEntry[] = []; + for (;;) { + const answer = ( + await deps.promptLine( + repos.length === 0 + ? "Repo the agent may work on (owner/name): " + : "Another repo (owner/name, Enter when done): ", + ) + ).trim(); + if (!answer) { + if (repos.length > 0) return repos; + deps.log(bad("At least one repo is needed.")); + continue; + } + const url = repoUrl(answer); + if (!url) { + deps.log(bad(`"${answer}" is not owner/name or https://github.com/owner/name`)); + continue; + } + const name = url.slice("https://github.com/".length, -".git".length); + const branch = (await deps.promptLine(`Branch of ${name} ${dim("[main]")}: `)).trim() || "main"; + const entry = { url, branch }; + const why = refusal([...repos, entry], { provider: "openai", ...LLM_DEFAULTS.openai }); + if (why) { + deps.log(bad(why)); + continue; + } + repos.push(entry); + } +} + +async function askLlm(repos: RepoEntry[], deps: CreateDeps): Promise { + let provider: Provider | undefined; + while (!provider) { + const a = (await deps.promptLine(`LLM provider ${dim("[openai/anthropic] (openai)")}: `)) + .trim() + .toLowerCase(); + if (a === "" || a === "openai") provider = "openai"; + else if (a === "anthropic") provider = "anthropic"; + else deps.log(bad('Answer "openai" or "anthropic".')); + } + const d = LLM_DEFAULTS[provider]; + for (;;) { + const model = (await deps.promptLine(`Model ${dim(`[${d.model}]`)}: `)).trim() || d.model; + const baseUrl = + (await deps.promptLine(`Base URL ${dim(`[${d.baseUrl}]`)}: `)).trim() || d.baseUrl; + const llm = { provider, baseUrl, model }; + const why = refusal(repos, llm); + if (!why) return llm; + deps.log(bad(why)); + } +} + +// Asks and writes; false when the user would rather write it themselves. +export async function createConfig(path: string, deps: CreateDeps): Promise { + deps.log("devbox.config.json not found. It lists the repos the agent may work on and your LLM."); + if (!(await deps.confirm(`Create it now? ${dim("[Y/n]")} `, { defaultYes: true }))) return false; + const repos = await askRepos(deps); + const llm = await askLlm(repos, deps); + const text = `${JSON.stringify({ repos, llm }, null, 2)}\n`; + writeFileSync(path, text); + deps.log( + `${good("✓")} Wrote ${path}. Edit it later for per-stage models and sandbox sizing (docs/configuration.md).`, + ); + return true; +} diff --git a/control-plane/scripts/quickstart/prerequisites.test.ts b/control-plane/scripts/quickstart/prerequisites.test.ts new file mode 100644 index 0000000..d279fde --- /dev/null +++ b/control-plane/scripts/quickstart/prerequisites.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "bun:test"; +import { checkPrerequisites, olderThan, parseVersion, type Ran, type Run } from "./prerequisites"; + +const GiB = 1024 ** 3; +const ok = (stdout = ""): Ran => ({ code: 0, stdout, stderr: "" }); +const MISSING: Ran = { code: 127, stdout: "", stderr: "", missing: true }; + +// Every tool present and new enough, unless `over` says otherwise; keyed by +// the command's first two words. +function tools(over: Record = {}): { run: Run; ran: string[] } { + const all: Record = { + "docker info": ok(`28.0.1 ${16 * GiB}`), + "docker compose": ok("2.29.1"), + "make --version": ok("GNU Make 4.4"), + "openssl version": ok("OpenSSL 3.0.13"), + "kind version": ok("kind v0.27.0 go1.23.6 darwin/arm64"), + "kubectl version": ok("Client Version: v1.32.2"), + "helm version": ok("v3.17.1+g980d8ac"), + "git --version": ok("git version 2.47.1"), + ...over, + }; + const ran: string[] = []; + const run: Run = async (cmd) => { + const key = cmd.slice(0, 2).join(" "); + ran.push(key); + const r = all[key]; + if (!r) throw new Error(`unexpected command: ${cmd.join(" ")}`); + return r; + }; + return { run, ran }; +} + +describe("versions", () => { + it("parses with or without a v", () => { + expect(parseVersion("v2.13.0")).toEqual([2, 13, 0]); + expect(parseVersion("Docker Compose version 2.29.1-desktop.1")).toEqual([2, 29, 1]); + expect(parseVersion("nonsense")).toBeUndefined(); + }); + + it("compares component by component", () => { + expect(olderThan([2, 12, 9], [2, 13, 0])).toBe(true); + expect(olderThan([2, 13, 0], [2, 13, 0])).toBe(false); + expect(olderThan([3, 0, 0], [2, 13, 0])).toBe(false); + }); +}); + +describe("checkPrerequisites", () => { + it("Compose: checks docker, compose, make and openssl", async () => { + const t = tools(); + expect(await checkPrerequisites("compose", t.run)).toEqual({ problems: [], warnings: [] }); + expect(t.ran).toEqual(["docker info", "make --version", "docker compose", "openssl version"]); + }); + + it("kind: checks kind, kubectl, helm and git instead of compose and openssl", async () => { + const t = tools(); + const f = await checkPrerequisites("kind", t.run); + expect(f.problems).toEqual([]); + expect(f.warnings).toEqual([ + "kind needs about 30 GB of free disk for the cluster, the images and the volumes", + ]); + expect(t.ran).toEqual([ + "docker info", + "make --version", + "kind version", + "kubectl version", + "helm version", + "git --version", + ]); + }); + + it("reports every problem at once", async () => { + const t = tools({ + "docker compose": ok("2.12.2"), + "make --version": MISSING, + "openssl version": MISSING, + }); + expect((await checkPrerequisites("compose", t.run)).problems).toEqual([ + "make not found: install it", + "Docker Compose 2.12.2 is too old: devbox needs 2.13.0 or newer", + "openssl not found: install it", + ]); + }); + + it("kind and helm too old, kubectl missing", async () => { + const t = tools({ + "kind version": ok("kind v0.23.0 go1.22"), + "helm version": ok("v3.13.3+gc8b9489"), + "kubectl version": MISSING, + }); + expect((await checkPrerequisites("kind", t.run)).problems).toEqual([ + "kind 0.23.0 is too old: devbox needs 0.24.0 or newer", + "kubectl not found: install it", + "helm 3.13.3 is too old: devbox needs 3.14.0 or newer", + ]); + }); + + it("without docker, skips the compose check", async () => { + const t = tools({ "docker info": MISSING }); + const f = await checkPrerequisites("compose", t.run); + expect(f.problems).toEqual([ + "docker not found: install Docker (Docker Desktop, or Docker Engine on Linux)", + ]); + expect(t.ran).not.toContain("docker compose"); + }); + + it("explains a daemon that refuses the user", async () => { + const t = tools({ + "docker info": { code: 1, stdout: "", stderr: "permission denied while trying to connect" }, + }); + expect((await checkPrerequisites("compose", t.run)).problems[0]).toContain( + "sudo usermod -aG docker $USER", + ); + }); + + it("warns, without failing, when Docker has little memory", async () => { + const t = tools({ "docker info": ok(`28.0.1 ${4 * GiB}`) }); + expect(await checkPrerequisites("compose", t.run)).toEqual({ + problems: [], + warnings: [ + "Docker has 4.0 GB of memory; devbox needs about 8 GB (Docker Desktop: Settings → Resources)", + ], + }); + }); +}); diff --git a/control-plane/scripts/quickstart/prerequisites.ts b/control-plane/scripts/quickstart/prerequisites.ts new file mode 100644 index 0000000..bc89091 --- /dev/null +++ b/control-plane/scripts/quickstart/prerequisites.ts @@ -0,0 +1,121 @@ +// The tools the chosen platform needs, all checked before anything starts, so +// the user sees every missing one at once instead of one per run. Problems +// stop the quickstart; warnings do not. +import { parseKindVersion } from "../k8s/kind"; +import type { Platform } from "./choices"; + +// The result of a command; missing when it could not be started. +export type Ran = { code: number; stdout: string; stderr: string; missing?: boolean }; +export type Run = (cmd: string[]) => Promise; + +export type Findings = { problems: string[]; warnings: string[] }; + +// docker info answers slowly while Docker Desktop starts. +export const DOCKER_INFO_TIMEOUT_MS = 20_000; +const MIN_COMPOSE: Version = [2, 13, 0]; +const MIN_KIND: Version = [0, 24, 0]; +const MIN_HELM: Version = [3, 14, 0]; +// Docker Desktop's 8 GB setting reports a little less than 8 GiB. +const MIN_MEMORY_BYTES = 7.5 * 1024 ** 3; + +type Version = [number, number, number]; + +export function parseVersion(text: string): Version | undefined { + const m = /\bv?(\d+)\.(\d+)\.(\d+)/.exec(text); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined; +} + +export function olderThan(v: Version, min: Version): boolean { + for (let i = 0; i < 3; i++) if (v[i] !== min[i]) return (v[i] ?? 0) < (min[i] ?? 0); + return false; +} + +const show = (v: Version) => v.join("."); + +async function checkDocker(run: Run, f: Findings): Promise { + const r = await run(["docker", "info", "--format", "{{.ServerVersion}} {{.MemTotal}}"]); + if (r.missing) { + f.problems.push("docker not found: install Docker (Docker Desktop, or Docker Engine on Linux)"); + return false; + } + if (r.code !== 0) { + f.problems.push( + /permission denied/i.test(r.stderr) + ? "cannot reach the Docker daemon: permission denied. Add yourself to the docker group (sudo usermod -aG docker $USER), then log out and back in" + : `cannot reach the Docker daemon: ${r.stderr.trim().split("\n")[0] || "is it running?"}`, + ); + return false; + } + const memory = Number(r.stdout.trim().split(/\s+/)[1]); + if (Number.isFinite(memory) && memory > 0 && memory < MIN_MEMORY_BYTES) { + f.warnings.push( + `Docker has ${(memory / 1024 ** 3).toFixed(1)} GB of memory; devbox needs about 8 GB (Docker Desktop: Settings → Resources)`, + ); + } + return true; +} + +async function checkCompose(run: Run, f: Findings): Promise { + const r = await run(["docker", "compose", "version", "--short"]); + const v = r.code === 0 ? parseVersion(r.stdout) : undefined; + if (!v) { + f.problems.push( + `docker compose is not working: install Docker Compose ${show(MIN_COMPOSE)} or newer`, + ); + } else if (olderThan(v, MIN_COMPOSE)) { + f.problems.push( + `Docker Compose ${show(v)} is too old: devbox needs ${show(MIN_COMPOSE)} or newer`, + ); + } +} + +// A tool that only has to be there. +async function checkPresent(run: Run, f: Findings, cmd: string[]): Promise { + const r = await run(cmd); + if (r.missing || r.code !== 0) f.problems.push(`${cmd[0]} not found: install it`); +} + +async function checkVersioned( + run: Run, + f: Findings, + cmd: string[], + min: Version, + parse: (out: string) => Version | undefined, +): Promise { + const tool = cmd[0]; + const r = await run(cmd); + if (r.missing) { + f.problems.push(`${tool} not found: install ${tool} ${show(min)} or newer`); + return; + } + const v = r.code === 0 ? parse(r.stdout) : undefined; + if (!v) f.problems.push(`${tool} is not working: ${r.stderr.trim().split("\n")[0]}`); + else if (olderThan(v, min)) { + f.problems.push(`${tool} ${show(v)} is too old: devbox needs ${show(min)} or newer`); + } +} + +export async function checkPrerequisites(platform: Platform, run: Run): Promise { + const f: Findings = { problems: [], warnings: [] }; + const docker = await checkDocker(run, f); + await checkPresent(run, f, ["make", "--version"]); + if (platform === "compose") { + if (docker) await checkCompose(run, f); + await checkPresent(run, f, ["openssl", "version"]); + return f; + } + await checkVersioned(run, f, ["kind", "version"], MIN_KIND, (out) => { + try { + return parseKindVersion(out); + } catch { + return undefined; + } + }); + await checkPresent(run, f, ["kubectl", "version", "--client"]); + await checkVersioned(run, f, ["helm", "version", "--short"], MIN_HELM, parseVersion); + await checkPresent(run, f, ["git", "--version"]); + f.warnings.push( + "kind needs about 30 GB of free disk for the cluster, the images and the volumes", + ); + return f; +} diff --git a/control-plane/scripts/quickstart/steps.test.ts b/control-plane/scripts/quickstart/steps.test.ts new file mode 100644 index 0000000..7d41e8d --- /dev/null +++ b/control-plane/scripts/quickstart/steps.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import type { Choices } from "./choices"; +import { integrationSteps, startStep, statusCommands } from "./steps"; + +const COMPOSE: Choices = { platform: "compose", githubApp: false, linear: false }; +const KIND: Choices = { platform: "kind", githubApp: false, linear: false }; +const PLAIN = { noKeychain: false }; + +describe("startStep", () => { + it("runs up on Compose and k8s:kind on kind", () => { + expect(startStep(COMPOSE, PLAIN)).toEqual({ + title: "bun run up", + cmd: ["bun", "control-plane/scripts/up.ts"], + }); + expect(startStep(KIND, { noKeychain: true })).toEqual({ + title: "bun run k8s:kind", + cmd: ["bun", "control-plane/scripts/k8s.ts", "kind", "--no-keychain"], + }); + }); +}); + +describe("integrationSteps", () => { + it("is empty without integrations", () => { + expect(integrationSteps(COMPOSE, PLAIN)).toEqual([]); + }); + + it("runs the GitHub App's setup, then Linear's, passing the public URL", () => { + const steps = integrationSteps( + { ...COMPOSE, githubApp: true, linear: true }, + { noKeychain: false, publicUrl: "https://hooks.example.com" }, + ); + expect(steps).toEqual([ + { + title: "bun run github:setup", + cmd: [ + "bun", + "control-plane/scripts/github.ts", + "setup", + "--public-url", + "https://hooks.example.com", + ], + }, + { + title: "bun run linear:setup", + cmd: [ + "bun", + "control-plane/scripts/linear.ts", + "setup", + "--public-url", + "https://hooks.example.com", + ], + }, + ]); + }); + + it("targets devbox.kind.json on kind", () => { + expect(integrationSteps({ ...KIND, linear: true }, { noKeychain: true })).toEqual([ + { + title: "bun run linear:setup --target devbox.kind.json", + cmd: [ + "bun", + "control-plane/scripts/linear.ts", + "setup", + "--target", + "devbox.kind.json", + "--no-keychain", + ], + }, + ]); + }); +}); + +describe("statusCommands", () => { + it("covers what was set up now and what was on already", () => { + expect(statusCommands({ ...KIND, linear: true }, { githubApp: true, linear: false })).toEqual([ + "bun run github:status --target devbox.kind.json", + "bun run linear:status --target devbox.kind.json", + ]); + expect(statusCommands(COMPOSE, { githubApp: false, linear: false })).toEqual([]); + }); +}); diff --git a/control-plane/scripts/quickstart/steps.ts b/control-plane/scripts/quickstart/steps.ts new file mode 100644 index 0000000..6bf1b51 --- /dev/null +++ b/control-plane/scripts/quickstart/steps.ts @@ -0,0 +1,75 @@ +// The commands the quickstart runs, in order. It coordinates the existing +// scripts rather than repeating them, and runs each script directly (not +// through `bun run`), so `bun run setup` runs once, before the quickstart. +// +// The stack starts first, without new integrations, so a working devbox does +// not wait on a tunnel or an App registration; on kind the GitHub App's setup +// also needs the target file the first k8s:kind writes. Then each chosen +// integration's setup, which writes the config and applies nothing, and the +// start command once more to apply it. +import { KIND_TARGET_FILE } from "../k8s/kind"; +import type { Choices } from "./choices"; + +// `title` is the documented command the user can run alone. +export type Step = { title: string; cmd: string[] }; + +export type StepOptions = { noKeychain: boolean; publicUrl?: string }; + +const SCRIPTS = "control-plane/scripts"; + +export function startStep(c: Choices, o: StepOptions): Step { + const keychain = o.noKeychain ? ["--no-keychain"] : []; + return c.platform === "compose" + ? { title: "bun run up", cmd: ["bun", `${SCRIPTS}/up.ts`, ...keychain] } + : { title: "bun run k8s:kind", cmd: ["bun", `${SCRIPTS}/k8s.ts`, "kind", ...keychain] }; +} + +export function integrationSteps(c: Choices, o: StepOptions): Step[] { + const target = c.platform === "kind" ? ["--target", KIND_TARGET_FILE] : []; + const extra = [ + ...target, + ...(o.publicUrl ? ["--public-url", o.publicUrl] : []), + ...(o.noKeychain ? ["--no-keychain"] : []), + ]; + const steps: Step[] = []; + if (c.githubApp) { + steps.push({ + title: ["bun run github:setup", ...target].join(" "), + cmd: ["bun", `${SCRIPTS}/github.ts`, "setup", ...extra], + }); + } + if (c.linear) { + steps.push({ + title: ["bun run linear:setup", ...target].join(" "), + cmd: ["bun", `${SCRIPTS}/linear.ts`, "setup", ...extra], + }); + } + return steps; +} + +// Where the tunnel for webhooks must point. +export function tunnelHint(c: Choices): string[] { + if (c.platform === "compose") { + return [ + "GitHub and Linear send webhooks to a public HTTPS URL that forwards to port 3000", + "on this machine. Start a tunnel in another terminal, for example `ngrok http 3000`", + "or `cloudflared tunnel --url http://localhost:3000`; the setup asks for its URL.", + ]; + } + return [ + "GitHub and Linear send webhooks to a public HTTPS URL that forwards to the control", + "plane. On kind, run in another terminal and keep running:", + ` bun run k8s:connect --target ${KIND_TARGET_FILE}`, + "then point a tunnel at the port of the localhost URL it prints, for example", + "`ngrok http `, and enter the tunnel's URL below.", + ]; +} + +// The status commands for what is set up, printed at the end. +export function statusCommands(c: Choices, enabled: { githubApp: boolean; linear: boolean }) { + const target = c.platform === "kind" ? ` --target ${KIND_TARGET_FILE}` : ""; + return [ + ...(c.githubApp || enabled.githubApp ? [`bun run github:status${target}`] : []), + ...(c.linear || enabled.linear ? [`bun run linear:status${target}`] : []), + ]; +} diff --git a/control-plane/scripts/quickstart/style.test.ts b/control-plane/scripts/quickstart/style.test.ts new file mode 100644 index 0000000..f1a0338 --- /dev/null +++ b/control-plane/scripts/quickstart/style.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { colorWanted, good, setColor } from "./style"; + +afterEach(() => setColor(false)); + +describe("colorWanted", () => { + it("colors a terminal only", () => { + expect(colorWanted(true, {})).toBe(true); + expect(colorWanted(false, {})).toBe(false); + }); + + it("follows NO_COLOR, TERM=dumb and FORCE_COLOR", () => { + expect(colorWanted(true, { NO_COLOR: "1" })).toBe(false); + expect(colorWanted(true, { NO_COLOR: "" })).toBe(true); + expect(colorWanted(true, { TERM: "dumb" })).toBe(false); + expect(colorWanted(false, { FORCE_COLOR: "1" })).toBe(true); + expect(colorWanted(true, { FORCE_COLOR: "0" })).toBe(true); + expect(colorWanted(true, { FORCE_COLOR: "1", NO_COLOR: "1" })).toBe(true); + }); +}); + +describe("paint", () => { + it("is plain text until color is on", () => { + expect(good("ok")).toBe("ok"); + setColor(true); + expect(good("ok")).toBe("\u001b[32mok\u001b[39m"); + }); +}); diff --git a/control-plane/scripts/quickstart/style.ts b/control-plane/scripts/quickstart/style.ts new file mode 100644 index 0000000..412462a --- /dev/null +++ b/control-plane/scripts/quickstart/style.ts @@ -0,0 +1,33 @@ +// Colors for the quickstart's own output, with node:util's styleText (no +// dependency). Whether to color is decided here: off until the entry point +// turns it on, which keeps tests on plain text. validateStream is off because +// Bun 1.4.4, like Node, otherwise drops colors when stdout is not a terminal. +import { styleText } from "node:util"; + +type Format = Parameters[0]; + +let enabled = false; + +// A terminal, unless NO_COLOR is set or TERM is dumb; FORCE_COLOR wins. +export function colorWanted(isTTY: boolean, env: Record): boolean { + if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "0") return true; + if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false; + return isTTY && env.TERM !== "dumb"; +} + +export function setColor(on: boolean): void { + enabled = on; +} + +const paint = + (format: Format) => + (text: string): string => + enabled ? styleText(format, text, { validateStream: false }) : text; + +export const heading = paint(["bold", "cyan"]); +export const bold = paint("bold"); +export const dim = paint("dim"); +export const good = paint("green"); +export const warn = paint("yellow"); +export const bad = paint("red"); +export const cmd = paint("cyan"); diff --git a/control-plane/scripts/secrets.test.ts b/control-plane/scripts/secrets.test.ts new file mode 100644 index 0000000..a414f4f --- /dev/null +++ b/control-plane/scripts/secrets.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "bun:test"; +import { DestructiveRefused } from "./lib/destructive"; +import { MemoryKeychain } from "./lib/keychain"; +import { type SecretsDeps, secretsCommand } from "./secrets"; +import { fakeTerminal } from "./up/fakeTerminal"; +import { confirm } from "./up/prompt"; + +function setup( + opts: { + env?: Record; + entries?: Record; + isTTY?: boolean; + // Keystrokes at the yes/no question, answered by the real confirm(). + keys?: string; + keychain?: SecretsDeps["keychain"]; + // What `gh auth token` does; by default gh is not installed. + gh?: { code: number; stdout: string }; + } = {}, +) { + const out: string[] = []; + const asked: string[] = []; + const ran: string[][] = []; + const keychain = new MemoryKeychain(opts.entries); + const deps: SecretsDeps = { + keychain: opts.keychain ?? keychain, + env: opts.env ?? {}, + isTTY: opts.isTTY ?? false, + confirm: (q, o) => { + asked.push(q); + return confirm(q, o, fakeTerminal(opts.keys ?? "y\r").term); + }, + run: async (cmd) => { + ran.push(cmd); + if (!opts.gh) throw new Error("ENOENT: gh"); + return opts.gh; + }, + log: (line) => out.push(line), + loadTarget: () => ({ context: "ctx", values: {} }), + }; + return { deps, out, asked, ran, keychain }; +} + +const ALL = { + "compose/GITHUB_TOKEN": "ghp-a", + "compose/LLM_API_KEY": "sk-b", + "compose/LINEAR_CLIENT_SECRET": "lc-c", + "compose/LINEAR_WEBHOOK_SECRET": "lw-d", +}; + +describe("bun run secrets", () => { + it("ls lists every secret with its source, never values", async () => { + const s = setup({ + env: { GITHUB_TOKEN: "ghp-env-value" }, + entries: { "compose/LLM_API_KEY": "sk-keychain-value" }, + }); + expect(await secretsCommand(["ls"], s.deps)).toBe(0); + expect(s.out.join("\n")).toBe( + [ + "Secrets for compose:", + " GITHUB_TOKEN env", + " LLM_API_KEY keychain", + " LINEAR_CLIENT_SECRET missing", + " LINEAR_WEBHOOK_SECRET missing", + " GITHUB_APP_PRIVATE_KEY missing", + " GITHUB_WEBHOOK_SECRET missing", + ].join("\n"), + ); + expect(s.ran).toEqual([]); + }); + + it("ls reports gh when only `gh auth token` has a GitHub token", async () => { + const s = setup({ gh: { code: 0, stdout: "gho_gh-cli-value\n" } }); + expect(await secretsCommand(["ls"], s.deps)).toBe(0); + expect(s.out.join("\n")).toBe( + [ + "Secrets for compose:", + " GITHUB_TOKEN gh", + " LLM_API_KEY missing", + " LINEAR_CLIENT_SECRET missing", + " LINEAR_WEBHOOK_SECRET missing", + " GITHUB_APP_PRIVATE_KEY missing", + " GITHUB_WEBHOOK_SECRET missing", + ].join("\n"), + ); + expect(s.ran).toEqual([["gh", "auth", "token"]]); + }); + + it("ls reports GITHUB_TOKEN missing when gh is logged out or absent", async () => { + for (const gh of [{ code: 1, stdout: "" }, undefined]) { + const s = setup(gh ? { gh } : {}); + expect(await secretsCommand(["ls"], s.deps)).toBe(0); + expect(s.out.join("\n")).toBe( + [ + "Secrets for compose:", + " GITHUB_TOKEN missing", + " LLM_API_KEY missing", + " LINEAR_CLIENT_SECRET missing", + " LINEAR_WEBHOOK_SECRET missing", + " GITHUB_APP_PRIVATE_KEY missing", + " GITHUB_WEBHOOK_SECRET missing", + ].join("\n"), + ); + expect(s.ran).toEqual([["gh", "auth", "token"]]); + } + }); + + it("secrets ls on k8s lists seven names; on Compose six", async () => { + const s = setup({ entries: { "k8s:ctx/devbox/LLM_API_KEY": "sk-x" } }); + expect(await secretsCommand(["ls", "--target", "t.json"], s.deps)).toBe(0); + expect(s.out.join("\n")).toBe( + [ + "Secrets for k8s:ctx/devbox:", + " GITHUB_TOKEN missing", + " LLM_API_KEY keychain", + " LINEAR_CLIENT_SECRET missing", + " LINEAR_WEBHOOK_SECRET missing", + " GITHUB_APP_PRIVATE_KEY missing", + " GITHUB_WEBHOOK_SECRET missing", + " SLACK_BOT_TOKEN missing", + ].join("\n"), + ); + }); + + it("rm deletes one entry", async () => { + const s = setup({ entries: ALL }); + expect(await secretsCommand(["rm", "LLM_API_KEY"], s.deps)).toBe(0); + expect(s.out).toEqual(["Removed LLM_API_KEY for compose."]); + expect(Object.keys(s.keychain.entries()).sort()).toEqual( + [ + "compose/GITHUB_TOKEN", + "compose/LINEAR_CLIENT_SECRET", + "compose/LINEAR_WEBHOOK_SECRET", + ].sort(), + ); + expect(await secretsCommand(["rm", "LLM_API_KEY"], s.deps)).toBe(0); + expect(s.out.at(-1)).toBe("LLM_API_KEY is not saved for compose."); + }); + + it("rm accepts the k8s-only names only with --target", async () => { + const s = setup({ entries: { "k8s:ctx/devbox/SLACK_BOT_TOKEN": "xoxb" } }); + expect(await secretsCommand(["rm", "SLACK_BOT_TOKEN"], s.deps)).toBe(2); + expect(await secretsCommand(["rm", "SLACK_BOT_TOKEN", "--target", "t.json"], s.deps)).toBe(0); + expect(s.out.at(-1)).toBe("Removed SLACK_BOT_TOKEN for k8s:ctx/devbox."); + expect(s.keychain.entries()).toEqual({}); + }); + + it("ls shows the private key from the key file, the environment or the keychain", async () => { + const listing = async (opts: Parameters[0], key: string) => { + const s = setup(opts); + await secretsCommand(["ls"], s.deps); + expect(s.out.join("\n")).toBe( + [ + "Secrets for compose:", + " GITHUB_TOKEN missing", + " LLM_API_KEY missing", + " LINEAR_CLIENT_SECRET missing", + " LINEAR_WEBHOOK_SECRET missing", + ` GITHUB_APP_PRIVATE_KEY ${key}`, + " GITHUB_WEBHOOK_SECRET missing", + ].join("\n"), + ); + }; + await listing({ env: { GITHUB_APP_PRIVATE_KEY_FILE: "/k.pem" } }, "env"); + await listing({ env: { GITHUB_APP_PRIVATE_KEY: "pem" } }, "env"); + await listing({ entries: { "compose/GITHUB_APP_PRIVATE_KEY": "pem" } }, "keychain"); + }); + + it("rm deletes the private key entry", async () => { + const s = setup({ entries: { ...ALL, "compose/GITHUB_APP_PRIVATE_KEY": "pem" } }); + expect(await secretsCommand(["rm", "GITHUB_APP_PRIVATE_KEY"], s.deps)).toBe(0); + expect(s.out).toEqual(["Removed GITHUB_APP_PRIVATE_KEY for compose."]); + expect(s.keychain.entries()["compose/GITHUB_APP_PRIVATE_KEY"]).toBeUndefined(); + expect(Object.keys(s.keychain.entries())).toHaveLength(4); + }); + + const PENDING_ENTRIES = { + "compose/GITHUB_APP_PRIVATE_KEY_PENDING": "pem2", + "compose/GITHUB_WEBHOOK_SECRET_PENDING": "w2", + }; + + it("ls lists a new App's saved secrets only when there are some", async () => { + const s = setup({ entries: { "compose/GITHUB_APP_PRIVATE_KEY_PENDING": "pem2" } }); + expect(await secretsCommand(["ls"], s.deps)).toBe(0); + expect(s.out.join("\n")).toBe( + [ + "Secrets for compose:", + " GITHUB_TOKEN missing", + " LLM_API_KEY missing", + " LINEAR_CLIENT_SECRET missing", + " LINEAR_WEBHOOK_SECRET missing", + " GITHUB_APP_PRIVATE_KEY missing", + " GITHUB_WEBHOOK_SECRET missing", + " GITHUB_APP_PRIVATE_KEY_PENDING keychain (GitHub App private key of the new App, not installed yet)", + ].join("\n"), + ); + const both = setup({ entries: PENDING_ENTRIES }); + await secretsCommand(["ls"], both.deps); + expect(both.out.join("\n")).toBe( + [ + "Secrets for compose:", + " GITHUB_TOKEN missing", + " LLM_API_KEY missing", + " LINEAR_CLIENT_SECRET missing", + " LINEAR_WEBHOOK_SECRET missing", + " GITHUB_APP_PRIVATE_KEY missing", + " GITHUB_WEBHOOK_SECRET missing", + " GITHUB_APP_PRIVATE_KEY_PENDING keychain (GitHub App private key of the new App, not installed yet)", + " GITHUB_WEBHOOK_SECRET_PENDING keychain (GitHub webhook secret of the new App, not installed yet)", + ].join("\n"), + ); + }); + + it("rm and clear take a new App's saved secrets", async () => { + const s = setup({ entries: { ...ALL, ...PENDING_ENTRIES } }); + expect(await secretsCommand(["rm", "GITHUB_WEBHOOK_SECRET_PENDING"], s.deps)).toBe(0); + expect(s.out).toEqual(["Removed GITHUB_WEBHOOK_SECRET_PENDING for compose."]); + expect(s.keychain.entries()).toEqual({ + ...ALL, + "compose/GITHUB_APP_PRIVATE_KEY_PENDING": "pem2", + }); + const c = setup({ entries: { ...ALL, ...PENDING_ENTRIES }, isTTY: true }); + expect(await secretsCommand(["clear"], c.deps)).toBe(0); + expect(c.out).toEqual([ + ...LISTED, + " - GITHUB_APP_PRIVATE_KEY_PENDING for compose", + " - GITHUB_WEBHOOK_SECRET_PENDING for compose", + "Removed 6 saved secrets for compose.", + ]); + expect(c.keychain.entries()).toEqual({}); + }); + + it("rm refuses an unknown name", async () => { + const s = setup({ entries: ALL }); + expect(await secretsCommand(["rm", "NOPE"], s.deps)).toBe(2); + expect(s.out).toEqual([ + 'unknown secret "NOPE": use one of GITHUB_TOKEN, LLM_API_KEY, LINEAR_CLIENT_SECRET, LINEAR_WEBHOOK_SECRET, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET, GITHUB_APP_PRIVATE_KEY_PENDING, GITHUB_WEBHOOK_SECRET_PENDING', + ]); + expect(Object.keys(s.keychain.entries())).toHaveLength(4); + }); + + const LISTED = [ + "This deletes:", + " - GITHUB_TOKEN for compose", + " - LLM_API_KEY for compose", + " - LINEAR_CLIENT_SECRET for compose", + " - LINEAR_WEBHOOK_SECRET for compose", + ]; + const QUESTION = "Remove these from the system keychain? [y/N] "; + + it("clear lists the saved entries and asks", async () => { + const s = setup({ entries: { ...ALL, "k8s:ctx/devbox/LLM_API_KEY": "keep" }, isTTY: true }); + expect(await secretsCommand(["clear"], s.deps)).toBe(0); + expect(s.asked).toEqual([QUESTION]); + expect(s.out).toEqual([...LISTED, "Removed 4 saved secrets for compose."]); + expect(s.keychain.entries()).toEqual({ "k8s:ctx/devbox/LLM_API_KEY": "keep" }); + }); + + it("clear lists only what is saved", async () => { + const s = setup({ entries: { "compose/LLM_API_KEY": "sk-b" }, isTTY: true }); + expect(await secretsCommand(["clear"], s.deps)).toBe(0); + expect(s.out).toEqual([ + "This deletes:", + " - LLM_API_KEY for compose", + "Removed 1 saved secret for compose.", + ]); + }); + + it("clear does nothing on no, on Enter alone or on Ctrl-D", async () => { + for (const keys of ["n\r", "\r", "\u0004", "y\u0004"]) { + const s = setup({ entries: ALL, isTTY: true, keys }); + expect(await secretsCommand(["clear"], s.deps)).toBe(0); + expect(s.asked).toEqual([QUESTION]); + expect(s.out).toEqual([...LISTED, "Nothing was removed."]); + expect(s.keychain.entries()).toEqual(ALL); + } + }); + + it("clear without a terminal refuses without --yes", async () => { + const s = setup({ entries: ALL }); + await expect(secretsCommand(["clear"], s.deps)).rejects.toThrow( + new DestructiveRefused( + "secrets clear deletes data: pass --yes to confirm without a terminal", + ), + ); + expect(s.asked).toEqual([]); + expect(s.keychain.entries()).toEqual(ALL); + }); + + it("clear --yes", async () => { + const s = setup({ entries: ALL }); + expect(await secretsCommand(["clear", "--yes"], s.deps)).toBe(0); + expect(s.asked).toEqual([]); + expect(s.out).toEqual(["Removed 4 saved secrets for compose."]); + expect(s.keychain.entries()).toEqual({}); + }); + + it("clear with nothing saved", async () => { + const s = setup({ isTTY: true }); + expect(await secretsCommand(["clear"], s.deps)).toBe(0); + expect(s.asked).toEqual([]); + expect(s.out).toEqual(["No saved secrets for compose."]); + }); + + it("--yes with ls or rm is a usage error", async () => { + const s = setup({ entries: ALL }); + expect(await secretsCommand(["ls", "--yes"], s.deps)).toBe(2); + expect(await secretsCommand(["rm", "LLM_API_KEY", "--yes"], s.deps)).toBe(2); + expect(s.keychain.entries()).toEqual(ALL); + }); + + it("a keychain that switched itself off is reported", async () => { + const s = setup({ + keychain: { + disabled: true, + get: async () => undefined, + set: async () => {}, + delete: async () => false, + }, + }); + expect(await secretsCommand(["ls"], s.deps)).toBe(1); + expect(s.out).toEqual(["The system keychain is not available on this machine."]); + }); + + it("a missing or extra argument is a usage error", async () => { + const s = setup(); + expect(await secretsCommand([], s.deps)).toBe(2); + expect(await secretsCommand(["rm"], s.deps)).toBe(2); + expect(await secretsCommand(["clear", "LLM_API_KEY"], s.deps)).toBe(2); + }); +}); diff --git a/control-plane/scripts/secrets.ts b/control-plane/scripts/secrets.ts new file mode 100644 index 0000000..62ee3a9 --- /dev/null +++ b/control-plane/scripts/secrets.ts @@ -0,0 +1,172 @@ +// `bun run secrets ls|rm|clear`: what devbox keeps in the system +// keychain for a deployment, and the way to remove it (`clear` lists what it +// removes and asks; without a terminal it needs --yes). `ls` also reports a +// secret set in the environment or, for GITHUB_TOKEN, available from `gh auth +// token`. Never prints a value. +import { parseArgs } from "node:util"; +import { NAMESPACE } from "./k8s/run"; +import { loadTarget, type Target } from "./k8s/target"; +import { capture } from "./lib/capture"; +import { confirmDestructive } from "./lib/destructive"; +import { peekPrivateKey } from "./lib/githubAppKey"; +import { + bunKeychain, + type Deployment, + deploymentScope, + entryName, + guarded, + type Keychain, +} from "./lib/keychain"; +import { peekSecret, type SecretName, secretNamesFor } from "./lib/secrets"; +import { confirm } from "./up/prompt"; + +export type SecretsDeps = { + keychain: Keychain; + env: Record; + isTTY: boolean; + confirm: (q: string, opts: { defaultYes: boolean }) => Promise; + // Runs `gh auth token` for `ls`; its output is never printed. + run: (cmd: string[]) => Promise<{ code: number; stdout: string }>; + log: (line: string) => void; + loadTarget: (path: string) => Target; +}; + +// A new GitHub App's secrets while it waits for its install (github:setup +// --reregister or --app-id over a working App): listed only when saved. +const PENDING_NAMES: Record = { + GITHUB_APP_PRIVATE_KEY_PENDING: "GitHub App private key of the new App, not installed yet", + GITHUB_WEBHOOK_SECRET_PENDING: "GitHub webhook secret of the new App, not installed yet", +}; + +const USAGE = "usage: bun run secrets |clear [--yes]> [--target ]"; + +export async function secretsCommand(argv: string[], deps: SecretsDeps): Promise { + const { log, keychain } = deps; + let parsed: ReturnType< + typeof parseArgs<{ + options: { target: { type: "string" }; yes: { type: "boolean" } }; + allowPositionals: true; + }> + >; + try { + parsed = parseArgs({ + args: argv, + options: { target: { type: "string" }, yes: { type: "boolean" } }, + allowPositionals: true, + }); + } catch (err) { + log(`${(err as Error).message}\n${USAGE}`); + return 2; + } + const [command, name, ...extra] = parsed.positionals; + if (!(command === "ls" || command === "clear" || command === "rm") || extra.length > 0) { + log(USAGE); + return 2; + } + if ((command === "rm") !== (name !== undefined) || (command === "clear" && name !== undefined)) { + log(USAGE); + return 2; + } + if (parsed.values.yes && command !== "clear") { + log(`--yes goes with clear\n${USAGE}`); + return 2; + } + let deployment: Deployment = { kind: "compose" }; + const targetPath = parsed.values.target; + if (targetPath !== undefined) { + deployment = { + kind: "k8s", + context: deps.loadTarget(targetPath).context, + namespace: NAMESPACE, + }; + } + const scope = deploymentScope(deployment); + const names = secretNamesFor(deployment); + const pendingNames = Object.keys(PENDING_NAMES); + const removable = [...names, ...pendingNames]; + + if (command === "rm" && !removable.includes(name as string)) { + log(`unknown secret "${name}": use one of ${removable.join(", ")}`); + return 2; + } + + // A first read tells whether the platform has a keychain at all. + await keychain.get(entryName(deployment, names[0] as SecretName)); + if (keychain.disabled) { + log("The system keychain is not available on this machine."); + return 1; + } + + if (command === "ls") { + const peek = { env: deps.env, keychain, deployment, run: deps.run }; + const rows: [string, string][] = []; + for (const n of names) { + const where = + n === "GITHUB_APP_PRIVATE_KEY" ? await peekPrivateKey(peek) : await peekSecret(n, peek); + rows.push([n, where]); + } + for (const n of pendingNames) { + if ((await keychain.get(entryName(deployment, n))) !== undefined) { + rows.push([n, `keychain (${PENDING_NAMES[n]})`]); + } + } + const width = Math.max(...rows.map(([n]) => n.length)) + 2; + const lines = [`Secrets for ${scope}:`]; + for (const [n, where] of rows) lines.push(` ${n.padEnd(width)}${where}`); + log(lines.join("\n")); + return 0; + } + + if (command === "rm") { + const removed = await keychain.delete(entryName(deployment, name as string)); + log(removed ? `Removed ${name} for ${scope}.` : `${name} is not saved for ${scope}.`); + return 0; + } + + // clear: destructive. + const saved: string[] = []; + for (const n of removable) { + if ((await keychain.get(entryName(deployment, n))) !== undefined) saved.push(n); + } + if (saved.length === 0) { + log(`No saved secrets for ${scope}.`); + return 0; + } + const go = await confirmDestructive( + { + command: "secrets clear", + items: saved.map((n) => `${n} for ${scope}`), + question: "Remove these from the system keychain? [y/N] ", + }, + { yes: parsed.values.yes === true, isTTY: deps.isTTY, confirm: deps.confirm, log }, + ); + if (!go) { + log("Nothing was removed."); + return 0; + } + let count = 0; + for (const n of saved) { + if (await keychain.delete(entryName(deployment, n))) count++; + } + log(`Removed ${count} saved ${count === 1 ? "secret" : "secrets"} for ${scope}.`); + return 0; +} + +if (import.meta.main) { + try { + process.exit( + await secretsCommand(process.argv.slice(2), { + keychain: guarded(bunKeychain()), + env: process.env, + isTTY: Boolean(process.stdin.isTTY), + confirm, + run: (cmd) => capture(cmd), + log: (line) => process.stdout.write(`${line}\n`), + loadTarget, + }), + ); + } catch (err) { + process.stderr.write(`bun run secrets: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + } +} diff --git a/control-plane/scripts/setup.ts b/control-plane/scripts/setup.ts new file mode 100644 index 0000000..0d87fce --- /dev/null +++ b/control-plane/scripts/setup.ts @@ -0,0 +1,25 @@ +// `bun run setup`: checks the Bun version, then installs the packages of the +// control plane and the CLI. `bun run up` runs it first. It imports no +// packages, so it runs before anything is installed and an old Bun gets a +// clear message instead of an install error. +import { join } from "node:path"; +import { bunVersionError } from "./up/bunVersion"; + +const ROOT = join(import.meta.dir, "..", ".."); + +const tooOld = bunVersionError(Bun.version); +if (tooOld) { + process.stderr.write(`bun run setup: ${tooOld}\n`); + process.exit(1); +} +for (const dir of ["control-plane", "cli"]) { + const proc = Bun.spawn(["bun", "install", "--frozen-lockfile"], { + cwd: join(ROOT, dir), + stdio: ["ignore", "inherit", "inherit"], + }); + const code = await proc.exited; + if (code !== 0) { + process.stderr.write(`bun run setup: bun install in ${dir}/ failed (exit ${code})\n`); + process.exit(1); + } +} diff --git a/control-plane/scripts/skill.test.ts b/control-plane/scripts/skill.test.ts new file mode 100644 index 0000000..c373bb1 --- /dev/null +++ b/control-plane/scripts/skill.test.ts @@ -0,0 +1,95 @@ +// The devbox skill (skills/devbox) points agents at commands and docs; this +// keeps it from drifting: every `bun run