# Phase 3: Build & Deploy — Design Spec (Concretized)

**Date:** 2026-05-29
**Inherits:** master design spec `2026-05-29-project-manager-design.md` (sections 3, 6.3, 8)
**Status:** Approved (master spec already approved by user)

---

## Goal

Add the full Build → Push → Deploy pipeline. User clicks "Deploy" on a project card → PM enqueues a build job → BuildKit produces a Docker image → image pushed to self-hosted registry → SSH into target → `docker pull` + `docker compose up -d`. All progress streamed via SSE. Deploy history persisted.

## Architectural Pragmatism

Master spec calls for 3 LXCs (PM + Builder + Target). For Phase 3 initial rollout we collapse to **1 LXC = PM + Builder + first Target**:

- Registry runs as `registry:2` Docker container on PM LXC, port `:5000`.
- Docker daemon installed on PM LXC; PM uses local docker socket.
- First DeployTarget = `localhost` (`127.0.0.1`), SSH to itself for compose-up. Same workflow as remote target — just shorter network hop.
- When user is ready, they add a real target LXC via UI; same SSH + Docker flow applies. No code change needed.

This validates the full pipeline end-to-end without provisioning extra LXCs. The architecture stays multi-target ready.

## Stack Additions

- `registry:2` (self-hosted Docker registry on `:5000`)
- `redis:7-alpine` (BullMQ backend on `:6379`)
- `node-ssh` library (SSH wrapper)
- `bullmq` (queue)
- `dockerode` (Docker daemon RPC) — used only for `image build/push/tag` orchestration
- AES-256-GCM crypto via Node `node:crypto` (no extra dep)

## Data Model (additions only)

Append to `prisma/schema.prisma`:

```prisma
model DeployTarget {
  id          String    @id @default(cuid())
  name        String    @unique
  host        String    // hostname/IP — e.g. "127.0.0.1" or "192.168.40.30"
  sshPort     Int       @default(22)
  sshUser     String    @default("root")
  authMode    String    // "password" | "key"
  authBlob    String    // AES-256-GCM(JSON{password|privateKey})
  composePath String    @default("/opt/{name}/docker-compose.yml") // {name} = project.name
  notes       String?
  projects    Project[] @relation("ProjectDeployTarget")
  createdAt   DateTime  @default(now())
}

model DeployHistory {
  id          String    @id @default(cuid())
  projectId   String
  project     Project   @relation(fields: [projectId], references: [id], onDelete: Cascade)
  targetId    String?
  imageTag    String    // registry:5000/<project>:<version>-<commit_short>
  commitHash  String?
  status      String    // "queued" | "building" | "pushing" | "deploying" | "success" | "failed"
  startedAt   DateTime  @default(now())
  finishedAt  DateTime?
  errorMsg    String?
  logBlob     String?   // last 64KB of build/deploy stdout
  @@index([projectId, startedAt])
}
```

Modify `Project`:
```prisma
  deployTargetId   String?
  deployTarget     DeployTarget? @relation("ProjectDeployTarget", fields: [deployTargetId], references: [id])
  envVarsEncrypted String?    // AES-256-GCM(JSON kv map)
  composeSource    String     @default("repo") // "repo" | "template" | "inline"
  composeInline    String?
  deployedVersion  String?
  deployedAt       DateTime?
  deploys          DeployHistory[]
```

## New Module Boundaries

| Module | Responsibility |
|---|---|
| `src/lib/crypto/aes.ts` | encrypt/decrypt with `PM_ENCRYPTION_KEY` |
| `src/lib/ssh/client.ts` | `runRemote(target, cmd)` returns stdout/stderr/exit |
| `src/lib/docker/build.ts` | wrap `docker buildx` invocation; stream stdout to event bus |
| `src/lib/docker/push.ts` | `docker push <tag>` |
| `src/lib/queue/index.ts` | BullMQ queue/worker bootstrap |
| `src/worker/index.ts` | standalone Node process; consumes jobs |
| `src/worker/jobs/build.ts` | build → push → enqueue deploy |
| `src/worker/jobs/deploy.ts` | SSH → write compose + .env → docker compose up -d |
| `src/app/api/deploy-targets/route.ts` + `[id]/route.ts` | CRUD |
| `src/app/api/projects/[id]/deploy/route.ts` | enqueue build for project |
| `src/app/deploy-targets/page.tsx` + `new/page.tsx` | UI |
| `src/app/projects/[id]/page.tsx` | add Deploy button + DeployHistory list |

## Workflow Detail

### Build Job
1. Resolve project by id, ensure has `deployTargetId`, has compose source.
2. Build context: `git archive HEAD | tar` of project path → `/tmp/build-ctx-<id>.tar`.
3. `docker buildx build -f Dockerfile -t registry:5000/<name>:<ver>-<sha7> -` < tar.
   (Project must contain a `Dockerfile`. If missing, fail with clear message.)
4. Stream stdout lines via `eventBus.emit({ type: "deploy.log", deployId, line })`.
5. `docker push registry:5000/<name>:<tag>`.
6. On success → enqueue `deploy` job with `imageTag`.

### Deploy Job
1. Decrypt target auth blob.
2. `ssh target` → ensure compose dir exists at `composePath` parent.
3. Write compose YAML (resolved from `composeSource`):
   - `repo` → read from project repo path on PM
   - `template` → render minimal template (image + env)
   - `inline` → use `composeInline`
4. Write `.env` (decrypted env vars) to compose dir, mode 0600.
5. `ssh target docker login registry:5000` (anonymous push allowed for dev — registry has no auth in Phase 3; v2 endpoint open).
6. `ssh target "cd <dir> && docker compose pull && docker compose up -d"`.
7. Update `Project.deployedVersion`/`deployedAt`. Update `DeployHistory.status=success`.
8. Emit `deploy.complete`.

## Security

- Registry **anonymous** in Phase 3 (LAN-only, behind LXC firewall). Adding registry auth = Phase 4.
- AES-256-GCM key from `PM_ENCRYPTION_KEY` env (already generated in Phase 1 .env).
- SSH password/key encrypted at rest. Decrypted only in worker process memory during deploy.
- Webhook still token-auth.

## Out of Scope (Phase 4)

- Registry auth (htpasswd/Harbor)
- Multi-arch builds (buildx multi-platform)
- Rollback button
- Health checks post-deploy (curl probe)
- Logs viewer for deploy stdout (we stream via SSE, but persistent log viewer = Phase 4)
- Env var manager UI (we have encrypted column; UI editor = Phase 4)
- DeployTarget connection-test button (we wire it but UI polish in Phase 4)

## Approval

Master spec approved. This concretization picks single-LXC initial deployment target. User pre-approved (current session: "treat all decisions as pre-approved").
