# Phase 3: Build & Deploy — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.

**Goal:** End-to-end "click Deploy → Docker build → push registry → SSH target → compose up", with progress streamed via SSE.

**Architecture:** Single-LXC initial deployment (PM=Builder=Target on `192.168.40.20`); registry as **native systemd service** on PM (LXC unprivileged blocks Docker container start), redis as native apt package. Docker daemon runs natively. Multi-target ready architecturally — adding remote target = adding a row in DeployTarget table.

**Tech Stack:** Phase 1+2 stack + `docker:29` (host native), `distribution/registry:v2.8.3` (native binary, systemd unit `pm-registry.service`), `redis:7` (apt package, systemd unit `redis-server.service`), `bullmq`, `node-ssh`, `dockerode` (optional), AES-256-GCM via `node:crypto`.

**Working Artifact After Phase 3:**
- LXC has Docker daemon + registry container (`:5000`) + redis container (`:6379`).
- PM has BullMQ queue + standalone worker process.
- UI: `/deploy-targets` CRUD, project detail page has "Deploy" button + history list.
- Deploy `project-manager` itself onto same LXC (registry tag `registry:5000/project-manager:0.1.0-<sha>`), service runs at compose-up location, dogfood verified.

---

## Task 1: Provision Builder/Registry/Redis on LXC

Files: none in repo (pure infra). Document in README later.

- [ ] Step 1: Install Docker on LXC
  ```bash
  curl -fsSL https://get.docker.com | sh
  systemctl enable --now docker
  docker --version
  ```
- [ ] Step 2: Configure Docker to allow insecure registry on `:5000`
  ```bash
  cat > /etc/docker/daemon.json <<EOF
  { "insecure-registries": ["192.168.40.20:5000", "127.0.0.1:5000", "localhost:5000"] }
  EOF
  systemctl restart docker
  ```
- [ ] Step 3: Run registry container
  ```bash
  docker run -d --restart=always --name pm-registry -p 5000:5000 \
    -v /var/lib/pm-registry:/var/lib/registry registry:2
  curl -s http://localhost:5000/v2/_catalog
  ```
  Expected: `{"repositories":[]}`
- [ ] Step 4: Run redis container
  ```bash
  docker run -d --restart=always --name pm-redis -p 6379:6379 redis:7-alpine
  docker exec pm-redis redis-cli PING
  ```
  Expected: `PONG`
- [ ] Step 5: Generate SSH key for PM-as-target
  ```bash
  ssh-keygen -t ed25519 -f /opt/project-manager/secrets/pm-deploy-key -N ""
  cat /opt/project-manager/secrets/pm-deploy-key.pub >> /root/.ssh/authorized_keys
  chmod 600 /root/.ssh/authorized_keys
  ssh -o StrictHostKeyChecking=no -i /opt/project-manager/secrets/pm-deploy-key root@127.0.0.1 hostname
  ```
  Expected: `Project-Manager`

(All deploy-time, no commit.)

---

## Task 2: Prisma migration — DeployTarget, DeployHistory, encrypted blobs

- [ ] Step 1: Edit `prisma/schema.prisma`. Append `DeployTarget`, `DeployHistory`, modify `Project` (see spec).
- [ ] Step 2: `npx prisma migrate dev --name phase3_deploy`
- [ ] Step 3: Apply to test.db. `DATABASE_URL="file:./test.db" npx prisma migrate deploy`
- [ ] Step 4: Smoke test — `npm test` (29/29 still pass).
- [ ] Step 5: Commit `feat(phase3): deploy target + history models`.

---

## Task 3: AES-256-GCM crypto module (TDD)

Files: `src/lib/crypto/aes.ts`, `tests/unit/crypto.test.ts`

- [ ] Step 1: Failing test
  ```ts
  import { describe, it, expect, beforeAll } from "vitest";
  import { encrypt, decrypt } from "@/lib/crypto/aes";
  beforeAll(() => { process.env.PM_ENCRYPTION_KEY = "0".repeat(64); });
  describe("aes-256-gcm", () => {
    it("roundtrips a string", () => {
      const enc = encrypt("hello world");
      expect(enc).not.toBe("hello world");
      expect(decrypt(enc)).toBe("hello world");
    });
    it("two encrypts of the same plaintext differ", () => {
      expect(encrypt("x")).not.toBe(encrypt("x"));
    });
    it("decrypt fails on tampered ciphertext", () => {
      const enc = encrypt("secret");
      const tampered = enc.slice(0, -2) + (enc.slice(-2) === "aa" ? "bb" : "aa");
      expect(() => decrypt(tampered)).toThrow();
    });
  });
  ```
- [ ] Step 2: Run, FAIL.
- [ ] Step 3: Implementation
  ```ts
  import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
  function key(): Buffer {
    const hex = process.env.PM_ENCRYPTION_KEY;
    if (!hex || hex.length !== 64) throw new Error("PM_ENCRYPTION_KEY must be 64 hex chars (32 bytes)");
    return Buffer.from(hex, "hex");
  }
  // format: base64(iv|tag|ciphertext)
  export function encrypt(plain: string): string {
    const iv = randomBytes(12);
    const cipher = createCipheriv("aes-256-gcm", key(), iv);
    const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
    const tag = cipher.getAuthTag();
    return Buffer.concat([iv, tag, ct]).toString("base64");
  }
  export function decrypt(payload: string): string {
    const buf = Buffer.from(payload, "base64");
    const iv = buf.subarray(0, 12);
    const tag = buf.subarray(12, 28);
    const ct = buf.subarray(28);
    const decipher = createDecipheriv("aes-256-gcm", key(), iv);
    decipher.setAuthTag(tag);
    return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8");
  }
  ```
- [ ] Step 4: Run, PASS (3 tests).
- [ ] Step 5: Commit `feat(phase3): AES-256-GCM crypto module`.

---

## Task 4: SSH client wrapper

Files: `src/lib/ssh/client.ts` (no test — covered by deploy job integration)

```ts
import { NodeSSH } from "node-ssh";
import { decrypt } from "@/lib/crypto/aes";

export interface SSHTarget {
  host: string; sshPort: number; sshUser: string;
  authMode: "password" | "key";
  authBlob: string; // encrypted JSON
}

interface AuthBlob { password?: string; privateKey?: string }

export async function runRemote(
  t: SSHTarget,
  cmd: string,
  onLine?: (line: string) => void,
): Promise<{ code: number; stdout: string; stderr: string }> {
  const auth: AuthBlob = JSON.parse(decrypt(t.authBlob));
  const ssh = new NodeSSH();
  await ssh.connect({
    host: t.host,
    port: t.sshPort,
    username: t.sshUser,
    ...(t.authMode === "password" ? { password: auth.password } : { privateKey: auth.privateKey }),
    readyTimeout: 15_000,
  });
  let stdout = "", stderr = "";
  const result = await ssh.execCommand(cmd, {
    onStdout(b) {
      const s = b.toString();
      stdout += s;
      if (onLine) s.split("\n").forEach(l => l && onLine(l));
    },
    onStderr(b) {
      const s = b.toString();
      stderr += s;
      if (onLine) s.split("\n").forEach(l => l && onLine(l));
    },
  });
  ssh.dispose();
  return { code: result.code ?? 0, stdout, stderr };
}

export async function putFile(t: SSHTarget, localPath: string, remotePath: string): Promise<void> {
  const auth: AuthBlob = JSON.parse(decrypt(t.authBlob));
  const ssh = new NodeSSH();
  await ssh.connect({
    host: t.host, port: t.sshPort, username: t.sshUser,
    ...(t.authMode === "password" ? { password: auth.password } : { privateKey: auth.privateKey }),
  });
  await ssh.putFile(localPath, remotePath);
  ssh.dispose();
}
```

Install `node-ssh`: `npm install node-ssh`

- [ ] Step 1: Add dep
- [ ] Step 2: Write file
- [ ] Step 3: Compile check `npx tsc --noEmit`
- [ ] Step 4: Commit `feat(phase3): SSH client wrapper`

---

## Task 5: Docker build/push wrappers

Files: `src/lib/docker/build.ts`

```ts
import { spawn } from "node:child_process";

export interface BuildOpts {
  contextDir: string;       // path to source
  dockerfile?: string;      // default "Dockerfile"
  imageTag: string;         // full tag e.g. "registry:5000/foo:bar"
  onLine?: (line: string) => void;
}

export function buildAndPush(opts: BuildOpts): Promise<{ ok: boolean; tag: string; logTail: string }> {
  return new Promise((resolve) => {
    const args = ["build", "-t", opts.imageTag, "-f", opts.dockerfile ?? "Dockerfile", opts.contextDir];
    const proc = spawn("docker", args, { env: { ...process.env, DOCKER_BUILDKIT: "1" } });
    const buf: string[] = [];
    const sink = (chunk: Buffer) => {
      const s = chunk.toString();
      buf.push(s);
      if (opts.onLine) s.split("\n").forEach(l => l && opts.onLine!(l));
    };
    proc.stdout.on("data", sink);
    proc.stderr.on("data", sink);
    proc.on("close", (code) => {
      if (code !== 0) return resolve({ ok: false, tag: opts.imageTag, logTail: buf.join("").slice(-65536) });
      // push
      const push = spawn("docker", ["push", opts.imageTag]);
      push.stdout.on("data", sink);
      push.stderr.on("data", sink);
      push.on("close", (pcode) => {
        resolve({ ok: pcode === 0, tag: opts.imageTag, logTail: buf.join("").slice(-65536) });
      });
    });
  });
}
```

- [ ] Step 1: Write
- [ ] Step 2: tsc check
- [ ] Step 3: Commit `feat(phase3): docker build+push wrapper`

---

## Task 6: BullMQ queue + worker scaffolding

Install: `npm install bullmq ioredis`

Files:
- `src/lib/queue/index.ts` — queue export + connection
- `src/worker/index.ts` — entrypoint
- `package.json` — add scripts `"worker": "tsx src/worker/index.ts"`, `"worker:build": "tsc -p tsconfig.worker.json"`

```ts
// src/lib/queue/index.ts
import { Queue } from "bullmq";
import IORedis from "ioredis";

const REDIS_URL = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
export const connection = new IORedis(REDIS_URL, { maxRetriesPerRequest: null });

export const buildQueue = new Queue<BuildJob>("pm-build", { connection });
export const deployQueue = new Queue<DeployJob>("pm-deploy", { connection });

export interface BuildJob {
  deployId: string;          // DeployHistory.id
  projectId: string;
  imageTag: string;
}
export interface DeployJob {
  deployId: string;
  projectId: string;
  targetId: string;
  imageTag: string;
}
```

```ts
// src/worker/index.ts
import "dotenv/config";
import { Worker } from "bullmq";
import { connection } from "@/lib/queue";
import { runBuild } from "./jobs/build";
import { runDeploy } from "./jobs/deploy";

const buildWorker = new Worker("pm-build", async (job) => runBuild(job.data), { connection });
const deployWorker = new Worker("pm-deploy", async (job) => runDeploy(job.data), { connection });

buildWorker.on("completed", (j) => console.log(`build:${j.id} done`));
buildWorker.on("failed", (j, err) => console.error(`build:${j?.id} failed`, err));
deployWorker.on("completed", (j) => console.log(`deploy:${j.id} done`));
deployWorker.on("failed", (j, err) => console.error(`deploy:${j?.id} failed`, err));

console.log("PM worker ready");
```

(jobs/build.ts and jobs/deploy.ts in next task.)

- [ ] Step 1: Install deps
- [ ] Step 2: Write files
- [ ] Step 3: Smoke run worker briefly: `timeout 3 npm run worker || true` — expect "PM worker ready"
- [ ] Step 4: Commit `feat(phase3): bullmq queue + worker scaffold`

---

## Task 7: Build job + Deploy job + API endpoint

Files:
- `src/worker/jobs/build.ts`
- `src/worker/jobs/deploy.ts`
- `src/app/api/projects/[id]/deploy/route.ts`

`src/worker/jobs/build.ts`:
```ts
import { prisma } from "@/lib/db";
import { eventBus } from "@/lib/events/bus";
import { buildAndPush } from "@/lib/docker/build";
import { deployQueue, type BuildJob } from "@/lib/queue";

export async function runBuild(data: BuildJob): Promise<void> {
  const dep = await prisma.deployHistory.findUnique({ where: { id: data.deployId }, include: { project: true } });
  if (!dep) throw new Error("deploy not found");
  await prisma.deployHistory.update({ where: { id: dep.id }, data: { status: "building" } });
  eventBus.emit({ type: "deploy.update", deployId: dep.id, status: "building" });

  const result = await buildAndPush({
    contextDir: dep.project.path,
    imageTag: data.imageTag,
    onLine: (line) => eventBus.emit({ type: "deploy.log", deployId: dep.id, line }),
  });
  if (!result.ok) {
    await prisma.deployHistory.update({
      where: { id: dep.id },
      data: { status: "failed", finishedAt: new Date(), errorMsg: "build_or_push_failed", logBlob: result.logTail },
    });
    eventBus.emit({ type: "deploy.update", deployId: dep.id, status: "failed" });
    return;
  }
  await prisma.deployHistory.update({ where: { id: dep.id }, data: { logBlob: result.logTail } });
  if (!dep.project.deployTargetId) {
    await prisma.deployHistory.update({
      where: { id: dep.id },
      data: { status: "failed", finishedAt: new Date(), errorMsg: "no_deploy_target" },
    });
    return;
  }
  await deployQueue.add("deploy", {
    deployId: dep.id,
    projectId: dep.projectId,
    targetId: dep.project.deployTargetId,
    imageTag: data.imageTag,
  });
}
```

`src/worker/jobs/deploy.ts`:
```ts
import { prisma } from "@/lib/db";
import { eventBus } from "@/lib/events/bus";
import { runRemote, putFile } from "@/lib/ssh/client";
import { decrypt } from "@/lib/crypto/aes";
import { writeFile, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { DeployJob } from "@/lib/queue";

export async function runDeploy(data: DeployJob): Promise<void> {
  const dep = await prisma.deployHistory.findUnique({ where: { id: data.deployId }, include: { project: true } });
  if (!dep) throw new Error("deploy not found");
  const target = await prisma.deployTarget.findUnique({ where: { id: data.targetId } });
  if (!target) throw new Error("target not found");

  await prisma.deployHistory.update({ where: { id: dep.id }, data: { status: "deploying" } });
  eventBus.emit({ type: "deploy.update", deployId: dep.id, status: "deploying" });

  const composeDir = target.composePath.replace("{name}", dep.project.name).replace(/\/[^/]+$/, "");

  // Resolve compose YAML
  let composeYaml: string;
  if (dep.project.composeSource === "inline" && dep.project.composeInline) {
    composeYaml = dep.project.composeInline;
  } else {
    // template: minimal
    composeYaml = `services:\n  app:\n    image: ${data.imageTag}\n    restart: unless-stopped\n    network_mode: host\n    env_file:\n      - .env\n`;
  }
  // env vars
  let envContent = "";
  if (dep.project.envVarsEncrypted) {
    const kv = JSON.parse(decrypt(dep.project.envVarsEncrypted)) as Record<string, string>;
    envContent = Object.entries(kv).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
  }

  // local tmp staging
  const tmp = await mkdtemp(join(tmpdir(), "pm-deploy-"));
  await writeFile(join(tmp, "docker-compose.yml"), composeYaml);
  await writeFile(join(tmp, ".env"), envContent);

  const tCfg = { host: target.host, sshPort: target.sshPort, sshUser: target.sshUser, authMode: target.authMode as "password" | "key", authBlob: target.authBlob };
  await runRemote(tCfg, `mkdir -p ${composeDir}`);
  await putFile(tCfg, join(tmp, "docker-compose.yml"), `${composeDir}/docker-compose.yml`);
  await putFile(tCfg, join(tmp, ".env"), `${composeDir}/.env`);
  await runRemote(tCfg, `chmod 600 ${composeDir}/.env`);

  const sink = (line: string) => eventBus.emit({ type: "deploy.log", deployId: dep.id, line });
  const pull = await runRemote(tCfg, `cd ${composeDir} && docker compose pull && docker compose up -d`, sink);
  if (pull.code !== 0) {
    await prisma.deployHistory.update({
      where: { id: dep.id },
      data: { status: "failed", finishedAt: new Date(), errorMsg: "compose_up_failed", logBlob: (dep.logBlob ?? "") + "\n" + pull.stderr.slice(-32000) },
    });
    eventBus.emit({ type: "deploy.update", deployId: dep.id, status: "failed" });
    return;
  }
  await prisma.deployHistory.update({
    where: { id: dep.id },
    data: { status: "success", finishedAt: new Date(), logBlob: (dep.logBlob ?? "") + "\n" + pull.stdout.slice(-32000) },
  });
  await prisma.project.update({
    where: { id: dep.projectId },
    data: { deployedVersion: dep.project.version, deployedAt: new Date() },
  });
  eventBus.emit({ type: "deploy.update", deployId: dep.id, status: "success" });
}
```

Add to `PMEvent` union in `src/lib/events/bus.ts`:
```ts
  | { type: "deploy.update"; deployId: string; status: string }
  | { type: "deploy.log"; deployId: string; line: string }
```

`src/app/api/projects/[id]/deploy/route.ts`:
```ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/auth/middleware";
import { buildQueue } from "@/lib/queue";

export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
  const auth = await requireAuth(req);
  if (!auth.ok) return NextResponse.json({ error: "unauthorized" }, { status: auth.status });
  const { id } = await ctx.params;
  const project = await prisma.project.findUnique({ where: { id } });
  if (!project) return NextResponse.json({ error: "project_not_found" }, { status: 404 });
  if (!project.deployTargetId) return NextResponse.json({ error: "no_deploy_target" }, { status: 400 });

  const registry = process.env.PM_REGISTRY ?? "127.0.0.1:5000";
  const sha = (project.lastCommitHash ?? "nocommit").slice(0, 7);
  const ver = project.version ?? "0.0.0";
  const imageTag = `${registry}/${project.name}:${ver}-${sha}`;

  const dep = await prisma.deployHistory.create({
    data: { projectId: id, imageTag, commitHash: project.lastCommitHash, status: "queued", targetId: project.deployTargetId },
  });
  await buildQueue.add("build", { deployId: dep.id, projectId: id, imageTag });
  return NextResponse.json({ ok: true, deployId: dep.id, imageTag });
}
```

- [ ] Step 1: Edit event bus union
- [ ] Step 2: Write 3 files
- [ ] Step 3: tsc check + build
- [ ] Step 4: Commit `feat(phase3): build/deploy jobs + deploy API`

---

## Task 8: Deploy targets API + UI

Files:
- `src/app/api/deploy-targets/route.ts` (GET list, POST create)
- `src/app/api/deploy-targets/[id]/route.ts` (DELETE)
- `src/app/deploy-targets/page.tsx`
- `src/app/deploy-targets/new/page.tsx`

API code mirrors projects API pattern. POST encrypts authBlob using `encrypt()` before save:
```ts
const auth = JSON.stringify({ password: parsed.data.password ?? undefined, privateKey: parsed.data.privateKey ?? undefined });
const authBlob = encrypt(auth);
const target = await prisma.deployTarget.create({ data: { ...rest, authBlob } });
```

UI: list page shows table (name, host, authMode, projects count), `+ New` button → form with name/host/sshPort/sshUser/authMode/(password|privateKey)/composePath.

- [ ] Step 1: Write API routes
- [ ] Step 2: Write pages
- [ ] Step 3: Add Nav link "Targets"
- [ ] Step 4: build
- [ ] Step 5: Commit `feat(phase3): deploy targets CRUD`

---

## Task 9: Project deploy button + history UI

Files:
- `src/components/deploy-button.tsx` (client) — POST to /api/projects/[id]/deploy, show pending toast
- `src/app/projects/[id]/page.tsx` — render last 10 DeployHistory rows + Deploy button + dropdown to assign DeployTarget

- [ ] Step 1: write deploy-button
- [ ] Step 2: extend detail page with target selector + history list
- [ ] Step 3: PATCH route in projects/[id] already exists; extend Body to accept `deployTargetId`, `composeSource`, `composeInline`
- [ ] Step 4: build
- [ ] Step 5: Commit `feat(phase3): project deploy UI + history`

---

## Task 10: Deploy Phase 3 to LXC + smoke test

- [ ] Step 1: Push tarball, extract, install new deps (node-ssh, bullmq, ioredis, dockerode)
- [ ] Step 2: Run prisma migrate deploy
- [ ] Step 3: Build app
- [ ] Step 4: Add systemd unit `pm-worker.service` for the worker process
- [ ] Step 5: Restart `project-manager.service`, start `pm-worker.service`
- [ ] Step 6: Add `Dockerfile` to `project-manager` repo (multistage Next.js standalone build)
- [ ] Step 7: Via UI or curl, create DeployTarget `localhost` (host=127.0.0.1, key=ed25519 generated in Task 1)
- [ ] Step 8: Set project's deployTargetId to that target
- [ ] Step 9: Click Deploy / curl POST → watch SSE, verify image pushed (`curl localhost:5000/v2/_catalog`), compose up succeeded
- [ ] Step 10: Verify `docker ps | grep project-manager` shows the deployed container

---

## Self-Review

- Spec coverage ✅ (build/push/deploy/registry/builder/SSH creds/encrypted env/compose hybrid)
- Type consistency: `PMEvent` extended in Task 7; `BuildJob`/`DeployJob` types in queue module shared by API + worker.
- YAGNI: skipped registry auth, multi-arch, rollback, log viewer (deferred Phase 4).
- Out of scope cleanly bounded.
