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 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-01-24 21:07:00 -07:00
parent 4a042a8b71
commit 0960b17eb2
22 changed files with 2206 additions and 107 deletions

6
.gitignore vendored
View File

@ -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

View File

@ -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

50
Dockerfile.api Normal file
View File

@ -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"]

51
PLAN.md
View File

@ -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 <project>`
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**:

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>" | 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

212
history/v0.2.0.md Normal file
View File

@ -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/<project>/settings/keys`
2. Click "Add deploy key"
3. Title: `rdev-<project>`
4. Paste contents of `<project>-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 `<project>-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 <pod> -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

180
history/v0.3.0.md Normal file
View File

@ -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 <rdev@orchard9.ai>
Date: Fri Jan 24 2026
Fix authentication bug in handler
Co-Authored-By: Claude <noreply@anthropic.com>
```
## 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

259
history/v0.4.0.md Normal file
View File

@ -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

View File

@ -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...>
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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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"

81
scripts/generate-deploy-key.sh Executable file
View File

@ -0,0 +1,81 @@
#!/bin/bash
# Generate SSH deploy key for a GitHub repository
#
# Usage: ./generate-deploy-key.sh <project-name>
# Example: ./generate-deploy-key.sh pantheon
#
# This generates:
# - <project>-deploy-key (private key)
# - <project>-deploy-key.pub (public key - add to GitHub)
# - <project>-deploy-key.b64 (base64 encoded for K8s secret)
set -e
if [ -z "$1" ]; then
echo "Usage: $0 <project-name>"
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 ""