diff --git a/docs/superpowers/plans/2026-06-05-tache2-sj4-docker-scan.md b/docs/superpowers/plans/2026-06-05-tache2-sj4-docker-scan.md new file mode 100644 index 0000000..ec04121 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-tache2-sj4-docker-scan.md @@ -0,0 +1,387 @@ +# 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 : `MachineView` inchangé ; nouvelles tables ; actions `docker_scan`/`docker_inspect_current` déjà dans l'union `ActionType` (SJ-0). +- Délimiteurs : `renderTemplate` reste rétro-compatible (`{{ }}` par défaut) ; seuls les templates `docker/*` passent `tags: ['<%','%>']`. +- 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) + +```ts +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 de `docker_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) + +```ts +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` + +```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) + +```sh +#!/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`** + +```sh +#!/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` + +```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`** + +```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 { + const out: Record = {}; + 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 { + 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 +```ts + docker_scan: "docker/scan-compose.sh.tpl", + docker_inspect_current: "docker/inspect-compose.sh.tpl", +``` +> `docker_inspect_current` requiert un `stackDir` (variable de rendu). Au MVP, `runAction` ne porte pas de paramètre de stack ; `docker_inspect_current` reste donc déclaré mais **son orchestration par stack viendra avec SJ-5** (qui itère les stacks `enabled`). Pour SJ-4, **seul `docker_scan` est réellement exécutable** via `runAction`. + +- [ ] **Step 3 : Spécialiser `docker_scan` dans `runAction`** — après obtention de `raw` (le script de scan a tourné via le flux normal), persister les stacks via le parseur. Le plus simple : router `docker_scan` vers le service dédié plutôt que le flux générique. Ajouter en début de `runAction`, juste après le `getMachineRow` et la création de l'`executionId`/insert execution : +```ts + 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.tpl` SANS racines (ce qui produirait un scan vide), faire en sorte que pour `action === "docker_scan"` le flux générique NE rende PAS le template lui-même. Option recommandée : extraire la logique commune, ou faire un `early return` après le scan pour `docker_scan` en construisant un `ExecutionResult` minimal (status ok, importantLogLines = lignes réduites, report/log archivés comme les autres actions). **Préférer** : router `docker_scan` AVANT le rendu générique et construire son propre `ExecutionResult` (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) → `/health` OK + tables `docker_*` créées. Nettoyer. +- [ ] **Step 3 :** Reporter. Vérif live : `setDockerRoots(machineId, ["/opt/stacks"])` puis action `docker_scan` ré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` + tables `docker_*` → Task 1 + Task 3 (`setDockerRoots`/`getComposeRoots`). ✓ +- Cycle `candidate` (détecté) + détection labels en complément → `scanDockerStacks`. ✓ +- Action `docker_scan` exé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`. +``` diff --git a/server/db/migrations/0004_thin_ted_forrester.sql b/server/db/migrations/0004_thin_ted_forrester.sql new file mode 100644 index 0000000..f2834f2 --- /dev/null +++ b/server/db/migrations/0004_thin_ted_forrester.sql @@ -0,0 +1,53 @@ +CREATE TABLE `docker_compose_roots` ( + `id` text PRIMARY KEY NOT NULL, + `machine_id` text NOT NULL, + `path` text NOT NULL, + `enabled` integer DEFAULT 1 NOT NULL, + `scan_depth` integer, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `docker_compose_stacks` ( + `id` text PRIMARY KEY NOT NULL, + `machine_id` text NOT NULL, + `name` text NOT NULL, + `working_dir` text NOT NULL, + `compose_files_json` text NOT NULL, + `project_name` text, + `env_file` text, + `status` text NOT NULL, + `detected_by` text, + `last_scan_at` text, + `last_update_at` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `docker_settings` ( + `machine_id` text PRIMARY KEY NOT NULL, + `enabled` integer DEFAULT 0 NOT NULL, + `scan_depth` integer DEFAULT 4 NOT NULL, + `prune_mode` text DEFAULT 'safe' NOT NULL, + `last_scan_at` text, + `last_pull_check_at` text, + `updated_at` text NOT NULL, + FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `docker_stack_services` ( + `id` text PRIMARY KEY NOT NULL, + `stack_id` text NOT NULL, + `service_name` text NOT NULL, + `image_ref` text, + `current_image_id` text, + `current_digest` text, + `candidate_image_id` text, + `candidate_digest` text, + `version_label` text, + `status` text, + `updated_at` text NOT NULL, + FOREIGN KEY (`stack_id`) REFERENCES `docker_compose_stacks`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/server/db/migrations/meta/0004_snapshot.json b/server/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..12933e2 --- /dev/null +++ b/server/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1949 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9bf2de9b-82f5-4a2e-91c8-1356847df53f", + "prevId": "f1e78d77-2163-47ef-81c7-d8194f03724d", + "tables": { + "api_clients": { + "name": "api_clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes_json": { + "name": "scopes_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_clients_token_hash_unique": { + "name": "api_clients_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "docker_compose_roots": { + "name": "docker_compose_roots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "scan_depth": { + "name": "scan_depth", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "docker_compose_roots_machine_id_machines_id_fk": { + "name": "docker_compose_roots_machine_id_machines_id_fk", + "tableFrom": "docker_compose_roots", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "docker_compose_stacks": { + "name": "docker_compose_stacks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compose_files_json": { + "name": "compose_files_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_file": { + "name": "env_file", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detected_by": { + "name": "detected_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_scan_at": { + "name": "last_scan_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_update_at": { + "name": "last_update_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "docker_compose_stacks_machine_id_machines_id_fk": { + "name": "docker_compose_stacks_machine_id_machines_id_fk", + "tableFrom": "docker_compose_stacks", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "docker_settings": { + "name": "docker_settings", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "scan_depth": { + "name": "scan_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 4 + }, + "prune_mode": { + "name": "prune_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'safe'" + }, + "last_scan_at": { + "name": "last_scan_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_pull_check_at": { + "name": "last_pull_check_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "docker_settings_machine_id_machines_id_fk": { + "name": "docker_settings_machine_id_machines_id_fk", + "tableFrom": "docker_settings", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "docker_stack_services": { + "name": "docker_stack_services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "stack_id": { + "name": "stack_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_image_id": { + "name": "current_image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_digest": { + "name": "current_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "candidate_image_id": { + "name": "candidate_image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "candidate_digest": { + "name": "candidate_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version_label": { + "name": "version_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "docker_stack_services_stack_id_docker_compose_stacks_id_fk": { + "name": "docker_stack_services_stack_id_docker_compose_stacks_id_fk", + "tableFrom": "docker_stack_services", + "tableTo": "docker_compose_stacks", + "columnsFrom": [ + "stack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "executions": { + "name": "executions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_json": { + "name": "result_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "important_json": { + "name": "important_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_path": { + "name": "report_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_log_path": { + "name": "raw_log_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_id": { + "name": "report_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_kind": { + "name": "error_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "executions_machine_id_machines_id_fk": { + "name": "executions_machine_id_machines_id_fk", + "tableFrom": "executions", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "important_messages": { + "name": "important_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_line_ref": { + "name": "raw_line_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acknowledged": { + "name": "acknowledged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "important_messages_machine_id_machines_id_fk": { + "name": "important_messages_machine_id_machines_id_fk", + "tableFrom": "important_messages", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_credentials": { + "name": "machine_credentials", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "auth_method": { + "name": "auth_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enc_password": { + "name": "enc_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enc_sudo_password": { + "name": "enc_sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enc_private_key": { + "name": "enc_private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enc_key_passphrase": { + "name": "enc_key_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sudo_mode": { + "name": "sudo_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_test_at": { + "name": "last_test_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "machine_credentials_machine_id_machines_id_fk": { + "name": "machine_credentials_machine_id_machines_id_fk", + "tableFrom": "machine_credentials", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_events": { + "name": "machine_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "machine_events_machine_id_machines_id_fk": { + "name": "machine_events_machine_id_machines_id_fk", + "tableFrom": "machine_events", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_hardware": { + "name": "machine_hardware", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "probe_snapshot_id": { + "name": "probe_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_model": { + "name": "cpu_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_cores": { + "name": "cpu_cores", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_bytes": { + "name": "memory_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gpus_json": { + "name": "gpus_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disks_json": { + "name": "disks_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "network_json": { + "name": "network_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firmware_json": { + "name": "firmware_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "driver_json": { + "name": "driver_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "warnings_json": { + "name": "warnings_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "machine_hardware_machine_id_machines_id_fk": { + "name": "machine_hardware_machine_id_machines_id_fk", + "tableFrom": "machine_hardware", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_host_keys": { + "name": "machine_host_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_type": { + "name": "key_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fingerprint_sha256": { + "name": "fingerprint_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "machine_host_keys_machine_id_machines_id_fk": { + "name": "machine_host_keys_machine_id_machines_id_fk", + "tableFrom": "machine_host_keys", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_metrics_latest": { + "name": "machine_metrics_latest", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collected_at": { + "name": "collected_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cpu_load1": { + "name": "cpu_load1", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_load5": { + "name": "cpu_load5", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_cores": { + "name": "cpu_cores", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_total_bytes": { + "name": "memory_total_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_used_bytes": { + "name": "memory_used_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_available_bytes": { + "name": "memory_available_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_used_percent": { + "name": "memory_used_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filesystems_json": { + "name": "filesystems_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_used_percent": { + "name": "root_used_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "warnings_json": { + "name": "warnings_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "machine_metrics_latest_machine_id_machines_id_fk": { + "name": "machine_metrics_latest_machine_id_machines_id_fk", + "tableFrom": "machine_metrics_latest", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_state": { + "name": "machine_state", + "columns": { + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apt_status": { + "name": "apt_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "apt_updates_count": { + "name": "apt_updates_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "apt_reboot_required": { + "name": "apt_reboot_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "apt_last_analyze_at": { + "name": "apt_last_analyze_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_status": { + "name": "docker_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_installed": { + "name": "docker_installed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "docker_stacks_count": { + "name": "docker_stacks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "docker_updates_count": { + "name": "docker_updates_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "docker_prune_available": { + "name": "docker_prune_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "post_install_status": { + "name": "post_install_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics_last_collected_at": { + "name": "metrics_last_collected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_load1": { + "name": "cpu_load1", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_used_percent": { + "name": "memory_used_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_used_percent": { + "name": "root_used_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_warnings_count": { + "name": "disk_warnings_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "hardware_warnings_count": { + "name": "hardware_warnings_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "running_job_id": { + "name": "running_job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_kind": { + "name": "last_error_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "machine_state_machine_id_machines_id_fk": { + "name": "machine_state_machine_id_machines_id_fk", + "tableFrom": "machine_state", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machines": { + "name": "machines", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 22 + }, + "os_family": { + "name": "os_family", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "os_version": { + "name": "os_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "os_codename": { + "name": "os_codename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arch": { + "name": "arch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_kind": { + "name": "machine_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "virtualization": { + "name": "virtualization", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hardware_profile": { + "name": "hardware_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enc_password": { + "name": "enc_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enc_sudo_password": { + "name": "enc_sudo_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "apt_proxy_mode": { + "name": "apt_proxy_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "apt_proxy_url": { + "name": "apt_proxy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "raw_artifacts": { + "name": "raw_artifacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned": { + "name": "pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "redacted": { + "name": "redacted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delete_reason": { + "name": "delete_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "raw_artifacts_machine_id_machines_id_fk": { + "name": "raw_artifacts_machine_id_machines_id_fk", + "tableFrom": "raw_artifacts", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reports": { + "name": "reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned": { + "name": "pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "summary_json": { + "name": "summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "reports_machine_id_machines_id_fk": { + "name": "reports_machine_id_machines_id_fk", + "tableFrom": "reports", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "snapshots": { + "name": "snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'apt_update_analyze'" + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "important_json": { + "name": "important_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_log_path": { + "name": "raw_log_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_artifact_id": { + "name": "raw_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_job_id": { + "name": "source_job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "snapshots_machine_id_machines_id_fk": { + "name": "snapshots_machine_id_machines_id_fk", + "tableFrom": "snapshots", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/server/db/migrations/meta/_journal.json b/server/db/migrations/meta/_journal.json index 0861e6d..f4198ac 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1780669200000, "tag": "0003_magical_psylocke", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1780684150263, + "tag": "0004_thin_ted_forrester", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema.test.ts b/server/db/schema.test.ts index a302583..6b7b13a 100644 --- a/server/db/schema.test.ts +++ b/server/db/schema.test.ts @@ -57,3 +57,39 @@ describe("schéma Phase 2", () => { expect(columnNames(sqlite, "machines")).toContain("enc_password"); }); }); + +describe("schéma SJ-4 Docker", () => { + it("crée les tables docker_*", () => { + const sqlite = freshMigratedDb(); + const tables = tableNames(sqlite); + for (const t of [ + "docker_settings", + "docker_compose_roots", + "docker_compose_stacks", + "docker_stack_services", + ]) { + expect(tables, `table ${t}`).toContain(t); + } + }); + + it("docker_settings a les colonnes attendues", () => { + const sqlite = freshMigratedDb(); + expect(columnNames(sqlite, "docker_settings")).toEqual( + expect.arrayContaining(["machine_id", "enabled", "scan_depth", "prune_mode", "last_scan_at", "updated_at"]), + ); + }); + + it("docker_compose_stacks a les colonnes attendues", () => { + const sqlite = freshMigratedDb(); + expect(columnNames(sqlite, "docker_compose_stacks")).toEqual( + expect.arrayContaining(["id", "machine_id", "name", "working_dir", "compose_files_json", "status", "detected_by"]), + ); + }); + + it("docker_stack_services a les colonnes attendues", () => { + const sqlite = freshMigratedDb(); + expect(columnNames(sqlite, "docker_stack_services")).toEqual( + expect.arrayContaining(["id", "stack_id", "service_name", "image_ref", "current_image_id", "current_digest"]), + ); + }); +}); diff --git a/server/db/schema.ts b/server/db/schema.ts index d08cc30..c3b2cd3 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -226,3 +226,54 @@ export const machineHostKeys = sqliteTable("machine_host_keys", { firstSeenAt: text("first_seen_at").notNull(), lastSeenAt: text("last_seen_at").notNull(), }); + +// --- SJ-4 : Docker (passif) --- +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(), +}); diff --git a/server/services/dockerScan.test.ts b/server/services/dockerScan.test.ts new file mode 100644 index 0000000..ddbee3b --- /dev/null +++ b/server/services/dockerScan.test.ts @@ -0,0 +1,29 @@ +// 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" }]); + }); + + it("retourne des listes vides si rien n'est trouvé", () => { + const r = parseDockerScan("===SU:DOCKER_SCAN===\n===SU:DOCKER_LABELS===\n===SU:EXIT=0==="); + expect(r.stacks).toHaveLength(0); + expect(r.active).toHaveLength(0); + }); +}); diff --git a/server/services/dockerScan.ts b/server/services/dockerScan.ts new file mode 100644 index 0000000..f925af5 --- /dev/null +++ b/server/services/dockerScan.ts @@ -0,0 +1,133 @@ +// 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 { + const out: Record = {}; + 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 { + 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; +} diff --git a/server/templates/render.test.ts b/server/templates/render.test.ts index 1bfaf76..a3a70d8 100644 --- a/server/templates/render.test.ts +++ b/server/templates/render.test.ts @@ -20,4 +20,17 @@ describe("renderTemplate", () => { expect(out).toContain("===SU:APT_SIM_DISTUPGRADE==="); expect(out).toContain("apt-mark showhold"); }); + + 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%>"); + }); + + it("rétro-compat : les templates APT ({{ }}) restent fonctionnels", () => { + const out = renderTemplate("apt/check.sh.tpl", { aptProxy: "http://proxy:3142" }); + expect(out).toContain("http://proxy:3142"); + expect(out).not.toContain("{{"); + }); }); diff --git a/server/templates/render.ts b/server/templates/render.ts index 73ff080..168f4ce 100644 --- a/server/templates/render.ts +++ b/server/templates/render.ts @@ -7,12 +7,23 @@ const TEMPLATES_ROOT = resolve(process.cwd(), "templates"); export interface TemplateVars { aptProxy?: string | null; + // Docker template vars + composeRoots?: string | number | null; + composeScanDepth?: string | number | null; + stackDir?: string | null; } -export function renderTemplate(relPath: string, vars: TemplateVars): string { +export function renderTemplate( + relPath: string, + vars: TemplateVars, + opts?: { tags?: [string, string] }, +): string { const tpl = readFileSync(resolve(TEMPLATES_ROOT, relPath), "utf8"); - // Mustache échappe le HTML par défaut; on désactive (ce sont des scripts shell). - return Mustache.render(tpl, vars, {}, { escape: (s) => s }); + // 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Mustache.render(tpl, vars, {}, { escape: (s: any) => s, ...(tags ? { tags } : {}) } as any); } /** Existence par défaut d'un template relatif à templates/. */ diff --git a/templates/docker/inspect-compose.sh.tpl b/templates/docker/inspect-compose.sh.tpl new file mode 100644 index 0000000..d3a7701 --- /dev/null +++ b/templates/docker/inspect-compose.sh.tpl @@ -0,0 +1,16 @@ +#!/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===" diff --git a/templates/docker/scan-compose.sh.tpl b/templates/docker/scan-compose.sh.tpl new file mode 100644 index 0000000..c5da636 --- /dev/null +++ b/templates/docker/scan-compose.sh.tpl @@ -0,0 +1,28 @@ +#!/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==="