- 4 tables Docker (settings/compose_roots/compose_stacks/stack_services)
+ migration 0004 (timestamps journal monotones)
- templates docker/scan-compose + inspect-compose ; renderTemplate bascule
sur délimiteurs <% %> pour les templates docker/ afin de préserver les
Go-templates {{.ID}} intacts
- dockerScan: parseDockerScan (TDD) + scanDockerStacks (persiste stacks
candidats, complète la détection par labels)
- action docker_scan branchée dans execute (route dédiée, archivage report/log)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
Tâche 2 — SJ-4 (Docker scan + inspect, passifs) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: subagent-driven-development / executing-plans. Étapes checkbox.
Goal: Ouvrir le volet Docker (passif) : tables Docker (docker_settings, docker_compose_roots, docker_compose_stacks, docker_stack_services), templates docker/scan-compose.sh.tpl + docker/inspect-compose.sh.tpl (avec délimiteurs Mustache custom pour cohabiter avec les Go-templates Docker), parsing du scan, service de configuration + scan qui persiste les stacks candidats, et branchement des actions docker_scan / docker_inspect_current.
Architecture: Référence docs/design/tache2/20-docker.md §1-4 + 40-contrats-json.md §3 (DockerSnapshot*). Découverte par racines déclarées (composeRoots) scannées en profondeur bornée, validées par docker compose config --quiet ; labels Compose en complément. Cycle stack candidate→enabled. Conflit de délimiteurs résolu : renderTemplate accepte des tags Mustache custom ; les templates Docker utilisent <% %> pour les variables, laissant les Go-templates {{...}} intacts. Réutilise runScriptSudo/executions/terminal/rawLogPath (pas de moteur parallèle). Passif : aucun pull/up/prune ici (SJ-5/6).
Tech Stack: Drizzle/SQLite, Mustache, ssh2, vitest.
Invariants
- Passif : SJ-4 ne télécharge/recrée/supprime rien (scan + inspect lecture seule).
- Additif :
MachineViewinchangé ; nouvelles tables ; actionsdocker_scan/docker_inspect_currentdéjà dans l'unionActionType(SJ-0). - Délimiteurs :
renderTemplatereste rétro-compatible ({{ }}par défaut) ; seuls les templatesdocker/*passenttags: ['<%','%>']. - Tree partagé / WIP concurrent : ne toucher QUE
server/db/schema.ts(+migration),server/templates/render.ts(+test),templates/docker/{scan-compose,inspect-compose}.sh.tpl,server/services/dockerScan.ts(+test),server/services/execute.ts. Ne pas committer.
File Structure
server/db/schema.ts # MODIF : +docker_settings/compose_roots/compose_stacks/stack_services
server/db/migrations/0004_*.sql # généré
server/db/schema.test.ts # MODIF : +assert tables docker
server/templates/render.ts # MODIF : tags Mustache custom (optionnels)
server/templates/render.test.ts # MODIF : +cas délimiteurs custom
templates/docker/scan-compose.sh.tpl # NOUVEAU (délimiteurs <% %>)
templates/docker/inspect-compose.sh.tpl # NOUVEAU
server/services/dockerScan.ts # NOUVEAU : config + parseDockerScan + scanDockerStacks
server/services/dockerScan.test.ts # NOUVEAU : parseDockerScan (TDD)
server/services/execute.ts # MODIF : actions docker_scan / docker_inspect_current
Task 1 : Tables Docker (migration)
Files: Modify server/db/schema.ts ; generate migration ; extend server/db/schema.test.ts.
-
Step 1 : Relire
schema.ts(préserver tout l'existant). -
Step 2 : Ajouter les tables (fin de fichier)
export const dockerSettings = sqliteTable("docker_settings", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
enabled: integer("enabled").notNull().default(0),
scanDepth: integer("scan_depth").notNull().default(4),
pruneMode: text("prune_mode").notNull().default("safe"),
lastScanAt: text("last_scan_at"),
lastPullCheckAt: text("last_pull_check_at"),
updatedAt: text("updated_at").notNull(),
});
export const dockerComposeRoots = sqliteTable("docker_compose_roots", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
path: text("path").notNull(),
enabled: integer("enabled").notNull().default(1),
scanDepth: integer("scan_depth"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
export const dockerComposeStacks = sqliteTable("docker_compose_stacks", {
id: text("id").primaryKey(),
machineId: text("machine_id").notNull().references(() => machines.id, { onDelete: "cascade" }),
name: text("name").notNull(),
workingDir: text("working_dir").notNull(),
composeFilesJson: text("compose_files_json").notNull(),
projectName: text("project_name"),
envFile: text("env_file"),
status: text("status").notNull(), // candidate | enabled | ignored | error
detectedBy: text("detected_by"), // root_scan | label | manual
lastScanAt: text("last_scan_at"),
lastUpdateAt: text("last_update_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
});
export const dockerStackServices = sqliteTable("docker_stack_services", {
id: text("id").primaryKey(),
stackId: text("stack_id").notNull().references(() => dockerComposeStacks.id, { onDelete: "cascade" }),
serviceName: text("service_name").notNull(),
imageRef: text("image_ref"),
currentImageId: text("current_image_id"),
currentDigest: text("current_digest"),
candidateImageId: text("candidate_image_id"),
candidateDigest: text("candidate_digest"),
versionLabel: text("version_label"),
status: text("status"), // up_to_date | updates_available | error
updatedAt: text("updated_at").notNull(),
});
-
Step 3 : Générer la migration —
rtk pnpm db:generate→server/db/migrations/0004_*.sql(4 CREATE TABLE, aucun DROP des tables existantes). Vérifier le SQL. -
Step 4 : Étendre
schema.test.ts— ajouter un test asserttant la présence dedocker_settings,docker_compose_roots,docker_compose_stacks,docker_stack_services. -
Step 5 :
rtk pnpm vitest run server/db/schema.test.ts→ PASS ;rtk pnpm check→ 0 erreur. (pas de commit)
Task 2 : Délimiteurs Mustache custom + templates Docker
Files: Modify server/templates/render.ts, server/templates/render.test.ts ; Create templates/docker/scan-compose.sh.tpl, templates/docker/inspect-compose.sh.tpl.
- Step 1 : Étendre
renderTemplate(tags optionnels, rétro-compatible)
export function renderTemplate(
relPath: string,
vars: TemplateVars,
opts?: { tags?: [string, string] },
): string {
const tpl = readFileSync(resolve(TEMPLATES_ROOT, relPath), "utf8");
// Les templates Docker contiennent des Go-templates {{...}} : on bascule les
// délimiteurs Mustache sur <% %> pour ne pas les interpréter.
const tags = opts?.tags ?? (relPath.startsWith("docker/") ? (["<%", "%>"] as [string, string]) : undefined);
return Mustache.render(tpl, vars, {}, { escape: (s) => s, ...(tags ? { tags } : {}) });
}
- Step 2 : Test délimiteurs — ajouter à
render.test.ts
it("rend les variables Docker en <% %> sans toucher aux Go-templates {{...}}", () => {
const out = renderTemplate("docker/scan-compose.sh.tpl", { composeRoots: "/opt/stacks", composeScanDepth: 3 });
expect(out).toContain("/opt/stacks");
expect(out).toContain("{{.ID}}"); // Go-template Docker resté littéral
expect(out).not.toContain("<%composeRoots%>");
});
- Step 3 : Créer
templates/docker/scan-compose.sh.tpl(variables en<% %>, Go-templates en{{ }}littéraux)
#!/bin/sh
export LC_ALL=C
echo "===SU:DOCKER_SCAN==="
ROOTS="<%composeRoots%>"
DEPTH="<%composeScanDepth%>"
for root in $ROOTS; do
[ -d "$root" ] || continue
find "$root" -maxdepth "$DEPTH" -type f \
\( -name 'compose.yaml' -o -name 'compose.yml' \
-o -name 'docker-compose.yaml' -o -name 'docker-compose.yml' \) \
-not -path '*/.git/*' -not -path '*/node_modules/*' \
-not -path '*/backup/*' -not -path '*/old/*' -not -path '*/archive/*' \
2>/dev/null | while IFS= read -r f; do
dir=$(dirname "$f")
if docker compose -f "$f" config --quiet >/dev/null 2>&1; then
echo "STACK_OK\tdir=$dir\tfile=$f"
else
echo "STACK_INVALID\tdir=$dir\tfile=$f"
fi
done
done
echo "===SU:DOCKER_LABELS==="
docker ps --format '{{.ID}}' 2>/dev/null | while read -r id; do
proj=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$id" 2>/dev/null)
wd=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null)
[ -n "$proj" ] && echo "ACTIVE\tproject=$proj\tworking_dir=$wd"
done
echo "===SU:EXIT=0==="
- Step 4 : Créer
templates/docker/inspect-compose.sh.tpl
#!/bin/sh
export LC_ALL=C
cd "<%stackDir%>" || { echo "===SU:DOCKER_ERR==="; echo "compose_not_found"; echo "===SU:EXIT=2==="; exit 2; }
echo "===SU:DOCKER_CONFIG_IMAGES==="
docker compose config --images 2>&1
echo "===SU:DOCKER_PS==="
docker compose ps --format json 2>&1
echo "===SU:DOCKER_IMAGES==="
docker compose images --format json 2>&1
echo "===SU:DOCKER_INSPECT==="
docker compose config --images 2>/dev/null | while IFS= read -r img; do
docker image inspect "$img" \
--format 'IMG\t{{.Id}}\t{{join .RepoDigests ","}}\t{{index .Config.Labels "org.opencontainers.image.version"}}\t{{index .Config.Labels "org.opencontainers.image.source"}}' 2>/dev/null \
|| echo "IMG_MISSING\t$img"
done
echo "===SU:EXIT=0==="
- Step 5 :
rtk pnpm vitest run server/templates/render.test.ts→ PASS.rtk pnpm check→ 0 erreur. (pas de commit)
Task 3 : Parsing du scan + service (TDD)
Files: Create server/services/dockerScan.ts, server/services/dockerScan.test.ts.
- Step 1 : Test (échec attendu) —
server/services/dockerScan.test.ts
import { describe, it, expect } from "vitest";
import { parseDockerScan } from "./dockerScan.js";
const raw = [
"===SU:DOCKER_SCAN===",
"STACK_OK\tdir=/opt/stacks/media\tfile=/opt/stacks/media/compose.yaml",
"STACK_INVALID\tdir=/opt/stacks/broken\tfile=/opt/stacks/broken/compose.yml",
"===SU:DOCKER_LABELS===",
"ACTIVE\tproject=media\tworking_dir=/opt/stacks/media",
"===SU:EXIT=0===",
].join("\n");
describe("parseDockerScan", () => {
it("extrait stacks valides/invalides et actifs", () => {
const r = parseDockerScan(raw);
expect(r.stacks).toEqual([
{ workingDir: "/opt/stacks/media", composeFile: "/opt/stacks/media/compose.yaml", valid: true },
{ workingDir: "/opt/stacks/broken", composeFile: "/opt/stacks/broken/compose.yml", valid: false },
]);
expect(r.active).toEqual([{ project: "media", workingDir: "/opt/stacks/media" }]);
});
});
-
Step 2 : Lancer (échec) —
rtk pnpm vitest run server/services/dockerScan.test.ts→ FAIL. -
Step 3 : Implémenter
server/services/dockerScan.ts
// server/services/dockerScan.ts
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { basename } from "node:path";
import { db, schema } from "../db/client.js";
import { getMachineRow, getCreds } from "./machines.js";
import { renderTemplate } from "../templates/render.js";
import { runScriptSudo } from "../ssh/client.js";
import { outputHub } from "../ws/outputHub.js";
export interface DockerScanResult {
stacks: { workingDir: string; composeFile: string; valid: boolean }[];
active: { project: string; workingDir: string }[];
}
function fields(line: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of line.split("\t")) {
const i = part.indexOf("=");
if (i > 0) out[part.slice(0, i)] = part.slice(i + 1);
}
return out;
}
export function parseDockerScan(raw: string): DockerScanResult {
const stacks: DockerScanResult["stacks"] = [];
const active: DockerScanResult["active"] = [];
for (const line of raw.split("\n")) {
const l = line.trimEnd();
if (l.startsWith("STACK_OK\t") || l.startsWith("STACK_INVALID\t")) {
const f = fields(l);
stacks.push({ workingDir: f.dir ?? "", composeFile: f.file ?? "", valid: l.startsWith("STACK_OK") });
} else if (l.startsWith("ACTIVE\t")) {
const f = fields(l);
active.push({ project: f.project ?? "", workingDir: f.working_dir ?? "" });
}
}
return { stacks, active };
}
/** Racines Compose déclarées (enabled) d'une machine. */
export function getComposeRoots(machineId: string): string[] {
return db.select().from(schema.dockerComposeRoots)
.where(eq(schema.dockerComposeRoots.machineId, machineId)).all()
.filter((r) => r.enabled).map((r) => r.path);
}
/** Déclare/active Docker pour une machine + ses racines Compose (idempotent). */
export function setDockerRoots(machineId: string, paths: string[], scanDepth = 4): void {
const now = new Date().toISOString();
db.insert(schema.dockerSettings)
.values({ machineId, enabled: 1, scanDepth, pruneMode: "safe", updatedAt: now })
.onConflictDoUpdate({ target: schema.dockerSettings.machineId, set: { enabled: 1, scanDepth, updatedAt: now } })
.run();
db.delete(schema.dockerComposeRoots).where(eq(schema.dockerComposeRoots.machineId, machineId)).run();
for (const path of paths) {
db.insert(schema.dockerComposeRoots).values({
id: randomUUID(), machineId, path, enabled: 1, createdAt: now, updatedAt: now,
}).run();
}
}
/** Scanne les racines déclarées et upsert les stacks candidats. Renvoie le résultat parsé. */
export async function scanDockerStacks(machineId: string): Promise<DockerScanResult> {
const m = getMachineRow(machineId);
if (!m) throw new Error("Machine introuvable");
const roots = getComposeRoots(machineId);
const settings = db.select().from(schema.dockerSettings)
.where(eq(schema.dockerSettings.machineId, machineId)).get();
const depth = settings?.scanDepth ?? 4;
if (roots.length === 0) return { stacks: [], active: [] };
const script = renderTemplate("docker/scan-compose.sh.tpl", {
composeRoots: roots.join(" "),
composeScanDepth: depth,
});
let raw = "";
const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); });
raw = res.stdout;
const parsed = parseDockerScan(raw);
const now = new Date().toISOString();
const activeDirs = new Set(parsed.active.map((a) => a.workingDir));
for (const s of parsed.stacks) {
if (!s.valid) continue;
const name = basename(s.workingDir);
const existing = db.select().from(schema.dockerComposeStacks)
.where(eq(schema.dockerComposeStacks.workingDir, s.workingDir)).get();
const detectedBy = activeDirs.has(s.workingDir) ? "label" : "root_scan";
if (existing) {
db.update(schema.dockerComposeStacks).set({ lastScanAt: now, detectedBy, updatedAt: now })
.where(eq(schema.dockerComposeStacks.id, existing.id)).run();
} else {
db.insert(schema.dockerComposeStacks).values({
id: randomUUID(), machineId, name, workingDir: s.workingDir,
composeFilesJson: JSON.stringify([s.composeFile]), status: "candidate",
detectedBy, lastScanAt: now, createdAt: now, updatedAt: now,
}).run();
}
}
db.update(schema.dockerSettings).set({ lastScanAt: now, updatedAt: now })
.where(eq(schema.dockerSettings.machineId, machineId)).run();
return parsed;
}
- Step 4 :
rtk pnpm vitest run server/services/dockerScan.test.ts→ PASS.rtk pnpm check→ 0 erreur. (pas de commit)
Task 4 : Brancher docker_scan / docker_inspect_current
Files: Modify server/services/execute.ts.
-
Step 1 : Relire
execute.ts. -
Step 2 :
TEMPLATE_FOR— ajouter
docker_scan: "docker/scan-compose.sh.tpl",
docker_inspect_current: "docker/inspect-compose.sh.tpl",
docker_inspect_currentrequiert unstackDir(variable de rendu). Au MVP,runActionne porte pas de paramètre de stack ;docker_inspect_currentreste donc déclaré mais son orchestration par stack viendra avec SJ-5 (qui itère les stacksenabled). Pour SJ-4, seuldocker_scanest réellement exécutable viarunAction.
- Step 3 : Spécialiser
docker_scandansrunAction— après obtention deraw(le script de scan a tourné via le flux normal), persister les stacks via le parseur. Le plus simple : routerdocker_scanvers le service dédié plutôt que le flux générique. Ajouter en début derunAction, juste après legetMachineRowet la création de l'executionId/insert execution :
if (action === "docker_scan") {
// Le rendu Docker nécessite les délimiteurs custom + les racines déclarées :
// on délègue au service de scan qui rend le template et persiste les stacks.
const { scanDockerStacks } = await import("./dockerScan.js");
try {
const parsed = await scanDockerStacks(machineId);
outputHub.publish(machineId, `\n===SU:DONE status=ok stacks=${parsed.stacks.length}===\n`);
} catch (err) {
outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`);
}
}
⚠️ Implémentation propre attendue : plutôt que de laisser le flux générique re-rendre
docker/scan-compose.sh.tplSANS racines (ce qui produirait un scan vide), faire en sorte que pouraction === "docker_scan"le flux générique NE rende PAS le template lui-même. Option recommandée : extraire la logique commune, ou faire unearly returnaprès le scan pourdocker_scanen construisant unExecutionResultminimal (status ok, importantLogLines = lignes réduites, report/log archivés comme les autres actions). Préférer : routerdocker_scanAVANT le rendu générique et construire son propreExecutionResult(réutiliser les helpers d'archivage). Le sous-agent doit choisir l'implémentation la plus propre qui évite un double rendu.
-
Step 4 : Vérifier —
rtk pnpm check && rtk pnpm test→ 0 erreur, tests verts. Les blocs Phase 1 et les actions APT restent intacts. -
Step 5 : (pas de commit)
Task 5 : Vérification finale SJ-4
- Step 1 :
rtk pnpm check && rtk pnpm test && rtk pnpm build→ tout vert. - Step 2 : Boot smoke (DB jetable) →
/healthOK + tablesdocker_*créées. Nettoyer. - Step 3 : Reporter. Vérif live :
setDockerRoots(machineId, ["/opt/stacks"])puis actiondocker_scanréelle sur une machine avec Docker → vérifier la détection des stacks. Ne pas committer.
Self-Review (couverture SJ-4)
docker/scan-compose.sh.tpl+inspect-compose.sh.tpl(passifs) → Task 2. ✓- Conflit délimiteurs Mustache/Go-template résolu (
<% %>pour Docker) → Task 2. ✓ - Config machine
composeRoots/scanDepth+ tablesdocker_*→ Task 1 + Task 3 (setDockerRoots/getComposeRoots). ✓ - Cycle
candidate(détecté) + détection labels en complément →scanDockerStacks. ✓ - Action
docker_scanexécutable → Task 4. ✓ - Validation
docker compose config --quiet(valid/invalid) → template + parser. ✓
Décisions : docker_inspect_current déclaré mais orchestré par stack en SJ-5 (nécessite stackDir). Pas d'API/UI de configuration des roots en SJ-4 (tâche 3/5) ; setDockerRoots est le point d'entrée backend. Aucun pull/up/prune (passif). Noms cohérents : parseDockerScan/getComposeRoots/setDockerRoots/scanDockerStacks.