feat(infra): prêt au déploiement GitOps Portainer + Telegram optionnel (v0.32.0)

Compose de prod docker-compose.prod.yml (GitOps, sans env_file, réseau proxy
réel, certresolver letsencrypt) + runbook docs/deploy.md (Phase 1, users
chlova restreints Portainer/n8n). Surface Telegram rendue optionnelle pour un
déploiement UI-only ; garde assertHasSurface fail-closed. Typecheck + 78 tests
verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
This commit is contained in:
Kantin-Petit
2026-06-23 11:25:30 +02:00
parent faa1e82301
commit d824d16eed
7 changed files with 379 additions and 21 deletions
+19 -2
View File
@@ -45,8 +45,10 @@ const schema = z.object({
.default("true")
.transform((v) => v.toLowerCase() !== "false"),
// Surface Telegram
telegramBotToken: nonEmpty, // SECRET
// Surface Telegram (OPTIONNELLE) : si le token est absent, la surface n'est
// pas démarrée. Le boot exige alors une autre surface (API/UI) — voir
// assertHasSurface(). Permet un déploiement UI-only sans bot Telegram.
telegramBotToken: z.string().optional(), // SECRET
telegramAllowedUserIds: z
.string()
.default("")
@@ -162,6 +164,21 @@ export function assertReadOnlyPhase(cfg: Config): void {
}
}
/**
* Au moins une surface doit être active, sinon le cerveau tourne sans entrée :
* démarrage refusé (fail-closed). Surfaces possibles : Telegram (token présent)
* ou API/UI (auth complète). Évite un déploiement « muet » par mégarde.
*/
export function assertHasSurface(cfg: Config): void {
if (!cfg.telegramBotToken && !apiAuth(cfg)) {
throw new Error(
"Aucune surface configurée : fournis TELEGRAM_BOT_TOKEN (bot) " +
"ou les 4 variables API/UI (CHLOVA_ADMIN_USER/_PASSWORD_HASH/" +
"_TOTP_SECRET/_JWT_SECRET). Voir docs/deploy.md.",
);
}
}
/**
* Config d'auth de l'API si les 4 valeurs requises sont présentes, sinon null
* (API/UI désactivée — surface non exposée).
+24 -16
View File
@@ -2,7 +2,7 @@ import Fastify, { type FastifyBaseLogger } from "fastify";
import fastifyStatic from "@fastify/static";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js";
import { loadConfig, assertReadOnlyPhase, assertHasSurface, redactedConfig, apiAuth } from "./config.js";
import { registerApi } from "./api/routes.js";
import { createLogger } from "./audit/log.js";
import { OllamaClient } from "./llm/ollama.js";
@@ -34,6 +34,7 @@ import { TelegramSurface } from "./surfaces/telegram.js";
async function main(): Promise<void> {
const cfg = loadConfig();
assertReadOnlyPhase(cfg);
assertHasSurface(cfg);
const logger = createLogger(cfg.logLevel);
logger.info({ config: redactedConfig(cfg) }, "CHLOVA backend démarrage");
@@ -109,14 +110,18 @@ async function main(): Promise<void> {
});
const chat = new ChatService({ client, tools, guard, systemPrompt, logger });
// ── Surface Telegram (long-polling) ─────────────────────────────────────
const telegram = new TelegramSurface(
{
botToken: cfg.telegramBotToken,
allowedUserIds: cfg.telegramAllowedUserIds,
},
logger,
);
// ── Surface Telegram (long-polling) — optionnelle ───────────────────────
// Démarrée seulement si un token est fourni. Sinon, surface API/UI seule
// (garantie par assertHasSurface au boot).
const telegram = cfg.telegramBotToken
? new TelegramSurface(
{
botToken: cfg.telegramBotToken,
allowedUserIds: cfg.telegramAllowedUserIds,
},
logger,
)
: null;
// ── Healthcheck interne (jamais publié) ─────────────────────────────────
// pino Logger est plus étroit que FastifyBaseLogger ; cast pour aligner le
@@ -166,7 +171,7 @@ async function main(): Promise<void> {
// Arrêt propre.
const shutdown = async (): Promise<void> => {
telegram.stop();
telegram?.stop();
stopCron?.();
stopAlerts?.();
await registry.close();
@@ -178,12 +183,15 @@ async function main(): Promise<void> {
process.on("SIGINT", () => void shutdown());
// Boucle de service : commande de review (Phase 2) ou tour d'agent.
await telegram.start(async ({ userId, text }) => {
if (review && isCommand(text)) {
return handleReviewCommand(review, text);
}
return chat.handle(`telegram:${userId}`, text);
});
// Sans Telegram, le process reste vivant via le serveur Fastify (API/UI).
if (telegram) {
await telegram.start(async ({ userId, text }) => {
if (review && isCommand(text)) {
return handleReviewCommand(review, text);
}
return chat.handle(`telegram:${userId}`, text);
});
}
}
main().catch((err: unknown) => {
+28 -1
View File
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { loadConfig, assertReadOnlyPhase, redactedConfig } from "../src/config.js";
import {
loadConfig,
assertReadOnlyPhase,
assertHasSurface,
redactedConfig,
} from "../src/config.js";
const fullEnv = (): NodeJS.ProcessEnv => ({
OLLAMA_BASE_URL: "http://ollama:11434",
@@ -61,6 +66,28 @@ describe("verrou lecture seule Phase 1", () => {
});
});
describe("garde de surface (fail-closed)", () => {
it("Telegram seul suffit", () => {
expect(() => assertHasSurface(loadConfig(fullEnv()))).not.toThrow();
});
it("API/UI seule suffit (sans Telegram)", () => {
const env = fullEnv();
delete env.TELEGRAM_BOT_TOKEN;
env.CHLOVA_ADMIN_USER = "kantin";
env.CHLOVA_ADMIN_PASSWORD_HASH = "hash";
env.CHLOVA_TOTP_SECRET = "totp";
env.CHLOVA_JWT_SECRET = "jwt";
expect(() => assertHasSurface(loadConfig(env))).not.toThrow();
});
it("refuse de démarrer sans aucune surface", () => {
const env = fullEnv();
delete env.TELEGRAM_BOT_TOKEN;
expect(() => assertHasSurface(loadConfig(env))).toThrow(/surface/i);
});
});
describe("redactedConfig masque les secrets", () => {
it("ne révèle aucun secret", () => {
const red = redactedConfig(loadConfig(fullEnv()));