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:
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user