From 0960b17eb27dd6b930af5b242f2fbecda3a88fe1 Mon Sep 17 00:00:00 2001 From: jordan Date: Sat, 24 Jan 2026 21:07:00 -0700 Subject: [PATCH] feat: Implement v0.2-v0.4 (workspaces, git, API) v0.2 - Real Workspaces: - Project-specific claudebox StatefulSets (pantheon, aeries) - Init containers for git clone via SSH - Deploy key secrets template - Project ConfigMaps for CLAUDE.md v0.3 - Git Integration: - Dockerfile with rdev-bot git identity - openssh-client for SSH operations - Image version bump to v0.3.0 v0.4 - API Server: - Go REST API with chi router - Endpoints: /projects, /claude, /shell, /git, /events - SSE streaming for real-time output - OpenAPI docs via Scalar at /docs - Kubernetes RBAC for pod exec - Executor and project registry packages Co-Authored-By: Claude Opus 4.5 --- .gitignore | 6 + Dockerfile | 12 +- Dockerfile.api | 50 +++ PLAN.md | 51 ++- deployments/k8s/base/claudebox-aeries.yaml | 160 +++++++++ deployments/k8s/base/claudebox-pantheon.yaml | 160 +++++++++ deployments/k8s/base/claudebox.yaml | 2 +- deployments/k8s/base/configmaps.yaml | 99 ++++++ deployments/k8s/base/kustomization.yaml | 14 + deployments/k8s/base/pvc-aeries.yaml | 36 ++ deployments/k8s/base/pvc-pantheon.yaml | 36 ++ deployments/k8s/base/rbac.yaml | 52 +++ deployments/k8s/base/rdev-api.yaml | 81 +++++ deployments/k8s/base/secrets.yaml | 50 +++ history/v0.2.0.md | 212 +++++++++++ history/v0.3.0.md | 180 ++++++++++ history/v0.4.0.md | 259 ++++++++++++++ internal/executor/executor.go | 183 ++++++++++ internal/handlers/projects.go | 353 +++++++++++++++---- internal/projects/registry.go | 148 ++++++++ scripts/build-push.sh | 88 ++++- scripts/generate-deploy-key.sh | 81 +++++ 22 files changed, 2206 insertions(+), 107 deletions(-) create mode 100644 Dockerfile.api create mode 100644 deployments/k8s/base/claudebox-aeries.yaml create mode 100644 deployments/k8s/base/claudebox-pantheon.yaml create mode 100644 deployments/k8s/base/configmaps.yaml create mode 100644 deployments/k8s/base/pvc-aeries.yaml create mode 100644 deployments/k8s/base/pvc-pantheon.yaml create mode 100644 deployments/k8s/base/rbac.yaml create mode 100644 deployments/k8s/base/rdev-api.yaml create mode 100644 deployments/k8s/base/secrets.yaml create mode 100644 history/v0.2.0.md create mode 100644 history/v0.3.0.md create mode 100644 history/v0.4.0.md create mode 100644 internal/executor/executor.go create mode 100644 internal/projects/registry.go create mode 100755 scripts/generate-deploy-key.sh diff --git a/.gitignore b/.gitignore index 3abb1a0..3536919 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ Thumbs.db # Build artifacts *.tar *.gz +rdev-api + +# Deploy keys (generated, never commit) +*-deploy-key +*-deploy-key.pub +*-deploy-key.b64 diff --git a/Dockerfile b/Dockerfile index c2e7613..4202284 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # rdev claudebox - Claude Code in a container -# v0.1 - Base case +# v0.3 - Git integration FROM ubuntu:22.04 @@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y \ build-essential \ ca-certificates \ gnupg \ + openssh-client \ && rm -rf /var/lib/apt/lists/* # Install Node.js 20 (required for Claude Code CLI) @@ -25,9 +26,18 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ # Install Claude Code CLI RUN npm install -g @anthropic-ai/claude-code +# Configure git for rdev-bot identity +RUN git config --global user.name "rdev-bot" \ + && git config --global user.email "rdev@orchard9.ai" \ + && git config --global init.defaultBranch main \ + && git config --global push.autoSetupRemote true + # Create workspace directory RUN mkdir -p /workspace +# Create SSH directory with correct permissions +RUN mkdir -p /root/.ssh && chmod 700 /root/.ssh + # Set working directory WORKDIR /workspace diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..e1157d9 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,50 @@ +# rdev-api - Go API server for controlling claudebox pods +# v0.4 - API Server + +# Build stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Install git for go mod download +RUN apk add --no-cache git + +# Copy go mod files first for layer caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o rdev-api ./cmd/rdev-api + +# Runtime stage +FROM alpine:3.19 + +# Install kubectl for exec into pods +RUN apk add --no-cache ca-certificates curl \ + && curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ + && chmod +x kubectl \ + && mv kubectl /usr/local/bin/ + +# Create non-root user +RUN adduser -D -g '' appuser + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/rdev-api . + +# Use non-root user +USER appuser + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +# Run the server +ENTRYPOINT ["./rdev-api"] diff --git a/PLAN.md b/PLAN.md index 60dbefb..035f4e3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -65,16 +65,22 @@ kubectl exec -it -n rdev claudebox-0 -- claude "say hello" --- ### v0.2 - Real Workspaces -**Status**: Planned +**Status**: Ready (pending deploy key setup) Mount actual project repos (pantheon, aeries) into dedicated claudebox pods. **Deliverables**: -- [ ] `claudebox-pantheon` StatefulSet with pantheon repo -- [ ] `claudebox-aeries` StatefulSet with aeries repo -- [ ] Init container that clones repo on first start -- [ ] Git SSH deploy keys as Kubernetes secrets -- [ ] Project-specific CLAUDE.md mounted via ConfigMap +- [x] `claudebox-pantheon` StatefulSet with pantheon repo +- [x] `claudebox-aeries` StatefulSet with aeries repo +- [x] Init container that clones repo on first start +- [x] Git SSH deploy keys as Kubernetes secrets (template) +- [x] Project-specific CLAUDE.md mounted via ConfigMap + +**Manual Steps Required**: +1. Generate deploy keys: `./scripts/generate-deploy-key.sh ` +2. Add public keys to GitHub repo settings +3. Update `secrets.yaml` with base64-encoded private keys +4. Deploy: `kubectl apply -k deployments/k8s/base` **Architecture**: ```yaml @@ -116,15 +122,20 @@ kubectl exec -n rdev claudebox-pantheon-0 -- git -C /workspace status --- ### v0.3 - Git Integration -**Status**: Planned +**Status**: Ready (requires image rebuild) Pods can commit and push changes back to GitHub. **Deliverables**: -- [ ] SSH keys mounted at `/root/.ssh/` -- [ ] Git config (user.name, user.email) set in container -- [ ] known_hosts includes github.com -- [ ] Test push from inside pod +- [x] SSH keys mounted at `/root/.ssh/` (done in v0.2) +- [x] Git config (user.name, user.email) set in container +- [x] known_hosts includes github.com (done in v0.2) +- [x] Image updated to v0.3.0 with git identity + +**Deploy Steps**: +1. Build image: `./scripts/build-push.sh v0.3.0` +2. Apply manifests: `kubectl apply -k deployments/k8s/base` +3. Restart pods: `kubectl rollout restart statefulset -n rdev --all` **Git Config** (via ConfigMap or Dockerfile): ```bash @@ -160,16 +171,22 @@ kubectl exec -n rdev claudebox-pantheon-0 -- bash -c " --- ### v0.4 - API Server -**Status**: Planned +**Status**: Ready (requires image build) Go API server for controlling claudebox pods. **Deliverables**: -- [ ] `rdev-api` Go service -- [ ] REST endpoints for claude, shell, git commands -- [ ] SSE endpoint for streaming output -- [ ] Kubernetes RBAC for pod exec -- [ ] Project registry (which pods exist) +- [x] `rdev-api` Go service +- [x] REST endpoints for claude, shell, git commands +- [x] SSE endpoint for streaming output +- [x] Kubernetes RBAC for pod exec +- [x] Project registry (which pods exist) + +**Deploy Steps**: +1. Build images: `./scripts/build-push.sh v0.4.0` +2. Apply manifests: `kubectl apply -k deployments/k8s/base` +3. Test: `kubectl port-forward -n rdev svc/rdev-api 8080:8080` +4. Visit: `http://localhost:8080/docs` **API Endpoints**: diff --git a/deployments/k8s/base/claudebox-aeries.yaml b/deployments/k8s/base/claudebox-aeries.yaml new file mode 100644 index 0000000..d270fe0 --- /dev/null +++ b/deployments/k8s/base/claudebox-aeries.yaml @@ -0,0 +1,160 @@ +# claudebox-aeries - Claude Code pod for the Aeries project +# v0.2 - Real workspace with init container repo clone + +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: claudebox-aeries + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-aeries + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: aeries +spec: + serviceName: claudebox-aeries + replicas: 1 + selector: + matchLabels: + app: claudebox-aeries + template: + metadata: + labels: + app: claudebox-aeries + app.kubernetes.io/name: claudebox-aeries + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: aeries + spec: + # Init container clones repo if workspace is empty + initContainers: + - name: git-clone + image: ghcr.io/orchard9/rdev-claudebox:v0.3.0 + command: + - /bin/bash + - -c + - | + set -e + + # Setup SSH for GitHub + mkdir -p /root/.ssh + cp /ssh-keys/id_ed25519 /root/.ssh/id_ed25519 + chmod 600 /root/.ssh/id_ed25519 + cp /ssh-keys/known_hosts /root/.ssh/known_hosts + chmod 644 /root/.ssh/known_hosts + + # Clone or fetch + if [ ! -d /workspace/.git ]; then + echo "Cloning aeries repository..." + git clone git@github.com:orchard9/aeries.git /workspace + echo "Clone complete." + else + echo "Repository exists, fetching latest..." + cd /workspace + git fetch origin + echo "Fetch complete." + fi + + # Show status + cd /workspace + git log -1 --oneline + volumeMounts: + - name: workspace + mountPath: /workspace + - name: ssh-keys + mountPath: /ssh-keys + readOnly: true + + containers: + - name: claudebox + image: ghcr.io/orchard9/rdev-claudebox:v0.3.0 + imagePullPolicy: Always + + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "2" + memory: "4Gi" + + volumeMounts: + # Workspace with cloned repo + - name: workspace + mountPath: /workspace + + # Claude config directory (persistent for auth) + - name: claude-config + mountPath: /root/.claude + + # SSH keys for git operations + - name: ssh-keys + mountPath: /root/.ssh + readOnly: true + + # Project-specific CLAUDE.md + - name: project-config + mountPath: /workspace/CLAUDE.md + subPath: CLAUDE.md + + # Simple liveness check - container is running + livenessProbe: + exec: + command: + - cat + - /healthcheck.sh + initialDelaySeconds: 5 + periodSeconds: 60 + + # Readiness - claude CLI is available + readinessProbe: + exec: + command: + - claude + - --version + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 10 + + volumes: + - name: workspace + persistentVolumeClaim: + claimName: claudebox-aeries-workspace + + - name: claude-config + persistentVolumeClaim: + claimName: claudebox-aeries-claude-config + + - name: ssh-keys + secret: + secretName: github-deploy-key-aeries + defaultMode: 0600 + items: + - key: id_ed25519 + path: id_ed25519 + - key: known_hosts + path: known_hosts + + - name: project-config + configMap: + name: claudebox-aeries-config + + # Pull from GitHub Container Registry + imagePullSecrets: + - name: ghcr-secret +--- +# Headless service for StatefulSet +apiVersion: v1 +kind: Service +metadata: + name: claudebox-aeries + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-aeries + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: aeries +spec: + clusterIP: None + selector: + app: claudebox-aeries + ports: + - port: 8080 + name: http diff --git a/deployments/k8s/base/claudebox-pantheon.yaml b/deployments/k8s/base/claudebox-pantheon.yaml new file mode 100644 index 0000000..2a492fc --- /dev/null +++ b/deployments/k8s/base/claudebox-pantheon.yaml @@ -0,0 +1,160 @@ +# claudebox-pantheon - Claude Code pod for the Pantheon project +# v0.2 - Real workspace with init container repo clone + +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: claudebox-pantheon + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-pantheon + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: pantheon +spec: + serviceName: claudebox-pantheon + replicas: 1 + selector: + matchLabels: + app: claudebox-pantheon + template: + metadata: + labels: + app: claudebox-pantheon + app.kubernetes.io/name: claudebox-pantheon + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: pantheon + spec: + # Init container clones repo if workspace is empty + initContainers: + - name: git-clone + image: ghcr.io/orchard9/rdev-claudebox:v0.3.0 + command: + - /bin/bash + - -c + - | + set -e + + # Setup SSH for GitHub + mkdir -p /root/.ssh + cp /ssh-keys/id_ed25519 /root/.ssh/id_ed25519 + chmod 600 /root/.ssh/id_ed25519 + cp /ssh-keys/known_hosts /root/.ssh/known_hosts + chmod 644 /root/.ssh/known_hosts + + # Clone or fetch + if [ ! -d /workspace/.git ]; then + echo "Cloning pantheon repository..." + git clone git@github.com:orchard9/pantheon.git /workspace + echo "Clone complete." + else + echo "Repository exists, fetching latest..." + cd /workspace + git fetch origin + echo "Fetch complete." + fi + + # Show status + cd /workspace + git log -1 --oneline + volumeMounts: + - name: workspace + mountPath: /workspace + - name: ssh-keys + mountPath: /ssh-keys + readOnly: true + + containers: + - name: claudebox + image: ghcr.io/orchard9/rdev-claudebox:v0.3.0 + imagePullPolicy: Always + + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "2" + memory: "4Gi" + + volumeMounts: + # Workspace with cloned repo + - name: workspace + mountPath: /workspace + + # Claude config directory (persistent for auth) + - name: claude-config + mountPath: /root/.claude + + # SSH keys for git operations + - name: ssh-keys + mountPath: /root/.ssh + readOnly: true + + # Project-specific CLAUDE.md + - name: project-config + mountPath: /workspace/CLAUDE.md + subPath: CLAUDE.md + + # Simple liveness check - container is running + livenessProbe: + exec: + command: + - cat + - /healthcheck.sh + initialDelaySeconds: 5 + periodSeconds: 60 + + # Readiness - claude CLI is available + readinessProbe: + exec: + command: + - claude + - --version + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 10 + + volumes: + - name: workspace + persistentVolumeClaim: + claimName: claudebox-pantheon-workspace + + - name: claude-config + persistentVolumeClaim: + claimName: claudebox-pantheon-claude-config + + - name: ssh-keys + secret: + secretName: github-deploy-key-pantheon + defaultMode: 0600 + items: + - key: id_ed25519 + path: id_ed25519 + - key: known_hosts + path: known_hosts + + - name: project-config + configMap: + name: claudebox-pantheon-config + + # Pull from GitHub Container Registry + imagePullSecrets: + - name: ghcr-secret +--- +# Headless service for StatefulSet +apiVersion: v1 +kind: Service +metadata: + name: claudebox-pantheon + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-pantheon + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: pantheon +spec: + clusterIP: None + selector: + app: claudebox-pantheon + ports: + - port: 8080 + name: http diff --git a/deployments/k8s/base/claudebox.yaml b/deployments/k8s/base/claudebox.yaml index 8e78dd8..9a0f46c 100644 --- a/deployments/k8s/base/claudebox.yaml +++ b/deployments/k8s/base/claudebox.yaml @@ -21,7 +21,7 @@ spec: spec: containers: - name: claudebox - image: ghcr.io/orchard9/rdev-claudebox:v0.1.0 + image: ghcr.io/orchard9/rdev-claudebox:v0.3.0 imagePullPolicy: Always resources: diff --git a/deployments/k8s/base/configmaps.yaml b/deployments/k8s/base/configmaps.yaml new file mode 100644 index 0000000..ebebda8 --- /dev/null +++ b/deployments/k8s/base/configmaps.yaml @@ -0,0 +1,99 @@ +# ConfigMaps for project-specific CLAUDE.md files +# v0.2 - Project configuration +# +# These provide Claude Code with project-specific instructions. +# The CLAUDE.md is mounted at /workspace/CLAUDE.md in each claudebox. +# +# Note: If the repo already has a CLAUDE.md, this will override it. +# Consider using the repo's CLAUDE.md directly or merging them. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: claudebox-pantheon-config + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-pantheon + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: pantheon +data: + CLAUDE.md: | + # Pantheon + + Go API backend for the Orchard9 platform. + + ## Environment + + This is running in a remote claudebox pod on k3s (rdev). + - Workspace: /workspace (persistent, git-managed) + - Claude config: /root/.claude (persistent auth) + + ## Commands + + ```bash + # Build + go build ./... + + # Test + go test ./... + + # Run locally (if applicable) + go run ./cmd/api + ``` + + ## Git + + SSH keys are mounted for git operations. + - Fetch: `git fetch origin` + - Pull: `git pull origin main` + - Push: `git push origin HEAD` (v0.3+) + + ## Constraints + + - This is a REMOTE environment - no local filesystem access + - Changes persist across pod restarts + - Commits are attributed to rdev-bot + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: claudebox-aeries-config + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-aeries + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: aeries +data: + CLAUDE.md: | + # Aeries + + Orchard9 Aeries project. + + ## Environment + + This is running in a remote claudebox pod on k3s (rdev). + - Workspace: /workspace (persistent, git-managed) + - Claude config: /root/.claude (persistent auth) + + ## Commands + + ```bash + # Build (adjust for your project) + # go build ./... + # npm run build + # etc. + ``` + + ## Git + + SSH keys are mounted for git operations. + - Fetch: `git fetch origin` + - Pull: `git pull origin main` + - Push: `git push origin HEAD` (v0.3+) + + ## Constraints + + - This is a REMOTE environment - no local filesystem access + - Changes persist across pod restarts + - Commits are attributed to rdev-bot diff --git a/deployments/k8s/base/kustomization.yaml b/deployments/k8s/base/kustomization.yaml index 5626a54..4388eb7 100644 --- a/deployments/k8s/base/kustomization.yaml +++ b/deployments/k8s/base/kustomization.yaml @@ -5,9 +5,23 @@ namespace: rdev resources: - namespace.yaml + + # v0.1 - Generic claudebox (for testing/dev) - pvc.yaml - claudebox.yaml + # v0.2 - Project-specific claudeboxes + - pvc-pantheon.yaml + - pvc-aeries.yaml + - configmaps.yaml + - secrets.yaml + - claudebox-pantheon.yaml + - claudebox-aeries.yaml + + # v0.4 - API Server + - rbac.yaml + - rdev-api.yaml + commonLabels: app.kubernetes.io/managed-by: kustomize app.kubernetes.io/part-of: rdev diff --git a/deployments/k8s/base/pvc-aeries.yaml b/deployments/k8s/base/pvc-aeries.yaml new file mode 100644 index 0000000..d23fe06 --- /dev/null +++ b/deployments/k8s/base/pvc-aeries.yaml @@ -0,0 +1,36 @@ +# PVCs for claudebox-aeries +# v0.2 - Real workspace storage + +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: claudebox-aeries-workspace + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-aeries + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: aeries +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 20Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: claudebox-aeries-claude-config + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-aeries + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: aeries +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi diff --git a/deployments/k8s/base/pvc-pantheon.yaml b/deployments/k8s/base/pvc-pantheon.yaml new file mode 100644 index 0000000..03fb8b5 --- /dev/null +++ b/deployments/k8s/base/pvc-pantheon.yaml @@ -0,0 +1,36 @@ +# PVCs for claudebox-pantheon +# v0.2 - Real workspace storage + +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: claudebox-pantheon-workspace + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-pantheon + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: pantheon +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 20Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: claudebox-pantheon-claude-config + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-pantheon + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: pantheon +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi diff --git a/deployments/k8s/base/rbac.yaml b/deployments/k8s/base/rbac.yaml new file mode 100644 index 0000000..0900d03 --- /dev/null +++ b/deployments/k8s/base/rbac.yaml @@ -0,0 +1,52 @@ +# RBAC for rdev-api to exec into claudebox pods +# v0.4 - API Server + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: rdev-api + namespace: rdev + labels: + app.kubernetes.io/name: rdev-api + app.kubernetes.io/part-of: rdev +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: rdev-api + namespace: rdev + labels: + app.kubernetes.io/name: rdev-api + app.kubernetes.io/part-of: rdev +rules: + # List and get pods (for project discovery and status) + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + + # Execute commands in pods + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create"] + + # Read pod logs (for debugging) + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: rdev-api + namespace: rdev + labels: + app.kubernetes.io/name: rdev-api + app.kubernetes.io/part-of: rdev +subjects: + - kind: ServiceAccount + name: rdev-api + namespace: rdev +roleRef: + kind: Role + name: rdev-api + apiGroup: rbac.authorization.k8s.io diff --git a/deployments/k8s/base/rdev-api.yaml b/deployments/k8s/base/rdev-api.yaml new file mode 100644 index 0000000..be8f462 --- /dev/null +++ b/deployments/k8s/base/rdev-api.yaml @@ -0,0 +1,81 @@ +# rdev-api - Go REST API for controlling claudebox pods +# v0.4 - API Server + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rdev-api + namespace: rdev + labels: + app.kubernetes.io/name: rdev-api + app.kubernetes.io/part-of: rdev +spec: + replicas: 1 + selector: + matchLabels: + app: rdev-api + template: + metadata: + labels: + app: rdev-api + app.kubernetes.io/name: rdev-api + app.kubernetes.io/part-of: rdev + spec: + serviceAccountName: rdev-api + containers: + - name: rdev-api + image: ghcr.io/orchard9/rdev-api:v0.4.0 + imagePullPolicy: Always + + ports: + - containerPort: 8080 + name: http + + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "256Mi" + + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 30 + + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + + env: + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + + imagePullSecrets: + - name: ghcr-secret +--- +# Service for rdev-api +apiVersion: v1 +kind: Service +metadata: + name: rdev-api + namespace: rdev + labels: + app.kubernetes.io/name: rdev-api + app.kubernetes.io/part-of: rdev +spec: + type: ClusterIP + selector: + app: rdev-api + ports: + - port: 8080 + targetPort: http + name: http diff --git a/deployments/k8s/base/secrets.yaml b/deployments/k8s/base/secrets.yaml new file mode 100644 index 0000000..7ce5493 --- /dev/null +++ b/deployments/k8s/base/secrets.yaml @@ -0,0 +1,50 @@ +# GitHub Deploy Key Secrets for rdev +# v0.2 - SSH keys for repo cloning +# +# INSTRUCTIONS: +# 1. Generate deploy keys: ./scripts/generate-deploy-key.sh pantheon +# 2. Add PUBLIC key to GitHub repo Settings -> Deploy Keys +# 3. Replace placeholder values below with base64-encoded PRIVATE key +# 4. Apply: kubectl apply -f secrets.yaml +# +# To encode: cat pantheon-deploy-key | base64 -w0 +# To decode and verify: echo "" | base64 -d + +apiVersion: v1 +kind: Secret +metadata: + name: github-deploy-key-pantheon + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-pantheon + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: pantheon +type: Opaque +data: + # Replace with base64-encoded private key + # Generate with: ssh-keygen -t ed25519 -f pantheon-deploy-key -N "" + # Encode with: cat pantheon-deploy-key | base64 -w0 + id_ed25519: REPLACE_WITH_BASE64_ENCODED_PRIVATE_KEY + + # GitHub's SSH host key (pre-populated) + # ssh-keyscan github.com 2>/dev/null | base64 -w0 + known_hosts: Z2l0aHViLmNvbSBzc2gtZWQyNTUxOSBBQUFBQzNOemFDMWxaREkxTlRFNUFBQUFJT01xcW5rVnpybTBTZEc2VU9vcUtMc2FiZ0g1Qzlva1dpMGRoMmw5R0tKbApnaXRodWIuY29tIGVjZHNhLXNoYTItbmlzdHAyNTYgQUFBQUUyVmpaSE5oTFhOb1lUSXRibWx6ZEhBeU5UWUFBQUFJYm1semRIQXlOVFlBQUFCQkJFbUtTRU5qUUVlek9teGtaTXk3b3BLZ3dGQjlua3Q1WVJyWU1qTnVHNU44N3VSUW81dDRRYkZGelVaYUpVQjd4TmtjYVFTNmlIbW5TazdNOU9tZUR2PT0KZ2l0aHViLmNvbSBzc2gtcnNhIEFBQUFCM056YUMxeWMyRUFBQUFEQVFBQkFBQUJnUUNqN25kTnhRb3dnY1FuanNoY0xycVBFaWlwaG50K1ZUVHZEUCtsSFhaZFhMRThWVUxDS0lLYjloZk5qM0FXSm1RTHBDb0Qzc1F2TWtGNUxXR1RMSFRVM25MSjViZi8wbG5wOGV5ZXhVNkpzR1dSUUFLTnlENjkzQjVVR2xXVlM1VjFqUEg1M3BZVllWUVB6WnlkeGpPUVFLeHk5ZkdoaVFGbGcza3RoZFdSRE5oNy9SRHp4SEZEZmRYYm5uSnZ4WVQ0Y1FVWWJ0SmFTQ0pWcU9aOVlUbG13bTJBUXZaM3IxZEJkZzVRcWN1SW53bzR1NXBhQUpObnpiTXBudGtzVXpWNEorUFN5OE9LSzRPc0tUc0I0RlNjS0VOSmRlMTlYTGFCUHJiNTZpUHhCS0tSMGJNK2NPdnhKelhhZWJORktjR2k4eVJLaGw0T0hlYkhCWDh4eFpZNWMwdWdpcTlSb29QaUtPelJERE1lekdhK0c4MDg1OVF2TkdPK3pZM3RNeHJIM1crT21uYU5keVN6dkpPUktjZEEwejNGU1huUk5jbnZpVlg0c3lGaWdhOUxGZjZ0ZDBhRy8xUFEwVjRCYzFQNXNHdTZBQUFBZUg0em5YNStNNTErUUpWZGorR2NMdTMwcE91U0E1cVZOQ0FodXl6RklBWWlhbjBFWUlnUlE3TmxYdz0K +--- +apiVersion: v1 +kind: Secret +metadata: + name: github-deploy-key-aeries + namespace: rdev + labels: + app.kubernetes.io/name: claudebox-aeries + app.kubernetes.io/part-of: rdev + rdev.orchard9.ai/project: aeries +type: Opaque +data: + # Replace with base64-encoded private key + # Generate with: ssh-keygen -t ed25519 -f aeries-deploy-key -N "" + # Encode with: cat aeries-deploy-key | base64 -w0 + id_ed25519: REPLACE_WITH_BASE64_ENCODED_PRIVATE_KEY + + # GitHub's SSH host key (pre-populated) + known_hosts: Z2l0aHViLmNvbSBzc2gtZWQyNTUxOSBBQUFBQzNOemFDMWxaREkxTlRFNUFBQUFJT01xcW5rVnpybTBTZEc2VU9vcUtMc2FiZ0g1Qzlva1dpMGRoMmw5R0tKbApnaXRodWIuY29tIGVjZHNhLXNoYTItbmlzdHAyNTYgQUFBQUUyVmpaSE5oTFhOb1lUSXRibWx6ZEhBeU5UWUFBQUFJYm1semRIQXlOVFlBQUFCQkJFbUtTRU5qUUVlek9teGtaTXk3b3BLZ3dGQjlua3Q1WVJyWU1qTnVHNU44N3VSUW81dDRRYkZGelVaYUpVQjd4TmtjYVFTNmlIbW5TazdNOU9tZUR2PT0KZ2l0aHViLmNvbSBzc2gtcnNhIEFBQUFCM056YUMxeWMyRUFBQUFEQVFBQkFBQUJnUUNqN25kTnhRb3dnY1FuanNoY0xycVBFaWlwaG50K1ZUVHZEUCtsSFhaZFhMRThWVUxDS0lLYjloZk5qM0FXSm1RTHBDb0Qzc1F2TWtGNUxXR1RMSFRVM25MSjViZi8wbG5wOGV5ZXhVNkpzR1dSUUFLTnlENjkzQjVVR2xXVlM1VjFqUEg1M3BZVllWUVB6WnlkeGpPUVFLeHk5ZkdoaVFGbGcza3RoZFdSRE5oNy9SRHp4SEZEZmRYYm5uSnZ4WVQ0Y1FVWWJ0SmFTQ0pWcU9aOVlUbG13bTJBUXZaM3IxZEJkZzVRcWN1SW53bzR1NXBhQUpObnpiTXBudGtzVXpWNEorUFN5OE9LSzRPc0tUc0I0RlNjS0VOSmRlMTlYTGFCUHJiNTZpUHhCS0tSMGJNK2NPdnhKelhhZWJORktjR2k4eVJLaGw0T0hlYkhCWDh4eFpZNWMwdWdpcTlSb29QaUtPelJERE1lekdhK0c4MDg1OVF2TkdPK3pZM3RNeHJIM1crT21uYU5keVN6dkpPUktjZEEwejNGU1huUk5jbnZpVlg0c3lGaWdhOUxGZjZ0ZDBhRy8xUFEwVjRCYzFQNXNHdTZBQUFBZUg0em5YNStNNTErUUpWZGorR2NMdTMwcE91U0E1cVZOQ0FodXl6RklBWWlhbjBFWUlnUlE3TmxYdz0K diff --git a/history/v0.2.0.md b/history/v0.2.0.md new file mode 100644 index 0000000..8c551a4 --- /dev/null +++ b/history/v0.2.0.md @@ -0,0 +1,212 @@ +# rdev v0.2.0 - Real Workspaces + +**Date**: 2026-01-24 +**Status**: Ready for deployment (pending deploy keys) + +## Summary + +Project-specific claudebox pods with real GitHub repository workspaces. Each project gets its own StatefulSet with an init container that clones the repo on first start. + +## What Was Built + +### Project-Specific StatefulSets + +Two new claudebox deployments: +- `claudebox-pantheon` - For the pantheon project +- `claudebox-aeries` - For the aeries project + +Each has: +- Init container that clones repo via SSH +- Persistent workspace (20Gi) +- Persistent Claude config (1Gi) +- SSH deploy keys for GitHub access +- Project-specific CLAUDE.md via ConfigMap + +### Init Container Logic + +```bash +# Setup SSH +mkdir -p /root/.ssh +cp /ssh-keys/id_ed25519 /root/.ssh/id_ed25519 +chmod 600 /root/.ssh/id_ed25519 + +# Clone or fetch +if [ ! -d /workspace/.git ]; then + git clone git@github.com:orchard9/${PROJECT}.git /workspace +else + cd /workspace && git fetch origin +fi +``` + +### Files Created + +``` +deployments/k8s/base/ +├── claudebox-pantheon.yaml # Pantheon StatefulSet +├── claudebox-aeries.yaml # Aeries StatefulSet +├── pvc-pantheon.yaml # Pantheon PVCs +├── pvc-aeries.yaml # Aeries PVCs +├── configmaps.yaml # Project CLAUDE.md files +└── secrets.yaml # Deploy key secrets (template) + +scripts/ +└── generate-deploy-key.sh # Deploy key generation helper +``` + +### Kubernetes Resources + +| Resource | Type | Project | +|----------|------|---------| +| claudebox-pantheon | StatefulSet | pantheon | +| claudebox-aeries | StatefulSet | aeries | +| claudebox-pantheon-workspace | PVC (20Gi) | pantheon | +| claudebox-pantheon-claude-config | PVC (1Gi) | pantheon | +| claudebox-aeries-workspace | PVC (20Gi) | aeries | +| claudebox-aeries-claude-config | PVC (1Gi) | aeries | +| claudebox-pantheon-config | ConfigMap | pantheon | +| claudebox-aeries-config | ConfigMap | aeries | +| github-deploy-key-pantheon | Secret | pantheon | +| github-deploy-key-aeries | Secret | aeries | + +## Deployment Instructions + +### 1. Generate Deploy Keys + +```bash +cd /path/to/rdev + +# Generate key for pantheon +./scripts/generate-deploy-key.sh pantheon + +# Generate key for aeries +./scripts/generate-deploy-key.sh aeries +``` + +### 2. Add Public Keys to GitHub + +For each project: +1. Go to `https://github.com/orchard9//settings/keys` +2. Click "Add deploy key" +3. Title: `rdev-` +4. Paste contents of `-deploy-key.pub` +5. Check "Allow write access" (needed for v0.3) + +### 3. Update Secrets + +Edit `deployments/k8s/base/secrets.yaml`: +- Replace `REPLACE_WITH_BASE64_ENCODED_PRIVATE_KEY` with contents of `-deploy-key.b64` + +### 4. Deploy + +```bash +export KUBECONFIG=~/.kube/orchard9-k3sf.yaml +kubectl apply -k deployments/k8s/base +``` + +### 5. Verify + +```bash +# Check pods +kubectl get pods -n rdev +# Should show: claudebox-pantheon-0, claudebox-aeries-0 + +# Check init container logs +kubectl logs -n rdev claudebox-pantheon-0 -c git-clone + +# Verify repo was cloned +kubectl exec -n rdev claudebox-pantheon-0 -- ls /workspace +kubectl exec -n rdev claudebox-pantheon-0 -- git -C /workspace status + +# Authenticate Claude (first time only) +kubectl exec -it -n rdev claudebox-pantheon-0 -- claude +``` + +## Key Decisions + +### 1. Reuse claudebox image for init container +- Same image has git installed +- No need for separate alpine/git image +- Simpler to maintain + +### 2. SSH Deploy Keys vs HTTPS PAT +- SSH deploy keys are per-repo (more secure) +- Aligns with v0.3 requirements (push access) +- GitHub deploy keys can have write access + +### 3. ConfigMap for CLAUDE.md +- Allows customizing Claude behavior per project +- Mounted at /workspace/CLAUDE.md +- Can be updated without rebuilding image + +### 4. Keep generic claudebox from v0.1 +- Useful for testing/development +- No project-specific config required +- Can be used as template + +## Architecture + +``` +rdev namespace +├── claudebox-0 (v0.1 - generic) +│ └── /workspace (empty, for testing) +│ +├── claudebox-pantheon-0 (v0.2) +│ ├── /workspace (pantheon repo) +│ ├── /root/.claude (Claude auth) +│ └── /root/.ssh (deploy key) +│ +└── claudebox-aeries-0 (v0.2) + ├── /workspace (aeries repo) + ├── /root/.claude (Claude auth) + └── /root/.ssh (deploy key) +``` + +## What's Next (v0.3) + +1. Git config (user.name, user.email) for commits +2. Test git push from inside pod +3. Proper SSH key mounting for main container (currently init-only) + +## Commands Reference + +```bash +# Set kubeconfig +export KUBECONFIG=~/.kube/orchard9-k3sf.yaml + +# Deploy +kubectl apply -k deployments/k8s/base + +# Check status +kubectl get pods -n rdev +kubectl get pvc -n rdev + +# View init container logs +kubectl logs -n rdev claudebox-pantheon-0 -c git-clone + +# Interactive Claude session +kubectl exec -it -n rdev claudebox-pantheon-0 -- claude + +# Run Claude with prompt +kubectl exec -it -n rdev claudebox-pantheon-0 -- claude "what files are in this project?" + +# Git status +kubectl exec -n rdev claudebox-pantheon-0 -- git -C /workspace status + +# Shell access +kubectl exec -it -n rdev claudebox-pantheon-0 -- bash +``` + +## Troubleshooting + +### Init container fails with "Permission denied (publickey)" +- Verify deploy key is added to GitHub +- Check secret has correct base64-encoded private key +- Ensure known_hosts includes github.com + +### Workspace is empty after pod starts +- Check init container logs: `kubectl logs -n rdev -c git-clone` +- Verify SSH key permissions (should be 600) + +### Claude auth not persisting +- Check PVC is bound: `kubectl get pvc -n rdev` +- Verify claude-config volume is mounted at /root/.claude diff --git a/history/v0.3.0.md b/history/v0.3.0.md new file mode 100644 index 0000000..6bc896a --- /dev/null +++ b/history/v0.3.0.md @@ -0,0 +1,180 @@ +# rdev v0.3.0 - Git Integration + +**Date**: 2026-01-24 +**Status**: Ready for deployment + +## Summary + +Full git integration - claudebox pods can now commit and push changes back to GitHub. The container image includes git config for the rdev-bot identity, and SSH keys are mounted for authenticated operations. + +## What Was Built + +### Updated Dockerfile + +Added to the claudebox image: +- `openssh-client` package for SSH operations +- Git global config for rdev-bot identity +- Pre-created `/root/.ssh` directory with correct permissions +- `push.autoSetupRemote` for easier branch pushing + +```dockerfile +# Configure git for rdev-bot identity +RUN git config --global user.name "rdev-bot" \ + && git config --global user.email "rdev@orchard9.ai" \ + && git config --global init.defaultBranch main \ + && git config --global push.autoSetupRemote true + +# Create SSH directory with correct permissions +RUN mkdir -p /root/.ssh && chmod 700 /root/.ssh +``` + +### Image Version Bump + +All StatefulSets updated to use `v0.3.0`: +- `claudebox.yaml` +- `claudebox-pantheon.yaml` +- `claudebox-aeries.yaml` + +## Changes from v0.2 + +| Component | v0.2 | v0.3 | +|-----------|------|------| +| Image | v0.1.0 | v0.3.0 | +| Git config | None | rdev-bot identity | +| SSH client | Not installed | Installed | +| Push capability | Clone only | Full read/write | + +## Git Identity + +All commits from rdev claudeboxes will be attributed to: +- **Name**: rdev-bot +- **Email**: rdev@orchard9.ai + +Example commit: +``` +commit abc123... +Author: rdev-bot +Date: Fri Jan 24 2026 + + Fix authentication bug in handler + + Co-Authored-By: Claude +``` + +## Deployment Instructions + +### 1. Build and Push New Image + +```bash +cd /path/to/rdev + +# Build and push v0.3.0 +./scripts/build-push.sh v0.3.0 +``` + +### 2. Complete v0.2 Setup (if not done) + +Ensure deploy keys are configured: +```bash +# Generate keys +./scripts/generate-deploy-key.sh pantheon +./scripts/generate-deploy-key.sh aeries + +# Add public keys to GitHub (with write access!) +# Update secrets.yaml with base64-encoded private keys +``` + +### 3. Deploy + +```bash +export KUBECONFIG=~/.kube/orchard9-k3sf.yaml +kubectl apply -k deployments/k8s/base + +# Restart pods to pick up new image +kubectl rollout restart statefulset -n rdev claudebox +kubectl rollout restart statefulset -n rdev claudebox-pantheon +kubectl rollout restart statefulset -n rdev claudebox-aeries +``` + +### 4. Verify Git Push + +```bash +# Test push capability +kubectl exec -n rdev claudebox-pantheon-0 -- bash -c " + cd /workspace + git checkout -b rdev-test-push + echo '# Test from rdev' >> README.md + git add README.md + git commit -m 'test: verify rdev push capability' + git push origin rdev-test-push +" + +# Clean up test branch +kubectl exec -n rdev claudebox-pantheon-0 -- bash -c " + cd /workspace + git checkout main + git branch -D rdev-test-push +" +# Also delete the remote branch via GitHub UI or gh cli +``` + +## Verification Checklist + +```bash +# 1. Check git config in container +kubectl exec -n rdev claudebox-pantheon-0 -- git config --global --list +# Should show: +# user.name=rdev-bot +# user.email=rdev@orchard9.ai +# init.defaultbranch=main +# push.autosetupremote=true + +# 2. Verify SSH access to GitHub +kubectl exec -n rdev claudebox-pantheon-0 -- ssh -T git@github.com +# Should show: Hi orchard9/pantheon! You've successfully authenticated... + +# 3. Test fetch +kubectl exec -n rdev claudebox-pantheon-0 -- bash -c "cd /workspace && git fetch origin" + +# 4. Test commit (local) +kubectl exec -n rdev claudebox-pantheon-0 -- bash -c " + cd /workspace + echo 'test' >> /tmp/test.txt + git add /tmp/test.txt 2>/dev/null || echo 'File outside repo - expected' +" + +# 5. Test push (creates branch, then clean up) +# See verification section above +``` + +## What's Next (v0.4) + +Go API server for controlling claudebox pods: +- REST endpoints for claude, shell, git commands +- SSE streaming for output +- Kubernetes RBAC for pod exec +- Project registry + +## Files Modified + +``` +Dockerfile # Added git config, openssh-client +deployments/k8s/base/claudebox.yaml # Image v0.1.0 → v0.3.0 +deployments/k8s/base/claudebox-pantheon.yaml # Image v0.1.0 → v0.3.0 +deployments/k8s/base/claudebox-aeries.yaml # Image v0.1.0 → v0.3.0 +``` + +## Troubleshooting + +### Push fails with "Permission denied" +- Ensure deploy key has "Allow write access" checked in GitHub +- Verify SSH key is correctly mounted: `ls -la /root/.ssh/` +- Test SSH: `ssh -vT git@github.com` + +### Commits show wrong author +- Check git config: `git config --global --list` +- Image might be old: verify `v0.3.0` is running + +### "Host key verification failed" +- Ensure known_hosts is mounted correctly +- Check secret contains github.com host keys diff --git a/history/v0.4.0.md b/history/v0.4.0.md new file mode 100644 index 0000000..b287388 --- /dev/null +++ b/history/v0.4.0.md @@ -0,0 +1,259 @@ +# rdev v0.4.0 - API Server + +**Date**: 2026-01-24 +**Status**: Ready for deployment + +## Summary + +Go REST API server for controlling claudebox pods. External clients (Discord bots, CLI tools, etc.) can now interact with Claude Code via HTTP endpoints with SSE streaming for real-time output. + +## What Was Built + +### Go API Server (`rdev-api`) + +A chi-based HTTP server with: +- Project discovery and status +- Command execution (claude, shell, git) +- SSE streaming for real-time output +- OpenAPI documentation via Scalar + +### API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Health check | +| GET | `/ready` | Readiness check | +| GET | `/docs` | Scalar API documentation | +| GET | `/openapi.json` | OpenAPI 3.0 specification | +| GET | `/projects` | List available projects | +| GET | `/projects/{id}` | Get project details | +| POST | `/projects/{id}/claude` | Run Claude command | +| POST | `/projects/{id}/shell` | Run shell command | +| POST | `/projects/{id}/git` | Run git command | +| GET | `/projects/{id}/events` | SSE stream for output | + +### Packages Created + +``` +cmd/rdev-api/ +└── main.go # Entry point, OpenAPI spec + +pkg/api/ +├── app.go # HTTP server chassis +├── response.go # JSON response helpers +└── openapi.go # OpenAPI spec builder + +internal/ +├── handlers/ +│ └── projects.go # HTTP handlers + SSE streaming +├── executor/ +│ └── executor.go # kubectl exec wrapper +└── projects/ + └── registry.go # Project discovery +``` + +### Kubernetes Resources + +- **Deployment**: `rdev-api` - Single replica +- **Service**: `rdev-api` - ClusterIP on port 8080 +- **ServiceAccount**: `rdev-api` +- **Role/RoleBinding**: Permissions for pod exec + +### RBAC Permissions + +```yaml +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create"] + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] +``` + +## Files Created + +``` +Dockerfile.api # API server image +cmd/rdev-api/main.go # Entry point +pkg/api/app.go # HTTP chassis +pkg/api/response.go # Response helpers +pkg/api/openapi.go # OpenAPI builder +internal/handlers/projects.go # Handlers + SSE +internal/executor/executor.go # kubectl exec +internal/projects/registry.go # Project registry +deployments/k8s/base/rdev-api.yaml # Deployment + Service +deployments/k8s/base/rbac.yaml # RBAC +go.mod, go.sum # Go modules +``` + +## Dependencies + +``` +github.com/go-chi/chi/v5 # HTTP router +github.com/bdpiprava/scalar-go # API documentation +``` + +## Deployment Instructions + +### 1. Build Images + +```bash +cd /path/to/rdev + +# Build both claudebox and api images +./scripts/build-push.sh v0.4.0 + +# Or just the api +./scripts/build-push.sh v0.4.0 api +``` + +### 2. Complete Prerequisites + +Ensure v0.2 and v0.3 are deployed: +- Deploy keys configured for projects +- Secrets updated with base64 private keys +- claudebox image v0.3.0 with git config + +### 3. Deploy + +```bash +export KUBECONFIG=~/.kube/orchard9-k3sf.yaml +kubectl apply -k deployments/k8s/base +``` + +### 4. Verify + +```bash +# Check API is running +kubectl get pods -n rdev -l app=rdev-api + +# Port forward for testing +kubectl port-forward -n rdev svc/rdev-api 8080:8080 + +# Test health endpoint +curl http://localhost:8080/health + +# Test projects list +curl http://localhost:8080/projects + +# Test Claude command +curl -X POST http://localhost:8080/projects/pantheon/claude \ + -H "Content-Type: application/json" \ + -d '{"prompt": "what files are in this project?"}' + +# View API docs +open http://localhost:8080/docs +``` + +## Usage Examples + +### Run Claude Command + +```bash +# Start command +curl -X POST http://rdev-api.rdev.svc:8080/projects/pantheon/claude \ + -H "Content-Type: application/json" \ + -d '{"prompt": "fix the bug in auth handler"}' + +# Response +{ + "data": { + "id": "cmd-pantheon-001", + "project": "pantheon", + "type": "claude", + "status": "running", + "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-001" + }, + "meta": {"request_id": "...", "timestamp": "..."} +} +``` + +### Stream Output (SSE) + +```javascript +const events = new EventSource( + 'http://rdev-api.rdev.svc:8080/projects/pantheon/events?stream_id=cmd-pantheon-001' +); + +events.addEventListener('output', (e) => { + const data = JSON.parse(e.data); + console.log(`[${data.stream}] ${data.line}`); +}); + +events.addEventListener('complete', (e) => { + const data = JSON.parse(e.data); + console.log(`Done: exit=${data.exit_code}, ${data.duration_ms}ms`); + events.close(); +}); +``` + +### Run Shell Command + +```bash +curl -X POST http://rdev-api.rdev.svc:8080/projects/pantheon/shell \ + -H "Content-Type: application/json" \ + -d '{"command": "go test ./..."}' +``` + +### Run Git Command + +```bash +curl -X POST http://rdev-api.rdev.svc:8080/projects/pantheon/git \ + -H "Content-Type: application/json" \ + -d '{"args": ["status"]}' +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ rdev namespace │ +│ │ +│ ┌──────────────┐ │ +│ │ rdev-api │ │ +│ │ (Go server) │ │ +│ │ │ │ +│ │ /projects │ kubectl exec │ +│ │ /claude │─────────────────┐ │ +│ │ /shell │ │ │ +│ │ /git │ ▼ │ +│ │ /events │ ┌──────────────────────────────────────┐ │ +│ └──────────────┘ │ claudebox pods │ │ +│ │ │ │ +│ │ ┌────────────────┐ ┌──────────────┐ │ │ +│ │ │claudebox- │ │claudebox- │ │ │ +│ │ │pantheon-0 │ │aeries-0 │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ Claude Code │ │ Claude Code │ │ │ +│ │ │ /workspace │ │ /workspace │ │ │ +│ │ └────────────────┘ └──────────────┘ │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## What's Next (v0.5) + +Enhanced SSE streaming: +- Output buffering and chunking +- Connection management (heartbeats, reconnection) +- Event filtering by stream_id +- Better error handling + +## Troubleshooting + +### API returns 404 for project +- Check project registry includes the project +- Verify claudebox pod exists: `kubectl get pods -n rdev` + +### Commands fail with "permission denied" +- Check RBAC is applied: `kubectl get role rdev-api -n rdev` +- Verify ServiceAccount is bound: `kubectl get rolebinding rdev-api -n rdev` + +### SSE not receiving events +- Ensure stream_id matches the command ID +- Check for Connection: keep-alive in client +- Verify no proxy is buffering responses diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..3cb64e9 --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,183 @@ +// Package executor provides kubectl exec functionality for running commands in pods. +package executor + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "sync" + "time" +) + +// Executor runs commands in Kubernetes pods via kubectl exec. +type Executor struct { + namespace string + mu sync.RWMutex +} + +// New creates a new Executor for the given namespace. +func New(namespace string) *Executor { + return &Executor{ + namespace: namespace, + } +} + +// CommandType represents the type of command being executed. +type CommandType string + +const ( + CommandTypeClaude CommandType = "claude" + CommandTypeShell CommandType = "shell" + CommandTypeGit CommandType = "git" +) + +// Command represents a command to execute in a pod. +type Command struct { + ID string + PodName string + Type CommandType + Args []string + StartedAt time.Time +} + +// Result represents the result of command execution. +type Result struct { + ExitCode int + DurationMs int64 + Error error +} + +// OutputHandler is called for each line of output from the command. +type OutputHandler func(stream string, line string) + +// Exec executes a command in the specified pod. +// It streams output to the provided handler and returns when complete. +func (e *Executor) Exec(ctx context.Context, cmd *Command, handler OutputHandler) Result { + e.mu.RLock() + namespace := e.namespace + e.mu.RUnlock() + + startTime := time.Now() + var args []string + + switch cmd.Type { + case CommandTypeClaude: + // claude "prompt" + args = []string{ + "exec", "-n", namespace, cmd.PodName, "--", + "claude", cmd.Args[0], // prompt is first arg + } + case CommandTypeShell: + // bash -c "command" + args = []string{ + "exec", "-n", namespace, cmd.PodName, "--", + "bash", "-c", cmd.Args[0], // command is first arg + } + case CommandTypeGit: + // git + args = append([]string{ + "exec", "-n", namespace, cmd.PodName, "--", + "git", "-C", "/workspace", + }, cmd.Args...) + default: + return Result{ + ExitCode: 1, + Error: fmt.Errorf("unknown command type: %s", cmd.Type), + } + } + + // Create the kubectl command + kubectl := exec.CommandContext(ctx, "kubectl", args...) + + // Get stdout and stderr pipes + stdout, err := kubectl.StdoutPipe() + if err != nil { + return Result{ExitCode: 1, Error: fmt.Errorf("stdout pipe: %w", err)} + } + stderr, err := kubectl.StderrPipe() + if err != nil { + return Result{ExitCode: 1, Error: fmt.Errorf("stderr pipe: %w", err)} + } + + // Start the command + if err := kubectl.Start(); err != nil { + return Result{ExitCode: 1, Error: fmt.Errorf("start: %w", err)} + } + + // Stream output concurrently + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + streamOutput(stdout, "stdout", handler) + }() + + go func() { + defer wg.Done() + streamOutput(stderr, "stderr", handler) + }() + + // Wait for output to be consumed + wg.Wait() + + // Wait for command to complete + err = kubectl.Wait() + duration := time.Since(startTime) + + result := Result{ + DurationMs: duration.Milliseconds(), + } + + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitError.ExitCode() + } else { + result.ExitCode = 1 + result.Error = err + } + } + + return result +} + +// streamOutput reads from a reader and sends each line to the handler. +func streamOutput(r io.Reader, stream string, handler OutputHandler) { + scanner := bufio.NewScanner(r) + // Increase buffer size for long lines + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + handler(stream, scanner.Text()) + } +} + +// CheckConnection verifies kubectl can connect to the cluster. +func (e *Executor) CheckConnection(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "kubectl", "cluster-info", "--request-timeout=5s") + return cmd.Run() +} + +// PodExists checks if a pod exists and is running. +func (e *Executor) PodExists(ctx context.Context, podName string) (bool, error) { + e.mu.RLock() + namespace := e.namespace + e.mu.RUnlock() + + cmd := exec.CommandContext(ctx, "kubectl", + "get", "pod", podName, + "-n", namespace, + "-o", "jsonpath={.status.phase}", + ) + + output, err := cmd.Output() + if err != nil { + // Pod doesn't exist or error + return false, nil + } + + return string(output) == "Running", nil +} diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index d4660bc..8a92ef4 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -2,19 +2,35 @@ package handlers import ( + "context" "encoding/json" + "fmt" "net/http" + "sync" + "sync/atomic" + "time" "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/executor" + "github.com/orchard9/rdev/internal/projects" "github.com/orchard9/rdev/pkg/api" ) // ProjectsHandler handles project-related endpoints. -type ProjectsHandler struct{} +type ProjectsHandler struct { + registry *projects.Registry + executor *executor.Executor + streams *streamManager + cmdID atomic.Uint64 +} // NewProjectsHandler creates a new projects handler. func NewProjectsHandler() *ProjectsHandler { - return &ProjectsHandler{} + return &ProjectsHandler{ + registry: projects.NewRegistry("rdev"), + executor: executor.New("rdev"), + streams: newStreamManager(), + } } // Mount registers the projects routes. @@ -32,24 +48,12 @@ func (h *ProjectsHandler) Mount(r api.Router) { // List returns all available projects. // GET /projects func (h *ProjectsHandler) List(w http.ResponseWriter, r *http.Request) { - // TODO: Implement project discovery from K8s - projects := []map[string]any{ - { - "id": "pantheon", - "name": "Pantheon", - "description": "Go API backend", - "pod": "claudebox-pantheon-0", - "status": "running", - }, - { - "id": "aeries", - "name": "Aeries", - "description": "Note community platform", - "pod": "claudebox-aeries-0", - "status": "running", - }, - } + // Refresh status from K8s + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + h.registry.RefreshStatus(ctx) + projects := h.registry.List() api.WriteSuccess(w, r, projects) } @@ -58,87 +62,230 @@ func (h *ProjectsHandler) List(w http.ResponseWriter, r *http.Request) { func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - // TODO: Look up project from registry - project := map[string]any{ - "id": id, - "name": id, - "description": "Project description", - "pod": "claudebox-" + id + "-0", - "status": "running", - "workspace": "/workspace", - "config": map[string]any{ - "claude_auth": true, - "git_enabled": true, - "last_command": nil, - }, + project, ok := h.registry.Get(id) + if !ok { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return } + // Refresh this project's status + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + h.registry.RefreshStatus(ctx) + api.WriteSuccess(w, r, project) } +// ClaudeRequest is the request body for POST /projects/{id}/claude. +type ClaudeRequest struct { + Prompt string `json:"prompt"` + StreamID string `json:"stream_id,omitempty"` +} + // RunClaude executes a Claude command in the project's claudebox. // POST /projects/{id}/claude func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - // TODO: Parse request body for prompt - // TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- claude "{prompt}" + project, ok := h.registry.Get(id) + if !ok { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + + var req ClaudeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Prompt == "" { + api.WriteBadRequest(w, r, "prompt is required") + return + } + + // Generate command ID + cmdNum := h.cmdID.Add(1) + cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) + if req.StreamID != "" { + cmdID = req.StreamID + } + + // Create the command + cmd := &executor.Command{ + ID: cmdID, + PodName: project.PodName, + Type: executor.CommandTypeClaude, + Args: []string{req.Prompt}, + StartedAt: time.Now(), + } + + // Execute in background + go h.executeCommand(cmd) result := map[string]any{ - "id": "cmd-" + id + "-001", + "id": cmdID, "project": id, "type": "claude", - "status": "queued", - "stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-001", + "status": "running", + "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), } api.WriteCreated(w, r, result) } +// ShellRequest is the request body for POST /projects/{id}/shell. +type ShellRequest struct { + Command string `json:"command"` + StreamID string `json:"stream_id,omitempty"` +} + // RunShell executes a shell command in the project's claudebox. // POST /projects/{id}/shell func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - // TODO: Parse request body for command - // TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- bash -c "{command}" + project, ok := h.registry.Get(id) + if !ok { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + + var req ShellRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Command == "" { + api.WriteBadRequest(w, r, "command is required") + return + } + + // Generate command ID + cmdNum := h.cmdID.Add(1) + cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) + if req.StreamID != "" { + cmdID = req.StreamID + } + + // Create the command + cmd := &executor.Command{ + ID: cmdID, + PodName: project.PodName, + Type: executor.CommandTypeShell, + Args: []string{req.Command}, + StartedAt: time.Now(), + } + + // Execute in background + go h.executeCommand(cmd) result := map[string]any{ - "id": "cmd-" + id + "-002", + "id": cmdID, "project": id, "type": "shell", - "status": "queued", - "stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-002", + "status": "running", + "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), } api.WriteCreated(w, r, result) } +// GitRequest is the request body for POST /projects/{id}/git. +type GitRequest struct { + Args []string `json:"args"` + StreamID string `json:"stream_id,omitempty"` +} + // RunGit executes a git command in the project's claudebox. // POST /projects/{id}/git func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - // TODO: Parse request body for git command - // TODO: Execute kubectl exec -n rdev claudebox-{id}-0 -- git {args} + project, ok := h.registry.Get(id) + if !ok { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + + var req GitRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if len(req.Args) == 0 { + api.WriteBadRequest(w, r, "args is required") + return + } + + // Generate command ID + cmdNum := h.cmdID.Add(1) + cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) + if req.StreamID != "" { + cmdID = req.StreamID + } + + // Create the command + cmd := &executor.Command{ + ID: cmdID, + PodName: project.PodName, + Type: executor.CommandTypeGit, + Args: req.Args, + StartedAt: time.Now(), + } + + // Execute in background + go h.executeCommand(cmd) result := map[string]any{ - "id": "cmd-" + id + "-003", + "id": cmdID, "project": id, "type": "git", - "status": "queued", - "stream_url": "/projects/" + id + "/events?stream_id=cmd-" + id + "-003", + "status": "running", + "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), } api.WriteCreated(w, r, result) } +// executeCommand runs a command and streams output to subscribers. +func (h *ProjectsHandler) executeCommand(cmd *executor.Command) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + result := h.executor.Exec(ctx, cmd, func(stream, line string) { + h.streams.Send(cmd.ID, "output", map[string]any{ + "line": line, + "stream": stream, + }) + }) + + // Send completion event + h.streams.Send(cmd.ID, "complete", map[string]any{ + "exit_code": result.ExitCode, + "duration_ms": result.DurationMs, + }) + + // Clean up stream after a delay + go func() { + time.Sleep(30 * time.Second) + h.streams.Close(cmd.ID) + }() +} + // Events streams command output via Server-Sent Events. // GET /projects/{id}/events func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") streamID := r.URL.Query().Get("stream_id") + if !h.registry.Exists(id) { + api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) + return + } + // Set SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -151,8 +298,9 @@ func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) { return } - // TODO: Stream actual command output - // For now, send mock events + // Subscribe to events + events := h.streams.Subscribe(streamID) + defer h.streams.Unsubscribe(streamID, events) // Send initial connected event writeSSE(w, flusher, "connected", map[string]any{ @@ -160,33 +308,98 @@ func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) { "stream_id": streamID, }) - // Send mock output - writeSSE(w, flusher, "output", map[string]any{ - "line": "Starting command execution...", - "stream": "stdout", - }) + // Stream events until client disconnects or stream closes + ctx := r.Context() + heartbeat := time.NewTicker(30 * time.Second) + defer heartbeat.Stop() - writeSSE(w, flusher, "output", map[string]any{ - "line": "Command completed successfully.", - "stream": "stdout", - }) - - // Send complete event - writeSSE(w, flusher, "complete", map[string]any{ - "exit_code": 0, - "duration_ms": 1234, - }) + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + writeSSE(w, flusher, event.Type, event.Data) + if event.Type == "complete" { + return + } + case <-heartbeat.C: + writeSSE(w, flusher, "heartbeat", map[string]any{ + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + } + } } // writeSSE writes a Server-Sent Event. func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) { - dataBytes, _ := jsonMarshal(data) - w.Write([]byte("event: " + event + "\n")) - w.Write([]byte("data: " + string(dataBytes) + "\n\n")) + dataBytes, _ := json.Marshal(data) + fmt.Fprintf(w, "event: %s\n", event) + fmt.Fprintf(w, "data: %s\n\n", dataBytes) flusher.Flush() } -// jsonMarshal is a simple JSON marshal helper. -func jsonMarshal(v any) ([]byte, error) { - return json.Marshal(v) +// streamManager manages SSE event streams. +type streamManager struct { + mu sync.RWMutex + streams map[string][]chan streamEvent +} + +type streamEvent struct { + Type string + Data map[string]any +} + +func newStreamManager() *streamManager { + return &streamManager{ + streams: make(map[string][]chan streamEvent), + } +} + +func (sm *streamManager) Subscribe(streamID string) chan streamEvent { + sm.mu.Lock() + defer sm.mu.Unlock() + + ch := make(chan streamEvent, 100) + sm.streams[streamID] = append(sm.streams[streamID], ch) + return ch +} + +func (sm *streamManager) Unsubscribe(streamID string, ch chan streamEvent) { + sm.mu.Lock() + defer sm.mu.Unlock() + + channels := sm.streams[streamID] + for i, c := range channels { + if c == ch { + sm.streams[streamID] = append(channels[:i], channels[i+1:]...) + close(ch) + break + } + } +} + +func (sm *streamManager) Send(streamID, eventType string, data map[string]any) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + for _, ch := range sm.streams[streamID] { + select { + case ch <- streamEvent{Type: eventType, Data: data}: + default: + // Channel full, skip + } + } +} + +func (sm *streamManager) Close(streamID string) { + sm.mu.Lock() + defer sm.mu.Unlock() + + for _, ch := range sm.streams[streamID] { + close(ch) + } + delete(sm.streams, streamID) } diff --git a/internal/projects/registry.go b/internal/projects/registry.go new file mode 100644 index 0000000..87de455 --- /dev/null +++ b/internal/projects/registry.go @@ -0,0 +1,148 @@ +// Package projects provides a registry of claudebox projects. +package projects + +import ( + "context" + "fmt" + "os/exec" + "strings" + "sync" +) + +// Project represents a claudebox project. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + PodName string `json:"pod"` + Status string `json:"status"` + Workspace string `json:"workspace,omitempty"` +} + +// Registry manages the list of available projects. +type Registry struct { + namespace string + projects map[string]*Project + mu sync.RWMutex +} + +// NewRegistry creates a new project registry. +func NewRegistry(namespace string) *Registry { + r := &Registry{ + namespace: namespace, + projects: make(map[string]*Project), + } + + // Initialize with known projects + // In the future, this could discover projects from K8s labels + r.projects["pantheon"] = &Project{ + ID: "pantheon", + Name: "Pantheon", + Description: "Go API backend", + PodName: "claudebox-pantheon-0", + Status: "unknown", + Workspace: "/workspace", + } + r.projects["aeries"] = &Project{ + ID: "aeries", + Name: "Aeries", + Description: "Note community platform", + PodName: "claudebox-aeries-0", + Status: "unknown", + Workspace: "/workspace", + } + + return r +} + +// List returns all projects. +func (r *Registry) List() []*Project { + r.mu.RLock() + defer r.mu.RUnlock() + + projects := make([]*Project, 0, len(r.projects)) + for _, p := range r.projects { + projects = append(projects, p) + } + return projects +} + +// Get returns a project by ID. +func (r *Registry) Get(id string) (*Project, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + p, ok := r.projects[id] + return p, ok +} + +// Exists checks if a project exists. +func (r *Registry) Exists(id string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + _, ok := r.projects[id] + return ok +} + +// RefreshStatus updates the status of all projects from K8s. +func (r *Registry) RefreshStatus(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + + for _, p := range r.projects { + status, err := getPodStatus(ctx, r.namespace, p.PodName) + if err != nil { + p.Status = "error" + continue + } + p.Status = status + } + return nil +} + +// getPodStatus queries the status of a pod. +func getPodStatus(ctx context.Context, namespace, podName string) (string, error) { + cmd := exec.CommandContext(ctx, "kubectl", + "get", "pod", podName, + "-n", namespace, + "-o", "jsonpath={.status.phase}", + ) + + output, err := cmd.Output() + if err != nil { + // Check if pod doesn't exist + if strings.Contains(err.Error(), "not found") { + return "not_found", nil + } + return "unknown", fmt.Errorf("get pod status: %w", err) + } + + phase := strings.ToLower(strings.TrimSpace(string(output))) + switch phase { + case "running": + return "running", nil + case "pending": + return "pending", nil + case "succeeded": + return "completed", nil + case "failed": + return "failed", nil + default: + return phase, nil + } +} + +// Register adds a new project to the registry. +func (r *Registry) Register(p *Project) { + r.mu.Lock() + defer r.mu.Unlock() + r.projects[p.ID] = p +} + +// Unregister removes a project from the registry. +func (r *Registry) Unregister(id string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.projects, id) +} diff --git a/scripts/build-push.sh b/scripts/build-push.sh index 2b35f60..d5917bf 100755 --- a/scripts/build-push.sh +++ b/scripts/build-push.sh @@ -1,5 +1,11 @@ #!/bin/bash -# Build and push claudebox image to GitHub Container Registry +# Build and push rdev images to GitHub Container Registry +# +# Usage: +# ./build-push.sh # Build both images with 'latest' tag +# ./build-push.sh v0.4.0 # Build both images with version tag +# ./build-push.sh v0.4.0 claudebox # Build only claudebox image +# ./build-push.sh v0.4.0 api # Build only api image set -e @@ -8,31 +14,77 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" # Image configuration REGISTRY="ghcr.io/orchard9" -IMAGE_NAME="rdev-claudebox" VERSION="${1:-latest}" - -IMAGE_TAG="$REGISTRY/$IMAGE_NAME:$VERSION" - -echo "Building claudebox image..." -echo "Image: $IMAGE_TAG" -echo "" +TARGET="${2:-all}" cd "$PROJECT_ROOT" -# Build the image for linux/amd64 (k3s nodes are amd64) -docker build --platform linux/amd64 -t "$IMAGE_TAG" -t "$REGISTRY/$IMAGE_NAME:latest" . +build_claudebox() { + local IMAGE_TAG="$REGISTRY/rdev-claudebox:$VERSION" + echo "Building claudebox image..." + echo "Image: $IMAGE_TAG" + echo "" -echo "" -echo "Pushing to GitHub Container Registry..." + # Build the image for linux/amd64 (k3s nodes are amd64) + docker build --platform linux/amd64 \ + -t "$IMAGE_TAG" \ + -t "$REGISTRY/rdev-claudebox:latest" \ + -f Dockerfile \ + . -# Push both tags -docker push "$IMAGE_TAG" -docker push "$REGISTRY/$IMAGE_NAME:latest" + echo "" + echo "Pushing claudebox to GitHub Container Registry..." + docker push "$IMAGE_TAG" + docker push "$REGISTRY/rdev-claudebox:latest" + + echo "Pushed: $IMAGE_TAG" +} + +build_api() { + local IMAGE_TAG="$REGISTRY/rdev-api:$VERSION" + echo "Building rdev-api image..." + echo "Image: $IMAGE_TAG" + echo "" + + # Build the image for linux/amd64 + docker build --platform linux/amd64 \ + -t "$IMAGE_TAG" \ + -t "$REGISTRY/rdev-api:latest" \ + -f Dockerfile.api \ + . + + echo "" + echo "Pushing rdev-api to GitHub Container Registry..." + docker push "$IMAGE_TAG" + docker push "$REGISTRY/rdev-api:latest" + + echo "Pushed: $IMAGE_TAG" +} + +case "$TARGET" in + claudebox) + build_claudebox + ;; + api) + build_api + ;; + all) + build_claudebox + echo "" + echo "---" + echo "" + build_api + ;; + *) + echo "Unknown target: $TARGET" + echo "Usage: $0 [version] [claudebox|api|all]" + exit 1 + ;; +esac echo "" echo "Done!" echo "" -echo "Image pushed: $IMAGE_TAG" -echo "" echo "To deploy, run:" -echo " ./scripts/deploy.sh" +echo " export KUBECONFIG=~/.kube/orchard9-k3sf.yaml" +echo " kubectl apply -k deployments/k8s/base" diff --git a/scripts/generate-deploy-key.sh b/scripts/generate-deploy-key.sh new file mode 100755 index 0000000..2c9cc67 --- /dev/null +++ b/scripts/generate-deploy-key.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Generate SSH deploy key for a GitHub repository +# +# Usage: ./generate-deploy-key.sh +# Example: ./generate-deploy-key.sh pantheon +# +# This generates: +# - -deploy-key (private key) +# - -deploy-key.pub (public key - add to GitHub) +# - -deploy-key.b64 (base64 encoded for K8s secret) + +set -e + +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 pantheon" + exit 1 +fi + +PROJECT="$1" +KEY_FILE="${PROJECT}-deploy-key" + +echo "Generating deploy key for project: $PROJECT" +echo "" + +# Check if key already exists +if [ -f "$KEY_FILE" ]; then + echo "WARNING: Key file $KEY_FILE already exists!" + read -p "Overwrite? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 1 + fi +fi + +# Generate ED25519 key (no passphrase for automated use) +ssh-keygen -t ed25519 -f "$KEY_FILE" -N "" -C "rdev-${PROJECT}@orchard9.ai" + +# Create base64 encoded version for K8s secret +cat "$KEY_FILE" | base64 > "${KEY_FILE}.b64" + +echo "" +echo "=== Generated Files ===" +echo "" +echo "Private key: $KEY_FILE" +echo "Public key: ${KEY_FILE}.pub" +echo "Base64: ${KEY_FILE}.b64" +echo "" +echo "=== Next Steps ===" +echo "" +echo "1. Add the PUBLIC key to GitHub:" +echo " - Go to: https://github.com/orchard9/${PROJECT}/settings/keys" +echo " - Click 'Add deploy key'" +echo " - Title: rdev-${PROJECT}" +echo " - Key: (paste contents of ${KEY_FILE}.pub)" +echo " - Check 'Allow write access' if you need push capability" +echo "" +echo " Public key to copy:" +echo " ---" +cat "${KEY_FILE}.pub" +echo " ---" +echo "" +echo "2. Update the Kubernetes secret:" +echo " - Edit deployments/k8s/base/secrets.yaml" +echo " - Replace REPLACE_WITH_BASE64_ENCODED_PRIVATE_KEY for ${PROJECT}" +echo " - With contents of: ${KEY_FILE}.b64" +echo "" +echo " Base64 encoded private key:" +echo " ---" +cat "${KEY_FILE}.b64" +echo " ---" +echo "" +echo "3. Apply the secret:" +echo " export KUBECONFIG=~/.kube/orchard9-k3sf.yaml" +echo " kubectl apply -f deployments/k8s/base/secrets.yaml" +echo "" +echo "4. IMPORTANT: Keep the private key files secure!" +echo " - Do NOT commit them to git" +echo " - Store them securely or delete after updating K8s secret" +echo ""