diff --git a/docs/superpowers/plans/2026-06-06-tache2-sj6-docker-apply-prune-down.md b/docs/superpowers/plans/2026-06-06-tache2-sj6-docker-apply-prune-down.md new file mode 100644 index 0000000..35ec37d --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-tache2-sj6-docker-apply-prune-down.md @@ -0,0 +1,45 @@ +# Tâche 2 — SJ-6 : Docker apply / prune / down + socle action_requests + +> Statut : **implémenté** (2026-06-06). tsc 0 erreur · 91 tests · build OK · boot OK (migrations 0000→0005). +> Réf. design : `docs/design/tache2/20-docker.md §4.4-4.6`, `40-contrats-json.md §4`, `70-securite.md §2`, `80-sous-jalons.md` SJ-6. + +## Périmètre livré + +Actions Docker **destructives** (recrée/supprime) protégées par un socle de +**validation explicite** (`action_requests`) : Hermes/UI proposent, l'opérateur approuve, +l'exécution part en arrière-plan. Aucune de ces actions n'est accessible directement +via `POST /:id/actions` (allowlist passive uniquement). + +## Composants + +- **Migration 0005** (`0005_silent_drax.sql`, timestamp monotone) : tables + `docker_image_events` (historique pulled/recreated/pruned + bytes) et + `action_requests` (pending|approved|rejected|executed|expired). +- **Templates** `docker/apply-compose.sh.tpl` (`up -d --remove-orphans`), + `docker/prune-images.sh.tpl` (safe par défaut / `<%#aggressive%>` = `-a --filter until=168h`), + `docker/down-compose.sh.tpl` (down simple, **`--volumes`/`--rmi` interdits**). +- **`server/services/dockerApply.ts`** : + - parsers purs (TDD) : `parseDockerApply` (recreated/running/exited via ps json), + `parseDockerPrune` (`imagesDeleted` + `Total reclaimed space` → octets), + `parseDockerDown` (removed), `parseHumanBytes` (unités décimales Docker). + - orchestration : `applyStack` / `pruneImages` / `downStack` — réservées aux stacks + `enabled`, insèrent les `docker_image_events`. Erreurs nettoyées (réutilise `cleanDockerError`). +- **`server/services/actionRequests.ts`** : `createActionRequest` (refuse une action non + destructive, exige `stackId` pour apply/down), `approve` (→ `runAction` en tâche de fond, + pose `executionId`/`executed`), `reject`, `get`, `list`. +- **Routes** `server/routes/actionRequests.ts` (montées à la racine `/api`) : + `POST /machines/:id/action-requests`, `GET …`, `GET/POST /action-requests/:id[/approve|/reject]`. +- **`execute.ts`** : `RunActionOpts.aggressive`, branches `docker_compose_apply` / + `docker_prune_images` / `docker_compose_down`, helper `archiveExecution` mutualisant + le boilerplate (log/rapport/DB/état/event) + `ExecutionResult.docker.up|prune`. + +## Sécurité + +- Destructives **hors API directe** : passent obligatoirement par un `action_request` approuvé. +- `down` sans volumes ni rmi (volumes préservés). Prune agressif = risque distinct (champ `aggressive`). +- Erreurs Docker nettoyées (URL/token/password) avant UI/MCP. + +## Reste tâche 2 + +SJ-7 (profils Proxmox/RPi + proxy persistent), SJ-8/9 (post-install). UI des boutons +validés (Appliquer/Prune/Down) = tâche 3 (frontend, design system). diff --git a/server/db/migrations/0005_silent_drax.sql b/server/db/migrations/0005_silent_drax.sql new file mode 100644 index 0000000..14d3b57 --- /dev/null +++ b/server/db/migrations/0005_silent_drax.sql @@ -0,0 +1,34 @@ +CREATE TABLE `action_requests` ( + `id` text PRIMARY KEY NOT NULL, + `machine_id` text, + `requested_by_type` text NOT NULL, + `requested_by_id` text, + `action` text NOT NULL, + `risk` text, + `status` text NOT NULL, + `summary` text, + `payload_json` text, + `created_at` text NOT NULL, + `approved_at` text, + `approved_by` text, + `execution_id` text, + `expires_at` text, + FOREIGN KEY (`machine_id`) REFERENCES `machines`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `docker_image_events` ( + `id` text PRIMARY KEY NOT NULL, + `execution_id` text, + `machine_id` text NOT NULL, + `stack_id` text, + `service_name` text, + `image_ref` text, + `from_image_id` text, + `to_image_id` text, + `from_digest` text, + `to_digest` text, + `operation` text, + `bytes_reclaimed` integer, + `created_at` text NOT NULL, + FOREIGN KEY (`execution_id`) REFERENCES `executions`(`id`) ON UPDATE no action ON DELETE set null +); diff --git a/server/db/migrations/meta/0005_snapshot.json b/server/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..c3fbba4 --- /dev/null +++ b/server/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,2186 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c9c200ce-086a-4c2c-8b95-1c295ab7ef2b", + "prevId": "9bf2de9b-82f5-4a2e-91c8-1356847df53f", + "tables": { + "action_requests": { + "name": "action_requests", + "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 + }, + "requested_by_type": { + "name": "requested_by_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "action_requests_machine_id_machines_id_fk": { + "name": "action_requests_machine_id_machines_id_fk", + "tableFrom": "action_requests", + "tableTo": "machines", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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_image_events": { + "name": "docker_image_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack_id": { + "name": "stack_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_image_id": { + "name": "from_image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_image_id": { + "name": "to_image_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_digest": { + "name": "from_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "to_digest": { + "name": "to_digest", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bytes_reclaimed": { + "name": "bytes_reclaimed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "docker_image_events_execution_id_executions_id_fk": { + "name": "docker_image_events_execution_id_executions_id_fk", + "tableFrom": "docker_image_events", + "tableTo": "executions", + "columnsFrom": [ + "execution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 f4198ac..95b9306 100644 --- a/server/db/migrations/meta/_journal.json +++ b/server/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1780684150263, "tag": "0004_thin_ted_forrester", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1780718324238, + "tag": "0005_silent_drax", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/db/schema.ts b/server/db/schema.ts index c3b2cd3..30adf34 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -277,3 +277,38 @@ export const dockerStackServices = sqliteTable("docker_stack_services", { status: text("status"), // up_to_date | updates_available | error updatedAt: text("updated_at").notNull(), }); + +// SJ-6 : historique pull/apply/prune (tache1.9.md §8). +export const dockerImageEvents = sqliteTable("docker_image_events", { + id: text("id").primaryKey(), + executionId: text("execution_id").references(() => executions.id, { onDelete: "set null" }), + machineId: text("machine_id").notNull(), + stackId: text("stack_id"), + serviceName: text("service_name"), + imageRef: text("image_ref"), + fromImageId: text("from_image_id"), + toImageId: text("to_image_id"), + fromDigest: text("from_digest"), + toDigest: text("to_digest"), + operation: text("operation"), // pulled | recreated | pruned + bytesReclaimed: integer("bytes_reclaimed"), + createdAt: text("created_at").notNull(), +}); + +// SJ-6 : demandes d'actions destructives à valider (UI/Hermes) (tache1.9.md §10). +export const actionRequests = sqliteTable("action_requests", { + id: text("id").primaryKey(), + machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }), + requestedByType: text("requested_by_type").notNull(), // user | hermes | schedule + requestedById: text("requested_by_id"), + action: text("action").notNull(), + risk: text("risk"), + status: text("status").notNull(), // pending | approved | rejected | executed | expired + summary: text("summary"), + payloadJson: text("payload_json"), + createdAt: text("created_at").notNull(), + approvedAt: text("approved_at"), + approvedBy: text("approved_by"), + executionId: text("execution_id"), + expiresAt: text("expires_at"), +}); diff --git a/server/routes/actionRequests.ts b/server/routes/actionRequests.ts new file mode 100644 index 0000000..35db39b --- /dev/null +++ b/server/routes/actionRequests.ts @@ -0,0 +1,63 @@ +// server/routes/actionRequests.ts +import { Hono } from "hono"; +import { + createActionRequest, + getActionRequest, + listActionRequests, + approveActionRequest, + rejectActionRequest, +} from "../services/actionRequests.js"; +import type { ActionType } from "@shared/types.js"; + +export const actionRequestsRoutes = new Hono(); + +// Crée une demande d'action destructive (pending). Hermes/UI proposent ; aucune exécution ici. +actionRequestsRoutes.post("/machines/:id/action-requests", async (c) => { + const body = (await c.req.json()) as { + action: ActionType; + stackId?: string; + aggressive?: boolean; + summary?: string; + requestedByType?: "user" | "hermes" | "schedule"; + }; + try { + const req = createActionRequest({ + machineId: c.req.param("id"), + action: body.action, + requestedByType: body.requestedByType, + summary: body.summary, + payload: { stackId: body.stackId, aggressive: body.aggressive }, + }); + return c.json(req, 201); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } +}); + +actionRequestsRoutes.get("/machines/:id/action-requests", (c) => + c.json(listActionRequests(c.req.param("id"))), +); + +actionRequestsRoutes.get("/action-requests/:reqId", (c) => { + const req = getActionRequest(c.req.param("reqId")); + return req ? c.json(req) : c.json({ error: "Demande introuvable" }, 404); +}); + +// Validation opérateur → déclenche l'exécution en arrière-plan. +actionRequestsRoutes.post("/action-requests/:reqId/approve", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { approvedBy?: string }; + try { + return c.json(approveActionRequest(c.req.param("reqId"), body.approvedBy), 202); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } +}); + +actionRequestsRoutes.post("/action-requests/:reqId/reject", async (c) => { + const body = (await c.req.json().catch(() => ({}))) as { by?: string }; + try { + return c.json(rejectActionRequest(c.req.param("reqId"), body.by)); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } +}); diff --git a/server/routes/index.ts b/server/routes/index.ts index bc5a186..3a4fe63 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { machinesRoutes } from "./machines.js"; import { actionsRoutes } from "./actions.js"; +import { actionRequestsRoutes } from "./actionRequests.js"; import { getServerCapabilities } from "../services/capabilities.js"; import { getSystemMetrics, getSystemStatus } from "../services/system.js"; @@ -11,3 +12,4 @@ api.get("/system/status", (c) => c.json(getSystemStatus())); api.get("/system/metrics", (c) => c.json(getSystemMetrics())); api.route("/machines", machinesRoutes); api.route("/machines", actionsRoutes); +api.route("/", actionRequestsRoutes); diff --git a/server/services/actionRequests.ts b/server/services/actionRequests.ts new file mode 100644 index 0000000..da91f77 --- /dev/null +++ b/server/services/actionRequests.ts @@ -0,0 +1,118 @@ +// server/services/actionRequests.ts +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { db, schema } from "../db/client.js"; +import { runAction, type RunActionOpts } from "./execute.js"; +import { recordEvent } from "./machineState.js"; +import type { ActionType } from "@shared/types.js"; + +// Actions destructives nécessitant une validation explicite (70-securite.md §2). +export const DESTRUCTIVE_ACTIONS: Partial> = { + docker_compose_apply: "medium", + docker_prune_images: "medium", + docker_compose_down: "high", + apt_full_upgrade: "medium", + apt_dist_upgrade: "medium", + apt_autoremove: "medium", + reboot: "high", + reboot_verified: "high", +}; + +const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"]; + +export interface CreateRequestInput { + machineId: string; + action: ActionType; + requestedByType?: "user" | "hermes" | "schedule"; + requestedById?: string | null; + summary?: string | null; + payload?: { stackId?: string; aggressive?: boolean } | null; +} + +export function createActionRequest(input: CreateRequestInput) { + const risk = DESTRUCTIVE_ACTIONS[input.action]; + if (!risk) throw new Error(`Action non destructive ou inconnue : ${input.action}`); + if (NEED_STACK.includes(input.action) && !input.payload?.stackId) { + throw new Error("stackId requis pour cette action"); + } + const id = randomUUID(); + const now = new Date().toISOString(); + db.insert(schema.actionRequests).values({ + id, + machineId: input.machineId, + requestedByType: input.requestedByType ?? "user", + requestedById: input.requestedById ?? null, + action: input.action, + risk, + status: "pending", + summary: input.summary ?? `Demande ${input.action}`, + payloadJson: input.payload ? JSON.stringify(input.payload) : null, + createdAt: now, + }).run(); + recordEvent({ + machineId: input.machineId, + eventType: "action_request_created", + severity: "info", + message: `Demande ${input.action} (risque ${risk}) en attente de validation`, + }); + return getActionRequest(id); +} + +export function getActionRequest(id: string) { + return db.select().from(schema.actionRequests).where(eq(schema.actionRequests.id, id)).get(); +} + +export function listActionRequests(machineId?: string) { + const q = db.select().from(schema.actionRequests); + const rows = machineId + ? q.where(eq(schema.actionRequests.machineId, machineId)).all() + : q.all(); + return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +export function rejectActionRequest(id: string, by?: string) { + const req = getActionRequest(id); + if (!req) throw new Error("Demande introuvable"); + if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`); + db.update(schema.actionRequests) + .set({ status: "rejected", approvedAt: new Date().toISOString(), approvedBy: by ?? null }) + .where(eq(schema.actionRequests.id, id)) + .run(); + return getActionRequest(id); +} + +/** + * Approuve une demande et déclenche l'action en arrière-plan. Renvoie immédiatement + * la demande passée à `approved` ; `executionId`/`executed` sont posés à la fin du run. + */ +export function approveActionRequest(id: string, approvedBy?: string) { + const req = getActionRequest(id); + if (!req) throw new Error("Demande introuvable"); + if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`); + if (!req.machineId) throw new Error("Demande sans machine"); + const now = new Date().toISOString(); + db.update(schema.actionRequests) + .set({ status: "approved", approvedAt: now, approvedBy: approvedBy ?? null }) + .where(eq(schema.actionRequests.id, id)) + .run(); + + const payload = req.payloadJson ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean }) : {}; + const opts: RunActionOpts = { stackId: payload.stackId, aggressive: payload.aggressive }; + const machineId = req.machineId; + runAction(machineId, req.action as ActionType, opts) + .then((result) => { + db.update(schema.actionRequests) + .set({ status: "executed", executionId: result.executionId }) + .where(eq(schema.actionRequests.id, id)) + .run(); + }) + .catch((err) => { + recordEvent({ + machineId, + eventType: "action_request_failed", + severity: "error", + message: `Demande ${req.action} échouée : ${(err as Error).message}`, + }); + }); + return getActionRequest(id); +} diff --git a/server/services/dockerApply.test.ts b/server/services/dockerApply.test.ts new file mode 100644 index 0000000..aea284c --- /dev/null +++ b/server/services/dockerApply.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { + parseDockerApply, + parseDockerPrune, + parseDockerDown, + parseHumanBytes, +} from "./dockerApply.js"; + +describe("parseDockerApply", () => { + const RAW = [ + "===SU:DOCKER_APPLY===", + " Container media-app-1 Recreate", + " Container media-app-1 Recreated", + " Container media-worker-1 Created", + " Container media-db-1 Running", + " Container media-app-1 Started", + "===SU:DOCKER_PS_AFTER===", + '{"Name":"media-app-1","Service":"app","State":"running","Health":""}', + '{"Name":"media-db-1","Service":"db","State":"running","Health":"healthy"}', + '{"Name":"media-worker-1","Service":"worker","State":"exited","Health":""}', + "===SU:DOCKER_INSPECT_AFTER===", + "IMG\tsha256:newapp\tapp@sha256:dapp", + "IMG\tsha256:db\tdb@sha256:ddb", + "===SU:EXIT=0===", + ].join("\n"); + + it("liste les conteneurs recréés/créés et l'état running/exited", () => { + const r = parseDockerApply(RAW); + expect(r.recreated.sort()).toEqual(["media-app-1", "media-worker-1"]); + expect(r.running.sort()).toEqual(["media-app-1", "media-db-1"]); + expect(r.exited).toEqual(["media-worker-1"]); + expect(r.errors).toHaveLength(0); + expect(r.exitCode).toBe(0); + }); + + it("remonte une erreur d'application nettoyée", () => { + const bad = [ + "===SU:DOCKER_APPLY===", + ' Container app-1 Error pulling image from https://reg.example/v2 token=SECRET123', + "===SU:DOCKER_PS_AFTER===", + "===SU:DOCKER_INSPECT_AFTER===", + "===SU:EXIT=1===", + ].join("\n"); + const r = parseDockerApply(bad); + expect(r.errors.length).toBeGreaterThan(0); + expect(r.errors[0]!.message).not.toContain("reg.example"); + expect(r.errors[0]!.message).not.toContain("SECRET123"); + expect(r.exitCode).toBe(1); + }); +}); + +describe("parseHumanBytes", () => { + it("convertit les unités décimales Docker", () => { + expect(parseHumanBytes("0B")).toBe(0); + expect(parseHumanBytes("512MB")).toBe(512_000_000); + expect(parseHumanBytes("1.234GB")).toBe(Math.round(1.234 * 1e9)); + expect(parseHumanBytes("1.5kB")).toBe(1500); + }); + it("renvoie 0 pour une entrée illisible", () => { + expect(parseHumanBytes("n/a")).toBe(0); + }); +}); + +describe("parseDockerPrune", () => { + const RAW = [ + "===SU:DOCKER_PRUNE===", + "Deleted Images:", + "untagged: redis:6", + "deleted: sha256:aaa", + "deleted: sha256:bbb", + "", + "Total reclaimed space: 1.234GB", + "===SU:EXIT=0===", + ].join("\n"); + + it("liste les images supprimées et l'espace récupéré", () => { + const r = parseDockerPrune(RAW); + expect(r.imagesDeleted).toEqual(["sha256:aaa", "sha256:bbb"]); + expect(r.bytesReclaimed).toBe(Math.round(1.234 * 1e9)); + expect(r.errors).toHaveLength(0); + }); +}); + +describe("parseDockerDown", () => { + it("liste les conteneurs retirés", () => { + const RAW = [ + "===SU:DOCKER_DOWN===", + " Container media-app-1 Stopping", + " Container media-app-1 Stopped", + " Container media-app-1 Removing", + " Container media-app-1 Removed", + " Network media_default Removed", + "===SU:EXIT=0===", + ].join("\n"); + const r = parseDockerDown(RAW); + expect(r.removed).toEqual(["media-app-1"]); + expect(r.errors).toHaveLength(0); + }); +}); diff --git a/server/services/dockerApply.ts b/server/services/dockerApply.ts new file mode 100644 index 0000000..eb55e01 --- /dev/null +++ b/server/services/dockerApply.ts @@ -0,0 +1,289 @@ +// server/services/dockerApply.ts +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +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"; +import { cleanDockerError } from "./dockerPull.js"; +import type { SnapshotError } from "@shared/types.js"; + +// ---------------------------------------------------------------------------- +// Fonctions pures (testables). +// ---------------------------------------------------------------------------- + +function section(raw: string, start: string, end?: string): string { + const i = raw.indexOf(start); + if (i < 0) return ""; + const from = i + start.length; + const j = end ? raw.indexOf(end, from) : -1; + return raw.slice(from, j < 0 ? undefined : j); +} + +const ERROR_RE = /\b(error|unauthorized|denied|forbidden|failed|no such host|connection refused|timeout|cannot)\b/i; + +function collectErrors(text: string, kind: string): SnapshotError[] { + const seen = new Set(); + const out: SnapshotError[] = []; + for (const line of text.split("\n")) { + if (!ERROR_RE.test(line)) continue; + const message = cleanDockerError(line); + if (!message || seen.has(message)) continue; + seen.add(message); + out.push({ source: "docker", kind, severity: "error", message }); + } + return out; +} + +function exitOf(raw: string): number | null { + const m = /===SU:EXIT=(\d+)===/.exec(raw); + return m ? Number(m[1]) : null; +} + +export interface DockerApplyParsed { + recreated: string[]; + running: string[]; + exited: string[]; + imagesAfter: { id: string | null; digests: string | null }[]; + errors: SnapshotError[]; + exitCode: number | null; +} + +export function parseDockerApply(raw: string): DockerApplyParsed { + const applySec = section(raw, "===SU:DOCKER_APPLY===", "===SU:DOCKER_PS_AFTER==="); + const psSec = section(raw, "===SU:DOCKER_PS_AFTER===", "===SU:DOCKER_INSPECT_AFTER==="); + const inspectSec = section(raw, "===SU:DOCKER_INSPECT_AFTER===", "===SU:EXIT="); + + const recreated = new Set(); + for (const m of applySec.matchAll(/Container\s+(\S+)\s+(Recreated|Created)\s*$/gm)) { + if (m[1]) recreated.add(m[1]); + } + + const running: string[] = []; + const exited: string[] = []; + const psLines = psSec.trim(); + const records: { Name?: string; State?: string }[] = []; + if (psLines.startsWith("[")) { + try { + records.push(...(JSON.parse(psLines) as typeof records)); + } catch { + /* ignore */ + } + } else { + for (const line of psLines.split("\n")) { + const t = line.trim(); + if (!t.startsWith("{")) continue; + try { + records.push(JSON.parse(t)); + } catch { + /* ignore */ + } + } + } + for (const r of records) { + if (!r.Name) continue; + if (r.State === "running") running.push(r.Name); + else if (r.State === "exited") exited.push(r.Name); + } + + const imagesAfter: DockerApplyParsed["imagesAfter"] = []; + for (const line of inspectSec.split("\n")) { + if (!line.startsWith("IMG\t")) continue; + const parts = line.split("\t"); + imagesAfter.push({ id: parts[1] || null, digests: parts[2] || null }); + } + + return { + recreated: [...recreated], + running, + exited, + imagesAfter, + errors: collectErrors(applySec, "compose_apply_failed"), + exitCode: exitOf(raw), + }; +} + +/** Convertit une taille humaine Docker (décimale) en octets. */ +export function parseHumanBytes(s: string): number { + const m = /([\d.]+)\s*([kKMGTP]?i?B)/.exec(s.trim()); + if (!m) return 0; + const value = Number(m[1]); + if (!Number.isFinite(value)) return 0; + const unit = (m[2] ?? "B").toUpperCase(); + const mult: Record = { + B: 1, + KB: 1e3, + MB: 1e6, + GB: 1e9, + TB: 1e12, + PB: 1e15, + }; + return Math.round(value * (mult[unit] ?? 1)); +} + +export interface DockerPruneParsed { + imagesDeleted: string[]; + bytesReclaimed: number; + errors: SnapshotError[]; + exitCode: number | null; +} + +export function parseDockerPrune(raw: string): DockerPruneParsed { + const sec = section(raw, "===SU:DOCKER_PRUNE===", "===SU:EXIT="); + const imagesDeleted: string[] = []; + let bytesReclaimed = 0; + for (const line of sec.split("\n")) { + const del = /^deleted:\s+(\S+)/.exec(line.trim()); + if (del?.[1]) imagesDeleted.push(del[1]); + const total = /Total reclaimed space:\s*(.+)$/.exec(line); + if (total?.[1]) bytesReclaimed = parseHumanBytes(total[1]); + } + return { imagesDeleted, bytesReclaimed, errors: collectErrors(sec, "prune_failed"), exitCode: exitOf(raw) }; +} + +export interface DockerDownParsed { + removed: string[]; + errors: SnapshotError[]; + exitCode: number | null; +} + +export function parseDockerDown(raw: string): DockerDownParsed { + const sec = section(raw, "===SU:DOCKER_DOWN===", "===SU:EXIT="); + const removed = new Set(); + for (const m of sec.matchAll(/Container\s+(\S+)\s+Removed\s*$/gm)) { + if (m[1]) removed.add(m[1]); + } + return { removed: [...removed], errors: collectErrors(sec, "compose_down_failed"), exitCode: exitOf(raw) }; +} + +// ---------------------------------------------------------------------------- +// Orchestration (SSH). Réservé aux stacks `enabled` ; déclenché via action_requests. +// ---------------------------------------------------------------------------- + +function getEnabledStack(machineId: string, stackId: string) { + const stack = db + .select() + .from(schema.dockerComposeStacks) + .where(eq(schema.dockerComposeStacks.id, stackId)) + .get(); + if (!stack || stack.machineId !== machineId) throw new Error("Stack introuvable"); + if (stack.status !== "enabled") throw new Error(`Stack non activé (statut ${stack.status})`); + return stack; +} + +async function runDockerScript( + machineId: string, + rel: string, + vars: Record, + onData?: (c: string) => void, +): Promise { + const m = getMachineRow(machineId); + if (!m) throw new Error("Machine introuvable"); + const script = renderTemplate(rel, vars); + const res = await runScriptSudo( + getCreds(m), + script, + (c) => { + onData?.(c); + outputHub.publish(machineId, c); + }, + 900000, + ); + return res.stdout; +} + +export interface ApplyOutcome { + parsed: DockerApplyParsed; + raw: string; + stackName: string; + events: typeof schema.dockerImageEvents.$inferInsert[]; +} + +/** `docker compose up -d --remove-orphans` sur un stack enabled + persistance des events. */ +export async function applyStack( + machineId: string, + stackId: string, + executionId: string, + onData?: (c: string) => void, +): Promise { + const stack = getEnabledStack(machineId, stackId); + const raw = await runDockerScript(machineId, "docker/apply-compose.sh.tpl", { stackDir: stack.workingDir }, onData); + const parsed = parseDockerApply(raw); + const now = new Date().toISOString(); + const events = parsed.recreated.map((name) => ({ + id: randomUUID(), + executionId, + machineId, + stackId, + serviceName: name, + imageRef: null, + fromImageId: null, + toImageId: null, + fromDigest: null, + toDigest: null, + operation: "recreated", + bytesReclaimed: null, + createdAt: now, + })); + for (const ev of events) db.insert(schema.dockerImageEvents).values(ev).run(); + db.update(schema.dockerComposeStacks) + .set({ lastUpdateAt: now, updatedAt: now }) + .where(eq(schema.dockerComposeStacks.id, stackId)) + .run(); + return { parsed, raw, stackName: stack.name, events }; +} + +export interface PruneOutcome { + parsed: DockerPruneParsed; + raw: string; +} + +/** `docker image prune` (safe par défaut, agressif si demandé) + event pruned. */ +export async function pruneImages( + machineId: string, + executionId: string, + aggressive: boolean, + onData?: (c: string) => void, +): Promise { + const raw = await runDockerScript(machineId, "docker/prune-images.sh.tpl", { aggressive }, onData); + const parsed = parseDockerPrune(raw); + if (parsed.imagesDeleted.length > 0 || parsed.bytesReclaimed > 0) { + db.insert(schema.dockerImageEvents) + .values({ + id: randomUUID(), + executionId, + machineId, + stackId: null, + serviceName: null, + imageRef: null, + fromImageId: null, + toImageId: null, + fromDigest: null, + toDigest: null, + operation: "pruned", + bytesReclaimed: parsed.bytesReclaimed, + createdAt: new Date().toISOString(), + }) + .run(); + } + return { parsed, raw }; +} + +export interface DownOutcome { + parsed: DockerDownParsed; + raw: string; + stackName: string; +} + +/** `docker compose down` (sans volumes/rmi) sur un stack enabled. */ +export async function downStack( + machineId: string, + stackId: string, + onData?: (c: string) => void, +): Promise { + const stack = getEnabledStack(machineId, stackId); + const raw = await runDockerScript(machineId, "docker/down-compose.sh.tpl", { stackDir: stack.workingDir }, onData); + const parsed = parseDockerDown(raw); + return { parsed, raw, stackName: stack.name }; +} diff --git a/server/services/dockerPull.ts b/server/services/dockerPull.ts index 71ae270..d406de4 100644 --- a/server/services/dockerPull.ts +++ b/server/services/dockerPull.ts @@ -41,7 +41,7 @@ export interface DockerPullResult { const nz = (s: string | undefined): string | null => (s && s.length ? s : null); /** Retire URLs et secrets (token/bearer/password) d'une ligne d'erreur Docker. */ -function cleanDockerError(line: string): string { +export function cleanDockerError(line: string): string { return line .replace(/https?:\/\/\S+/gi, "") .replace(/\b(token|bearer|authorization|auth|password|passwd|secret|key)=\S+/gi, "$1=") diff --git a/server/services/execute.ts b/server/services/execute.ts index a2343ba..8745e19 100644 --- a/server/services/execute.ts +++ b/server/services/execute.ts @@ -32,6 +32,74 @@ const TEMPLATE_FOR: Partial> = { export interface RunActionOpts { stackId?: string; + aggressive?: boolean; // docker_prune_images +} + +/** + * Archive une exécution terminée (log brut + rapport + lignes DB + état machine + + * event) et renvoie l'ExecutionResult. Mutualise le boilerplate des branches Docker. + */ +function archiveExecution(args: { + machineId: string; + machineName: string; + executionId: string; + action: ActionType; + startedAt: string; + status: ExecutionStatus; + raw: string; + importantLines: string[]; + docker?: ExecutionResult["docker"]; + errors?: ExecutionResult["errors"]; +}): ExecutionResult { + const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args; + const finishedAt = new Date().toISOString(); + const dir = join(env.reportsDir, machineId); + mkdirSync(dir, { recursive: true }); + const rawLogPath = join(dir, `${executionId}.log`); + const reportPath = join(dir, `${executionId}.md`); + writeFileSync(rawLogPath, raw || importantLines.join("\n") + "\n", "utf8"); + const result: ExecutionResult = { + executionId, machineId, startedAt, finishedAt, mode: "manual", action, status, + rebootRequiredAfterRun: false, + importantLogLines: importantLines, + rawLogRef: rawLogPath, reportRef: reportPath, + ...(args.docker ? { docker: args.docker } : {}), + ...(args.errors && args.errors.length ? { errors: args.errors } : {}), + }; + writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8"); + const reportId = randomUUID(); + db.update(schema.executions).set({ + finishedAt, status, schemaVersion: 1, + resultJson: JSON.stringify(result), importantJson: JSON.stringify(importantLines), + reportPath, rawLogPath, reportId, + exitCode: status === "ok" ? 0 : 1, + errorKind: status === "error" ? "execution_failed" : null, + errorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null, + }).where(eq(schema.executions.id, executionId)).run(); + db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" }) + .where(eq(schema.machines.id, machineId)).run(); + db.insert(schema.reports).values({ + id: reportId, machineId, executionId, kind: "machine", + title: `${machineName} — ${action}`, path: reportPath, createdAt: finishedAt, + }).run(); + db.insert(schema.rawArtifacts).values({ + id: randomUUID(), machineId, kind: "raw_log", path: rawLogPath, + bytes: statSync(rawLogPath).size, createdAt: finishedAt, + retentionPolicy: status === "error" ? "failed" : "default", + }).run(); + upsertMachineState(machineId, { + status: status === "error" ? "error" : "unknown", + runningJobId: null, + lastErrorKind: status === "error" ? "execution_failed" : null, + lastErrorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null, + }); + recordEvent({ + machineId, eventType: `action_${action}`, + severity: status === "error" ? "error" : "info", + executionId, message: `Action ${action} : ${status}`, + }); + outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`); + return result; } export async function runAction( @@ -201,6 +269,70 @@ export async function runAction( return resultPull; } + // --- SJ-6 : actions Docker destructives (apply / prune / down) --- + if (action === "docker_compose_apply") { + if (!opts?.stackId) throw new Error("docker_compose_apply requiert un stackId"); + const { applyStack } = await import("./dockerApply.js"); + try { + const o = await applyStack(machineId, opts.stackId, executionId, (c) => outputHub.publish(machineId, c)); + const p = o.parsed; + const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok"; + const important = [ + `docker_compose_apply ${o.stackName} : ${p.recreated.length} recréé(s), ${p.running.length} running, ${p.exited.length} exited`, + ...p.recreated.map((n) => ` recreated ${n}`), + ...p.exited.map((n) => ` exited ${n}`), + ...p.errors.map((e) => ` [${e.kind}] ${e.message}`), + ]; + return archiveExecution({ + machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw, + importantLines: important, docker: { up: { recreated: p.recreated, running: p.running, exited: p.exited, ...(p.errors.length ? { errors: p.errors } : {}) } }, + }); + } catch (err) { + return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); + } + } + + if (action === "docker_prune_images") { + const { pruneImages } = await import("./dockerApply.js"); + try { + const o = await pruneImages(machineId, executionId, !!opts?.aggressive, (c) => outputHub.publish(machineId, c)); + const p = o.parsed; + const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok"; + const mb = (p.bytesReclaimed / 1e6).toFixed(1); + const important = [ + `docker_prune_images (${opts?.aggressive ? "agressif" : "safe"}) : ${p.imagesDeleted.length} image(s), ${mb} Mo récupérés`, + ...p.errors.map((e) => ` [${e.kind}] ${e.message}`), + ]; + return archiveExecution({ + machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw, + importantLines: important, docker: { prune: { imagesDeleted: p.imagesDeleted, bytesReclaimed: p.bytesReclaimed, ...(p.errors.length ? { errors: p.errors } : {}) } }, + }); + } catch (err) { + return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); + } + } + + if (action === "docker_compose_down") { + if (!opts?.stackId) throw new Error("docker_compose_down requiert un stackId"); + const { downStack } = await import("./dockerApply.js"); + try { + const o = await downStack(machineId, opts.stackId, (c) => outputHub.publish(machineId, c)); + const p = o.parsed; + const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok"; + const important = [ + `docker_compose_down ${o.stackName} : ${p.removed.length} conteneur(s) retiré(s)`, + ...p.removed.map((n) => ` removed ${n}`), + ...p.errors.map((e) => ` [${e.kind}] ${e.message}`), + ]; + return archiveExecution({ + machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw, + importantLines: important, errors: p.errors, + }); + } catch (err) { + return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); + } + } + const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null; const rel = TEMPLATE_FOR[action]; if (!rel) throw new Error("Action sans template: " + action); diff --git a/templates/docker/apply-compose.sh.tpl b/templates/docker/apply-compose.sh.tpl new file mode 100644 index 0000000..9ae6ad0 --- /dev/null +++ b/templates/docker/apply-compose.sh.tpl @@ -0,0 +1,13 @@ +#!/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_APPLY===" +docker compose up -d --remove-orphans 2>&1 +CODE=$? +echo "===SU:DOCKER_PS_AFTER===" +docker compose ps --format json 2>&1 +echo "===SU:DOCKER_INSPECT_AFTER===" +docker compose config --images 2>/dev/null | while IFS= read -r img; do + docker image inspect "$img" --format 'IMG\t{{.Id}}\t{{join .RepoDigests ","}}' 2>/dev/null || echo "IMG_MISSING\t$img" +done +echo "===SU:EXIT=${CODE}===" diff --git a/templates/docker/down-compose.sh.tpl b/templates/docker/down-compose.sh.tpl new file mode 100644 index 0000000..90ed46f --- /dev/null +++ b/templates/docker/down-compose.sh.tpl @@ -0,0 +1,8 @@ +#!/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_DOWN===" +# --volumes et --rmi INTERDITS au MVP : down simple uniquement (préserve les volumes). +docker compose down 2>&1 +CODE=$? +echo "===SU:EXIT=${CODE}===" diff --git a/templates/docker/prune-images.sh.tpl b/templates/docker/prune-images.sh.tpl new file mode 100644 index 0000000..955156e --- /dev/null +++ b/templates/docker/prune-images.sh.tpl @@ -0,0 +1,13 @@ +#!/bin/sh +export LC_ALL=C +echo "===SU:DOCKER_PRUNE===" +<%#aggressive%> +# Mode agressif : supprime TOUTES les images non référencées (>168h). Validation UI distincte. +docker image prune -a -f --filter "until=168h" 2>&1 +<%/aggressive%> +<%^aggressive%> +# Mode sûr par défaut : images dangling uniquement. +docker image prune -f 2>&1 +<%/aggressive%> +CODE=$? +echo "===SU:EXIT=${CODE}==="