feat: add K8s API server, orchestrator abstraction, and CI pipeline
- Add apps/api/ — Hono REST API server for managing pentest scans via K8s Jobs - POST/GET /api/scans, GET /api/scans/:id, cancel, report endpoints - Bearer token auth, Temporal client integration, K8s Job builder - Dockerfile, Kustomize manifests (Deployment, Service, RBAC) - Add CLI orchestrator abstraction (docker.ts → Orchestrator interface) - DockerOrchestrator and K8sOrchestrator implementations - Backend detection via SHANNON_BACKEND env var or --backend flag - Add CI workflow: type-check + lint on PR, build+push both images on main - Switch all workflows to self-hosted runners (runners-farhoodliquor) - Add shannon-api image build to release and release-beta workflows - Add root infra/kustomization.yaml as Flux entry point - Export PipelineProgress from @shannon/worker/pipeline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Type-check & lint
|
||||||
|
runs-on: runners-farhoodliquor
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: pnpm run check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm biome
|
||||||
|
|
||||||
|
build-images:
|
||||||
|
name: Build & push images
|
||||||
|
needs: check
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: runners-farhoodliquor
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push worker image
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
keygraph/shannon:latest
|
||||||
|
keygraph/shannon:sha-${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Build and push API image
|
||||||
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/api/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
keygraph/shannon-api:latest
|
||||||
|
keygraph/shannon-api:sha-${{ github.sha }}
|
||||||
@@ -13,7 +13,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
preflight:
|
preflight:
|
||||||
name: Preflight
|
name: Preflight
|
||||||
runs-on: ubuntu-latest
|
runs-on: runners-farhoodliquor
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
@@ -45,19 +45,11 @@ jobs:
|
|||||||
run: 'echo "Next beta version: ${{ steps.version.outputs.version }}"'
|
run: 'echo "Next beta version: ${{ steps.version.outputs.version }}"'
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
name: Build Docker (${{ matrix.platform }})
|
name: Build Docker (worker)
|
||||||
needs: preflight
|
needs: preflight
|
||||||
|
runs-on: runners-farhoodliquor
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
strategy:
|
|
||||||
fail-fast: true
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -72,47 +64,25 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push by digest
|
- name: Build and push worker image
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
push: true
|
||||||
provenance: mode=max
|
provenance: mode=max
|
||||||
sbom: true
|
sbom: true
|
||||||
outputs: type=image,name=keygraph/shannon,push-by-digest=true,name-canonical=true,push=true
|
tags: keygraph/shannon:${{ needs.preflight.outputs.version }}
|
||||||
|
|
||||||
- name: Export digest
|
build-docker-api:
|
||||||
run: |
|
name: Build Docker (API)
|
||||||
mkdir -p /tmp/digests
|
needs: preflight
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
runs-on: runners-farhoodliquor
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
||||||
with:
|
|
||||||
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge-docker:
|
|
||||||
name: Push Docker manifests
|
|
||||||
needs: [preflight, build-docker]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
|
||||||
outputs:
|
|
||||||
digest: ${{ steps.inspect.outputs.digest }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Checkout
|
||||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
@@ -123,38 +93,79 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create manifest list and push
|
- name: Build and push API image
|
||||||
working-directory: /tmp/digests
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||||
run: |
|
with:
|
||||||
docker buildx imagetools create \
|
context: .
|
||||||
--tag "keygraph/shannon:${{ needs.preflight.outputs.version }}" \
|
file: apps/api/Dockerfile
|
||||||
$(printf 'keygraph/shannon@sha256:%s ' *)
|
push: true
|
||||||
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
|
tags: keygraph/shannon-api:${{ needs.preflight.outputs.version }}
|
||||||
|
|
||||||
- name: Inspect image
|
sign-docker:
|
||||||
id: inspect
|
name: Sign Docker images
|
||||||
|
needs: [preflight, build-docker, build-docker-api]
|
||||||
|
runs-on: runners-farhoodliquor
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
outputs:
|
||||||
|
worker_digest: ${{ steps.inspect-worker.outputs.digest }}
|
||||||
|
api_digest: ${{ steps.inspect-api.outputs.digest }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Inspect worker image
|
||||||
|
id: inspect-worker
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect "keygraph/shannon:${{ needs.preflight.outputs.version }}"
|
docker buildx imagetools inspect "keygraph/shannon:${{ needs.preflight.outputs.version }}"
|
||||||
DIGEST="sha256:$(docker buildx imagetools inspect --raw "keygraph/shannon:${{ needs.preflight.outputs.version }}" | sha256sum | cut -d' ' -f1)"
|
DIGEST="sha256:$(docker buildx imagetools inspect --raw "keygraph/shannon:${{ needs.preflight.outputs.version }}" | sha256sum | cut -d' ' -f1)"
|
||||||
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
|
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Inspect API image
|
||||||
|
id: inspect-api
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect "keygraph/shannon-api:${{ needs.preflight.outputs.version }}"
|
||||||
|
DIGEST="sha256:$(docker buildx imagetools inspect --raw "keygraph/shannon-api:${{ needs.preflight.outputs.version }}" | sha256sum | cut -d' ' -f1)"
|
||||||
|
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||||
|
|
||||||
- name: Sign Docker image
|
- name: Sign worker image
|
||||||
run: cosign sign --yes "keygraph/shannon@${{ steps.inspect.outputs.digest }}"
|
run: cosign sign --yes "keygraph/shannon@${{ steps.inspect-worker.outputs.digest }}"
|
||||||
|
|
||||||
- name: Verify Docker image signature
|
- name: Sign API image
|
||||||
|
run: cosign sign --yes "keygraph/shannon-api@${{ steps.inspect-api.outputs.digest }}"
|
||||||
|
|
||||||
|
- name: Verify worker image signature
|
||||||
run: |
|
run: |
|
||||||
sleep 10
|
sleep 10
|
||||||
cosign verify \
|
cosign verify \
|
||||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||||
--certificate-identity https://github.com/${{ github.repository }}/.github/workflows/release-beta.yml@${{ github.ref }} \
|
--certificate-identity https://github.com/${{ github.repository }}/.github/workflows/release-beta.yml@${{ github.ref }} \
|
||||||
"keygraph/shannon@${{ steps.inspect.outputs.digest }}"
|
"keygraph/shannon@${{ steps.inspect-worker.outputs.digest }}"
|
||||||
|
|
||||||
|
- name: Verify API image signature
|
||||||
|
run: |
|
||||||
|
cosign verify \
|
||||||
|
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||||
|
--certificate-identity https://github.com/${{ github.repository }}/.github/workflows/release-beta.yml@${{ github.ref }} \
|
||||||
|
"keygraph/shannon-api@${{ steps.inspect-api.outputs.digest }}"
|
||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
name: Publish npm (beta)
|
name: Publish npm (beta)
|
||||||
needs: [preflight, merge-docker]
|
needs: [preflight, sign-docker]
|
||||||
runs-on: ubuntu-latest
|
runs-on: runners-farhoodliquor
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
preflight:
|
preflight:
|
||||||
name: Preflight
|
name: Preflight
|
||||||
runs-on: ubuntu-latest
|
runs-on: runners-farhoodliquor
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
outputs:
|
outputs:
|
||||||
@@ -57,20 +57,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
build-docker:
|
build-docker:
|
||||||
name: Build Docker (${{ matrix.platform }})
|
name: Build Docker (worker)
|
||||||
needs: preflight
|
needs: preflight
|
||||||
if: needs.preflight.outputs.should_release == 'true'
|
if: needs.preflight.outputs.should_release == 'true'
|
||||||
|
runs-on: runners-farhoodliquor
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
strategy:
|
|
||||||
fail-fast: true
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -85,47 +77,28 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push by digest
|
- name: Build and push worker image
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
push: true
|
||||||
provenance: mode=max
|
provenance: mode=max
|
||||||
sbom: true
|
sbom: true
|
||||||
outputs: type=image,name=keygraph/shannon,push-by-digest=true,name-canonical=true,push=true
|
tags: |
|
||||||
|
keygraph/shannon:${{ needs.preflight.outputs.version }}
|
||||||
|
keygraph/shannon:latest
|
||||||
|
|
||||||
- name: Export digest
|
build-docker-api:
|
||||||
run: |
|
name: Build Docker (API)
|
||||||
mkdir -p /tmp/digests
|
needs: preflight
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
if: needs.preflight.outputs.should_release == 'true'
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
runs-on: runners-farhoodliquor
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
|
||||||
with:
|
|
||||||
name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge-docker:
|
|
||||||
name: Push Docker manifests
|
|
||||||
needs: [preflight, build-docker]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
|
||||||
outputs:
|
|
||||||
digest: ${{ steps.inspect.outputs.digest }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Checkout
|
||||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digests-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
@@ -136,39 +109,81 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create manifest list and push
|
- name: Build and push API image
|
||||||
working-directory: /tmp/digests
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||||
run: |
|
with:
|
||||||
docker buildx imagetools create \
|
context: .
|
||||||
--tag "keygraph/shannon:${{ needs.preflight.outputs.version }}" \
|
file: apps/api/Dockerfile
|
||||||
--tag "keygraph/shannon:latest" \
|
push: true
|
||||||
$(printf 'keygraph/shannon@sha256:%s ' *)
|
provenance: mode=max
|
||||||
|
sbom: true
|
||||||
|
tags: |
|
||||||
|
keygraph/shannon-api:${{ needs.preflight.outputs.version }}
|
||||||
|
keygraph/shannon-api:latest
|
||||||
|
|
||||||
- name: Inspect image
|
sign-docker:
|
||||||
id: inspect
|
name: Sign Docker images
|
||||||
|
needs: [preflight, build-docker, build-docker-api]
|
||||||
|
runs-on: runners-farhoodliquor
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
outputs:
|
||||||
|
worker_digest: ${{ steps.inspect-worker.outputs.digest }}
|
||||||
|
api_digest: ${{ steps.inspect-api.outputs.digest }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Inspect worker image
|
||||||
|
id: inspect-worker
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect "keygraph/shannon:${{ needs.preflight.outputs.version }}"
|
docker buildx imagetools inspect "keygraph/shannon:${{ needs.preflight.outputs.version }}"
|
||||||
DIGEST="sha256:$(docker buildx imagetools inspect --raw "keygraph/shannon:${{ needs.preflight.outputs.version }}" | sha256sum | cut -d' ' -f1)"
|
DIGEST="sha256:$(docker buildx imagetools inspect --raw "keygraph/shannon:${{ needs.preflight.outputs.version }}" | sha256sum | cut -d' ' -f1)"
|
||||||
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
|
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Inspect API image
|
||||||
|
id: inspect-api
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect "keygraph/shannon-api:${{ needs.preflight.outputs.version }}"
|
||||||
|
DIGEST="sha256:$(docker buildx imagetools inspect --raw "keygraph/shannon-api:${{ needs.preflight.outputs.version }}" | sha256sum | cut -d' ' -f1)"
|
||||||
|
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||||
|
|
||||||
- name: Sign Docker image
|
- name: Sign worker image
|
||||||
run: cosign sign --yes "keygraph/shannon@${{ steps.inspect.outputs.digest }}"
|
run: cosign sign --yes "keygraph/shannon@${{ steps.inspect-worker.outputs.digest }}"
|
||||||
|
|
||||||
- name: Verify Docker image signature
|
- name: Sign API image
|
||||||
|
run: cosign sign --yes "keygraph/shannon-api@${{ steps.inspect-api.outputs.digest }}"
|
||||||
|
|
||||||
|
- name: Verify worker image signature
|
||||||
run: |
|
run: |
|
||||||
sleep 10
|
sleep 10
|
||||||
cosign verify \
|
cosign verify \
|
||||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||||
--certificate-identity https://github.com/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }} \
|
--certificate-identity https://github.com/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }} \
|
||||||
"keygraph/shannon@${{ steps.inspect.outputs.digest }}"
|
"keygraph/shannon@${{ steps.inspect-worker.outputs.digest }}"
|
||||||
|
|
||||||
|
- name: Verify API image signature
|
||||||
|
run: |
|
||||||
|
cosign verify \
|
||||||
|
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||||
|
--certificate-identity https://github.com/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }} \
|
||||||
|
"keygraph/shannon-api@${{ steps.inspect-api.outputs.digest }}"
|
||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
name: Publish npm
|
name: Publish npm
|
||||||
needs: [preflight, merge-docker]
|
needs: [preflight, sign-docker]
|
||||||
runs-on: ubuntu-latest
|
runs-on: runners-farhoodliquor
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -213,7 +228,7 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Create GitHub release
|
name: Create GitHub release
|
||||||
needs: [preflight, publish-npm]
|
needs: [preflight, publish-npm]
|
||||||
runs-on: ubuntu-latest
|
runs-on: runners-farhoodliquor
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
rollback:
|
rollback:
|
||||||
name: Roll back npm beta dist-tag
|
name: Roll back npm beta dist-tag
|
||||||
runs-on: ubuntu-latest
|
runs-on: runners-farhoodliquor
|
||||||
steps:
|
steps:
|
||||||
- name: Validate target version
|
- name: Validate target version
|
||||||
id: target
|
id: target
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
rollback:
|
rollback:
|
||||||
name: Roll back npm, Docker, and GitHub release latest
|
name: Roll back npm, Docker, and GitHub release latest
|
||||||
runs-on: ubuntu-latest
|
runs-on: runners-farhoodliquor
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout tags
|
- name: Checkout tags
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#
|
||||||
|
# Shannon API Server — minimal Node.js image (no security tools)
|
||||||
|
#
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
RUN npm install -g pnpm@10.33.0
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy workspace manifests for install layer caching
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
|
||||||
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
COPY apps/worker/package.json ./apps/worker/
|
||||||
|
COPY apps/cli/package.json ./apps/cli/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
COPY apps/worker/ ./apps/worker/
|
||||||
|
COPY apps/api/ ./apps/api/
|
||||||
|
|
||||||
|
# Build worker first (API depends on it for types), then API
|
||||||
|
RUN pnpm --filter @shannon/worker run build && pnpm --filter @shannon/api run build
|
||||||
|
|
||||||
|
# Production-only deps
|
||||||
|
RUN rm -rf node_modules apps/*/node_modules && pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/package.json /app/pnpm-workspace.yaml /app/pnpm-lock.yaml /app/.npmrc /app/
|
||||||
|
COPY --from=builder /app/node_modules /app/node_modules
|
||||||
|
COPY --from=builder /app/apps/api/dist /app/apps/api/dist
|
||||||
|
COPY --from=builder /app/apps/api/package.json /app/apps/api/package.json
|
||||||
|
COPY --from=builder /app/apps/api/node_modules /app/apps/api/node_modules
|
||||||
|
COPY --from=builder /app/apps/worker/dist /app/apps/worker/dist
|
||||||
|
COPY --from=builder /app/apps/worker/package.json /app/apps/worker/package.json
|
||||||
|
COPY --from=builder /app/apps/worker/node_modules /app/apps/worker/node_modules
|
||||||
|
|
||||||
|
RUN mkdir -p /app/workspaces
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "apps/api/dist/index.js"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@shannon/api",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.14.0",
|
||||||
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"@shannon/worker": "workspace:*",
|
||||||
|
"@temporalio/client": "^1.11.0",
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Hono app factory.
|
||||||
|
* Creates the app with middleware and routes. Deps injected for testability.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as k8s from '@kubernetes/client-node';
|
||||||
|
import type { Client } from '@temporalio/client';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { Config } from './config.js';
|
||||||
|
import { authMiddleware } from './middleware/auth.js';
|
||||||
|
import { errorHandler } from './middleware/error-handler.js';
|
||||||
|
import { healthRoutes } from './routes/health.js';
|
||||||
|
import { scanRoutes } from './routes/scans.js';
|
||||||
|
|
||||||
|
export interface AppDeps {
|
||||||
|
readonly temporalClient: Client;
|
||||||
|
readonly batchApi: k8s.BatchV1Api;
|
||||||
|
readonly coreApi: k8s.CoreV1Api;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(config: Config, deps: AppDeps): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.onError(errorHandler);
|
||||||
|
|
||||||
|
// Auth middleware (skips /healthz and /readyz)
|
||||||
|
app.use('*', authMiddleware(config.apiKey));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.route('/', healthRoutes(deps));
|
||||||
|
app.route('/api/scans', scanRoutes(config, deps));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Environment-driven configuration for the API server.
|
||||||
|
* Parsed once at startup — missing required values cause a hard exit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
readonly port: number;
|
||||||
|
readonly temporalAddress: string;
|
||||||
|
readonly apiKey: string;
|
||||||
|
readonly k8sNamespace: string;
|
||||||
|
readonly workerImage: string;
|
||||||
|
readonly workspacesDir: string;
|
||||||
|
readonly credentialsSecretName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
const apiKey = process.env.API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error('ERROR: API_KEY environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerImage = process.env.WORKER_IMAGE;
|
||||||
|
if (!workerImage) {
|
||||||
|
console.error('ERROR: WORKER_IMAGE environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: Number(process.env.PORT) || 3000,
|
||||||
|
temporalAddress: process.env.TEMPORAL_ADDRESS || 'shannon-temporal:7233',
|
||||||
|
apiKey,
|
||||||
|
k8sNamespace: process.env.K8S_NAMESPACE || 'shannon',
|
||||||
|
workerImage,
|
||||||
|
workspacesDir: process.env.WORKSPACES_DIR || '/app/workspaces',
|
||||||
|
credentialsSecretName: process.env.CREDENTIALS_SECRET_NAME || 'shannon-credentials',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Shannon API Server — entry point.
|
||||||
|
* Connects to Temporal, initializes K8s client, starts the Hono server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { serve } from '@hono/node-server';
|
||||||
|
import * as k8s from '@kubernetes/client-node';
|
||||||
|
import { createApp } from './app.js';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
|
import { connectTemporal, disconnectTemporal } from './services/temporal-client.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// 1. Load configuration
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// 2. Connect to Temporal
|
||||||
|
const temporal = await connectTemporal(config.temporalAddress);
|
||||||
|
|
||||||
|
// 3. Initialize K8s client (in-cluster or from kubeconfig)
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
try {
|
||||||
|
kc.loadFromCluster();
|
||||||
|
} catch {
|
||||||
|
// Fallback to default kubeconfig (for local development)
|
||||||
|
kc.loadFromDefault();
|
||||||
|
}
|
||||||
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
|
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
|
|
||||||
|
// 4. Create app
|
||||||
|
const app = createApp(config, {
|
||||||
|
temporalClient: temporal.client,
|
||||||
|
batchApi,
|
||||||
|
coreApi,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Start server
|
||||||
|
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||||
|
console.log(`Shannon API server listening on port ${info.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Graceful shutdown
|
||||||
|
const shutdown = async (): Promise<void> => {
|
||||||
|
console.log('Shutting down...');
|
||||||
|
server.close();
|
||||||
|
await disconnectTemporal(temporal);
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Failed to start API server:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Bearer token authentication middleware.
|
||||||
|
* Validates the Authorization header against the configured API key.
|
||||||
|
* Skips health check endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import type { Context, Next } from 'hono';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = new Set(['/healthz', '/readyz']);
|
||||||
|
|
||||||
|
export function authMiddleware(apiKey: string) {
|
||||||
|
const expectedBuffer = Buffer.from(apiKey);
|
||||||
|
|
||||||
|
return async (c: Context, next: Next) => {
|
||||||
|
if (PUBLIC_PATHS.has(c.req.path)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = c.req.header('Authorization');
|
||||||
|
if (!header?.startsWith('Bearer ')) {
|
||||||
|
return c.json({ error: 'Missing or invalid Authorization header' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = header.slice(7);
|
||||||
|
const tokenBuffer = Buffer.from(token);
|
||||||
|
|
||||||
|
if (tokenBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(tokenBuffer, expectedBuffer)) {
|
||||||
|
return c.json({ error: 'Invalid API key' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Global error handler middleware.
|
||||||
|
* Catches unhandled errors and returns structured JSON responses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
|
||||||
|
export function errorHandler(err: Error, c: Context): Response {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
|
||||||
|
const status = 'statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : 500;
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: status === 500 ? 'Internal server error' : err.message,
|
||||||
|
code: err.name || 'UNKNOWN_ERROR',
|
||||||
|
},
|
||||||
|
status as 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Health and readiness endpoints.
|
||||||
|
* /healthz — always 200 (server is running)
|
||||||
|
* /readyz — checks Temporal connectivity
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { AppDeps } from '../app.js';
|
||||||
|
|
||||||
|
export function healthRoutes(deps: AppDeps): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get('/healthz', (c) => {
|
||||||
|
return c.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/readyz', async (c) => {
|
||||||
|
try {
|
||||||
|
// Lightweight Temporal connectivity check — list with a filter that matches nothing
|
||||||
|
const iter = deps.temporalClient.workflow.list({ query: 'ExecutionStatus = "Running"' });
|
||||||
|
// Consume iterator to trigger the gRPC call, then break immediately
|
||||||
|
for await (const _ of iter) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return c.json({ status: 'ok' });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
return c.json({ status: 'error', error: `Temporal unreachable: ${message}` }, 503);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Scan CRUD routes — POST/GET /api/scans, GET/POST /api/scans/:id/*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { AppDeps } from '../app.js';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
|
import { cancelScan, getReport, getScan, listScans, startScan } from '../services/scan-manager.js';
|
||||||
|
import { CreateScanSchema } from '../types/api.js';
|
||||||
|
|
||||||
|
export function scanRoutes(config: Config, deps: AppDeps): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// POST /api/scans — start a new scan
|
||||||
|
app.post('/', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const parsed = CreateScanSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return c.json({ error: 'Validation failed', details: parsed.error.issues }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await startScan(config, deps.batchApi, parsed.data);
|
||||||
|
return c.json(result, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scans — list all scans
|
||||||
|
app.get('/', async (c) => {
|
||||||
|
const scans = await listScans(config, deps.temporalClient, deps.batchApi);
|
||||||
|
return c.json({ scans });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scans/:id — get scan status/progress
|
||||||
|
app.get('/:id', async (c) => {
|
||||||
|
const scanId = c.req.param('id');
|
||||||
|
const result = await getScan(config, deps.temporalClient, scanId);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return c.json({ error: 'Scan not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/scans/:id/cancel — cancel a running scan
|
||||||
|
app.post('/:id/cancel', async (c) => {
|
||||||
|
const scanId = c.req.param('id');
|
||||||
|
await cancelScan(config, deps.temporalClient, deps.batchApi, scanId);
|
||||||
|
return c.json({ status: 'cancelled' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scans/:id/report — get the scan report
|
||||||
|
app.get('/:id/report', async (c) => {
|
||||||
|
const scanId = c.req.param('id');
|
||||||
|
const report = await getReport(config, scanId);
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return c.json({ error: 'Report not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.text(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* K8s Job spec builder for worker scan Jobs.
|
||||||
|
* Constructs a Job that runs the Shannon worker image with the correct
|
||||||
|
* volumes, env, and security context. Optionally includes a git clone init container.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as k8s from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
export interface JobParams {
|
||||||
|
readonly jobName: string;
|
||||||
|
readonly namespace: string;
|
||||||
|
readonly workerImage: string;
|
||||||
|
readonly targetUrl: string;
|
||||||
|
readonly taskQueue: string;
|
||||||
|
readonly workspace: string;
|
||||||
|
readonly credentialsSecretName: string;
|
||||||
|
readonly gitUrl?: string;
|
||||||
|
readonly gitRef?: string;
|
||||||
|
readonly repoPath?: string;
|
||||||
|
readonly configYaml?: string;
|
||||||
|
readonly pipelineTesting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKER_LABEL = 'shannon-worker';
|
||||||
|
const REPO_MOUNT_PATH = '/repo';
|
||||||
|
|
||||||
|
export function buildJobSpec(params: JobParams): k8s.V1Job {
|
||||||
|
const repoPath = params.repoPath ?? REPO_MOUNT_PATH;
|
||||||
|
|
||||||
|
// 1. Build worker command
|
||||||
|
const command = ['node', 'apps/worker/dist/temporal/worker.js', params.targetUrl, repoPath];
|
||||||
|
const args: string[] = ['--task-queue', params.taskQueue, '--workspace', params.workspace];
|
||||||
|
if (params.pipelineTesting) {
|
||||||
|
args.push('--pipeline-testing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build volumes and mounts
|
||||||
|
const volumes: k8s.V1Volume[] = [
|
||||||
|
{ name: 'workspaces', persistentVolumeClaim: { claimName: 'shannon-workspaces' } },
|
||||||
|
{ name: 'shm', emptyDir: { medium: 'Memory', sizeLimit: '2Gi' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const volumeMounts: k8s.V1VolumeMount[] = [
|
||||||
|
{ name: 'workspaces', mountPath: '/app/workspaces' },
|
||||||
|
{ name: 'shm', mountPath: '/dev/shm' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Overlay dirs (writable areas over the read-only repo)
|
||||||
|
for (const overlay of ['deliverables', 'scratchpad', 'playwright-cli']) {
|
||||||
|
const volName = `overlay-${overlay}`;
|
||||||
|
volumes.push({ name: volName, emptyDir: {} });
|
||||||
|
volumeMounts.push({
|
||||||
|
name: volName,
|
||||||
|
mountPath: `${repoPath}/.shannon/${overlay === 'playwright-cli' ? '.playwright-cli' : overlay}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Repo volume — emptyDir for git clone, or PVC sub-path for pre-staged repos
|
||||||
|
const initContainers: k8s.V1Container[] = [];
|
||||||
|
|
||||||
|
if (params.gitUrl) {
|
||||||
|
// Git clone into an emptyDir
|
||||||
|
volumes.push({ name: 'repo', emptyDir: {} });
|
||||||
|
volumeMounts.push({ name: 'repo', mountPath: REPO_MOUNT_PATH, readOnly: true });
|
||||||
|
|
||||||
|
const cloneArgs = ['clone', '--depth', '1'];
|
||||||
|
if (params.gitRef) {
|
||||||
|
cloneArgs.push('--branch', params.gitRef);
|
||||||
|
}
|
||||||
|
cloneArgs.push(params.gitUrl, REPO_MOUNT_PATH);
|
||||||
|
|
||||||
|
initContainers.push({
|
||||||
|
name: 'git-clone',
|
||||||
|
image: 'bitnami/git:2',
|
||||||
|
command: ['git'],
|
||||||
|
args: cloneArgs,
|
||||||
|
volumeMounts: [{ name: 'repo', mountPath: REPO_MOUNT_PATH }],
|
||||||
|
});
|
||||||
|
} else if (params.repoPath) {
|
||||||
|
// Repo already on a PVC — mount the workspaces PVC (assumes repo is staged there)
|
||||||
|
volumeMounts.push({
|
||||||
|
name: 'workspaces',
|
||||||
|
mountPath: repoPath,
|
||||||
|
readOnly: true,
|
||||||
|
subPath: `repos/${params.workspace}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Env vars
|
||||||
|
const env: k8s.V1EnvVar[] = [{ name: 'TEMPORAL_ADDRESS', value: 'shannon-temporal:7233' }];
|
||||||
|
|
||||||
|
// 5. Construct the Job
|
||||||
|
return {
|
||||||
|
apiVersion: 'batch/v1',
|
||||||
|
kind: 'Job',
|
||||||
|
metadata: {
|
||||||
|
name: params.jobName,
|
||||||
|
namespace: params.namespace,
|
||||||
|
labels: {
|
||||||
|
app: WORKER_LABEL,
|
||||||
|
'shannon.io/workspace': params.workspace,
|
||||||
|
'shannon.io/scan-id': params.jobName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
backoffLimit: 0,
|
||||||
|
ttlSecondsAfterFinished: 3600,
|
||||||
|
template: {
|
||||||
|
metadata: {
|
||||||
|
labels: {
|
||||||
|
app: WORKER_LABEL,
|
||||||
|
'shannon.io/workspace': params.workspace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
restartPolicy: 'Never',
|
||||||
|
serviceAccountName: 'default',
|
||||||
|
securityContext: {
|
||||||
|
seccompProfile: { type: 'Unconfined' },
|
||||||
|
},
|
||||||
|
...(initContainers.length > 0 && { initContainers }),
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'worker',
|
||||||
|
image: params.workerImage,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
envFrom: [{ secretRef: { name: params.credentialsSecretName } }],
|
||||||
|
volumeMounts,
|
||||||
|
resources: {
|
||||||
|
requests: { memory: '2Gi' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* K8s Job lifecycle management — create, delete, list worker Jobs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as k8s from '@kubernetes/client-node';
|
||||||
|
|
||||||
|
const WORKER_LABEL = 'shannon-worker';
|
||||||
|
|
||||||
|
export async function createJob(batchApi: k8s.BatchV1Api, namespace: string, job: k8s.V1Job): Promise<void> {
|
||||||
|
await batchApi.createNamespacedJob({ namespace, body: job });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteJob(batchApi: k8s.BatchV1Api, namespace: string, name: string): Promise<void> {
|
||||||
|
await batchApi.deleteNamespacedJob({
|
||||||
|
name,
|
||||||
|
namespace,
|
||||||
|
propagationPolicy: 'Background',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getJob(batchApi: k8s.BatchV1Api, namespace: string, name: string): Promise<k8s.V1Job | null> {
|
||||||
|
try {
|
||||||
|
return await batchApi.readNamespacedJob({ name, namespace });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkerJobs(batchApi: k8s.BatchV1Api, namespace: string): Promise<k8s.V1Job[]> {
|
||||||
|
const response = await batchApi.listNamespacedJob({
|
||||||
|
namespace,
|
||||||
|
labelSelector: `app=${WORKER_LABEL}`,
|
||||||
|
});
|
||||||
|
return response.items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Scan lifecycle orchestration — combines Temporal queries with K8s Job management.
|
||||||
|
* This is the main service that route handlers delegate to.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import type * as k8s from '@kubernetes/client-node';
|
||||||
|
import type { Client } from '@temporalio/client';
|
||||||
|
import type { Config } from '../config.js';
|
||||||
|
import type { CreateScanInput, ScanResponse } from '../types/api.js';
|
||||||
|
import { buildJobSpec } from './job-builder.js';
|
||||||
|
import { createJob, deleteJob, listWorkerJobs } from './job-manager.js';
|
||||||
|
import { cancelWorkflow, queryProgress } from './temporal-client.js';
|
||||||
|
import { listWorkspaces, readReport, readSessionJson } from './workspace-reader.js';
|
||||||
|
|
||||||
|
function randomSuffix(): string {
|
||||||
|
return crypto.randomBytes(4).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Start Scan ===
|
||||||
|
|
||||||
|
export async function startScan(
|
||||||
|
config: Config,
|
||||||
|
batchApi: k8s.BatchV1Api,
|
||||||
|
input: CreateScanInput,
|
||||||
|
): Promise<ScanResponse> {
|
||||||
|
const suffix = randomSuffix();
|
||||||
|
const taskQueue = `api-${suffix}`;
|
||||||
|
const jobName = `shannon-worker-${suffix}`;
|
||||||
|
|
||||||
|
const workspace =
|
||||||
|
input.workspace ?? `${new URL(input.targetUrl).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||||
|
|
||||||
|
const job = buildJobSpec({
|
||||||
|
jobName,
|
||||||
|
namespace: config.k8sNamespace,
|
||||||
|
workerImage: config.workerImage,
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
taskQueue,
|
||||||
|
workspace,
|
||||||
|
credentialsSecretName: config.credentialsSecretName,
|
||||||
|
...(input.gitUrl && { gitUrl: input.gitUrl }),
|
||||||
|
...(input.gitRef && { gitRef: input.gitRef }),
|
||||||
|
...(input.repoPath && { repoPath: input.repoPath }),
|
||||||
|
...(input.configYaml && { configYaml: input.configYaml }),
|
||||||
|
...(input.pipelineTesting && { pipelineTesting: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await createJob(batchApi, config.k8sNamespace, job);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: jobName,
|
||||||
|
workspace,
|
||||||
|
targetUrl: input.targetUrl,
|
||||||
|
status: 'running',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Get Scan ===
|
||||||
|
|
||||||
|
export async function getScan(config: Config, temporalClient: Client, scanId: string): Promise<ScanResponse | null> {
|
||||||
|
// 1. Try Temporal query for live progress
|
||||||
|
try {
|
||||||
|
const progress = await queryProgress(temporalClient, scanId);
|
||||||
|
return {
|
||||||
|
id: scanId,
|
||||||
|
workspace: scanId,
|
||||||
|
targetUrl: '',
|
||||||
|
status: progress.status,
|
||||||
|
createdAt: new Date(progress.startTime).toISOString(),
|
||||||
|
completedAgents: progress.completedAgents,
|
||||||
|
agentMetrics: progress.agentMetrics,
|
||||||
|
...(progress.currentPhase && { currentPhase: progress.currentPhase }),
|
||||||
|
...(progress.currentAgent && { currentAgent: progress.currentAgent }),
|
||||||
|
...(progress.summary && { summary: progress.summary }),
|
||||||
|
...(progress.error && { error: progress.error }),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Workflow not found in Temporal — try workspace session.json
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to workspace session.json (completed/historical scans)
|
||||||
|
const session = readSessionJson(config.workspacesDir, scanId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.originalWorkflowId ?? scanId,
|
||||||
|
workspace: session.workspace,
|
||||||
|
targetUrl: session.webUrl ?? '',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: session.startTime ? new Date(session.startTime).toISOString() : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// === List Scans ===
|
||||||
|
|
||||||
|
export async function listScans(
|
||||||
|
config: Config,
|
||||||
|
_temporalClient: Client,
|
||||||
|
batchApi: k8s.BatchV1Api,
|
||||||
|
): Promise<ScanResponse[]> {
|
||||||
|
const results: ScanResponse[] = [];
|
||||||
|
|
||||||
|
// 1. Running scans from K8s Jobs
|
||||||
|
const jobs = await listWorkerJobs(batchApi, config.k8sNamespace);
|
||||||
|
for (const job of jobs) {
|
||||||
|
const jobName = job.metadata?.name ?? '';
|
||||||
|
const workspace = job.metadata?.labels?.['shannon.io/workspace'] ?? jobName;
|
||||||
|
const startTime = job.status?.startTime;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: jobName,
|
||||||
|
workspace,
|
||||||
|
targetUrl: '',
|
||||||
|
status: job.status?.succeeded ? 'completed' : job.status?.failed ? 'failed' : 'running',
|
||||||
|
createdAt: startTime ? new Date(startTime).toISOString() : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Historical scans from workspace session.json files
|
||||||
|
const workspaces = listWorkspaces(config.workspacesDir);
|
||||||
|
const jobNames = new Set(results.map((r) => r.workspace));
|
||||||
|
|
||||||
|
for (const ws of workspaces) {
|
||||||
|
if (jobNames.has(ws.workspace)) continue;
|
||||||
|
results.push({
|
||||||
|
id: ws.originalWorkflowId ?? ws.workspace,
|
||||||
|
workspace: ws.workspace,
|
||||||
|
targetUrl: ws.webUrl ?? '',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: ws.startTime ? new Date(ws.startTime).toISOString() : '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Cancel Scan ===
|
||||||
|
|
||||||
|
export async function cancelScan(
|
||||||
|
config: Config,
|
||||||
|
temporalClient: Client,
|
||||||
|
batchApi: k8s.BatchV1Api,
|
||||||
|
scanId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Cancel Temporal workflow (best-effort)
|
||||||
|
try {
|
||||||
|
await cancelWorkflow(temporalClient, scanId);
|
||||||
|
} catch {
|
||||||
|
// Workflow may have already completed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete K8s Job
|
||||||
|
try {
|
||||||
|
await deleteJob(batchApi, config.k8sNamespace, scanId);
|
||||||
|
} catch {
|
||||||
|
// Job may have already been cleaned up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Get Report ===
|
||||||
|
|
||||||
|
export async function getReport(config: Config, scanId: string): Promise<string | null> {
|
||||||
|
return readReport(config.workspacesDir, scanId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Temporal client management — connection lifecycle and workflow operations.
|
||||||
|
* Uses @temporalio/client (not worker) since the API server only submits and queries workflows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PipelineProgress } from '@shannon/worker/pipeline';
|
||||||
|
import { Client, Connection } from '@temporalio/client';
|
||||||
|
|
||||||
|
export interface TemporalClients {
|
||||||
|
readonly client: Client;
|
||||||
|
readonly connection: Connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectTemporal(address: string): Promise<TemporalClients> {
|
||||||
|
console.log(`Connecting to Temporal at ${address}...`);
|
||||||
|
const connection = await Connection.connect({ address });
|
||||||
|
const client = new Client({ connection });
|
||||||
|
console.log('Temporal connected.');
|
||||||
|
return { client, connection };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectTemporal(clients: TemporalClients): Promise<void> {
|
||||||
|
await clients.connection.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query a workflow's progress via the getProgress query. */
|
||||||
|
export async function queryProgress(client: Client, workflowId: string): Promise<PipelineProgress> {
|
||||||
|
const handle = client.workflow.getHandle(workflowId);
|
||||||
|
return handle.query<PipelineProgress>('getProgress');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a running workflow. */
|
||||||
|
export async function cancelWorkflow(client: Client, workflowId: string): Promise<void> {
|
||||||
|
const handle = client.workflow.getHandle(workflowId);
|
||||||
|
await handle.cancel();
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Workspace reader — reads session.json and deliverables from the shared workspaces PVC.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
readonly workspace: string;
|
||||||
|
readonly originalWorkflowId?: string;
|
||||||
|
readonly webUrl?: string;
|
||||||
|
readonly startTime?: number;
|
||||||
|
readonly cost?: number;
|
||||||
|
readonly resumeAttempts?: readonly { workflowId: string; timestamp: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSessionJson(workspacesDir: string, workspace: string): SessionInfo | null {
|
||||||
|
const sessionPath = path.join(workspacesDir, workspace, 'session.json');
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(sessionPath, 'utf-8');
|
||||||
|
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
const session = data.session as Record<string, unknown> | undefined;
|
||||||
|
const originalWorkflowId = session?.originalWorkflowId as string | undefined;
|
||||||
|
const webUrl = session?.webUrl as string | undefined;
|
||||||
|
const startTime = session?.startTime as number | undefined;
|
||||||
|
const cost = session?.totalCostUsd as number | undefined;
|
||||||
|
const resumeAttempts = session?.resumeAttempts as SessionInfo['resumeAttempts'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace,
|
||||||
|
...(originalWorkflowId && { originalWorkflowId }),
|
||||||
|
...(webUrl && { webUrl }),
|
||||||
|
...(startTime && { startTime }),
|
||||||
|
...(cost && { cost }),
|
||||||
|
...(resumeAttempts && { resumeAttempts }),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readReport(workspacesDir: string, workspace: string): string | null {
|
||||||
|
const delivDir = path.join(workspacesDir, workspace, 'deliverables');
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(delivDir);
|
||||||
|
const reportFile = files.find((f) => f.includes('report') && f.endsWith('.md'));
|
||||||
|
if (!reportFile) return null;
|
||||||
|
return fs.readFileSync(path.join(delivDir, reportFile), 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWorkspaces(workspacesDir: string): SessionInfo[] {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(workspacesDir, { withFileTypes: true });
|
||||||
|
const results: SessionInfo[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const session = readSessionJson(workspacesDir, entry.name);
|
||||||
|
if (session) {
|
||||||
|
results.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.sort((a, b) => (b.startTime ?? 0) - (a.startTime ?? 0));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Request/response types and Zod validation schemas for the scan API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentMetrics, PipelineSummary } from '@shannon/worker/pipeline';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// === Request Schemas ===
|
||||||
|
|
||||||
|
export const CreateScanSchema = z
|
||||||
|
.object({
|
||||||
|
targetUrl: z.string().url(),
|
||||||
|
gitUrl: z.string().url().optional(),
|
||||||
|
repoPath: z.string().optional(),
|
||||||
|
gitRef: z.string().optional(),
|
||||||
|
configYaml: z.string().optional(),
|
||||||
|
workspace: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/)
|
||||||
|
.optional(),
|
||||||
|
pipelineTesting: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.gitUrl || data.repoPath, {
|
||||||
|
message: 'Either gitUrl or repoPath is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateScanInput = z.infer<typeof CreateScanSchema>;
|
||||||
|
|
||||||
|
// === Response Types ===
|
||||||
|
|
||||||
|
export interface ScanResponse {
|
||||||
|
id: string;
|
||||||
|
workspace: string;
|
||||||
|
targetUrl: string;
|
||||||
|
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
createdAt: string;
|
||||||
|
currentPhase?: string;
|
||||||
|
currentAgent?: string;
|
||||||
|
completedAgents?: string[];
|
||||||
|
agentMetrics?: Record<string, AgentMetrics>;
|
||||||
|
summary?: PipelineSummary;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanListResponse {
|
||||||
|
scans: ScanResponse[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.1.0",
|
"@clack/prompts": "^1.1.0",
|
||||||
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"smol-toml": "^1.6.1"
|
"smol-toml": "^1.6.1"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Backend detection — Docker (default) vs Kubernetes.
|
||||||
|
*
|
||||||
|
* Orthogonal to the local/npx mode axis. Mode controls where state lives
|
||||||
|
* and where the image comes from. Backend controls how containers are orchestrated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Orchestrator } from './orchestrator.js';
|
||||||
|
|
||||||
|
export type Backend = 'docker' | 'k8s';
|
||||||
|
|
||||||
|
let cachedBackend: Backend | undefined;
|
||||||
|
let cachedOrchestrator: Orchestrator | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the orchestration backend.
|
||||||
|
* SHANNON_BACKEND env var takes precedence, otherwise defaults to docker.
|
||||||
|
*/
|
||||||
|
export function getBackend(): Backend {
|
||||||
|
if (cachedBackend !== undefined) return cachedBackend;
|
||||||
|
|
||||||
|
const env = process.env.SHANNON_BACKEND;
|
||||||
|
if (env === 'k8s' || env === 'kubernetes') {
|
||||||
|
cachedBackend = 'k8s';
|
||||||
|
} else {
|
||||||
|
cachedBackend = 'docker';
|
||||||
|
}
|
||||||
|
return cachedBackend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBackend(backend: Backend): void {
|
||||||
|
cachedBackend = backend;
|
||||||
|
cachedOrchestrator = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the orchestrator for the current backend.
|
||||||
|
* Lazy-loads the implementation to avoid importing unused dependencies.
|
||||||
|
*/
|
||||||
|
export async function getOrchestrator(): Promise<Orchestrator> {
|
||||||
|
if (cachedOrchestrator) return cachedOrchestrator;
|
||||||
|
|
||||||
|
let orchestrator: Orchestrator;
|
||||||
|
if (getBackend() === 'k8s') {
|
||||||
|
const { K8sOrchestrator } = await import('./k8s.js');
|
||||||
|
orchestrator = new K8sOrchestrator();
|
||||||
|
} else {
|
||||||
|
const { DockerOrchestrator } = await import('./docker.js');
|
||||||
|
orchestrator = new DockerOrchestrator();
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedOrchestrator = orchestrator;
|
||||||
|
return orchestrator;
|
||||||
|
}
|
||||||
@@ -5,11 +5,11 @@
|
|||||||
* and npx mode (Docker Hub pull, ~/.shannon/).
|
* and npx mode (Docker Hub pull, ~/.shannon/).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
import { getOrchestrator } from '../backend.js';
|
||||||
|
import { randomSuffix } from '../docker.js';
|
||||||
import { buildEnvFlags, isRouterConfigured, loadEnv, validateCredentials } from '../env.js';
|
import { buildEnvFlags, isRouterConfigured, loadEnv, validateCredentials } from '../env.js';
|
||||||
import { getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
|
import { getCredentialsPath, getWorkspacesDir, initHome } from '../home.js';
|
||||||
import { isLocal } from '../mode.js';
|
import { isLocal } from '../mode.js';
|
||||||
@@ -55,9 +55,10 @@ export async function start(args: StartArgs): Promise<void> {
|
|||||||
process.env.ANTHROPIC_AUTH_TOKEN = 'shannon-router-key';
|
process.env.ANTHROPIC_AUTH_TOKEN = 'shannon-router-key';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Ensure image (auto-build in dev, pull in npx) and start infra
|
// 6. Ensure image and start infra via orchestrator
|
||||||
ensureImage(args.version);
|
const orchestrator = await getOrchestrator();
|
||||||
await ensureInfra(useRouter);
|
orchestrator.ensureImage(args.version);
|
||||||
|
await orchestrator.ensureInfra(useRouter);
|
||||||
|
|
||||||
// 7. Generate unique task queue and container name
|
// 7. Generate unique task queue and container name
|
||||||
const suffix = randomSuffix();
|
const suffix = randomSuffix();
|
||||||
@@ -94,20 +95,20 @@ export async function start(args: StartArgs): Promise<void> {
|
|||||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = '/app/credentials/google-sa-key.json';
|
process.env.GOOGLE_APPLICATION_CREDENTIALS = '/app/credentials/google-sa-key.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Resolve output directory
|
// 11. Resolve output directory
|
||||||
const outputDir = args.output ? path.resolve(args.output) : undefined;
|
const outputDir = args.output ? path.resolve(args.output) : undefined;
|
||||||
if (outputDir) {
|
if (outputDir) {
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Resolve prompts directory (local mode only)
|
// 12. Resolve prompts directory (local mode only)
|
||||||
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
||||||
|
|
||||||
// 12. Display splash screen
|
// 13. Display splash screen
|
||||||
displaySplash(isLocal() ? undefined : args.version);
|
displaySplash(isLocal() ? undefined : args.version);
|
||||||
|
|
||||||
// 13. Spawn worker container
|
// 14. Spawn worker via orchestrator
|
||||||
const proc = spawnWorker({
|
const handle = orchestrator.spawnWorker({
|
||||||
version: args.version,
|
version: args.version,
|
||||||
url: args.url,
|
url: args.url,
|
||||||
repo,
|
repo,
|
||||||
@@ -123,8 +124,8 @@ export async function start(args: StartArgs): Promise<void> {
|
|||||||
...(args.pipelineTesting && { pipelineTesting: true }),
|
...(args.pipelineTesting && { pipelineTesting: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 14. Wait for workflow to register, then display info
|
// 15. Wait for workflow to register, then display info
|
||||||
proc.on('error', (err) => {
|
handle.onError((err) => {
|
||||||
console.error(`Failed to start worker: ${err.message}`);
|
console.error(`Failed to start worker: ${err.message}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -181,18 +182,14 @@ export async function start(args: StartArgs): Promise<void> {
|
|||||||
process.stdout.write('.');
|
process.stdout.write('.');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
// Stop the worker container only if it hasn't started yet
|
// Stop the worker only if it hasn't started yet
|
||||||
let cleaned = false;
|
let cleaned = false;
|
||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
if (cleaned || started) return;
|
if (cleaned || started) return;
|
||||||
cleaned = true;
|
cleaned = true;
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
console.log(`\nStopping worker ${containerName}...`);
|
console.log(`\nStopping worker ${containerName}...`);
|
||||||
try {
|
handle.kill();
|
||||||
execFileSync('docker', ['stop', containerName], { stdio: 'pipe' });
|
|
||||||
} catch {
|
|
||||||
// Container may have already exited
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
* `shannon status` command — show running workers and Temporal health.
|
* `shannon status` command — show running workers and Temporal health.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isTemporalReady, listRunningWorkers } from '../docker.js';
|
import { getOrchestrator } from '../backend.js';
|
||||||
|
|
||||||
|
export async function status(): Promise<void> {
|
||||||
|
const orchestrator = await getOrchestrator();
|
||||||
|
|
||||||
export function status(): void {
|
|
||||||
// 1. Temporal health
|
// 1. Temporal health
|
||||||
const temporalUp = isTemporalReady();
|
const temporalUp = orchestrator.isTemporalReady();
|
||||||
console.log(`Temporal: ${temporalUp ? 'running' : 'not running'}`);
|
console.log(`Temporal: ${temporalUp ? 'running' : 'not running'}`);
|
||||||
if (temporalUp) {
|
if (temporalUp) {
|
||||||
console.log(' Web UI: http://localhost:8233');
|
console.log(' Web UI: http://localhost:8233');
|
||||||
@@ -14,7 +16,7 @@ export function status(): void {
|
|||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
// 2. Running workers
|
// 2. Running workers
|
||||||
const workers = listRunningWorkers();
|
const workers = orchestrator.listRunningWorkers();
|
||||||
if (workers) {
|
if (workers) {
|
||||||
console.log('Workers:');
|
console.log('Workers:');
|
||||||
console.log(workers);
|
console.log(workers);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import { stopInfra, stopWorkers } from '../docker.js';
|
import { getOrchestrator } from '../backend.js';
|
||||||
|
|
||||||
export async function stop(clean: boolean): Promise<void> {
|
export async function stop(clean: boolean): Promise<void> {
|
||||||
if (clean) {
|
if (clean) {
|
||||||
@@ -16,6 +16,7 @@ export async function stop(clean: boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopWorkers();
|
const orchestrator = await getOrchestrator();
|
||||||
stopInfra(clean);
|
orchestrator.stopWorkers();
|
||||||
|
orchestrator.stopInfra(clean);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import { stopInfra, stopWorkers } from '../docker.js';
|
import { getOrchestrator } from '../backend.js';
|
||||||
|
|
||||||
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
const SHANNON_HOME = path.join(os.homedir(), '.shannon');
|
||||||
|
|
||||||
@@ -28,8 +28,9 @@ export async function uninstall(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop any running containers first
|
// Stop any running containers first
|
||||||
stopWorkers();
|
const orchestrator = await getOrchestrator();
|
||||||
stopInfra(false);
|
orchestrator.stopWorkers();
|
||||||
|
orchestrator.stopInfra(false);
|
||||||
|
|
||||||
fs.rmSync(SHANNON_HOME, { recursive: true, force: true });
|
fs.rmSync(SHANNON_HOME, { recursive: true, force: true });
|
||||||
p.log.success('All Shannon data has been removed.');
|
p.log.success('All Shannon data has been removed.');
|
||||||
|
|||||||
@@ -2,30 +2,19 @@
|
|||||||
* `shannon workspaces` command — list all workspaces.
|
* `shannon workspaces` command — list all workspaces.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { getOrchestrator } from '../backend.js';
|
||||||
import os from 'node:os';
|
|
||||||
import { getWorkerImage } from '../docker.js';
|
|
||||||
import { getWorkspacesDir } from '../home.js';
|
import { getWorkspacesDir } from '../home.js';
|
||||||
|
|
||||||
export function workspaces(version: string): void {
|
export async function workspaces(version: string): Promise<void> {
|
||||||
|
const orchestrator = await getOrchestrator();
|
||||||
const workspacesDir = getWorkspacesDir();
|
const workspacesDir = getWorkspacesDir();
|
||||||
const image = getWorkerImage(version);
|
const image = orchestrator.getWorkerImage(version);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync(
|
orchestrator.runEphemeral(
|
||||||
'docker',
|
image,
|
||||||
[
|
['node', 'apps/worker/dist/temporal/workspaces.js'],
|
||||||
'run',
|
[`${workspacesDir}:/app/workspaces`],
|
||||||
'--rm',
|
|
||||||
'-v',
|
|
||||||
`${workspacesDir}:/app/workspaces`,
|
|
||||||
'-e',
|
|
||||||
'WORKSPACES_DIR=/app/workspaces',
|
|
||||||
image,
|
|
||||||
'node',
|
|
||||||
'apps/worker/dist/temporal/workspaces.js',
|
|
||||||
],
|
|
||||||
{ stdio: 'inherit', ...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }) },
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
console.error('ERROR: Failed to list workspaces. Is the Docker image available?');
|
console.error('ERROR: Failed to list workspaces. Is the Docker image available?');
|
||||||
|
|||||||
+255
-245
@@ -12,27 +12,22 @@ import path from 'node:path';
|
|||||||
import { setTimeout as sleep } from 'node:timers/promises';
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { getMode } from './mode.js';
|
import { getMode } from './mode.js';
|
||||||
|
import type { Orchestrator, WorkerHandle, WorkerOptions } from './orchestrator.js';
|
||||||
|
|
||||||
|
export type { WorkerOptions };
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const NPX_IMAGE_REPO = 'keygraph/shannon';
|
const NPX_IMAGE_REPO = 'keygraph/shannon';
|
||||||
const DEV_IMAGE = 'shannon-worker';
|
const DEV_IMAGE = 'shannon-worker';
|
||||||
|
|
||||||
export function getWorkerImage(version: string): string {
|
|
||||||
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getComposeFile(): string {
|
|
||||||
return getMode() === 'local'
|
|
||||||
? path.resolve('docker-compose.yml')
|
|
||||||
: path.resolve(__dirname, '..', 'infra', 'compose.yml');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate an 8-char random hex suffix for container/queue names. */
|
/** Generate an 8-char random hex suffix for container/queue names. */
|
||||||
export function randomSuffix(): string {
|
export function randomSuffix(): string {
|
||||||
return crypto.randomBytes(4).toString('hex');
|
return crypto.randomBytes(4).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Internal Helpers ===
|
||||||
|
|
||||||
/** Run a command silently, return true if it succeeds. */
|
/** Run a command silently, return true if it succeeds. */
|
||||||
function runQuiet(cmd: string, args: string[]): boolean {
|
function runQuiet(cmd: string, args: string[]): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -52,21 +47,10 @@ function runOutput(cmd: string, args: string[]): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getComposeFile(): string {
|
||||||
* Check if Temporal is running and healthy.
|
return getMode() === 'local'
|
||||||
*/
|
? path.resolve('docker-compose.yml')
|
||||||
export function isTemporalReady(): boolean {
|
: path.resolve(__dirname, '..', 'infra', 'compose.yml');
|
||||||
const output = runOutput('docker', [
|
|
||||||
'exec',
|
|
||||||
'shannon-temporal',
|
|
||||||
'temporal',
|
|
||||||
'operator',
|
|
||||||
'cluster',
|
|
||||||
'health',
|
|
||||||
'--address',
|
|
||||||
'localhost:7233',
|
|
||||||
]);
|
|
||||||
return output.includes('SERVING');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if the router container is running and healthy. */
|
/** Check if the router container is running and healthy. */
|
||||||
@@ -75,99 +59,6 @@ function isRouterReady(): boolean {
|
|||||||
return status === 'healthy';
|
return status === 'healthy';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure Temporal (and optionally router) are running via compose.
|
|
||||||
* If Temporal is already up but router is needed and missing, starts router only.
|
|
||||||
*/
|
|
||||||
export async function ensureInfra(useRouter: boolean): Promise<void> {
|
|
||||||
const temporalReady = isTemporalReady();
|
|
||||||
const routerNeeded = useRouter && !isRouterReady();
|
|
||||||
|
|
||||||
if (temporalReady && !routerNeeded) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const composeFile = getComposeFile();
|
|
||||||
const composeArgs = ['compose', '-f', composeFile];
|
|
||||||
if (useRouter) composeArgs.push('--profile', 'router');
|
|
||||||
composeArgs.push('up', '-d');
|
|
||||||
|
|
||||||
if (temporalReady && routerNeeded) {
|
|
||||||
console.log('Starting router...');
|
|
||||||
} else {
|
|
||||||
console.log('Starting Shannon infrastructure...');
|
|
||||||
}
|
|
||||||
execFileSync('docker', composeArgs, { stdio: 'inherit' });
|
|
||||||
|
|
||||||
// Wait for Temporal if it wasn't already running
|
|
||||||
if (!temporalReady) {
|
|
||||||
console.log('Waiting for Temporal to be ready...');
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
if (isTemporalReady()) {
|
|
||||||
console.log('Temporal is ready!');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (i === 29) {
|
|
||||||
console.error('Timeout waiting for Temporal');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
await sleep(2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for router if needed
|
|
||||||
if (routerNeeded) {
|
|
||||||
console.log('Waiting for router to be ready...');
|
|
||||||
for (let i = 0; i < 15; i++) {
|
|
||||||
if (isRouterReady()) {
|
|
||||||
console.log('Router is ready!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await sleep(2000);
|
|
||||||
}
|
|
||||||
console.error('Timeout waiting for router');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the worker image locally (local mode only).
|
|
||||||
*/
|
|
||||||
export function buildImage(noCache: boolean): void {
|
|
||||||
console.log(`Building ${DEV_IMAGE}...`);
|
|
||||||
const args = ['build'];
|
|
||||||
if (noCache) args.push('--no-cache');
|
|
||||||
args.push('-t', DEV_IMAGE, '.');
|
|
||||||
execFileSync('docker', args, { stdio: 'inherit' });
|
|
||||||
console.log(`Build complete: ${DEV_IMAGE}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the worker image is available.
|
|
||||||
* Local mode: auto-builds if missing. NPX mode: pulls from Docker Hub.
|
|
||||||
*/
|
|
||||||
export function ensureImage(version: string): void {
|
|
||||||
const image = getWorkerImage(version);
|
|
||||||
const exists = runQuiet('docker', ['image', 'inspect', image]);
|
|
||||||
if (exists) return;
|
|
||||||
|
|
||||||
if (getMode() === 'local') {
|
|
||||||
console.log('Worker image not found, building...');
|
|
||||||
buildImage(false);
|
|
||||||
} else {
|
|
||||||
console.log(`Pulling ${image}...`);
|
|
||||||
try {
|
|
||||||
execFileSync('docker', ['pull', image], { stdio: 'inherit' });
|
|
||||||
} catch {
|
|
||||||
console.error(`\nERROR: Failed to pull ${image}`);
|
|
||||||
console.error('The image may not be available for your platform yet.');
|
|
||||||
console.error('Check https://hub.docker.com/r/keygraph/shannon for available tags.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
pruneOldImages(version);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if --add-host is needed (Linux without Podman).
|
* Detect if --add-host is needed (Linux without Podman).
|
||||||
* macOS has host.docker.internal built in.
|
* macOS has host.docker.internal built in.
|
||||||
@@ -182,140 +73,259 @@ function addHostFlag(): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkerOptions {
|
/** Remove old keygraph/shannon images that don't match the current version. */
|
||||||
version: string;
|
|
||||||
url: string;
|
|
||||||
repo: { hostPath: string; containerPath: string };
|
|
||||||
workspacesDir: string;
|
|
||||||
taskQueue: string;
|
|
||||||
containerName: string;
|
|
||||||
envFlags: string[];
|
|
||||||
config?: { hostPath: string; containerPath: string };
|
|
||||||
credentials?: string;
|
|
||||||
promptsDir?: string;
|
|
||||||
outputDir?: string;
|
|
||||||
workspace: string;
|
|
||||||
pipelineTesting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spawn the worker container in detached mode and return the process.
|
|
||||||
*/
|
|
||||||
export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
|
||||||
const args = ['run', '-d', '--rm', '--name', opts.containerName, '--network', 'shannon-net'];
|
|
||||||
|
|
||||||
// Add host flag for Linux
|
|
||||||
args.push(...addHostFlag());
|
|
||||||
|
|
||||||
// UID remapping for Linux bind mounts
|
|
||||||
if (os.platform() === 'linux' && process.getuid && process.getgid) {
|
|
||||||
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume mounts
|
|
||||||
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
|
|
||||||
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
|
|
||||||
|
|
||||||
// Writable overlays: shadow .shannon/ inside the :ro repo with workspace-backed dirs
|
|
||||||
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
|
|
||||||
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
|
|
||||||
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
|
|
||||||
args.push('-v', `${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`);
|
|
||||||
|
|
||||||
// Local mode: mount prompts for live editing
|
|
||||||
if (opts.promptsDir) {
|
|
||||||
args.push('-v', `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.config) {
|
|
||||||
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output directory for deliverables copy
|
|
||||||
if (opts.outputDir) {
|
|
||||||
args.push('-v', `${opts.outputDir}:/app/output`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount credentials file to fixed container path
|
|
||||||
if (opts.credentials) {
|
|
||||||
args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment
|
|
||||||
args.push(...opts.envFlags);
|
|
||||||
|
|
||||||
// Container settings
|
|
||||||
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
|
|
||||||
|
|
||||||
// Image
|
|
||||||
args.push(getWorkerImage(opts.version));
|
|
||||||
|
|
||||||
// Worker command
|
|
||||||
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
|
|
||||||
args.push('--task-queue', opts.taskQueue);
|
|
||||||
if (opts.config) {
|
|
||||||
args.push('--config', opts.config.containerPath);
|
|
||||||
}
|
|
||||||
if (opts.outputDir) {
|
|
||||||
args.push('--output', '/app/output');
|
|
||||||
}
|
|
||||||
args.push('--workspace', opts.workspace);
|
|
||||||
if (opts.pipelineTesting) {
|
|
||||||
args.push('--pipeline-testing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent MSYS/Git Bash from converting Unix paths (e.g. /repos/my-repo) to Windows paths
|
|
||||||
return spawn('docker', args, {
|
|
||||||
stdio: 'pipe',
|
|
||||||
...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop all running shannon-worker-* containers.
|
|
||||||
*/
|
|
||||||
export function stopWorkers(): void {
|
|
||||||
const workers = runOutput('docker', ['ps', '-q', '--filter', 'name=shannon-worker-']);
|
|
||||||
if (!workers) return;
|
|
||||||
|
|
||||||
const ids = workers.split('\n').filter(Boolean);
|
|
||||||
console.log('Stopping worker containers...');
|
|
||||||
execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tear down the compose stack.
|
|
||||||
*/
|
|
||||||
export function stopInfra(clean: boolean): void {
|
|
||||||
const composeFile = getComposeFile();
|
|
||||||
const args = ['compose', '-f', composeFile, '--profile', 'router', 'down'];
|
|
||||||
if (clean) args.push('-v');
|
|
||||||
execFileSync('docker', args, { stdio: 'inherit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove old keygraph/shannon images that don't match the current version.
|
|
||||||
*/
|
|
||||||
function pruneOldImages(currentVersion: string): void {
|
function pruneOldImages(currentVersion: string): void {
|
||||||
const output = runOutput('docker', ['images', NPX_IMAGE_REPO, '--format', '{{.Tag}}']);
|
const output = runOutput('docker', ['images', NPX_IMAGE_REPO, '--format', '{{.Tag}}']);
|
||||||
if (!output) return;
|
if (!output) return;
|
||||||
|
|
||||||
const currentTag = currentVersion;
|
const stale = output.split('\n').filter((tag) => tag && tag !== currentVersion);
|
||||||
const stale = output.split('\n').filter((tag) => tag && tag !== currentTag);
|
|
||||||
for (const tag of stale) {
|
for (const tag of stale) {
|
||||||
runQuiet('docker', ['rmi', `${NPX_IMAGE_REPO}:${tag}`]);
|
runQuiet('docker', ['rmi', `${NPX_IMAGE_REPO}:${tag}`]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// === DockerOrchestrator ===
|
||||||
* List running worker containers.
|
|
||||||
*/
|
/** Docker-based orchestration backend. */
|
||||||
export function listRunningWorkers(): string {
|
export class DockerOrchestrator implements Orchestrator {
|
||||||
return runOutput('docker', [
|
getWorkerImage(version: string): string {
|
||||||
'ps',
|
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
||||||
'--filter',
|
}
|
||||||
'name=shannon-worker-',
|
|
||||||
'--format',
|
isTemporalReady(): boolean {
|
||||||
'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
|
const output = runOutput('docker', [
|
||||||
]);
|
'exec',
|
||||||
|
'shannon-temporal',
|
||||||
|
'temporal',
|
||||||
|
'operator',
|
||||||
|
'cluster',
|
||||||
|
'health',
|
||||||
|
'--address',
|
||||||
|
'localhost:7233',
|
||||||
|
]);
|
||||||
|
return output.includes('SERVING');
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureInfra(useRouter: boolean): Promise<void> {
|
||||||
|
const temporalReady = this.isTemporalReady();
|
||||||
|
const routerNeeded = useRouter && !isRouterReady();
|
||||||
|
|
||||||
|
if (temporalReady && !routerNeeded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composeFile = getComposeFile();
|
||||||
|
const composeArgs = ['compose', '-f', composeFile];
|
||||||
|
if (useRouter) composeArgs.push('--profile', 'router');
|
||||||
|
composeArgs.push('up', '-d');
|
||||||
|
|
||||||
|
if (temporalReady && routerNeeded) {
|
||||||
|
console.log('Starting router...');
|
||||||
|
} else {
|
||||||
|
console.log('Starting Shannon infrastructure...');
|
||||||
|
}
|
||||||
|
execFileSync('docker', composeArgs, { stdio: 'inherit' });
|
||||||
|
|
||||||
|
// Wait for Temporal if it wasn't already running
|
||||||
|
if (!temporalReady) {
|
||||||
|
console.log('Waiting for Temporal to be ready...');
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (this.isTemporalReady()) {
|
||||||
|
console.log('Temporal is ready!');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (i === 29) {
|
||||||
|
console.error('Timeout waiting for Temporal');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for router if needed
|
||||||
|
if (routerNeeded) {
|
||||||
|
console.log('Waiting for router to be ready...');
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
if (isRouterReady()) {
|
||||||
|
console.log('Router is ready!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
console.error('Timeout waiting for router');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureImage(version: string): void {
|
||||||
|
const image = this.getWorkerImage(version);
|
||||||
|
const exists = runQuiet('docker', ['image', 'inspect', image]);
|
||||||
|
if (exists) return;
|
||||||
|
|
||||||
|
if (getMode() === 'local') {
|
||||||
|
console.log('Worker image not found, building...');
|
||||||
|
this.buildImage(false);
|
||||||
|
} else {
|
||||||
|
console.log(`Pulling ${image}...`);
|
||||||
|
try {
|
||||||
|
execFileSync('docker', ['pull', image], { stdio: 'inherit' });
|
||||||
|
} catch {
|
||||||
|
console.error(`\nERROR: Failed to pull ${image}`);
|
||||||
|
console.error('The image may not be available for your platform yet.');
|
||||||
|
console.error('Check https://hub.docker.com/r/keygraph/shannon for available tags.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
pruneOldImages(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnWorker(opts: WorkerOptions): WorkerHandle {
|
||||||
|
const args = ['run', '-d', '--rm', '--name', opts.containerName, '--network', 'shannon-net'];
|
||||||
|
|
||||||
|
// Add host flag for Linux
|
||||||
|
args.push(...addHostFlag());
|
||||||
|
|
||||||
|
// UID remapping for Linux bind mounts
|
||||||
|
if (os.platform() === 'linux' && process.getuid && process.getgid) {
|
||||||
|
args.push('-e', `SHANNON_HOST_UID=${process.getuid()}`, '-e', `SHANNON_HOST_GID=${process.getgid()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume mounts
|
||||||
|
args.push('-v', `${opts.workspacesDir}:/app/workspaces`);
|
||||||
|
args.push('-v', `${opts.repo.hostPath}:${opts.repo.containerPath}:ro`);
|
||||||
|
|
||||||
|
// Writable overlays: shadow .shannon/ inside the :ro repo with workspace-backed dirs
|
||||||
|
const workspacePath = path.join(opts.workspacesDir, opts.workspace);
|
||||||
|
args.push('-v', `${path.join(workspacePath, 'deliverables')}:${opts.repo.containerPath}/.shannon/deliverables`);
|
||||||
|
args.push('-v', `${path.join(workspacePath, 'scratchpad')}:${opts.repo.containerPath}/.shannon/scratchpad`);
|
||||||
|
args.push(
|
||||||
|
'-v',
|
||||||
|
`${path.join(workspacePath, '.playwright-cli')}:${opts.repo.containerPath}/.shannon/.playwright-cli`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Local mode: mount prompts for live editing
|
||||||
|
if (opts.promptsDir) {
|
||||||
|
args.push('-v', `${opts.promptsDir}:/app/apps/worker/prompts:ro`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.config) {
|
||||||
|
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output directory for deliverables copy
|
||||||
|
if (opts.outputDir) {
|
||||||
|
args.push('-v', `${opts.outputDir}:/app/output`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount credentials file to fixed container path
|
||||||
|
if (opts.credentials) {
|
||||||
|
args.push('-v', `${opts.credentials}:/app/credentials/google-sa-key.json:ro`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
args.push(...opts.envFlags);
|
||||||
|
|
||||||
|
// Container settings
|
||||||
|
args.push('--shm-size', '2gb', '--security-opt', 'seccomp=unconfined');
|
||||||
|
|
||||||
|
// Image
|
||||||
|
args.push(this.getWorkerImage(opts.version));
|
||||||
|
|
||||||
|
// Worker command
|
||||||
|
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
|
||||||
|
args.push('--task-queue', opts.taskQueue);
|
||||||
|
if (opts.config) {
|
||||||
|
args.push('--config', opts.config.containerPath);
|
||||||
|
}
|
||||||
|
if (opts.outputDir) {
|
||||||
|
args.push('--output', '/app/output');
|
||||||
|
}
|
||||||
|
args.push('--workspace', opts.workspace);
|
||||||
|
if (opts.pipelineTesting) {
|
||||||
|
args.push('--pipeline-testing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent MSYS/Git Bash from converting Unix paths (e.g. /repos/my-repo) to Windows paths
|
||||||
|
const proc = spawn('docker', args, {
|
||||||
|
stdio: 'pipe',
|
||||||
|
...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new DockerWorkerHandle(proc, opts.containerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWorkers(): void {
|
||||||
|
const workers = runOutput('docker', ['ps', '-q', '--filter', 'name=shannon-worker-']);
|
||||||
|
if (!workers) return;
|
||||||
|
|
||||||
|
const ids = workers.split('\n').filter(Boolean);
|
||||||
|
console.log('Stopping worker containers...');
|
||||||
|
execFileSync('docker', ['stop', ...ids], { stdio: 'inherit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
stopInfra(clean: boolean): void {
|
||||||
|
const composeFile = getComposeFile();
|
||||||
|
const args = ['compose', '-f', composeFile, '--profile', 'router', 'down'];
|
||||||
|
if (clean) args.push('-v');
|
||||||
|
execFileSync('docker', args, { stdio: 'inherit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
listRunningWorkers(): string {
|
||||||
|
return runOutput('docker', [
|
||||||
|
'ps',
|
||||||
|
'--filter',
|
||||||
|
'name=shannon-worker-',
|
||||||
|
'--format',
|
||||||
|
'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
runEphemeral(image: string, args: string[], mounts: string[]): void {
|
||||||
|
const dockerArgs = ['run', '--rm'];
|
||||||
|
for (const mount of mounts) {
|
||||||
|
dockerArgs.push('-v', mount);
|
||||||
|
}
|
||||||
|
dockerArgs.push(image, ...args);
|
||||||
|
execFileSync('docker', dockerArgs, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
...(os.platform() === 'win32' && { env: { ...process.env, MSYS_NO_PATHCONV: '1' } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the worker image locally (local mode only). */
|
||||||
|
buildImage(noCache: boolean): void {
|
||||||
|
console.log(`Building ${DEV_IMAGE}...`);
|
||||||
|
const args = ['build'];
|
||||||
|
if (noCache) args.push('--no-cache');
|
||||||
|
args.push('-t', DEV_IMAGE, '.');
|
||||||
|
execFileSync('docker', args, { stdio: 'inherit' });
|
||||||
|
console.log(`Build complete: ${DEV_IMAGE}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WorkerHandle wrapping a Docker container's ChildProcess. */
|
||||||
|
class DockerWorkerHandle implements WorkerHandle {
|
||||||
|
constructor(
|
||||||
|
private readonly proc: ChildProcess,
|
||||||
|
private readonly containerName: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onError(cb: (err: Error) => void): void {
|
||||||
|
this.proc.on('error', cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
kill(): void {
|
||||||
|
try {
|
||||||
|
execFileSync('docker', ['stop', this.containerName], { stdio: 'pipe' });
|
||||||
|
} catch {
|
||||||
|
// Container may have already exited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Backward-compatible exports ===
|
||||||
|
|
||||||
|
// NOTE: Used by commands/build.ts which doesn't go through the orchestrator
|
||||||
|
export function buildImage(noCache: boolean): void {
|
||||||
|
new DockerOrchestrator().buildImage(noCache);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-1
@@ -10,7 +10,7 @@ import { resolveConfig } from './config/resolver.js';
|
|||||||
import { getMode } from './mode.js';
|
import { getMode } from './mode.js';
|
||||||
|
|
||||||
/** Environment variables forwarded to worker containers. */
|
/** Environment variables forwarded to worker containers. */
|
||||||
const FORWARD_VARS = [
|
export const FORWARD_VARS = [
|
||||||
'ANTHROPIC_API_KEY',
|
'ANTHROPIC_API_KEY',
|
||||||
'ANTHROPIC_BASE_URL',
|
'ANTHROPIC_BASE_URL',
|
||||||
'ANTHROPIC_AUTH_TOKEN',
|
'ANTHROPIC_AUTH_TOKEN',
|
||||||
@@ -61,6 +61,23 @@ export function buildEnvFlags(): string[] {
|
|||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a key-value record of env vars to forward to workers.
|
||||||
|
* Used by the K8s backend to create Secrets instead of Docker `-e` flags.
|
||||||
|
*/
|
||||||
|
export function buildEnvRecord(): Record<string, string> {
|
||||||
|
const env: Record<string, string> = { TEMPORAL_ADDRESS: 'shannon-temporal:7233' };
|
||||||
|
|
||||||
|
for (const key of FORWARD_VARS) {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (value) {
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
interface CredentialValidation {
|
interface CredentialValidation {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
+16
-2
@@ -12,6 +12,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { setBackend } from './backend.js';
|
||||||
import { build } from './commands/build.js';
|
import { build } from './commands/build.js';
|
||||||
import { logs } from './commands/logs.js';
|
import { logs } from './commands/logs.js';
|
||||||
import { setup } from './commands/setup.js';
|
import { setup } from './commands/setup.js';
|
||||||
@@ -179,6 +180,19 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
|
|||||||
// === Main Dispatch ===
|
// === Main Dispatch ===
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
// Parse --backend flag before command dispatch
|
||||||
|
const backendIdx = args.indexOf('--backend');
|
||||||
|
if (backendIdx !== -1) {
|
||||||
|
const backendVal = args[backendIdx + 1];
|
||||||
|
if (backendVal === 'k8s' || backendVal === 'kubernetes') {
|
||||||
|
setBackend('k8s');
|
||||||
|
} else if (backendVal === 'docker') {
|
||||||
|
setBackend('docker');
|
||||||
|
}
|
||||||
|
args.splice(backendIdx, 2);
|
||||||
|
}
|
||||||
|
|
||||||
const command = args[0];
|
const command = args[0];
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
@@ -201,10 +215,10 @@ switch (command) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'workspaces':
|
case 'workspaces':
|
||||||
workspaces(getVersion());
|
await workspaces(getVersion());
|
||||||
break;
|
break;
|
||||||
case 'status':
|
case 'status':
|
||||||
status();
|
await status();
|
||||||
break;
|
break;
|
||||||
case 'setup':
|
case 'setup':
|
||||||
if (getMode() === 'local') {
|
if (getMode() === 'local') {
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
/**
|
||||||
|
* Kubernetes orchestration backend.
|
||||||
|
*
|
||||||
|
* Replaces Docker CLI commands with Kubernetes API calls:
|
||||||
|
* - `docker compose up` → apply Deployments, Services, PVCs
|
||||||
|
* - `docker run --rm` → K8s Job per scan
|
||||||
|
* - `docker stop` → delete Jobs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as k8s from '@kubernetes/client-node';
|
||||||
|
import { buildEnvRecord } from './env.js';
|
||||||
|
import { getMode } from './mode.js';
|
||||||
|
import type { Orchestrator, WorkerHandle, WorkerOptions } from './orchestrator.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const NAMESPACE = 'shannon';
|
||||||
|
const NPX_IMAGE_REPO = 'keygraph/shannon';
|
||||||
|
const DEV_IMAGE = 'shannon-worker';
|
||||||
|
const WORKER_LABEL = 'shannon-worker';
|
||||||
|
const K8S_MANIFESTS_DIR = path.resolve(__dirname, '..', 'infra', 'k8s');
|
||||||
|
|
||||||
|
// === K8s Client Setup ===
|
||||||
|
|
||||||
|
function loadKubeConfig(): k8s.KubeConfig {
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
kc.loadFromDefault();
|
||||||
|
return kc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect if running on kind or minikube (local K8s). */
|
||||||
|
function isLocalCluster(kc: k8s.KubeConfig): boolean {
|
||||||
|
const context = kc.getCurrentContext();
|
||||||
|
return context.startsWith('kind-') || context === 'minikube' || context.startsWith('minikube');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === K8sOrchestrator ===
|
||||||
|
|
||||||
|
/** Kubernetes-based orchestration backend. */
|
||||||
|
export class K8sOrchestrator implements Orchestrator {
|
||||||
|
private readonly kc: k8s.KubeConfig;
|
||||||
|
private readonly coreApi: k8s.CoreV1Api;
|
||||||
|
private readonly appsApi: k8s.AppsV1Api;
|
||||||
|
private readonly batchApi: k8s.BatchV1Api;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.kc = loadKubeConfig();
|
||||||
|
this.coreApi = this.kc.makeApiClient(k8s.CoreV1Api);
|
||||||
|
this.appsApi = this.kc.makeApiClient(k8s.AppsV1Api);
|
||||||
|
this.batchApi = this.kc.makeApiClient(k8s.BatchV1Api);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkerImage(version: string): string {
|
||||||
|
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Infrastructure ===
|
||||||
|
|
||||||
|
async ensureInfra(useRouter: boolean): Promise<void> {
|
||||||
|
// 1. Create namespace if it doesn't exist
|
||||||
|
await this.ensureNamespace();
|
||||||
|
|
||||||
|
// 2. Create or update credentials secret
|
||||||
|
await this.ensureCredentialsSecret();
|
||||||
|
|
||||||
|
// 3. Apply Temporal manifests
|
||||||
|
await this.applyManifest('temporal.yaml');
|
||||||
|
|
||||||
|
// 4. Apply workspaces PVC
|
||||||
|
await this.applyManifest('workspaces-pvc.yaml');
|
||||||
|
|
||||||
|
// 5. Optionally apply router
|
||||||
|
if (useRouter) {
|
||||||
|
await this.applyManifest('router.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Wait for Temporal to be ready
|
||||||
|
if (!(await this.isTemporalReadyAsync())) {
|
||||||
|
console.log('Waiting for Temporal to be ready...');
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (await this.isTemporalReadyAsync()) {
|
||||||
|
console.log('Temporal is ready!');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (i === 29) {
|
||||||
|
console.error('Timeout waiting for Temporal');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureImage(_version: string): void {
|
||||||
|
// K8s pulls images via imagePullPolicy — no-op for remote clusters.
|
||||||
|
// For kind, users must run `kind load docker-image shannon-worker` manually.
|
||||||
|
if (getMode() === 'local' && isLocalCluster(this.kc)) {
|
||||||
|
console.log('NOTE: For kind/minikube, ensure the worker image is loaded:');
|
||||||
|
console.log(' kind load docker-image shannon-worker');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isTemporalReady(): boolean {
|
||||||
|
// K8s API is async — synchronous check returns false, ensureInfra uses async polling
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isTemporalReadyAsync(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.coreApi.listNamespacedPod({
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
labelSelector: 'app=shannon-temporal',
|
||||||
|
});
|
||||||
|
return response.items.some((pod) => {
|
||||||
|
const conditions = pod.status?.conditions ?? [];
|
||||||
|
return conditions.some((c) => c.type === 'Ready' && c.status === 'True');
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Worker Lifecycle ===
|
||||||
|
|
||||||
|
spawnWorker(opts: WorkerOptions): WorkerHandle {
|
||||||
|
const image = this.getWorkerImage(opts.version);
|
||||||
|
const jobName = opts.containerName;
|
||||||
|
|
||||||
|
// Build command + args for the worker
|
||||||
|
const command = ['node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath];
|
||||||
|
const args: string[] = ['--task-queue', opts.taskQueue, '--workspace', opts.workspace];
|
||||||
|
if (opts.config) {
|
||||||
|
args.push('--config', opts.config.containerPath);
|
||||||
|
}
|
||||||
|
if (opts.outputDir) {
|
||||||
|
args.push('--output', '/app/output');
|
||||||
|
}
|
||||||
|
if (opts.pipelineTesting) {
|
||||||
|
args.push('--pipeline-testing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build volume mounts and volumes
|
||||||
|
const volumeMounts: k8s.V1VolumeMount[] = [
|
||||||
|
{ name: 'workspaces', mountPath: '/app/workspaces' },
|
||||||
|
{ name: 'shm', mountPath: '/dev/shm' },
|
||||||
|
];
|
||||||
|
const volumes: k8s.V1Volume[] = [
|
||||||
|
{
|
||||||
|
name: 'workspaces',
|
||||||
|
persistentVolumeClaim: { claimName: 'shannon-workspaces' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shm',
|
||||||
|
emptyDir: { medium: 'Memory', sizeLimit: '2Gi' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Repo volume — hostPath for local clusters, PVC for managed
|
||||||
|
if (isLocalCluster(this.kc)) {
|
||||||
|
volumes.push({
|
||||||
|
name: 'repo',
|
||||||
|
hostPath: { path: opts.repo.hostPath, type: 'Directory' },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
volumes.push({
|
||||||
|
name: 'repo',
|
||||||
|
persistentVolumeClaim: { claimName: `shannon-repo-${jobName}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
volumeMounts.push({
|
||||||
|
name: 'repo',
|
||||||
|
mountPath: opts.repo.containerPath,
|
||||||
|
readOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overlay dirs for deliverables/scratchpad/playwright (writable areas over :ro repo)
|
||||||
|
for (const overlay of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||||
|
const volName = `overlay-${overlay.replace('.', '')}`;
|
||||||
|
volumes.push({
|
||||||
|
name: volName,
|
||||||
|
emptyDir: {},
|
||||||
|
});
|
||||||
|
volumeMounts.push({
|
||||||
|
name: volName,
|
||||||
|
mountPath: `${opts.repo.containerPath}/.shannon/${overlay}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional volume mounts
|
||||||
|
if (opts.config) {
|
||||||
|
// Config would need a ConfigMap — for now, pass via env or mount differently
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build env vars from the secret + TEMPORAL_ADDRESS
|
||||||
|
const env: k8s.V1EnvVar[] = [{ name: 'TEMPORAL_ADDRESS', value: 'shannon-temporal:7233' }];
|
||||||
|
|
||||||
|
const job: k8s.V1Job = {
|
||||||
|
apiVersion: 'batch/v1',
|
||||||
|
kind: 'Job',
|
||||||
|
metadata: {
|
||||||
|
name: jobName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
labels: {
|
||||||
|
app: WORKER_LABEL,
|
||||||
|
'shannon.io/workspace': opts.workspace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
backoffLimit: 0,
|
||||||
|
ttlSecondsAfterFinished: 3600,
|
||||||
|
template: {
|
||||||
|
metadata: {
|
||||||
|
labels: {
|
||||||
|
app: WORKER_LABEL,
|
||||||
|
'shannon.io/workspace': opts.workspace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
restartPolicy: 'Never',
|
||||||
|
securityContext: {
|
||||||
|
seccompProfile: { type: 'Unconfined' },
|
||||||
|
},
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'worker',
|
||||||
|
image,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
envFrom: [{ secretRef: { name: 'shannon-credentials' } }],
|
||||||
|
volumeMounts,
|
||||||
|
resources: {
|
||||||
|
requests: { memory: '2Gi' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the Job asynchronously — errors are reported via the handle
|
||||||
|
const createPromise = this.batchApi.createNamespacedJob({ namespace: NAMESPACE, body: job }).then(() => {
|
||||||
|
console.log(`Worker job ${jobName} created in namespace ${NAMESPACE}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new K8sWorkerHandle(jobName, this.batchApi, createPromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWorkers(): void {
|
||||||
|
// Delete all worker jobs — fire and forget
|
||||||
|
this.batchApi
|
||||||
|
.deleteCollectionNamespacedJob({
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
labelSelector: `app=${WORKER_LABEL}`,
|
||||||
|
propagationPolicy: 'Background',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('Worker jobs deleted.');
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`Failed to stop workers: ${message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stopInfra(clean: boolean): void {
|
||||||
|
if (clean) {
|
||||||
|
// Delete the entire namespace (removes everything)
|
||||||
|
this.coreApi
|
||||||
|
.deleteNamespace({ name: NAMESPACE })
|
||||||
|
.then(() => {
|
||||||
|
console.log(`Namespace ${NAMESPACE} deleted.`);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`Failed to delete namespace: ${message}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Just delete the Temporal deployment and services
|
||||||
|
this.appsApi.deleteNamespacedDeployment({ name: 'shannon-temporal', namespace: NAMESPACE }).catch(() => {});
|
||||||
|
this.coreApi.deleteNamespacedService({ name: 'shannon-temporal', namespace: NAMESPACE }).catch(() => {});
|
||||||
|
this.appsApi.deleteNamespacedDeployment({ name: 'shannon-router', namespace: NAMESPACE }).catch(() => {});
|
||||||
|
this.coreApi.deleteNamespacedService({ name: 'shannon-router', namespace: NAMESPACE }).catch(() => {});
|
||||||
|
console.log('Infrastructure resources deleted.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listRunningWorkers(): string {
|
||||||
|
// This is called synchronously by the status command — return empty for now,
|
||||||
|
// actual implementation needs async refactor of the status command
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
runEphemeral(image: string, args: string[], mounts: string[]): void {
|
||||||
|
// For K8s, run an ephemeral pod and wait for completion
|
||||||
|
const podName = `shannon-ephemeral-${Date.now()}`;
|
||||||
|
|
||||||
|
const volumeMounts: k8s.V1VolumeMount[] = [];
|
||||||
|
const volumes: k8s.V1Volume[] = [];
|
||||||
|
|
||||||
|
// Parse Docker-style mount strings (src:dst)
|
||||||
|
for (let i = 0; i < mounts.length; i++) {
|
||||||
|
const mount = mounts[i];
|
||||||
|
if (!mount) continue;
|
||||||
|
const parts = mount.split(':');
|
||||||
|
const dst = parts[1];
|
||||||
|
if (parts.length >= 2 && dst) {
|
||||||
|
const volName = `vol-${i}`;
|
||||||
|
volumeMounts.push({ name: volName, mountPath: dst });
|
||||||
|
volumes.push({
|
||||||
|
name: volName,
|
||||||
|
persistentVolumeClaim: { claimName: 'shannon-workspaces' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pod: k8s.V1Pod = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Pod',
|
||||||
|
metadata: {
|
||||||
|
name: podName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
restartPolicy: 'Never',
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'ephemeral',
|
||||||
|
image,
|
||||||
|
command: args,
|
||||||
|
volumeMounts,
|
||||||
|
env: [{ name: 'WORKSPACES_DIR', value: '/app/workspaces' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create pod and wait for completion
|
||||||
|
this.coreApi
|
||||||
|
.createNamespacedPod({ namespace: NAMESPACE, body: pod })
|
||||||
|
.then(async () => {
|
||||||
|
// Poll for completion
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const status = await this.coreApi.readNamespacedPod({ name: podName, namespace: NAMESPACE });
|
||||||
|
if (status.status?.phase === 'Succeeded' || status.status?.phase === 'Failed') {
|
||||||
|
// Read logs
|
||||||
|
const log = await this.coreApi.readNamespacedPodLog({ name: podName, namespace: NAMESPACE });
|
||||||
|
console.log(log);
|
||||||
|
// Clean up
|
||||||
|
await this.coreApi.deleteNamespacedPod({ name: podName, namespace: NAMESPACE });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(2000);
|
||||||
|
}
|
||||||
|
console.error('Timeout waiting for ephemeral pod');
|
||||||
|
await this.coreApi.deleteNamespacedPod({ name: podName, namespace: NAMESPACE });
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`Failed to run ephemeral pod: ${message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Private Helpers ===
|
||||||
|
|
||||||
|
private async ensureNamespace(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.coreApi.readNamespace({ name: NAMESPACE });
|
||||||
|
} catch {
|
||||||
|
console.log(`Creating namespace ${NAMESPACE}...`);
|
||||||
|
await this.coreApi.createNamespace({
|
||||||
|
body: {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Namespace',
|
||||||
|
metadata: { name: NAMESPACE, labels: { 'app.kubernetes.io/part-of': 'shannon' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCredentialsSecret(): Promise<void> {
|
||||||
|
const envRecord = buildEnvRecord();
|
||||||
|
const stringData: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(envRecord)) {
|
||||||
|
if (key !== 'TEMPORAL_ADDRESS') {
|
||||||
|
stringData[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret: k8s.V1Secret = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Secret',
|
||||||
|
metadata: {
|
||||||
|
name: 'shannon-credentials',
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
},
|
||||||
|
stringData,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.coreApi.replaceNamespacedSecret({
|
||||||
|
name: 'shannon-credentials',
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
body: secret,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await this.coreApi.createNamespacedSecret({ namespace: NAMESPACE, body: secret });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyManifest(filename: string): Promise<void> {
|
||||||
|
const manifestPath = path.join(K8S_MANIFESTS_DIR, filename);
|
||||||
|
const content = fs.readFileSync(manifestPath, 'utf-8');
|
||||||
|
|
||||||
|
// Split multi-document YAML
|
||||||
|
const docs = content.split(/^---$/m).filter((doc) => doc.trim());
|
||||||
|
|
||||||
|
for (const doc of docs) {
|
||||||
|
await this.applyResource(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyResource(yamlDoc: string): Promise<void> {
|
||||||
|
const objects = k8s.loadAllYaml(yamlDoc) as k8s.KubernetesObject[];
|
||||||
|
const objectApi = k8s.KubernetesObjectApi.makeApiClient(this.kc);
|
||||||
|
|
||||||
|
for (const obj of objects) {
|
||||||
|
if (!obj || !obj.kind || !obj.metadata?.name) continue;
|
||||||
|
|
||||||
|
// Ensure metadata has required fields for the typed API
|
||||||
|
const spec = {
|
||||||
|
...obj,
|
||||||
|
metadata: { ...obj.metadata, name: obj.metadata.name },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await objectApi.read(spec);
|
||||||
|
await objectApi.patch(spec);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await objectApi.create(spec);
|
||||||
|
} catch (createErr: unknown) {
|
||||||
|
const message = createErr instanceof Error ? createErr.message : String(createErr);
|
||||||
|
console.error(`Failed to apply ${obj.kind}/${obj.metadata.name}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === K8sWorkerHandle ===
|
||||||
|
|
||||||
|
/** WorkerHandle wrapping a K8s Job. */
|
||||||
|
class K8sWorkerHandle implements WorkerHandle {
|
||||||
|
private errorCallback: ((err: Error) => void) | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jobName: string,
|
||||||
|
private readonly batchApi: k8s.BatchV1Api,
|
||||||
|
createPromise: Promise<void>,
|
||||||
|
) {
|
||||||
|
// Wire up creation errors to the error callback
|
||||||
|
createPromise.catch((err: unknown) => {
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
if (this.errorCallback) {
|
||||||
|
this.errorCallback(error);
|
||||||
|
} else {
|
||||||
|
console.error(`Worker job creation failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(cb: (err: Error) => void): void {
|
||||||
|
this.errorCallback = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
kill(): void {
|
||||||
|
this.batchApi
|
||||||
|
.deleteNamespacedJob({
|
||||||
|
name: this.jobName,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
propagationPolicy: 'Background',
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Job may have already completed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Orchestrator interface — abstraction over container orchestration backends.
|
||||||
|
*
|
||||||
|
* Docker and Kubernetes implement this interface so the CLI commands
|
||||||
|
* can swap backends without changing their logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WorkerOptions {
|
||||||
|
version: string;
|
||||||
|
url: string;
|
||||||
|
repo: { hostPath: string; containerPath: string };
|
||||||
|
workspacesDir: string;
|
||||||
|
taskQueue: string;
|
||||||
|
containerName: string;
|
||||||
|
envFlags: string[];
|
||||||
|
config?: { hostPath: string; containerPath: string };
|
||||||
|
credentials?: string;
|
||||||
|
promptsDir?: string;
|
||||||
|
outputDir?: string;
|
||||||
|
workspace: string;
|
||||||
|
pipelineTesting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle to a running worker, returned by Orchestrator.spawnWorker(). */
|
||||||
|
export interface WorkerHandle {
|
||||||
|
onError(cb: (err: Error) => void): void;
|
||||||
|
kill(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Container orchestration backend. */
|
||||||
|
export interface Orchestrator {
|
||||||
|
ensureInfra(useRouter: boolean): Promise<void>;
|
||||||
|
ensureImage(version: string): void;
|
||||||
|
spawnWorker(opts: WorkerOptions): WorkerHandle;
|
||||||
|
stopWorkers(): void;
|
||||||
|
stopInfra(clean: boolean): void;
|
||||||
|
listRunningWorkers(): string;
|
||||||
|
isTemporalReady(): boolean;
|
||||||
|
getWorkerImage(version: string): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a one-shot ephemeral container and inherit stdio.
|
||||||
|
* Used by commands like `workspaces` that need to run worker-side scripts.
|
||||||
|
*/
|
||||||
|
runEphemeral(image: string, args: string[], mounts: string[]): void;
|
||||||
|
}
|
||||||
@@ -6,6 +6,6 @@ export default defineConfig({
|
|||||||
target: 'node18',
|
target: 'node18',
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
clean: true,
|
clean: true,
|
||||||
deps: { neverBundle: ['@clack/prompts', 'dotenv', 'smol-toml'] },
|
deps: { neverBundle: ['@clack/prompts', 'dotenv', 'smol-toml', '@kubernetes/client-node'] },
|
||||||
banner: { js: '#!/usr/bin/env node' },
|
banner: { js: '#!/usr/bin/env node' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
* within their own workflow context.
|
* within their own workflow context.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { pentestPipeline } from './workflows.js';
|
export type { ActivityInput } from './activities.js';
|
||||||
export type {
|
export type {
|
||||||
AgentMetrics,
|
AgentMetrics,
|
||||||
PipelineInput,
|
PipelineInput,
|
||||||
|
PipelineProgress,
|
||||||
PipelineState,
|
PipelineState,
|
||||||
PipelineSummary,
|
PipelineSummary,
|
||||||
ResumeState,
|
ResumeState,
|
||||||
VulnExploitPipelineResult,
|
VulnExploitPipelineResult,
|
||||||
} from './shared.js';
|
} from './shared.js';
|
||||||
export type { ActivityInput } from './activities.js';
|
export { pentestPipeline } from './workflows.js';
|
||||||
|
|||||||
Generated
+634
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user