@nodefony/devkit 10.0.0-alpha.5 → 10.0.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.149.0 → _@oxc-project_runtime@0.150.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.149.0 → _@oxc-project_runtime@0.150.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.149.0 → _@oxc-project_runtime@0.150.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/nodefony/controllers/DevkitController.js +2 -2
  6. package/dist/nodefony/controllers/McpController.js +3 -3
  7. package/dist/nodefony/service/DevkitService.js +2 -2
  8. package/package.json +8 -8
  9. package/skills/nodefony-add-crud/SKILL.md +9 -0
  10. package/skills/nodefony-add-realtime-channel/SKILL.md +9 -0
  11. package/skills/nodefony-add-service/SKILL.md +9 -0
  12. package/skills/nodefony-browser/SKILL.md +22 -19
  13. package/skills/nodefony-dev/SKILL.md +273 -0
  14. package/skills/nodefony-dev/scripts/docs.mjs +544 -0
  15. package/skills/nodefony-devops/SKILL.md +198 -0
  16. package/skills/nodefony-devops/references/compose.md +125 -0
  17. package/skills/nodefony-devops/references/frontal.md +119 -0
  18. package/skills/nodefony-devops/references/image.md +136 -0
  19. package/skills/nodefony-devops/references/kubernetes.md +189 -0
  20. package/skills/nodefony-devops/references/podman.md +103 -0
  21. package/skills/nodefony-devops/references/secrets.md +108 -0
  22. package/skills/nodefony-devops/references/variables.md +174 -0
  23. package/skills/nodefony-migrate-schema/SKILL.md +29 -17
  24. package/skills/nodefony-protect-route/SKILL.md +9 -0
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: nodefony-devops
3
+ metadata:
4
+ version: 1.0.0
5
+ description: >
6
+ Exploite CETTE application Nodefony en conteneur : image, compose, frontal, secrets, variables,
7
+ Kubernetes, Podman. N'enseigne pas Docker — il énonce le CONTRAT d'exploitation, chiffré :
8
+ quelles sondes elle expose, combien de temps elle draine, quel code de sortie elle rend, sous
9
+ quel identifiant elle tourne, où elle écrit, ce qui casse quand une variable manque. Sert
10
+ d'abord à savoir ce qu'il ne faut PAS réécrire. À charger AVANT d'écrire un manifeste, un
11
+ compose, une étape de déploiement, ou de diagnostiquer un conteneur qui redémarre.
12
+ Déclencheurs : "déployer mon application en production", "mettre mon app en production",
13
+ "construire et vérifier l'image", "docker compose", "derrière nginx", "Kubernetes",
14
+ "mon pod redémarre", "mon pod perd des requêtes", "redéploiement sans coupure",
15
+ "arrêt gracieux", "readOnlyRootFilesystem", "politique Restricted", "secrets",
16
+ "mot de passe en production", "variables d'environnement", "Podman", "GitLab CI".
17
+ ---
18
+
19
+ # nodefony-devops — exploiter cette application
20
+
21
+ > **Maintenance** : vérité courante, jamais un journal. Éditer en place.
22
+
23
+ Tu sais faire du Docker. Ce que tu ne sais pas encore, c'est ce que **cette** application garantit
24
+ — et ce qu'elle attend de toi. C'est tout ce que porte cette page.
25
+
26
+ ## 1. Le contrat d'exploitation
27
+
28
+ Ce que l'application tient, sans que tu aies rien à coder. Chiffré, parce qu'un ordonnanceur se
29
+ règle avec des nombres.
30
+
31
+ | Ce que tu veux | Ce qu'elle fait |
32
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
+ | **Savoir si le process vit** | `GET /livez` → `200`. Reste à `200` **pendant tout l'arrêt** : un exemplaire qui draine est vivant, le tuer ferait exactement ce qu'on veut éviter. |
34
+ | **Savoir s'il peut servir** | `GET /readyz` → `503` avant d'être prêt, `200` ensuite, et `503` **dès la première milliseconde du SIGTERM**. C'est lui qui te retire du répartiteur. |
35
+ | **Ne pas être cru mort à tort** | Les deux sondes **court-circuitent le pipeline avant la limitation de débit**. Un agent d'ordonnancement limité recevrait `429`, qu'il lirait comme un échec de sonde. |
36
+ | **Un arrêt propre** | `SIGTERM` → refus des nouvelles requêtes, on laisse finir celles en vol, borné à **15 s**. Sous les 30 s qu'un kubelet accorde par défaut. |
37
+ | **Laisser le répartiteur suivre** | Un délai configurable **avant** le début du drain, le temps que ton frontal retire l'exemplaire de sa table. |
38
+ | **Vérifier que l'arrêt a marché** | Code de sortie **`0`**. Un **`137`** signifie qu'il a été tué : les requêtes en vol sont perdues, à chaque déploiement, **sans une ligne de journal**. |
39
+ | **Un utilisateur non root** | `USER 1000:1000` — **numérique**, donc utilisable tel quel par un `runAsUser`. Un `USER nom` ne l'est pas, et certains ordonnanceurs le refusent. |
40
+ | **Des secrets montés** | **Toute** variable accepte le suffixe `_FILE` : `NF_CSRF_SECRET_FILE=/run/secrets/csrf` lit le contenu du fichier. Secrets Docker **et** Kubernetes. |
41
+ | **Identifier l'exemplaire** | `NF_POD_NAME` — à alimenter depuis `metadata.name`. Elle dérive l'identité d'origine du bus temps réel ; sans elle, plusieurs exemplaires se confondent. |
42
+ | **Un port** | `5151`, déclaré par `EXPOSE`. Au-dessus de 1024, donc aucune capacité privilégiée n'est requise. |
43
+ | **Un contrôle de santé natif** | `HEALTHCHECK` sur `/readyz`, déjà dans l'image. |
44
+
45
+ **Où l'application écrit — la question qui décide de tout le reste.** Deux dossiers, et deux
46
+ seulement : **`tmp/`** et **`var/`**. Tout le reste du système de fichiers peut être scellé.
47
+
48
+ > 🔴 **Ces deux dossiers ne sont pas ancrés de la même façon.** `tmp/` est résolu depuis le
49
+ > **répertoire courant du process**, `var/` depuis la **racine de l'application**. Dans l'image
50
+ > livrée les deux coïncident (`WORKDIR /app`). Dès qu'une commande personnalisée change de
51
+ > dossier, ils divergent — et l'application écrit ailleurs que là où tu as monté ton volume,
52
+ > **sans aucune erreur**. Si tu changes le répertoire de travail, vérifie les deux.
53
+
54
+ ## 2. Ce qui est déjà dans ton dépôt
55
+
56
+ Le générateur a rendu ces fichiers **à ton nom**. Ils ne se recopient pas d'un guide : ils
57
+ s'ajustent.
58
+
59
+ | Fichier | Ce qu'il porte |
60
+ | -------------------------- | ------------------------------------------------------------------------------------- |
61
+ | `Dockerfile` | Image de production multi-étages → [`references/image.md`](references/image.md) |
62
+ | `.dockerignore` | Ce qui n'entre jamais dans l'image → [`references/secrets.md`](references/secrets.md) |
63
+ | `compose.yaml` | Services par profils → [`references/compose.md`](references/compose.md) |
64
+ | `.env` | Les variables → [`references/variables.md`](references/variables.md) |
65
+ | `deploy/migrate-job.yaml` | Le travail Kubernetes des migrations — mode d'emploi en tête du fichier |
66
+ | `.github/workflows/ci.yml` | La chaîne d'intégration |
67
+ | `.gitlab-ci.yml` | La même, pour GitLab |
68
+
69
+ Un fichier absent signifie un preset plus léger à la création. **Le régénérer, pas le réécrire.**
70
+
71
+ ## 3. Ce que tu dois poser en face
72
+
73
+ Ce que l'application ne peut pas deviner. Chacun de ces oublis produit une dégradation
74
+ **silencieuse** — pas une erreur, pas une alerte.
75
+
76
+ - **`NF__HTTP__TRUSTPROXY`** dès qu'un frontal termine le TLS. Sans lui, l'application constate un
77
+ schéma `http` et **un cookie `__Host-` n'est pas émis** : les sessions ne tiennent plus, sans le
78
+ moindre message. Détail et preuve → [`references/frontal.md`](references/frontal.md).
79
+ - **Deux volumes inscriptibles**, sur `/app/tmp` et `/app/var`, si tu scelles la racine.
80
+ - **Une fenêtre d'arrêt ≥ 30 s**, strictement supérieure au drain de 15 s.
81
+ - **Pas de limite processeur.** Une limite mémoire tue proprement et l'exemplaire redémarre ; une
82
+ limite processeur, elle, ne tue pas : elle ralentit, en permanence, et ça ne ressemble pas à une
83
+ limite mais à une application lente.
84
+ - **Une base administrée, jamais dans le cluster** — et le plafond de connexions qui va avec (§6).
85
+
86
+ ## 4. Mise en production — la checklist
87
+
88
+ Chaque ligne a la commande qui la **prouve**. Une case cochée sans commande est une supposition.
89
+
90
+ | # | Le point | La preuve |
91
+ | --- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
92
+ | 1 | L'image se construit | `docker build -t <app> .` |
93
+ | 2 | Elle n'embarque **aucun secret** | `npx nodefony image:check <app>` — lit les **couches**, pas l'image aplatie (§5) |
94
+ | 3 | Elle démarre et se déclare prête | `docker run -d --name t <app>` puis `curl -fsS localhost:5151/readyz` |
95
+ | 4 | Elle s'arrête **proprement** | `docker stop t; docker inspect t --format '{{.State.ExitCode}}'` → doit rendre **`0`** |
96
+ | 5 | Les variables requises sont posées | `npx nodefony doctor --env production` |
97
+ | 6 | Les secrets sont **montés**, pas dans l'environnement | Suffixe `_FILE` → [`references/secrets.md`](references/secrets.md) |
98
+ | 7 | Les migrations passent **avant** les nouveaux exemplaires | `kubectl apply -f deploy/migrate-job.yaml`, attendre la fin |
99
+ | 8 | Le frontal est **dérivé**, pas écrit à la main | `npx nodefony proxy:generate nginx` |
100
+ | 9 | Le schéma constaté derrière le frontal est le bon | Une requête réelle pose un cookie `__Host-` → [`references/frontal.md`](references/frontal.md) |
101
+ | 10 | Un redéploiement ne perd **aucune** requête | Charge constante pendant un `rollout restart` ; zéro `5xx` |
102
+ | 11 | La racine du conteneur est **scellée** | `--read-only` + volumes sur `/app/tmp` et `/app/var` |
103
+ | 12 | Tu sais ce que tu **ne** peux **pas** garantir | Podman sans privilèges perd l'adresse du client (§7) — à énoncer, pas à masquer |
104
+
105
+ ## 5. Symptôme → cause → correction
106
+
107
+ Le tableau qu'on lit en urgence.
108
+
109
+ | Symptôme | Cause | Correction |
110
+ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
111
+ | L'exemplaire redémarre en boucle, journaux muets | La sonde de vivacité tombe avant la fin du démarrage | Sonde de démarrage sur `/readyz`, dimensionnée sur le **pire** cas |
112
+ | Le conteneur sort en **137** à l'arrêt | Il a été tué : la fenêtre d'arrêt est ≤ au drain | Fenêtre ≥ 30 s (Podman : `--stop-timeout 20`, son défaut est **10 s**) |
113
+ | Des `5xx` **pendant** chaque déploiement | Le répartiteur envoie encore du trafic quand le drain a commencé | Un délai avant drain, et un court arrêt avant terminaison |
114
+ | Les sessions ne tiennent pas derrière le frontal | Schéma constaté `http` → le cookie `__Host-` n'est pas émis | `NF__HTTP__TRUSTPROXY` |
115
+ | Les journaux d'audit montrent tous la **même adresse** | L'adresse du client n'est pas propagée, ou le frontal n'est pas de confiance | `trustProxy` ; en Podman sans privilèges, **c'est irréparable** (§7) |
116
+ | `EACCES` au premier démarrage sur un volume | Le volume est né `root:root` : monté **par-dessus** un dossier dont l'image avait fixé le porteur | Volume nommé (il hérite du porteur) ou groupe de système de fichiers |
117
+ | La base refuse des connexions dès qu'on monte en exemplaires | Une base administrée les compte ; le pool n'est pas borné | Réduire les exemplaires — le plafond n'est pas encore réglable (§6) |
118
+ | La sonde répond `429` | Ce n'est **pas** la sonde : c'est la limitation de débit — donc l'appel n'a pas pris le raccourci | Interroger `/livez` et `/readyz` nus, sans en-tête ni préfixe ajouté |
119
+ | Un secret retiré du `Dockerfile` reste dans l'image | Une couche antérieure le contient encore ; l'effacer plus loin ne l'enlève pas | `npx nodefony image:check`, et reconstruire sans jamais l'avoir copié |
120
+ | La variable posée « ne fait rien » | Le nom est faux d'un caractère — une variable inconnue est **ignorée**, pas refusée | `npx nodefony doctor` → [`references/variables.md`](references/variables.md) |
121
+
122
+ ## 6. Kubernetes — ce qui est rendu, et ce qui te reste
123
+
124
+ 🔴 **Un seul manifeste est rendu à ton nom : `deploy/migrate-job.yaml`.** Le Deployment, le
125
+ Service et l'Ingress ne le sont pas — tu les écris, et les §1 et §3 te disent quoi y mettre.
126
+
127
+ ```bash
128
+ kubectl apply -f deploy/migrate-job.yaml # les migrations AVANT les exemplaires
129
+ ```
130
+
131
+ **Le manifeste rendu est ton modèle : il est conforme à la politique Restricted.** Recopie-en le
132
+ contexte de sécurité dans ton Deployment — c'est le seul endroit du dépôt où il est déjà juste.
133
+
134
+ La politique exige six champs. Aucun n'est « fourni par l'image » : ce sont des champs de
135
+ **manifeste**, c'est toi qui les poses. Ce que l'image apporte, c'est de les rendre tous
136
+ satisfaisables :
137
+
138
+ | Le champ | Ce qu'il te coûte |
139
+ | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
140
+ | `runAsNonRoot: true` | rien |
141
+ | `runAsUser` / `runAsGroup` numériques | rien — l'image déclare `USER 1000:1000`, tu reportes `1000` |
142
+ | `allowPrivilegeEscalation: false` | rien |
143
+ | `capabilities.drop: ["ALL"]` | rien — l'application n'écoute qu'au-dessus de 1024 |
144
+ | `seccompProfile: RuntimeDefault` | rien — mais **à ne pas oublier** : la politique traite l'**absence** de profil comme une violation, pas comme un défaut permissif |
145
+ | `readOnlyRootFilesystem: true` | **les deux volumes du §3** — sans eux le conteneur ne démarre pas |
146
+
147
+ **La base de données n'est pas dans le cluster**, et ça a trois conséquences dures →
148
+ [`references/kubernetes.md`](references/kubernetes.md).
149
+
150
+ Éprouver ton manifeste sans rien laisser derrière :
151
+
152
+ ```bash
153
+ kind create cluster --name <app>
154
+ kubectl label ns default pod-security.kubernetes.io/enforce=restricted # rend la politique OPPOSABLE
155
+ kubectl apply -f <ton-deployment>.yaml
156
+ kind delete cluster --name <app>
157
+ ```
158
+
159
+ Sans ce label, un manifeste non conforme passe — et on croit avoir prouvé quelque chose.
160
+
161
+ ## 7. Podman
162
+
163
+ Six écarts avec Docker, dont un qu'on ne répare pas →
164
+ [`references/podman.md`](references/podman.md). Le plus coûteux, parce qu'il est **muet** : en
165
+ mode sans privilèges, l'adresse source du client est perdue. Derrière un frontal, l'audit et toute
166
+ limitation par adresse deviennent faux **sans erreur**. Ça s'énonce, ça ne se masque pas.
167
+
168
+ `podman kube play` joue tes manifestes **sans cluster** — utile, mais il ne rejoue ni l'admission
169
+ de la politique, ni l'Ingress, ni un déploiement progressif. Ce qu'il ne prouve pas doit être dit.
170
+
171
+ ## 8. Les références
172
+
173
+ | Fichier | Quand l'ouvrir |
174
+ | ------------------------------------------------------ | --------------------------------------------------------------------------------------- |
175
+ | [`references/image.md`](references/image.md) | Modifier le `Dockerfile` : étages, arguments, bases candidates, ce qui ne se touche pas |
176
+ | [`references/compose.md`](references/compose.md) | Services, profils, volumes, réseaux, le profil de topologie de production |
177
+ | [`references/variables.md`](references/variables.md) | Le catalogue, la grammaire des noms, `_FILE`, **ce qui casse si absent** |
178
+ | [`references/secrets.md`](references/secrets.md) | Mots de passe, rotation, couches d'image, ce qui n'entre jamais |
179
+ | [`references/frontal.md`](references/frontal.md) | nginx et haproxy dérivés, TLS, en-têtes, `trustProxy`, `__Host-` |
180
+ | [`references/kubernetes.md`](references/kubernetes.md) | Ton Deployment, Restricted, la base administrée |
181
+ | [`references/podman.md`](references/podman.md) | Les six écarts, et celui qui ne se répare pas |
182
+
183
+ ## 9. Passer la main
184
+
185
+ | Le besoin | Le skill |
186
+ | ---------------------------------- | ------------------------- |
187
+ | Coder dans l'application | `nodefony-dev` |
188
+ | Faire évoluer le schéma de la base | `nodefony-migrate-schema` |
189
+ | Regarder ou mesurer un écran | `nodefony-browser` |
190
+ | Ajouter une entité et son CRUD | `nodefony-add-crud` |
191
+
192
+ ## 10. Deux règles qui valent pour tout le reste
193
+
194
+ - **« Ça construit » ne prouve rien.** Une image qui se construit peut refuser de démarrer, perdre
195
+ ses requêtes à l'arrêt, ou embarquer une clé. Seule une **exécution** prouve — d'où le §4.
196
+ - **Une variable absente ne lève presque jamais** : elle bascule sur un défaut, et le défaut est
197
+ souvent raisonnable. C'est ce qui rend l'erreur invisible jusqu'en production. Vérifie ce que
198
+ l'application a **réellement lu**, pas ce que ton manifeste déclare.
@@ -0,0 +1,125 @@
1
+ # Le `compose.yaml` — services, profils, topologie
2
+
3
+ > **Maintenance** : vérité courante. Éditer en place.
4
+
5
+ ## 1. La règle qui explique tout le fichier : les profils
6
+
7
+ **Rien de ce qui n'est pas dans un profil actif ne démarre.** Les services d'infrastructure dont
8
+ l'application a besoin — cache, base de données — n'ont **pas** de profil : ils démarrent avec un
9
+ `docker compose up -d` nu. Tout le reste est derrière un profil, et c'est ce qui rend le fichier
10
+ utilisable en développement comme en production.
11
+
12
+ ```bash
13
+ docker compose up -d # le décor : cache + base. C'est ce qu'il faut pour `npm run dev`
14
+ docker compose --profile app up -d # + l'application en conteneur, port publié
15
+ docker compose --profile edge up -d # la TOPOLOGIE DE PRODUCTION : frontal + application SANS port
16
+ docker compose --profile tools up -d # les explorateurs de données
17
+ docker compose --profile loki up -d # journalisation centralisée + tableaux de bord
18
+ docker compose --profile browser up -d # un navigateur piloté
19
+ ```
20
+
21
+ > Le profil `app` et le profil `edge` sont **exclusifs par nature** : le premier publie un port sur
22
+ > ta machine, le second ne publie rien du tout et laisse le frontal être la seule porte d'entrée.
23
+ > Les lancer ensemble démarre deux exemplaires de l'application.
24
+
25
+ ## 2. Les services
26
+
27
+ | Service | Profil | Image | Port publié _(sur `127.0.0.1` uniquement)_ |
28
+ | -------------------------- | ------------- | ---------------------------------------------------- | ------------------------------------------ |
29
+ | `redis` | _(aucun)_ | `redis:8-alpine` | `6379` |
30
+ | la base _(selon le choix)_ | _(aucun)_ | selon le dialecte retenu à la création | `5432` ou `3306` |
31
+ | `migrate` | `app`, `edge` | l'image de l'application | aucun |
32
+ | `app` | `app` | l'image de l'application | `5251` → `5151` |
33
+ | `app-edge` | `edge` | l'image de l'application | **aucun, volontairement** |
34
+ | `edge` | `edge` | construite avec `--target edge` | `8080`, `8443` |
35
+ | `redisinsight` | `tools` | explorateur du cache | `5540` |
36
+ | `loki` / `grafana` | `loki` | journaux et tableaux de bord | `3100`, `3000` |
37
+ | `browser` | `browser` | navigateur piloté (image **épinglée par empreinte**) | `3001` |
38
+
39
+ **Les ports sont publiés sur la boucle locale**, pas sur `0.0.0.0`. Une base de développement
40
+ accessible depuis le réseau est une base de développement qui finit indexée.
41
+
42
+ Le service de la base n'existe que si tu as retenu un dialecte serveur à la création. En SQLite, il
43
+ n'y a pas de service : la base est un fichier dans le volume de l'application.
44
+
45
+ ## 3. L'ordre de démarrage — et le service `migrate`
46
+
47
+ C'est le point du fichier qui mérite d'être lu, parce qu'il encode une règle de production.
48
+
49
+ ```
50
+ redis (sain) ┐
51
+ ├──► migrate (jusqu'à SUCCÈS) ──► app / app-edge ──► edge (app-edge sain)
52
+ base (saine)┘
53
+ ```
54
+
55
+ `app` ne démarre pas sur `migrate` _lancé_, mais sur `migrate` **terminé avec succès**. Un schéma à
56
+ jour avant le premier exemplaire, toujours, sans que tu aies à t'en souvenir. Si `migrate` échoue,
57
+ l'application **ne démarre pas** — c'est le comportement voulu : mieux vaut pas d'application qu'une
58
+ application qui écrit dans un schéma qu'elle ne comprend pas.
59
+
60
+ Les dépendances vers le cache et la base attendent leur **état de santé**, pas leur démarrage.
61
+
62
+ ## 4. Les deux ancres partagées
63
+
64
+ Le fichier ne se répète pas : trois services de l'application dérivent d'un même modèle.
65
+
66
+ | Ancre | Ce qu'elle porte |
67
+ | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
68
+ | `x-app-env` | L'URL de la base — pointée vers le **nom de service** du compose, pas `127.0.0.1` — et l'URL du cache |
69
+ | `x-app-service` | La construction, le nom d'image local, `restart: unless-stopped`, le réseau, les volumes, **`stop_grace_period: 20s`** et les dépendances |
70
+
71
+ > 🔴 **`stop_grace_period: 20s` n'est pas décoratif.** Le défaut de Docker est **10 s**, et le drain
72
+ > de l'application est borné à **15 s** : au défaut, chaque `docker compose down` tuerait
73
+ > l'application au milieu de son drain — code de sortie `137`, requêtes en vol perdues, aucun
74
+ > message. Si tu écris ton propre compose, reporte ce réglage.
75
+
76
+ > 🔴 **L'URL de la base du compose ne se reporte JAMAIS dans un manifeste Kubernetes.** Elle
77
+ > désigne un service du compose ; le même nom dans un cluster désigne un Service qui n'existe pas.
78
+ > Rien ne casse au déploiement, tout casse au premier accès.
79
+
80
+ ## 5. Le profil `edge` — la topologie de production, en local
81
+
82
+ C'est le profil qui mérite le plus d'attention : il reproduit une production, sur ta machine.
83
+
84
+ - **`app-edge` ne publie aucun port.** Elle n'est joignable que par le réseau du compose. C'est
85
+ exactement ce qu'est une application derrière un frontal : injoignable directement.
86
+ - Elle reçoit des variables que `app` n'a pas :
87
+
88
+ | Variable | Valeur | Pourquoi |
89
+ | ---------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- |
90
+ | `NF__HTTP__TRUSTPROXY` | `uniquelocal` | Le frontal est sur le réseau privé du compose. Sans ça, le schéma constaté serait `http` et le cookie `__Host-` ne partirait pas |
91
+ | `NF__HTTP__TRUSTEDHOSTS` | les hôtes servis | Un `Host` étranger est refusé |
92
+ | `NF__APP__DOMAINCHECK` | `"true"` | Le contrôle de domaine est actif, comme en production |
93
+ | `NF__HTTP__STATICS__ENABLED` | `"false"` | **C'est nginx qui sert les fichiers statiques**, pas l'application |
94
+
95
+ - `edge` monte les certificats **en lecture seule depuis le disque** — ils ne sont pas dans
96
+ l'image. Voir [`secrets.md`](secrets.md).
97
+ - `edge` attend que `app-edge` soit **saine**, pas seulement démarrée.
98
+
99
+ ```bash
100
+ docker compose --profile edge up -d --build
101
+ curl -kfsS https://localhost:8443/ # par le frontal, pas par l'application
102
+ ```
103
+
104
+ ## 6. Volumes et réseau
105
+
106
+ Volumes nommés : un par service qui garde quelque chose — le cache, la base, les explorateurs, les
107
+ tableaux de bord — plus **un pour les données de l'application** (son dossier `var`).
108
+
109
+ Le réseau est un pont nommé d'après l'application, **sans sous-réseau figé** : figer un sous-réseau
110
+ provoque des collisions dès qu'une autre pile tourne sur la même machine, et le symptôme
111
+ (`Pool overlaps with other one`) ne dit pas laquelle.
112
+
113
+ ## 7. Les gestes courants
114
+
115
+ ```bash
116
+ docker compose ps # ce qui tourne, et l'état de santé
117
+ docker compose logs -f app # suivre
118
+ docker compose --profile edge down # arrêter CE profil
119
+ docker compose down -v # ⚠️ supprime AUSSI les volumes : base et cache PERDUS
120
+ docker compose run --rm migrate # jouer les migrations seules
121
+ docker compose config # le fichier RÉSOLU — variables substituées
122
+ ```
123
+
124
+ `docker compose config` est le geste qui tranche quand « la variable ne fait rien » : il montre ce
125
+ que Docker a **réellement** compris, substitutions comprises.
@@ -0,0 +1,119 @@
1
+ # Derrière un frontal — nginx, haproxy, Ingress
2
+
3
+ > **Maintenance** : vérité courante. Éditer en place.
4
+
5
+ ## 1. La configuration se DÉRIVE, elle ne s'écrit pas
6
+
7
+ ```bash
8
+ npx nodefony proxy:generate nginx [-o <fichier>] [-b <hôte>] [-l <port>] [--reencrypt]
9
+ npx nodefony proxy:generate haproxy
10
+ ```
11
+
12
+ La commande **démarre l'application sans ouvrir de port**, lit ses montages statiques réels, sa
13
+ taille de corps maximale et son battement de cœur temps réel, puis écrit la configuration.
14
+
15
+ **Pourquoi ça compte** : une configuration écrite à la main est juste le jour où on l'écrit. Au
16
+ premier dossier statique ajouté, à la première route de téléversement, elle devient fausse — et
17
+ elle ne le dit pas : elle sert un 404 ou un 413 que personne ne rattache à la configuration du
18
+ frontal.
19
+
20
+ Dans l'image, cette génération est un étage de construction : la configuration est produite **au
21
+ moment où l'image est construite**, donc à partir du code exact qu'elle embarque.
22
+
23
+ ## 2. Le point qui casse tout si on l'oublie
24
+
25
+ 🔴 **`NF__HTTP__TRUSTPROXY`**
26
+
27
+ Quand ton frontal termine le TLS, l'application reçoit du **HTTP en clair**, plus un en-tête qui
28
+ annonce le schéma d'origine. Sans réglage, elle ne croit pas cet en-tête — et c'est la bonne
29
+ position par défaut, parce qu'un en-tête est trivialement falsifiable par un client.
30
+
31
+ Conséquence si tu ne le poses pas : le schéma constaté est `http`, donc :
32
+
33
+ - **le cookie `__Host-` n'est pas émis** — ce préfixe impose qu'il soit posé sur une origine
34
+ sécurisée. Les sessions ne tiennent pas, **sans aucun message** ;
35
+ - l'adresse cliente enregistrée est celle du frontal, donc l'audit et toute limitation par adresse
36
+ portent sur une seule IP ;
37
+ - les redirections peuvent pointer sur `http://`.
38
+
39
+ ```yaml
40
+ NF__HTTP__TRUSTPROXY: uniquelocal # le frontal est sur un réseau privé
41
+ ```
42
+
43
+ **Le prouver, plutôt que le croire** — c'est un réglage dont l'effet est invisible tant qu'on ne
44
+ regarde pas le bon endroit :
45
+
46
+ ```bash
47
+ curl -ksSI https://<hôte>/
48
+ # dans les en-têtes rendus, un Set-Cookie doit porter le préfixe __Host-
49
+ ```
50
+
51
+ Puis **débranche-le** : retire la variable, redéploie, refais la requête. Le cookie doit tomber. Un
52
+ réglage qu'on n'a jamais vu manquer n'est pas un réglage prouvé.
53
+
54
+ ## 3. Les autres réglages du mode « derrière un frontal »
55
+
56
+ | Variable | À poser | Ce qu'elle fait |
57
+ | ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
58
+ | `NF__HTTP__TRUSTEDHOSTS` | les hôtes réellement servis | Un `Host` étranger est refusé — coupe l'empoisonnement d'en-tête |
59
+ | `NF__APP__DOMAINCHECK` | `"true"` | Contrôle du domaine actif |
60
+ | `NF__HTTP__STATICS__ENABLED` | `"false"` | **Le frontal sert les fichiers statiques** ; l'application cesse de les servir en double |
61
+
62
+ Laisser l'application servir les statiques derrière un frontal qui les sert aussi n'est pas une
63
+ erreur fonctionnelle — c'est un gaspillage silencieux : chaque fichier peut être servi par le
64
+ mauvais des deux, avec des en-têtes de cache différents.
65
+
66
+ ## 4. Ne publie aucun port de l'application
67
+
68
+ Derrière un frontal, l'application **ne doit publier aucun port**. C'est la définition d'être
69
+ derrière quelque chose : le seul chemin doit passer par le frontal. C'est ce que fait le profil de
70
+ topologie de production du compose — l'application n'y a aucune ligne `ports:`.
71
+
72
+ Si tu publies quand même un port « pour déboguer », tu as deux portes d'entrée, dont une sans TLS,
73
+ sans limitation et sans les en-têtes que ton frontal pose. Utilise `docker compose exec` ou un
74
+ transfert de port temporaire.
75
+
76
+ ## 5. Les sondes, vues du frontal
77
+
78
+ Ton frontal a besoin de savoir quand retirer un exemplaire. Les deux sondes ne servent pas à la
79
+ même chose :
80
+
81
+ | Sonde | Pour quoi | Pendant l'arrêt |
82
+ | --------- | ------------------------------- | --------------- |
83
+ | `/livez` | « le process est-il vivant ? » | **reste `200`** |
84
+ | `/readyz` | « peut-il prendre du trafic ? » | passe à `503` |
85
+
86
+ **Ton frontal doit sonder `/readyz`**, jamais `/livez` : c'est `/readyz` qui bascule dès le début de
87
+ l'arrêt, donc c'est lui qui te fait retirer l'exemplaire **avant** qu'il cesse de répondre. Un
88
+ frontal qui sonde `/livez` garde l'exemplaire dans sa table pendant tout le drain et envoie du
89
+ trafic à quelque chose qui refuse déjà.
90
+
91
+ Les deux répondent **avant la limitation de débit**. Si tu obtiens un `429` sur une sonde, ce n'est
92
+ pas la sonde que tu as appelée : vérifie que tu interroges bien le chemin nu, sans préfixe ajouté
93
+ par une règle de réécriture.
94
+
95
+ ## 6. En Kubernetes — l'Ingress, pas une configuration nginx
96
+
97
+ Le frontal dérivé sert pour une machine, un compose, une machine virtuelle. **Dans un cluster, ce
98
+ rôle appartient au contrôleur d'Ingress**, que tu as choisi et que l'application ne connaît pas.
99
+
100
+ Donc : un `Ingress` standard — chemins, TLS, taille de corps en annotation — et non une
101
+ configuration nginx générée, qui ne fonctionnerait qu'avec un seul contrôleur.
102
+
103
+ Ce qui reste vrai, et ce qui change :
104
+
105
+ | Point | Avec un frontal à toi | Avec un Ingress |
106
+ | ------------------------------- | --------------------- | ---------------------------------- |
107
+ | `NF__HTTP__TRUSTPROXY` | **obligatoire** | **obligatoire** |
108
+ | `NF__HTTP__TRUSTEDHOSTS` | recommandé | recommandé |
109
+ | Configuration dérivée | oui | non — un `Ingress` |
110
+ | Qui sert les fichiers statiques | le frontal | l'application, ou un service dédié |
111
+
112
+ ## 7. Sous Podman sans privilèges — ce qu'on ne peut pas réparer
113
+
114
+ L'adresse source du client **est perdue** : le trafic arrive depuis la passerelle de redirection.
115
+ Derrière un frontal, l'en-tête d'adresse d'origine porte donc la passerelle, pas le client.
116
+
117
+ Ce n'est pas un réglage à trouver : c'est une propriété du mode sans privilèges. **Énonce-le** —
118
+ ton audit et ta limitation par adresse sont faux, sans aucune erreur pour le signaler. Voir
119
+ [`podman.md`](podman.md).
@@ -0,0 +1,136 @@
1
+ # L'image — le `Dockerfile` rendu, étage par étage
2
+
3
+ > **Maintenance** : vérité courante. Éditer en place.
4
+
5
+ Quatre étages, deux images finales possibles. Tu ne construis normalement que la dernière ; l'étage
6
+ du frontal ne sort que si tu le demandes explicitement.
7
+
8
+ ## 1. La carte
9
+
10
+ | Étage | Base | Ce qu'il fait | Descend dans l'image finale ? |
11
+ | ----------- | ------------------- | --------------------------------------------------------------------- | ----------------------------- |
12
+ | `build` | `node:24-alpine` | Installe, compile, élague les dépendances de développement | seulement son `/app` |
13
+ | `proxyconf` | hérite de `build` | **Dérive** la configuration nginx et rassemble les fichiers statiques | non — étage intermédiaire |
14
+ | `edge` | `nginx:1.27-alpine` | Le frontal, avec la configuration dérivée ci-dessus | non — cible séparée |
15
+ | _(final)_ | `node:24-alpine` | L'application en production | **oui** |
16
+
17
+ ```bash
18
+ docker build -t <app> . # l'application
19
+ docker build -t <app>-edge --target edge . # le frontal, seulement si tu le veux
20
+ ```
21
+
22
+ ## 2. L'image finale — ce qui te concerne
23
+
24
+ | Directive | Valeur |
25
+ | ------------- | ------------------------------------------------------------------------------ |
26
+ | `WORKDIR` | `/app` |
27
+ | `USER` | **`1000:1000`** — numérique, donc reportable tel quel dans un `runAsUser` |
28
+ | `ENV` | `NODE_ENV=production` |
29
+ | `EXPOSE` | `5151` |
30
+ | `CMD` | `["node_modules/.bin/nodefony", "production"]` |
31
+ | `HEALTHCHECK` | `/readyz`, intervalle 10 s, délai 2 s, **période de démarrage 20 s**, 3 essais |
32
+
33
+ **`node` est le process n° 1.** Il n'y a pas d'`ENTRYPOINT` d'init : le signal `SIGTERM` arrive
34
+ donc **directement** au process qui sait drainer. N'ajoute pas un init « pour les signaux » — tu
35
+ t'interposerais entre l'ordonnanceur et le seul process qui sait quoi en faire.
36
+
37
+ ### Les deux dossiers inscriptibles
38
+
39
+ ```dockerfile
40
+ RUN mkdir -p /app/tmp /app/var && chown 1000:1000 /app/tmp /app/var
41
+ ```
42
+
43
+ Ils sont créés **et donnés au bon porteur avant** tout montage. C'est ce qui fait qu'un **volume
44
+ nommé** neuf hérite du bon propriétaire : Docker recopie le porteur du dossier sous-jacent. Un
45
+ **montage lié** (`bind`), lui, ne le fait pas — il arrive `root:root`, et le premier `mkdir` de
46
+ l'application échoue en `EACCES`. C'est la cause n° 1 des « ça marche chez moi ».
47
+
48
+ **Le reste du code appartient à `root`** : l'application tourne en `1000` et ne peut donc pas se
49
+ réécrire elle-même. C'est voulu, et c'est ce qui rend `readOnlyRootFilesystem: true` atteignable
50
+ dès que ces deux dossiers sont montés.
51
+
52
+ ### Les arguments de construction
53
+
54
+ | `ARG` | Défaut | À quoi il sert |
55
+ | ------------ | --------- | ------------------------------------------ |
56
+ | `VERSION` | `"0.1.0"` | Étiquette OCI `image.version` |
57
+ | `VCS_REF` | `""` | Étiquette OCI `image.revision` — le commit |
58
+ | `BUILD_DATE` | `""` | Étiquette OCI `image.created` |
59
+
60
+ Les trois valeurs se relèvent d'abord — `git describe --tags --always` pour l'étiquette,
61
+ `git rev-parse --short HEAD` pour la révision, et l'horodatage UTC au format ISO 8601 — puis se
62
+ recopient dans la commande. Les poser ainsi plutôt que par substitution garde la ligne exécutable
63
+ sous `cmd.exe` et PowerShell autant que sous un shell POSIX.
64
+
65
+ ```bash
66
+ docker build -t <app>:<version> --build-arg VERSION=<version> --build-arg VCS_REF=<sha-court> --build-arg BUILD_DATE=<date-iso-8601-utc> .
67
+ ```
68
+
69
+ Sans eux l'image se construit quand même — avec des étiquettes vides. Une image de production sans
70
+ `revision` est une image dont personne ne saura dire de quel code elle vient.
71
+
72
+ ## 3. Changer la base — c'est prévu, et c'est écrit dans le fichier
73
+
74
+ Le gabarit porte deux alternatives en commentaire, avec leur mode d'emploi. La règle commune :
75
+ **remplacer les DEUX `FROM`, jamais un seul** — un étage de compilation `alpine` et une exécution
76
+ `slim` produisent des binaires natifs qui ne se chargeront pas.
77
+
78
+ | Tu veux… | Bascule | Ce qu'il faut aussi changer |
79
+ | ----------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- |
80
+ | Un paquet natif qui ne publie **pas** de binaire musl | `node:24-slim` (glibc) sur les deux `FROM` | rien |
81
+ | La surface d'attaque minimale | `gcr.io/distroless/nodejs24-debian12` en finale, `slim` en build | **`CMD` et `HEALTHCHECK` en `/nodejs/bin/node`** — pas de shell |
82
+
83
+ Le symptôme d'un mélange musl/glibc est franc : un `.node` introuvable, ou `Error relocating`, **au
84
+ démarrage** — jamais à la construction. L'image se construit parfaitement et ne démarre pas.
85
+
86
+ ## 4. L'étage du frontal — la configuration est DÉRIVÉE
87
+
88
+ L'étage `proxyconf` lance `proxy:generate` : il démarre l'application **sans ouvrir de port**, lit
89
+ ses montages statiques réels, sa taille de corps maximale et son battement de cœur temps réel, puis
90
+ écrit la configuration nginx. Tu ne l'écris pas à la main, et tu ne la corriges pas à la main : au
91
+ premier fichier statique ajouté, ta correction serait fausse et personne ne le verrait.
92
+
93
+ Ses arguments :
94
+
95
+ | `ARG` | Défaut | Rôle |
96
+ | ---------------- | ----------- | --------------------------------------------------- |
97
+ | `EDGE_BACKEND` | `app` | Le nom par lequel le frontal joint l'application |
98
+ | `EDGE_HOSTS` | `localhost` | Les hôtes servis — devient `NF__HTTP__TRUSTEDHOSTS` |
99
+ | `EDGE_HTTP_PORT` | `8080` | Port en clair |
100
+ | `EDGE_TLS_PORT` | `8443` | Port TLS |
101
+
102
+ Les ports sont **au-dessus de 1024** exprès : le frontal tourne donc sans capacité privilégiée, y
103
+ compris sous Podman sans privilèges.
104
+
105
+ 🔴 **Aucun certificat n'est gravé dans l'image.** Ils se **montent** à l'exécution. Une clé privée
106
+ dans une couche est publiée avec l'image, et l'effacer dans une couche ultérieure ne l'enlève pas —
107
+ voir [`secrets.md`](secrets.md).
108
+
109
+ L'étage `edge` expose `8080` et `8443`, avec son propre `HEALTHCHECK` sur `/livez` (période de
110
+ démarrage 5 s, plus courte : nginx démarre vite).
111
+
112
+ ## 5. Ce qu'on ne touche pas, et pourquoi
113
+
114
+ | La ligne | Pourquoi elle est là |
115
+ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
116
+ | `--ignore-scripts` à l'installation | Un script d'installation d'une dépendance s'exécute pendant ta construction, avec ton réseau et ton cache. C'est la porte d'entrée classique d'une compromission de chaîne. |
117
+ | `npm ci` si le verrou existe, sinon `npm install` | `npm ci` refuse de démarrer sans verrou ; le gabarit doit marcher dans les deux cas |
118
+ | `npm prune --omit=dev` après la compilation | Les dépendances de développement sont dans l'image de construction, pas dans la finale |
119
+ | `rm -rf nodefony/config/certificates var tmp` en fin de construction | La matière de développement — certificats auto-signés, base locale — ne descend pas en production |
120
+ | Le code laissé à `root` | L'application ne peut pas se réécrire. Condition de la racine scellée. |
121
+
122
+ ## 6. Vérifier l'image qu'on a RÉELLEMENT construite
123
+
124
+ ```bash
125
+ npx nodefony image:check <app>
126
+ ```
127
+
128
+ Il lit les **couches**, pas l'image aplatie. La différence est tout le sujet : un fichier copié puis
129
+ supprimé plus loin n'apparaît plus dans un conteneur qui démarre, mais il est toujours **dans
130
+ l'archive** — et quiconque tire l'image peut l'extraire. Un contrôle qui regarde l'image aplatie
131
+ serait vert dans le cas exactement le plus fautif.
132
+
133
+ ```bash
134
+ docker history <app> --no-trunc # d'où vient chaque couche, et son poids
135
+ docker image inspect <app> --format '{{json .Config.Labels}}' | jq # les étiquettes OCI
136
+ ```