Compare commits
10
Commits
e1576d2360
...
777e74f04b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777e74f04b | ||
|
|
2f5065bf37 | ||
|
|
354c8cd9ef | ||
|
|
10f7798afb | ||
|
|
3a52131d1e | ||
|
|
4b45521f01 | ||
|
|
da748c2cae | ||
|
|
4a5cbb950b | ||
|
|
600c07e358 | ||
|
|
9a4942daad |
@@ -0,0 +1,14 @@
|
|||||||
|
# Contexte Docker = racine du dépôt (voir server/Dockerfile)
|
||||||
|
node_modules
|
||||||
|
dist-portal
|
||||||
|
dist-*
|
||||||
|
artifacts
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
e2e
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
blob-report
|
||||||
|
playwright/.cache
|
||||||
|
.cursor
|
||||||
|
*.log
|
||||||
@@ -1,20 +1,32 @@
|
|||||||
name: Talks slides — image & chart
|
name: Talks slides — image & chart
|
||||||
|
|
||||||
# Déclenché uniquement si l’image Docker ou le chart Helm change (évite les builds sur contenu slides seul).
|
# Build image + chart quand le site statique (portail, decks, toolchain) ou l’infra change.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop, cicd]
|
branches: [main, master, develop, cicd]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
paths:
|
paths:
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
- 'talks-slides-dist/**'
|
- 'talks-slides-dist/**'
|
||||||
- '.gitea/workflows/**'
|
- '.gitea/workflows/**'
|
||||||
|
- 'portal/**'
|
||||||
|
- 'slides/**'
|
||||||
|
- 'tools/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- '.dockerignore'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, develop]
|
branches: [main, master, develop]
|
||||||
paths:
|
paths:
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
- 'talks-slides-dist/**'
|
- 'talks-slides-dist/**'
|
||||||
- '.gitea/workflows/**'
|
- '.gitea/workflows/**'
|
||||||
|
- 'portal/**'
|
||||||
|
- 'slides/**'
|
||||||
|
- 'tools/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- '.dockerignore'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# GIT_DEFAULT_HASH: sha256
|
# GIT_DEFAULT_HASH: sha256
|
||||||
@@ -65,7 +77,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
IMAGE="${IMAGE_REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
IMAGE="${IMAGE_REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
||||||
docker build -f server/Dockerfile -t "${IMAGE}" server/
|
docker build -f server/Dockerfile -t "${IMAGE}" .
|
||||||
docker push "${IMAGE}"
|
docker push "${IMAGE}"
|
||||||
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||||
docker tag "${IMAGE}" "${IMAGE_REGISTRY}/${IMAGE_NAME}:latest"
|
docker tag "${IMAGE}" "${IMAGE_REGISTRY}/${IMAGE_NAME}:latest"
|
||||||
@@ -75,7 +87,7 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker build -f server/Dockerfile -t talks-slides:ci server/
|
docker build -f server/Dockerfile -t talks-slides:ci .
|
||||||
|
|
||||||
helm:
|
helm:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+13
@@ -1 +1,14 @@
|
|||||||
|
# Slidev peut laisser un index.html à côté de slides.md (dev) — ne pas versionner
|
||||||
|
slides/**/index.html
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-*/
|
||||||
|
artifacts/
|
||||||
|
portal/.vitepress/cache/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
playwright/.cache/
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Talks — Makefile unique pour build, run, gestion des présentations
|
||||||
|
# Variables : SITE_BASE, SLUG, FROM, TO
|
||||||
|
|
||||||
|
NPM ?= npm
|
||||||
|
NODE ?= node
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help install sync run build clean test \
|
||||||
|
create-presentation delete-presentation clone-presentation rename-presentation \
|
||||||
|
dev-portal dev-slides
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Talks — commandes principales"
|
||||||
|
@echo ""
|
||||||
|
@echo " make run Sync, build tous les decks, VitePress dev + watch (sources = vérité)"
|
||||||
|
@echo " make build sync + vitepress build → dist-portal + build chaque dist-<slug>/"
|
||||||
|
@echo " make sync Régénère le portail depuis slides/*/meta.json"
|
||||||
|
@echo " make clean Supprime dist-portal, dist-*, .slide-wip, cache VitePress, artifacts"
|
||||||
|
@echo " make test Tests Node + Playwright"
|
||||||
|
@echo ""
|
||||||
|
@echo "Présentations (slugs : a-z 0-9 -) :"
|
||||||
|
@echo " make create-presentation SLUG=mon-talk"
|
||||||
|
@echo " make delete-presentation SLUG=mon-talk"
|
||||||
|
@echo " make clone-presentation FROM=src TO=dst"
|
||||||
|
@echo " make rename-presentation FROM=old TO=new"
|
||||||
|
@echo ""
|
||||||
|
@echo " make install npm install"
|
||||||
|
@echo " make dev-portal Portail seul (sync + VitePress dev, base=/ ; pas de build decks)"
|
||||||
|
@echo " make dev-slides SLUG=… slidev interactif sur un deck"
|
||||||
|
@echo ""
|
||||||
|
@echo "Sous-chemin (Pages) : make sync build SITE_BASE=/mon-repo/"
|
||||||
|
|
||||||
|
install:
|
||||||
|
$(NPM) install
|
||||||
|
|
||||||
|
sync:
|
||||||
|
SITE_BASE=$(SITE_BASE) $(NODE) tools/sync-portal.mjs
|
||||||
|
|
||||||
|
run:
|
||||||
|
$(NPM) run start
|
||||||
|
|
||||||
|
build: sync
|
||||||
|
SITE_BASE=$(SITE_BASE) $(NPM) run build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist-portal .slide-wip portal/.vitepress/cache artifacts
|
||||||
|
@sh -c 'for d in dist-*; do [ -d "$$d" ] && rm -rf "$$d"; done' || true
|
||||||
|
|
||||||
|
create-presentation:
|
||||||
|
@test -n "$(SLUG)" || (echo "usage: make create-presentation SLUG=mon-talk"; exit 1)
|
||||||
|
$(NODE) tools/presentation-cli.mjs create $(SLUG)
|
||||||
|
|
||||||
|
delete-presentation:
|
||||||
|
@test -n "$(SLUG)" || (echo "usage: make delete-presentation SLUG=mon-talk"; exit 1)
|
||||||
|
$(NODE) tools/presentation-cli.mjs delete $(SLUG)
|
||||||
|
|
||||||
|
clone-presentation:
|
||||||
|
@test -n "$(FROM)" && test -n "$(TO)" || (echo "usage: make clone-presentation FROM=a TO=b"; exit 1)
|
||||||
|
$(NODE) tools/presentation-cli.mjs clone $(FROM) $(TO)
|
||||||
|
|
||||||
|
rename-presentation:
|
||||||
|
@test -n "$(FROM)" && test -n "$(TO)" || (echo "usage: make rename-presentation FROM=a TO=b"; exit 1)
|
||||||
|
$(NODE) tools/presentation-cli.mjs rename $(FROM) $(TO)
|
||||||
|
|
||||||
|
dev-portal:
|
||||||
|
$(NPM) run dev:portal
|
||||||
|
|
||||||
|
SLUG ?= kubernetes-operators
|
||||||
|
dev-slides:
|
||||||
|
slidev slides/$(SLUG)/slides.md --open
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(NPM) run test
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Talks techniques
|
||||||
|
|
||||||
|
Portail statique (**VitePress**) + decks (**Slidev**). Le portail sort dans **`dist/portal/`** ; les decks Slidev sont buildés dans **`artifacts/slides/`** (hors `dist/`, pour ne pas être effacés par Vite), puis **`assemble-dist`** les copie dans **`dist/portal/slides/`** pour le déploiement (`npx serve dist/portal`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvre le portail en local : les decks buildés sont servis sous `/slides/` par le **plugin Vite** (`portal/.vitepress/vite-plugin-slides-fs.mjs`) depuis `dist/slides/` — pas de second serveur HTTP. Pour le portail seul (sans decks) : `npm run dev:portal`.
|
||||||
|
|
||||||
|
Pour prévisualiser le **résultat de production** après un build :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
| Commande | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `npm run dev` | Sync + build slides → `dist/slides/` + VitePress (plugin sert `/slides/` depuis ce dossier) |
|
||||||
|
| `npm run dev:portal` | Sync + VitePress seul — pas de decks sous `/slides/` en local |
|
||||||
|
| `npm run dev:slides:kubernetes-operators` | Deck Slidev avec rechargement à chaud |
|
||||||
|
| `npm run dev:slides:spring-ai` | Idem pour l’autre exemple |
|
||||||
|
| `npm run sync` | Régénère uniquement le portail (liste, fiches, « À la une ») à partir de `slides/*/meta.json` |
|
||||||
|
|
||||||
|
En **`npm run dev`**, un seul processus VitePress ; les builds Slidev vont sur le disque (`dist/slides/`) et le plugin les expose. Pour éditer un deck avec le mode Slidev interactif, utilisez `npm run dev:slides:…`.
|
||||||
|
|
||||||
|
### Thème et QR codes
|
||||||
|
|
||||||
|
L’apparence reprend la charte de [talk.specificat.io](https://talk.specificat.io/) (fond sombre, accent cyan, cartes). Les QR codes sont générés côté client avec la librairie [`qrcode`](https://www.npmjs.com/package/qrcode) (canvas).
|
||||||
|
|
||||||
|
- URL du dépôt Git affichée sur l’accueil : `talkRepoUrl` (et textes associés) dans `portal/.vitepress/config.mjs` → `themeConfig`.
|
||||||
|
- Chaque fiche présentation inclut un QR vers le deck `/slides/<slug>/`.
|
||||||
|
|
||||||
|
### Tests (base solide avant corrections ciblées)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright install chromium # une fois (navigateur pour les E2E)
|
||||||
|
npm test # tout : Node + Playwright (make test)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Script | Contenu |
|
||||||
|
|--------|---------|
|
||||||
|
| `npm run test:node` | Tous les tests **Node** (`node:test`) : chemins `SITE_BASE`, **contrats** de génération (`scripts/presentation-contracts.mjs`), cohérence dépôt après sync, sync sur dépôt temporaire (`TALKS_ROOT`), layout `dist/` si présent |
|
||||||
|
| `npm run test:e2e` | **Playwright** : build + `serve dist/portal` — parcours UI, **HTTP 200**, matrice par slug |
|
||||||
|
| `npm run test:e2e:ui` | Mode UI Playwright |
|
||||||
|
| `npm run test:unit` | Alias de `test:node` |
|
||||||
|
|
||||||
|
**Fichiers** : `tests/site-base.test.mjs`, `tests/presentation-contracts.test.mjs`, `tests/repo-consistency.test.mjs`, `tests/sync-tmp-repo.test.mjs`, `tests/dist-layout.test.mjs`, `e2e/flows.spec.js`, `e2e/http-routes.spec.js`, `e2e/presentations-matrix.spec.js`.
|
||||||
|
|
||||||
|
La logique du portail généré est dans **`scripts/presentation-contracts.mjs`** (une seule source pour `sync-portal.mjs` et les tests).
|
||||||
|
|
||||||
|
**CI** : `.github/workflows/ci.yml` (Node + E2E), `.github/workflows/build.yml` (build + test `dist/`).
|
||||||
|
|
||||||
|
**Aller plus vite en local** : `npx serve dist/portal -p 4174 -L` puis `PW_SKIP_WEBSERVER=1 npm run test:e2e`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Enchaîne : sync → **VitePress** → `dist/portal/` → **Slidev** → `artifacts/slides/<slug>/` (hors `dist/`, donc pas effacé par le build VitePress) → **`assemble-dist`** copie vers `dist/portal/slides/`.
|
||||||
|
|
||||||
|
| Commande | Quand l’utiliser |
|
||||||
|
|----------|------------------|
|
||||||
|
| `npm run build` | Complet : sync (`prebuild`) + portail + decks + assemble |
|
||||||
|
| `npm run build:portal` | Idem `build` (via Makefile, sync explicite) |
|
||||||
|
| `npm run build:vitepress-only` | **Uniquement** VitePress → `dist/portal/` |
|
||||||
|
| `npm run build:slides` | Decks seuls → `dist/slides/` |
|
||||||
|
|
||||||
|
Après build : **`dist/portal/`** contient le site prêt à servir. La sortie brute Slidev est dans **`artifacts/slides/`** (gitignoré) jusqu’à fusion ; surcharger avec **`SLIDES_BUILD_OUT`** si besoin.
|
||||||
|
|
||||||
|
Servir **`dist/portal`** (ex. `npx serve dist/portal`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Déploiement sous un sous-chemin
|
||||||
|
|
||||||
|
Exemple : site hébergé en `https://pages.example.org/mon-repo/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SITE_BASE=/mon-repo/
|
||||||
|
npm run sync
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Même variable pour VitePress, les liens générés et les assets Slidev. À la racine du domaine, ne rien exporter (comportement par défaut).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ajouter une présentation
|
||||||
|
|
||||||
|
1. Créer `slides/<slug>/meta.json` et `slides/<slug>/slides.md`.
|
||||||
|
2. Optionnel : ajouter un script `dev:slides:<slug>` dans `package.json`.
|
||||||
|
3. `npm run sync` puis `npm run build` (le `prebuild` lance déjà le sync).
|
||||||
|
|
||||||
|
Champs utiles dans `meta.json` : `title`, `description`, `tags`, `order`, `featured` (sinon la home prend les premières entrées par `order`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure du dépôt
|
||||||
|
|
||||||
|
```text
|
||||||
|
scripts/ # sync-portal, build-slides, assemble-dist, préfixe SITE_BASE
|
||||||
|
portal/ # sources VitePress + vite-plugin-slides-fs (dev)
|
||||||
|
slides/<slug>/ # slides.md + meta.json par deck
|
||||||
|
dist/portal/ # portail buildé (+ slides copiés après assemble)
|
||||||
|
artifacts/slides/ # sortie Slidev avant fusion (hors dist/, non versionné)
|
||||||
|
```
|
||||||
|
|
||||||
|
Les fichiers sous `portal/presentations/` et `portal/.vitepress/data/presentations.json` sont **régénérés** par `sync`. La zone « À la une » dans `portal/index.md` est entre `<!-- FEATURED_START -->` et `<!-- FEATURED_END -->`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes techniques
|
||||||
|
|
||||||
|
- **VitePress** : ne pas définir `outDir` dans `vite.build` — utiliser `outDir` au **premier niveau** de la config (sinon le build SSR casse).
|
||||||
|
- **Slidev** : le paquet `@slidev/theme-default` est requis pour un build non interactif.
|
||||||
|
|
||||||
|
Stack : Node.js **18+** (LTS 20 ou 22 recommandées), `npm`.
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/* Aligné sur content/kubernetes-hell-to-heaven/css/custom.css (specificat.io) */
|
|
||||||
:root {
|
|
||||||
--bg-dark: #050709;
|
|
||||||
--bg-medium: #0d1115;
|
|
||||||
--bg-light: #1b1f22;
|
|
||||||
--bg-gradient-start: #10151a;
|
|
||||||
--text-primary: #f5f5f5;
|
|
||||||
--text-muted: rgba(245, 245, 245, 0.9);
|
|
||||||
--text-subtle: rgba(245, 245, 245, 0.75);
|
|
||||||
--accent-cyan: #2bc9c6;
|
|
||||||
--accent-cyan-light: #e9fefd;
|
|
||||||
--accent-cyan-bg: rgba(43, 201, 198, 0.08);
|
|
||||||
--accent-cyan-border: rgba(43, 201, 198, 0.55);
|
|
||||||
--accent-copper: #d19c5a;
|
|
||||||
--border-subtle: rgba(255, 255, 255, 0.12);
|
|
||||||
--border-very-subtle: rgba(255, 255, 255, 0.07);
|
|
||||||
--panel-glow: 0 0 0 1px rgba(43, 201, 198, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background-color: var(--bg-dark);
|
|
||||||
background-image: radial-gradient(
|
|
||||||
circle at 10% 0%,
|
|
||||||
var(--bg-gradient-start) 0%,
|
|
||||||
var(--bg-dark) 52%,
|
|
||||||
var(--bg-light) 100%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
ellipse 100% 80% at 50% -20%,
|
|
||||||
rgba(43, 201, 198, 0.12),
|
|
||||||
transparent 55%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
body::after {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background: radial-gradient(
|
|
||||||
ellipse 95% 75% at 50% 50%,
|
|
||||||
transparent 35%,
|
|
||||||
rgba(5, 7, 9, 0.35) 100%
|
|
||||||
);
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: clamp(1.5rem, 4vw, 2.75rem) clamp(1.25rem, 4vw, 2rem) 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masthead {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masthead h1 {
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.masthead p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
max-width: 42em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.talks-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 560px) {
|
|
||||||
.talks-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.talk-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1.15rem 1.25rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
background: rgba(13, 17, 21, 0.55);
|
|
||||||
box-shadow: var(--panel-glow), inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.talk-card:hover,
|
|
||||||
.talk-card:focus-visible {
|
|
||||||
border-color: var(--accent-cyan-border);
|
|
||||||
box-shadow: var(--panel-glow), 0 0 0 1px rgba(43, 201, 198, 0.18),
|
|
||||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.talk-card:focus-visible {
|
|
||||||
outline: 2px solid var(--accent-cyan);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.talk-card h2 {
|
|
||||||
margin: 0 0 0.4rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.talk-card p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.talk-card .meta {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer {
|
|
||||||
margin-top: 2.5rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--border-very-subtle);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer a {
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer a:hover,
|
|
||||||
.site-footer a:focus-visible {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Talks — Specificat</title>
|
|
||||||
<link rel="stylesheet" href="css/home.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<header class="masthead">
|
|
||||||
<h1>Talks</h1>
|
|
||||||
<p>Présentations (Reveal.js) — même charte visuelle que le site Specificat.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="talks-grid" aria-label="Liste des présentations">
|
|
||||||
<a class="talk-card" href="kubernetes-hell-to-heaven/">
|
|
||||||
<h2>Kubernetes Operators</h2>
|
|
||||||
<p>De l’enfer au paradis — Kubernetes, opérateurs et boucle de réconciliation.</p>
|
|
||||||
<span class="meta">Dossier · kubernetes-hell-to-heaven</span>
|
|
||||||
</a>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="site-footer">
|
|
||||||
Contenu servi depuis le dépôt <em>Talks</em> (dossier <code>content/</code>).
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
/* Thème aligné sur https://specificat.io/ (variables officielles du site) */
|
|
||||||
:root {
|
|
||||||
--bg-dark: #050709;
|
|
||||||
--bg-medium: #0d1115;
|
|
||||||
--bg-light: #1b1f22;
|
|
||||||
--bg-gradient-start: #10151a;
|
|
||||||
--text-primary: #f5f5f5;
|
|
||||||
--text-muted: rgba(245, 245, 245, 0.9);
|
|
||||||
--text-subtle: rgba(245, 245, 245, 0.75);
|
|
||||||
--accent-cyan: #2bc9c6;
|
|
||||||
--accent-cyan-light: #e9fefd;
|
|
||||||
--accent-cyan-bg: rgba(43, 201, 198, 0.08);
|
|
||||||
--accent-cyan-border: rgba(43, 201, 198, 0.55);
|
|
||||||
--accent-copper: #d19c5a;
|
|
||||||
--border-subtle: rgba(255, 255, 255, 0.12);
|
|
||||||
--border-very-subtle: rgba(255, 255, 255, 0.07);
|
|
||||||
--panel-glow: 0 0 0 1px rgba(43, 201, 198, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fond slide = même logique que le body du site (dégradé radial + profondeur) */
|
|
||||||
.reveal .slide-background-content {
|
|
||||||
background-color: var(--bg-dark);
|
|
||||||
background-image: radial-gradient(
|
|
||||||
circle at 10% 0%,
|
|
||||||
var(--bg-gradient-start) 0%,
|
|
||||||
var(--bg-dark) 52%,
|
|
||||||
var(--bg-light) 100%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
ellipse 100% 80% at 50% -20%,
|
|
||||||
rgba(43, 201, 198, 0.12),
|
|
||||||
transparent 55%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal .slide-background-content::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background: radial-gradient(
|
|
||||||
ellipse 95% 75% at 50% 50%,
|
|
||||||
transparent 35%,
|
|
||||||
rgba(5, 7, 9, 0.35) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canvas Reveal : typo et couleur de texte comme le site */
|
|
||||||
.reveal {
|
|
||||||
font-size: 32px;
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
|
|
||||||
color: var(--text-primary);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Réserve le bas du cadre pour le footer (évite de marcher sur le dernier texte / la progress bar) */
|
|
||||||
.reveal .slides {
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding-bottom: 2.25rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pied de page deck — sous la barre de progression Reveal (souvent z-index 10) */
|
|
||||||
.deck-footer {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 8;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.35em 0.5em;
|
|
||||||
padding: 0.35rem 2.5rem 0.55rem;
|
|
||||||
font-size: clamp(10px, 1.5vw, 12px);
|
|
||||||
line-height: 1.3;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
pointer-events: none;
|
|
||||||
border-top: 1px solid var(--border-very-subtle);
|
|
||||||
background: linear-gradient(to top, rgba(5, 7, 9, 0.65), transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-footer a {
|
|
||||||
pointer-events: auto;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-footer a:hover,
|
|
||||||
.deck-footer a:focus-visible {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-footer-sep {
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-footer-rights {
|
|
||||||
opacity: 0.85;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal.overview .deck-footer {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal .slides section {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal h1 {
|
|
||||||
font-size: 1.6em;
|
|
||||||
text-transform: none;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal h2 {
|
|
||||||
font-size: 1.15em;
|
|
||||||
margin-top: 0.6em;
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
text-transform: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal h3 {
|
|
||||||
font-size: 0.95em;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal a {
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal ul,
|
|
||||||
.reveal ol {
|
|
||||||
display: block;
|
|
||||||
text-align: left;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal table {
|
|
||||||
margin: 0.5em auto;
|
|
||||||
font-size: 0.75em;
|
|
||||||
border-color: var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal table th,
|
|
||||||
.reveal table td {
|
|
||||||
padding: 0.35em 0.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal pre {
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: var(--panel-glow), 0 1px 0 0 rgba(255, 255, 255, 0.03) inset;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.45em;
|
|
||||||
max-height: 480px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal pre code {
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Citations : bordure cyan + fond comme les panneaux du site */
|
|
||||||
.reveal blockquote {
|
|
||||||
width: 90%;
|
|
||||||
margin: 0.5em auto;
|
|
||||||
font-family: Georgia, "Times New Roman", serif;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: var(--accent-cyan-bg);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-left: 3px solid var(--accent-cyan-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--panel-glow);
|
|
||||||
padding: 0.45em 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal aside.notes {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bloc diagramme : même esprit « panel » que specificat.io (bordure discrète, fond légèrement relevé) */
|
|
||||||
.reveal .mermaid {
|
|
||||||
margin: 0.5em auto;
|
|
||||||
padding: 0.55em 0.85em;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
background: rgba(13, 17, 21, 0.55);
|
|
||||||
box-shadow: var(--panel-glow), inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Évite les libellés écrasés quand le conteneur est encore étroit au 1er rendu */
|
|
||||||
.reveal .mermaid {
|
|
||||||
min-width: min(100%, 240px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Mermaid met souvent width="100%" sur le SVG → il prend toute la largeur du slide, puis
|
|
||||||
* height: auto recalcule une hauteur énorme (viewBox étroit mais très haut). D’où des SVG à ~1000px de haut.
|
|
||||||
* On borne par la hauteur et on laisse width: auto recalculer la largeur au ratio du viewBox.
|
|
||||||
*/
|
|
||||||
.reveal .mermaid svg {
|
|
||||||
width: auto !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
max-height: min(52vh, 520px) !important;
|
|
||||||
height: auto !important;
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Libellés Mermaid (SVG) : couleur explicite — le thème base ne peint pas toujours les <text> */
|
|
||||||
.reveal .mermaid svg text {
|
|
||||||
fill: var(--text-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Barre de progression et numéros de slide : accent cyan */
|
|
||||||
.reveal .progress span {
|
|
||||||
background: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal .slide-number {
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-size: 0.65em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Slide « boucle » (n° 6) : graphe cyclique Mermaid = très haut ; graphe linéaire + libellés courts.
|
|
||||||
* Pas de scale() : ça ne réduit pas la hauteur de layout (Reveal mesure scrollHeight).
|
|
||||||
*/
|
|
||||||
.reveal .slides section.slide-boucle {
|
|
||||||
padding-top: 0.5em !important;
|
|
||||||
padding-bottom: 0.5em !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-boucle .slide-boucle-inner {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-boucle .slide-boucle-title {
|
|
||||||
font-size: clamp(1rem, 4.2vw, 1.35rem);
|
|
||||||
margin: 0 0 0.35em;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mermaid sur la slide « boucle » : hauteur mini correcte (éviter l’écrasement à ~78px qui tuait les libellés) */
|
|
||||||
.reveal section.slide-boucle .mermaid {
|
|
||||||
margin: 0.25em auto 0.15em;
|
|
||||||
line-height: normal;
|
|
||||||
padding: 0.45em 0.65em;
|
|
||||||
min-width: min(100%, 320px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-boucle .mermaid svg {
|
|
||||||
max-height: min(34vh, 220px) !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-boucle .slide-boucle-foot {
|
|
||||||
margin: 0.4em 0 0;
|
|
||||||
font-size: clamp(0.7rem, 2.8vw, 0.88rem);
|
|
||||||
line-height: 1.35;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reveal section.slide-boucle .slide-boucle-foot strong {
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
@@ -1,804 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Kubernetes Operators — De l’enfer au paradis</title>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/reveal.min.css" />
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/theme/moon.min.css" />
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/plugin/highlight/monokai.min.css" />
|
|
||||||
<link rel="stylesheet" href="css/custom.css" />
|
|
||||||
<!--
|
|
||||||
Reveal.js 4.6.1 — plugins : Notes orateur + surlignage code.
|
|
||||||
S : fenêtre notes (même origine, servir en HTTP, pas file://).
|
|
||||||
? : aide raccourcis · Échap : aperçu · F : plein écran
|
|
||||||
-->
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="reveal">
|
|
||||||
<div class="slides">
|
|
||||||
<section>
|
|
||||||
<h1>Kubernetes Operators — De l’enfer… au paradis</h1>
|
|
||||||
<blockquote>
|
|
||||||
<p>Kubernetes n’est <strong>pas</strong> compliqué.<br />
|
|
||||||
Mais <strong>mal compris</strong>… c’est un enfer.</p>
|
|
||||||
</blockquote>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>On va parler de Kubernetes… mais surtout de pourquoi les opérateurs changent complètement la manière de
|
|
||||||
travailler — et pourquoi c’est <strong>puissant</strong> mais <strong>dangereux</strong> si on se plante sur
|
|
||||||
le mental model.</p>
|
|
||||||
<p>Illustration suggérée : diagramme moderne et minimal, flat design, lisible (éviter le poster CNCF
|
|
||||||
illisible).</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Kubernetes : délire d’Ops ?</h1>
|
|
||||||
<blockquote>
|
|
||||||
<p>On vous l’a vendu comme un truc d’infra</p>
|
|
||||||
</blockquote>
|
|
||||||
<ul>
|
|
||||||
<li>Complexe</li>
|
|
||||||
<li>Réservé aux DevOps</li>
|
|
||||||
<li>Magie noire</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>On vous a vendu Kubernetes comme un délire d’infra, réservé à des gens chelous.<br />
|
|
||||||
En réalité… c’est beaucoup plus proche de ce que vous faites déjà.</p>
|
|
||||||
<p><em>Chaos scripts</em> — dev entouré de scripts, crons, alertes, terminal chargé, ambiance tendue
|
|
||||||
(contraste avec la suite).</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Kubernetes = API + état + boucle</h1>
|
|
||||||
<ul>
|
|
||||||
<li>API REST</li>
|
|
||||||
<li>Base d’état</li>
|
|
||||||
<li>Boucle de réconciliation</li>
|
|
||||||
<li>Pas de mystère : des mécanismes explicites</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Kubernetes, c’est juste une API, une base d’état, et une boucle qui corrige la réalité.<br />
|
|
||||||
Pas de magie — des patterns qu’on retrouve ailleurs en dev.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>De vos outils au noyau Linux</h1>
|
|
||||||
<pre><code class="language-mermaid">flowchart LR
|
|
||||||
A[Clients] --> B[kube-api]
|
|
||||||
B --> C[etcd]
|
|
||||||
B --> D[Controllers]
|
|
||||||
D --> E[kubelet]
|
|
||||||
E --> F[containerd]
|
|
||||||
F --> G[Kernel Linux]
|
|
||||||
</code></pre>
|
|
||||||
<ul>
|
|
||||||
<li><strong>kube-api</strong> — point d’entrée</li>
|
|
||||||
<li><strong>etcd</strong> — source de vérité</li>
|
|
||||||
<li><strong>kubelet</strong> — agent sur chaque nœud</li>
|
|
||||||
<li><strong>container runtime</strong> (ex. containerd) — exécution des conteneurs</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Tout ça, ce sont des clients : Helm, kubectl, Flux… ils appellent l’API.<br />
|
|
||||||
L’API parle à etcd, les controllers poussent le travail vers les kubelets, et le runtime lance les
|
|
||||||
conteneurs.</p>
|
|
||||||
<p>Schéma ASCII de secours :</p>
|
|
||||||
<pre><code>Clients → kube-api → etcd
|
|
||||||
↓
|
|
||||||
controllers → kubelet → containerd → kernel
|
|
||||||
</code></pre>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Kubernetes ne fait pas tourner vos apps</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Il <strong>orchestre</strong></li>
|
|
||||||
<li>Il <strong>délègue</strong></li>
|
|
||||||
<li>Il <strong>observe</strong></li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Ce n’est pas Kubernetes qui « exécute votre métier » comme un process magique : il coordonne, délègue aux
|
|
||||||
agents, et observe l’état.<br />
|
|
||||||
La suite : le cœur, c’est la boucle de réconciliation.</p>
|
|
||||||
<p>(Cette slide remplace l’ancien zoom « kube-api → etcd » seul : le détail etcd est déjà dans la vue
|
|
||||||
d’ensemble slide 4.)</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section class="slide-boucle">
|
|
||||||
<div class="slide-boucle-inner">
|
|
||||||
<h1 class="slide-boucle-title">État voulu → réel : la boucle</h1>
|
|
||||||
<pre><code class="language-mermaid">flowchart LR
|
|
||||||
A[Désiré] --> B[API] --> C[Controllers] --> D[Réel]
|
|
||||||
</code></pre>
|
|
||||||
<p class="slide-boucle-foot">
|
|
||||||
<strong>↻</strong> réinjecte vers les contrôleurs · <em>chaque tour : observer, décider, agir</em>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Des agents exécutent les instructions côté nœud (kubelet, runtime), mais le fil conducteur, c’est : état
|
|
||||||
voulu, comparaison, correction en boucle.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Sous containerd : cgroups et namespaces</h1>
|
|
||||||
<pre><code class="language-mermaid">flowchart TD
|
|
||||||
F[containerd] --> K[Linux Kernel]
|
|
||||||
</code></pre>
|
|
||||||
<ul>
|
|
||||||
<li><strong>cgroups</strong></li>
|
|
||||||
<li><strong>namespaces</strong></li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Et derrière containerd… le noyau Linux. Cgroups, namespaces…</p>
|
|
||||||
<p><em>(pause)</em></p>
|
|
||||||
<p>Et ça… vous n’avez pas envie d’y manipuler les fichiers à la main au quotidien.</p>
|
|
||||||
<p><em>Kernel danger zone</em> — mécanique dense, style industriel sombre (le « trou du souffleur » sous
|
|
||||||
Kubernetes).</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Kubernetes vous évite de vivre dans le noyau</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Manipuler les <strong>cgroups</strong> à la main</li>
|
|
||||||
<li>Gérer <strong>isolation</strong>, <strong>CPU</strong>, <strong>mémoire</strong> au niveau noyau</li>
|
|
||||||
</ul>
|
|
||||||
<blockquote>
|
|
||||||
<p>À côté de ça, un cluster qui râle, c’est presque du repos.</p>
|
|
||||||
</blockquote>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Si Kubernetes existe, c’est pour vous éviter de vivre dans ces couches-là.<br />
|
|
||||||
À côté du noyau, Kubernetes est presque rassurant.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>CNI, CSI, CRI : de l’infra branchable</h1>
|
|
||||||
<ul>
|
|
||||||
<li><strong>CNI</strong> → réseau (ex : Istio, Cilium ,Calico, Flannel, etc.)</li>
|
|
||||||
<li><strong>CSI</strong> → stockage (ex : Ceph, Longhorn , Amazon,etc.)</li>
|
|
||||||
<li><strong>CRI</strong> → runtime (ex : containerd, CRI-O,etc.)</li>
|
|
||||||
<li>Ici : brancher l’infra — pas encoder la logique métier (<em>≠</em> opérateur)</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>On peut brancher réseau, stockage, runtime… mais ce n’est pas notre sujet du jour — et surtout, ne les
|
|
||||||
confondez pas avec la logique métier.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Brancher l’infra ou coder le métier ?</h1>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Rôle</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><strong>CNI / CSI / CRI</strong></td>
|
|
||||||
<td>Infrastructure</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Opérateur</strong></td>
|
|
||||||
<td>Logique métier</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>D’un côté : des briques d’infra branchables.<br />
|
|
||||||
De l’autre : du <strong>code</strong> qui encode <strong>comment</strong> votre système doit se comporter
|
|
||||||
dans le cluster.</p>
|
|
||||||
<p>Icônes utiles :</p>
|
|
||||||
<ul>
|
|
||||||
<li><a href="https://github.com/kubernetes/community/tree/master/icons">Icônes Kubernetes (officiel)</a>
|
|
||||||
</li>
|
|
||||||
<li><a href="https://landscape.cncf.io/">CNCF Landscape</a></li>
|
|
||||||
<li><a href="https://simpleicons.org/">Simple Icons (SVG)</a></li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Qui écrit la logique du cluster ?</h1>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Qui décide de ce qu’il faut faire quand l’état réel diverge de ce qu’on veut ?</p>
|
|
||||||
<p><em>(pause)</em></p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>La logique vit dans les controllers</h1>
|
|
||||||
<p><em>Ils ferment la boucle : observation, décision, action.</em></p>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>La vraie intelligence de Kubernetes… elle est dans les controllers.<br />
|
|
||||||
Ce sont eux qui ferment la boucle : observation, décision, action.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Les opérateurs : des controllers spécialisés</h1>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Les opérateurs, ce sont des controllers… mais focalisés sur un domaine métier ou un logiciel précis (base,
|
|
||||||
queue, etc.).<br />
|
|
||||||
Même mécanisme, autre niveau d’abstraction.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Automatiser le métier… en code dans le cluster</h1>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Un opérateur, c’est du code qui dit au cluster comment faire vivre un service au-delà du simple « Pod +
|
|
||||||
Service » : upgrades, backup, réplication, etc.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Sans opérateur : scripts, crons, humains dans la boucle</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Scripts bash</li>
|
|
||||||
<li>Cron jobs</li>
|
|
||||||
<li>Interventions humaines</li>
|
|
||||||
<li>État incohérent</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Avant : bricolage, des humains dans la boucle, et un état qui part dans tous les sens dès qu’il y a une
|
|
||||||
alerte à 3 h du matin.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Automatisation éclatée : la logique reste dans les têtes</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Runbooks Confluence</li>
|
|
||||||
<li>Scripts bash « magiques »</li>
|
|
||||||
<li>Cron jobs oubliés</li>
|
|
||||||
<li>Interventions humaines</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Avant les opérateurs, l’automatisation existe… mais elle est <strong>fragmentée</strong>.<br />
|
|
||||||
Chaque problème a sa solution bricolée.</p>
|
|
||||||
<p>Et surtout :<br />
|
|
||||||
<strong>la logique est dans la tête des gens, pas dans le système.</strong>
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Drift, pas de reproductibilité, 3 h du matin</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Drift de configuration</li>
|
|
||||||
<li>Actions non reproductibles</li>
|
|
||||||
<li>Dépendance aux humains</li>
|
|
||||||
<li>Incidents à 3 h du matin</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Le problème, ce n’est pas juste « moins automatisé ».</p>
|
|
||||||
<p>C’est :</p>
|
|
||||||
<ul>
|
|
||||||
<li>pas <strong>reproductible</strong></li>
|
|
||||||
<li>pas <strong>observable</strong> au sens cluster</li>
|
|
||||||
<li>pas <strong>fiable</strong> dans le temps</li>
|
|
||||||
</ul>
|
|
||||||
<p>Surtout : impossible de <strong>raisonner globalement</strong> sur l’état du système.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Vous déclarez ; le système réconcilie</h1>
|
|
||||||
<pre><code class="language-yaml">kind: Database
|
|
||||||
spec:
|
|
||||||
size: 3
|
|
||||||
</code></pre>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Après : on décrit l’état voulu… et le contrôleur / l’opérateur fait le travail répétitif et les
|
|
||||||
transitions.</p>
|
|
||||||
<p><em>Paradis opérateur</em> — ingénieur calme, infra automatisée, UI claire, atmosphère sereine (contraste
|
|
||||||
avec slide enfer).</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Spec, opérateur, cluster : ça tourne en boucle</h1>
|
|
||||||
<pre><code class="language-mermaid">flowchart LR
|
|
||||||
A[Spec] --> B[Operator]
|
|
||||||
B --> C[Cluster]
|
|
||||||
C --> B
|
|
||||||
</code></pre>
|
|
||||||
<p><em>Dès que le réel diverge, la réconciliation repart.</em></p>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Le système réobserve, recompare, recorrige. C’est la même philosophie que le reste de Kubernetes, appliquée
|
|
||||||
à votre domaine.</p>
|
|
||||||
<p>Quelques <strong>exemples réels</strong> : d’abord des outils qui <strong>étendent</strong> des ressources
|
|
||||||
natives avec des <strong>annotations</strong>, puis des opérateurs qui posent de <strong>vrais CRD</strong>
|
|
||||||
— et un projet maison pour ancrer le discours.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Reflector & Reloader : la puissance des annotations</h1>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Outil</th>
|
|
||||||
<th>Idée</th>
|
|
||||||
<th>Déclencheur typique</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Reflector</strong> (EmberStack)</td>
|
|
||||||
<td>Copie / réplique des <strong>Secrets</strong> et <strong>ConfigMaps</strong> (autres namespaces,
|
|
||||||
miroirs)</td>
|
|
||||||
<td>Annotations sur la ressource source ou la copie</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Reloader</strong> (Stakater)</td>
|
|
||||||
<td><strong>Redémarre</strong> les Pods quand un Secret / ConfigMap référencé change</td>
|
|
||||||
<td>Annotation sur le <strong>Deployment</strong> / <strong>StatefulSet</strong> / etc.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<ul>
|
|
||||||
<li>Reflector : annotations du type <code>reflector.v1.k8s.emberstack.com/*</code> pour autoriser la réflexion
|
|
||||||
et cibler où copier.</li>
|
|
||||||
<li>Reloader : <code>reloader.stakater.com/auto: "true"</code> (ou annotations plus fines par config) pour
|
|
||||||
lier redéploiement ↔ mise à jour de config.</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Ce n’est pas toujours un « gros » opérateur avec CRD : parfois c’est un <strong>contrôleur</strong> qui
|
|
||||||
<strong>regarde</strong> des objets standards et réagit à des <strong>annotations</strong>.<br />
|
|
||||||
Très utile pour TLS partagé, configs dupliquées, ou « je change un secret → je veux un rollout » sans
|
|
||||||
pipeline ad hoc.
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li><a href="https://github.com/emberstack/kubernetes-reflector">kubernetes-reflector</a></li>
|
|
||||||
<li><a href="https://github.com/stakater/Reloader">Reloader</a></li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Cert-manager, Postgres, Keycloak : la spec devient une entité</h1>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Opérateur</th>
|
|
||||||
<th>CRD (exemples)</th>
|
|
||||||
<th>Ce que ça <strong>matérialise</strong></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><strong>cert-manager</strong></td>
|
|
||||||
<td><code>Certificate</code>, <code>Issuer</code>, <code>ClusterIssuer</code></td>
|
|
||||||
<td>Secrets TLS, chaînes ACME / PKI</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Postgres Operator</strong> (Zalando)</td>
|
|
||||||
<td><code>postgresql</code> (<code>acid.zalan.do</code>)</td>
|
|
||||||
<td>Cluster PostgreSQL (Patroni, volumes, users…)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><strong>Keycloak Operator</strong></td>
|
|
||||||
<td><code>Keycloak</code>, <code>KeycloakRealm</code>, <code>KeycloakClient</code>, …</td>
|
|
||||||
<td>Instances Keycloak, royaumes, clients</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p>La spec décrit l’état métier (cert, base, realm) ; le contrôleur crée / met à jour Deployments, Services,
|
|
||||||
Secrets, etc.</p>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>On est dans le pattern « Database, size: 3 » mais en prod : certificats, bases gérées, IdP.<br />
|
|
||||||
Trois domaines, <strong>même mécanisme</strong> : CRD + boucle de réconciliation.</p>
|
|
||||||
<p>Liens :</p>
|
|
||||||
<ul>
|
|
||||||
<li><a href="https://cert-manager.io/">cert-manager</a></li>
|
|
||||||
<li><a href="https://github.com/zalando/postgres-operator">Zalando Postgres operator</a></li>
|
|
||||||
<li><a href="https://www.keycloak.org/operator/installation">Keycloak Operator</a></li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Un certificat déclaratif, tout le cycle TLS en dessous</h1>
|
|
||||||
<pre><code class="language-yaml">apiVersion: cert-manager.io/v1
|
|
||||||
kind: Certificate
|
|
||||||
metadata:
|
|
||||||
name: api-example
|
|
||||||
spec:
|
|
||||||
dnsNames:
|
|
||||||
- api.example.com
|
|
||||||
</code></pre>
|
|
||||||
<p>La suite est dérivée automatiquement :</p>
|
|
||||||
<ul>
|
|
||||||
<li><code>CertificateRequest</code></li>
|
|
||||||
<li>Challenge ACME</li>
|
|
||||||
<li>Secret TLS</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Tu déclares <strong>juste</strong> un certificat.</p>
|
|
||||||
<p>Et derrière : génération de clé, challenge ACME, renouvellement automatique.</p>
|
|
||||||
<p><strong>Aucun humain dans la boucle</strong> pour le cycle de vie du cert.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Certificats : du Certbot au renouvellement automatique</h1>
|
|
||||||
<p><em>Avant — sans opérateur</em></p>
|
|
||||||
<ul>
|
|
||||||
<li>Certbot manuel</li>
|
|
||||||
<li>Scripts cron</li>
|
|
||||||
<li>Cert expiré = prod down</li>
|
|
||||||
</ul>
|
|
||||||
<p><em>Avec cert-manager</em></p>
|
|
||||||
<ul>
|
|
||||||
<li>Déclaratif</li>
|
|
||||||
<li>Auto-renouvellement</li>
|
|
||||||
<li>Observabilité native (<code>Certificate</code>, conditions, events)</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>cert-manager, c’est typiquement : <strong>une complexité énorme</strong> encapsulée dans un opérateur.</p>
|
|
||||||
<p><strong>Là, ça vaut clairement le coup</strong> — parce que le domaine est clair, répétitif et critique.
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Postgres répliqué sans bricoler le failover à la main</h1>
|
|
||||||
<pre><code class="language-yaml">apiVersion: acid.zalan.do/v1
|
|
||||||
kind: postgresql
|
|
||||||
metadata:
|
|
||||||
name: my-cluster
|
|
||||||
spec:
|
|
||||||
numberOfInstances: 3
|
|
||||||
</code></pre>
|
|
||||||
<p>Derrière cette spec, le contrôleur s’occupe de :</p>
|
|
||||||
<ul>
|
|
||||||
<li>Réplication</li>
|
|
||||||
<li>Failover</li>
|
|
||||||
<li>Backup</li>
|
|
||||||
<li>Users / rôles</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Ça, c’est intéressant parce que ce n’est <strong>pas</strong> trivial : ce n’est <strong>pas</strong>
|
|
||||||
stateless, ce n’est <strong>pas</strong> safe à bricoler à la main sur du long terme.</p>
|
|
||||||
<p>Un opérateur mature sur ce terrain, c’est du <strong>métier base de données</strong> version déclarative.
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Quand un opérateur est légitime</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Système <strong>complexe</strong></li>
|
|
||||||
<li><strong>Règles</strong> claires et stables</li>
|
|
||||||
<li><strong>État</strong> critique pour l’activité</li>
|
|
||||||
<li>Opérations <strong>répétitives</strong></li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Un opérateur est pertinent quand tu peux formaliser des règles du type <strong>« si X alors Y »</strong>,
|
|
||||||
que tu dois les appliquer <strong>souvent</strong>, et que tu veux <strong>réduire l’erreur
|
|
||||||
humaine</strong>.</p>
|
|
||||||
<p>Si tu ne peux pas écrire ça proprement… <strong>pause</strong>… <strong>ça, c’est une très mauvaise
|
|
||||||
idée</strong> en opérateur.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Keycloak Credential Manager — le même schéma, chez moi</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Objectif : <strong>réconcilier</strong> des <strong>credentials Keycloak</strong> (clients, secrets,
|
|
||||||
rotation…) avec ce qui est <strong>déclaré dans le cluster</strong> (CRD, Secrets annotés, ou les deux —
|
|
||||||
selon ton design).</li>
|
|
||||||
<li>Intérêt conf : montrer le <strong>même schéma</strong> que les opérateurs « connus » : <em>observe →
|
|
||||||
compare → corrige</em> sur <strong>ton</strong> domaine métier.</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Je vous montre ça pas pour vendre un produit, mais pour prouver que <strong>la logique</strong> est la même
|
|
||||||
: une API Kubernetes, un état désiré, et du code qui parle à Keycloak à la place d’un humain et d’un
|
|
||||||
runbook.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Lien repo</strong> : <code>https://…</code> (README, une spec d’exemple, capture d’un
|
|
||||||
<code>kubectl get</code>).
|
|
||||||
</li>
|
|
||||||
<li><strong>Une phrase</strong> sur le périmètre exact : ex. clients OAuth uniquement, rotation de secrets,
|
|
||||||
sync depuis External Secrets, etc.</li>
|
|
||||||
</ul>
|
|
||||||
<p>Schéma minimal : <code>CRD / Secret</code> → <strong>Keycloak Credential Manager</strong> → API Keycloak,
|
|
||||||
style diagramme propre (flat design).</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Ce qu’un opérateur bien choisi vous apporte</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Automatiser du <strong>répétitif</strong></li>
|
|
||||||
<li><strong>Encapsuler</strong> de la complexité</li>
|
|
||||||
<li><strong>Stabiliser</strong> un système</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>On remplace des opérations manuelles par du code — quand c’est le bon outil pour du vrai répétitif et des
|
|
||||||
règles claires.</p>
|
|
||||||
<p>On vient de voir du <strong>concret</strong> (annotations, CRD, projet maison). La question :
|
|
||||||
<strong>quand</strong> est-ce un bon outil ?
|
|
||||||
</p>
|
|
||||||
<p>…<strong>mais</strong> les opérateurs peuvent aussi devenir un enfer.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Quand ne pas écrire d’opérateur</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Logique <strong>floue</strong> ou en perpétuelle négociation métier</li>
|
|
||||||
<li>Processus <strong>instables</strong> (la règle change toutes les semaines)</li>
|
|
||||||
<li>Dépendances <strong>externes</strong> imprévisibles</li>
|
|
||||||
<li><strong>One-shot</strong> : automatisation ponctuelle sans cycle de vie</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Si tu ne peux pas écrire clairement <strong>« si X alors Y »</strong> avec des conditions observables… tu
|
|
||||||
n’as <strong>rien à faire</strong> dans un opérateur.</p>
|
|
||||||
<p>Sinon tu recodes un outil métier opaque <strong>dans</strong> le cluster.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Cinq signaux qu’un opérateur part en vrille</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Spec <strong>incompréhensible</strong> sans lire le code</li>
|
|
||||||
<li>Effets <strong>implicites</strong> (créations ailleurs sans que ce soit visible)</li>
|
|
||||||
<li><strong>Couplage</strong> externe fort (APIs fragiles, ordre d’appels magique)</li>
|
|
||||||
<li><strong>Debug</strong> impossible sans les mainteneurs à côté de toi</li>
|
|
||||||
<li><strong>État</strong> non observable (pas de conditions / status utiles)</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Un opérateur doit être <strong>lisible</strong>, <strong>prévisible</strong>, <strong>observable</strong>.
|
|
||||||
</p>
|
|
||||||
<p>Sinon, ce n’est pas de la plateforme : c’est une <strong>bombe à retardement</strong> avec un
|
|
||||||
<code>kubectl</code> dessus.
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Débugger un opérateur : describe, events, status, logs</h1>
|
|
||||||
<ul>
|
|
||||||
<li><code>kubectl describe</code> (resource + events)</li>
|
|
||||||
<li><strong>Events</strong> du namespace</li>
|
|
||||||
<li><strong>Status / conditions</strong> sur la CRD</li>
|
|
||||||
<li><strong>Logs</strong> du contrôleur</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Le debug devient <strong>indirect</strong>, <strong>distribué</strong>, parfois <strong>opaque</strong>.
|
|
||||||
</p>
|
|
||||||
<p>Si ton opérateur est mal conçu — pas de status clair, pas d’events utiles — <strong>bonne chance</strong>
|
|
||||||
pour le post-mortem à 3 h du matin.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Un opérateur, c’est un produit logiciel — pas un script</h1>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Code</strong> et revues</li>
|
|
||||||
<li><strong>Tests</strong> (unitaires, intégration, e2e si possible)</li>
|
|
||||||
<li><strong>Maintenance</strong> dans la durée</li>
|
|
||||||
<li><strong>Compatibilité</strong> avec les versions Kubernetes</li>
|
|
||||||
<li><strong>Migrations</strong> de CRD / spec</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Un opérateur, ce n’est <strong>pas</strong> un script du vendredi.</p>
|
|
||||||
<p>C’est un <strong>produit logiciel</strong> avec un <strong>cycle de vie</strong> — et une dette si tu
|
|
||||||
l’abandonnes.</p>
|
|
||||||
<p>Ensuite : la synthèse <strong>« ça part en vrille »</strong> — smells qu’on a déjà nommés, mais côté vécu
|
|
||||||
d’équipe.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Quand l’opérateur devient l’enfer</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Logique <strong>implicite</strong></li>
|
|
||||||
<li><strong>Effets de bord</strong></li>
|
|
||||||
<li><strong>Debugging</strong> difficile</li>
|
|
||||||
<li><strong>Couplage</strong> fort</li>
|
|
||||||
</ul>
|
|
||||||
<p>En pratique, ça donne souvent :</p>
|
|
||||||
<ul>
|
|
||||||
<li>Tout mettre en opérateur</li>
|
|
||||||
<li>Logique métier <strong>floue</strong></li>
|
|
||||||
<li>Effets <strong>cachés</strong></li>
|
|
||||||
</ul>
|
|
||||||
<blockquote>
|
|
||||||
<p>Un opérateur mal conçu, c’est un microservice sous LSD.</p>
|
|
||||||
</blockquote>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Un mauvais opérateur, c’est de la complexité distribuée avec un debugger qui vous hait.<br />
|
|
||||||
Le piège classique : « formation Kubernetes par la trappe » parce qu’on empile des comportements opaques.
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Kubernetes, plateforme de programmation</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Kubernetes vous évite <strong>l’enfer de l’infrastructure</strong> bas niveau.</li>
|
|
||||||
<li>Les bons opérateurs vous évitent <strong>l’enfer du manuel</strong> — les mauvais vous y renvoient
|
|
||||||
autrement.</li>
|
|
||||||
</ul>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Kubernetes n’est pas qu’un outil d’infra : c’est une surface où l’on encode du
|
|
||||||
<strong>comportement</strong>.<br />
|
|
||||||
Les opérateurs sont l’extension naturelle… à condition de rester honnêtes sur la complexité.
|
|
||||||
</p>
|
|
||||||
<p>Fil de la conf (rappel) :</p>
|
|
||||||
<ol>
|
|
||||||
<li>Kubernetes n’est pas magique </li>
|
|
||||||
<li>API + état + boucle ; kernel = vrai « enfer » bas niveau </li>
|
|
||||||
<li>Controllers / opérateurs : où vit la logique </li>
|
|
||||||
<li><strong>Avant</strong> : vécu (runbooks, scripts, drift) → <strong>pourquoi</strong> ça casse </li>
|
|
||||||
<li><strong>Après</strong> + boucle ; exemples Reflector/Reloader, panorama CRD, creux cert-manager &
|
|
||||||
Postgres </li>
|
|
||||||
<li>Projet perso ; <strong>bon usage</strong> puis <strong>quand ne pas</strong>, smells, debug,
|
|
||||||
<strong>coût</strong>
|
|
||||||
</li>
|
|
||||||
<li>Pièges ; plateforme de programmation ; <strong>checklist</strong> ; dernière phrase + conclusion brutale
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Avant d’écrire un opérateur : la checklist</h1>
|
|
||||||
<ul>
|
|
||||||
<li>Répétitif</li>
|
|
||||||
<li>Règles claires</li>
|
|
||||||
<li>Déclaratif possible</li>
|
|
||||||
<li>Idempotent</li>
|
|
||||||
<li>Observable</li>
|
|
||||||
</ul>
|
|
||||||
<p>Si une case importante manque, reculez : pas d’opérateur (ou pas tout de suite).</p>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>C’est probablement <strong>la</strong> slide la plus importante pour une salle de devs.</p>
|
|
||||||
<p>Kubernetes donne <strong>envie</strong> d’en mettre partout. <strong>Mauvaise idée</strong> par défaut.</p>
|
|
||||||
<p>👉 Tu valides la checklist, ou tu <strong>recules</strong>.</p>
|
|
||||||
<p>Les <strong>pauses</strong> et les <strong>regards</strong> sur la salle après « Mauvaise idée » font
|
|
||||||
autant le travail qu’un diagramme.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Votre logique, exécutée par le cluster au lieu d’un humain</h1>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Tu vises deux réactions :</p>
|
|
||||||
<ul>
|
|
||||||
<li>« OK, Kubernetes je comprends enfin le cadre. »</li>
|
|
||||||
<li>« OK, les opérateurs : puissants, mais à manier avec précision. »</li>
|
|
||||||
</ul>
|
|
||||||
<p><strong>Éviter le piège</strong> : faire une « formation Kubernetes » sans le vouloir — rester sur le
|
|
||||||
message <em>comportement / automatisation / risques</em>.</p>
|
|
||||||
<p>Un opérateur, c’est votre logique métier… mais exécutée par le cluster au lieu d’un humain.<br />
|
|
||||||
À vous de garder cette logique <strong>lisible</strong>, <strong>testable</strong>, et
|
|
||||||
<strong>bornée</strong>.
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h1>Kubernetes simplifie l’infra — les opérateurs, ça peut simplifier ou empirer</h1>
|
|
||||||
<aside class="notes">
|
|
||||||
<p>Kubernetes vous évite une partie du chaos du bas niveau.</p>
|
|
||||||
<p>Mais les opérateurs : soit ils <strong>encapsulent</strong> intelligemment une complexité maîtrisée, soit
|
|
||||||
ils recréent un enfer <strong>plus sophistiqué</strong>.</p>
|
|
||||||
<p>Ce que la salle retient : les <strong>pauses</strong>, les <strong>« très mauvaise idée »</strong>
|
|
||||||
honnêtes, plus que chaque ligne du deck.</p>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<footer class="deck-footer">
|
|
||||||
<a href="https://specificat.io/" rel="noopener noreferrer" target="_blank">Specificat.io</a>
|
|
||||||
<span class="deck-footer-sep" aria-hidden="true">·</span>
|
|
||||||
<span class="deck-footer-rights">All rights reserved</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
<!-- Ordre : cœur Reveal → plugins officiels (notes, highlight) → Mermaid -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/reveal.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/plugin/notes/notes.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/4.6.1/plugin/highlight/highlight.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
if (typeof RevealNotes === "undefined" || typeof RevealHighlight === "undefined") {
|
|
||||||
console.error("Reveal : plugins Notes ou Highlight introuvables (vérifiez le réseau / CDN).");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Thème Mermaid aligné sur https://specificat.io/ (variables en hex — requis par Mermaid) */
|
|
||||||
mermaid.initialize({
|
|
||||||
startOnLoad: false,
|
|
||||||
theme: "base",
|
|
||||||
securityLevel: "loose",
|
|
||||||
/* foreignObject + thème base = libellés vides chez certains navigateurs ; SVG text est fiable */
|
|
||||||
htmlLabels: false,
|
|
||||||
fontFamily:
|
|
||||||
'system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif',
|
|
||||||
themeVariables: {
|
|
||||||
darkMode: true,
|
|
||||||
background: "#050709",
|
|
||||||
fontSize: "15px",
|
|
||||||
primaryColor: "#0d1115",
|
|
||||||
primaryTextColor: "#f5f5f5",
|
|
||||||
primaryBorderColor: "#2bc9c6",
|
|
||||||
secondaryColor: "#1b1f22",
|
|
||||||
secondaryTextColor: "#f5f5f5",
|
|
||||||
secondaryBorderColor: "#3d6d6a",
|
|
||||||
tertiaryColor: "#050709",
|
|
||||||
tertiaryTextColor: "#c8d0d4",
|
|
||||||
tertiaryBorderColor: "#2bc9c6",
|
|
||||||
lineColor: "#4dbfb8",
|
|
||||||
textColor: "#f5f5f5",
|
|
||||||
mainBkg: "#0d1115",
|
|
||||||
nodeBorder: "#2bc9c6",
|
|
||||||
nodeTextColor: "#f5f5f5",
|
|
||||||
clusterBkg: "#0a1014",
|
|
||||||
clusterBorder: "#2bc9c6",
|
|
||||||
defaultLinkColor: "#4dbfb8",
|
|
||||||
edgeLabelBackground: "#0d1115",
|
|
||||||
titleColor: "#f5f5f5",
|
|
||||||
nodeSpacing: 10,
|
|
||||||
rankSpacing: 10
|
|
||||||
},
|
|
||||||
flowchart: {
|
|
||||||
useMaxWidth: true,
|
|
||||||
curve: "natural",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function upgradeMermaidBlocks(container) {
|
|
||||||
container.querySelectorAll("pre code.language-mermaid").forEach(function (code) {
|
|
||||||
var pre = code.parentElement;
|
|
||||||
var div = document.createElement("div");
|
|
||||||
div.className = "mermaid";
|
|
||||||
var raw = code.textContent.replace(/\r\n/g, "\n");
|
|
||||||
div.setAttribute("data-mermaid-src", raw);
|
|
||||||
div.textContent = raw;
|
|
||||||
pre.parentNode.replaceChild(div, pre);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mermaid dans une slide display:none a souvent largeur 0 → SVG vide ou taille absurde : on ne rend que la slide visible. */
|
|
||||||
function resetMermaidIfBroken(el) {
|
|
||||||
if (!el.getAttribute("data-processed")) return;
|
|
||||||
var svg = el.querySelector("svg");
|
|
||||||
if (svg && svg.getBoundingClientRect().height > 8) return;
|
|
||||||
var src = el.getAttribute("data-mermaid-src");
|
|
||||||
if (!src) return;
|
|
||||||
el.removeAttribute("data-processed");
|
|
||||||
el.innerHTML = "";
|
|
||||||
el.textContent = src;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runMermaidInSlide(slide) {
|
|
||||||
if (!slide || typeof mermaid.run !== "function") return;
|
|
||||||
slide.querySelectorAll(".mermaid").forEach(resetMermaidIfBroken);
|
|
||||||
var pending = slide.querySelectorAll(".mermaid:not([data-processed])");
|
|
||||||
if (!pending.length) return;
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
mermaid.run({ nodes: pending, suppressErrors: true }).catch(function () { });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyHighlight() {
|
|
||||||
if (typeof hljs === "undefined") return;
|
|
||||||
document.querySelectorAll(".reveal .slides pre code").forEach(function (el) {
|
|
||||||
if (!el.classList.contains("language-mermaid")) hljs.highlightElement(el);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Reveal.initialize({
|
|
||||||
hash: true,
|
|
||||||
slideNumber: "c/t",
|
|
||||||
progress: true,
|
|
||||||
center: true,
|
|
||||||
transition: "slide",
|
|
||||||
backgroundTransition: "fade",
|
|
||||||
// Notes orateur en premier (touche S) ; puis coloration des blocs de code
|
|
||||||
plugins: [RevealNotes, RevealHighlight],
|
|
||||||
}).then(function () {
|
|
||||||
applyHighlight();
|
|
||||||
document.querySelectorAll(".reveal .slides section").forEach(function (sec) {
|
|
||||||
upgradeMermaidBlocks(sec);
|
|
||||||
});
|
|
||||||
runMermaidInSlide(Reveal.getCurrentSlide());
|
|
||||||
});
|
|
||||||
|
|
||||||
Reveal.on("slidechanged", function (event) {
|
|
||||||
runMermaidInSlide(event.currentSlide);
|
|
||||||
/* Slide « boucle » : sans ça, flex + centrage Reveal donnent souvent une 1ʳᵉ mesure de largeur fausse ; resize appelle déjà layout() — on le déclenche ici. */
|
|
||||||
if (event.currentSlide && event.currentSlide.classList.contains("slide-boucle")) {
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
if (typeof Reveal.layout === "function") Reveal.layout();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Portail (build statique)', () => {
|
||||||
|
test('accueil : titre, intro, liens rapides, grille présentations', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Talks', level: 1 })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'À propos de ce site' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Liens rapides' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Présentations' })).toBeVisible()
|
||||||
|
|
||||||
|
const qrCanvases = page.locator('.qr-section canvas')
|
||||||
|
// Un seul QR sur l’accueil quand talkRepoUrl est vide (QR dépôt optionnel).
|
||||||
|
await expect(qrCanvases).toHaveCount(1)
|
||||||
|
|
||||||
|
const cards = page.locator('.talks-section--home .talk-card')
|
||||||
|
await expect(cards).toHaveCount(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navigation : liste → fiche → href slides cohérents', async ({ page }) => {
|
||||||
|
await page.goto('/presentations/')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Présentations', level: 1 })).toBeVisible()
|
||||||
|
await expect(page.locator('article.talk-card--list')).toHaveCount(2)
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('article.talk-card--list')
|
||||||
|
.filter({ hasText: 'Kubernetes Operators' })
|
||||||
|
.getByRole('link', { name: 'Kubernetes Operators' })
|
||||||
|
.click()
|
||||||
|
await expect(page).toHaveURL(/\/presentations\/kubernetes-operators\/?$/)
|
||||||
|
|
||||||
|
const slidesLink = page.getByRole('link', { name: 'Voir les slides' })
|
||||||
|
await expect(slidesLink).toHaveAttribute('href', /\/slides\/kubernetes-operators\/1\/?$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fiche kubernetes-operators : clic « Voir les slides » ouvre la 1ère planche Slidev', async ({ page }) => {
|
||||||
|
await page.goto('/presentations/kubernetes-operators/')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Kubernetes Operators', level: 1 })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Voir les slides' }).click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/slides\/kubernetes-operators\/1\/?$/)
|
||||||
|
await expect(page.locator('#app')).toBeAttached()
|
||||||
|
await expect(page).toHaveTitle(/Kubernetes Operators/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fiche : boutons slides + bloc QR deck', async ({ page }) => {
|
||||||
|
await page.goto('/presentations/spring-ai/')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Spring AI', level: 1 })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: 'Voir les slides' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
/\/slides\/spring-ai\/1\/?$/,
|
||||||
|
)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Partager les slides' })).toBeVisible()
|
||||||
|
await expect(page.locator('.qr-deck canvas')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deck Slidev servi sous /slides/<slug>/1', async ({ page }) => {
|
||||||
|
await page.goto('/slides/kubernetes-operators/1')
|
||||||
|
await expect(page.locator('#app')).toBeAttached()
|
||||||
|
await expect(page).toHaveTitle(/Kubernetes Operators/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lien accueil → liste complète', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('link', { name: /Voir toutes les présentations/ }).click()
|
||||||
|
await expect(page).toHaveURL(/\/presentations\/?$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join as pathJoin } from 'path'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const presJson = pathJoin(process.cwd(), 'portal', '.vitepress', 'data', 'presentations.json')
|
||||||
|
const SLUGS = JSON.parse(readFileSync(presJson, 'utf8')).map((p) => p.slug)
|
||||||
|
|
||||||
|
const ROUTES = ['/', '/presentations/', ...SLUGS.map((s) => `/presentations/${s}/`)]
|
||||||
|
|
||||||
|
test.describe('HTTP / routes statiques', () => {
|
||||||
|
for (const route of ROUTES) {
|
||||||
|
test(`GET ${route} → 200`, async ({ request }) => {
|
||||||
|
const res = await request.get(route)
|
||||||
|
expect(res.ok(), await res.text()).toBeTruthy()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET slides decks → 200', async ({ request }) => {
|
||||||
|
for (const slug of SLUGS) {
|
||||||
|
const res = await request.get(`/slides/${slug}/1`)
|
||||||
|
expect(res.ok(), `slides ${slug}: ${await res.text()}`).toBeTruthy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join as pathJoin } from 'path'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
const presJson = pathJoin(process.cwd(), 'portal', '.vitepress', 'data', 'presentations.json')
|
||||||
|
const SLUGS = JSON.parse(readFileSync(presJson, 'utf8')).map((p) => p.slug)
|
||||||
|
|
||||||
|
test.describe('Matrice présentations (chaque slug)', () => {
|
||||||
|
for (const slug of SLUGS) {
|
||||||
|
test(`fiche ${slug} : titre, liens, QR`, async ({ page }) => {
|
||||||
|
await page.goto(`/presentations/${slug}/`)
|
||||||
|
await expect(page.locator('h1')).toBeVisible()
|
||||||
|
const title = await page.locator('h1').textContent()
|
||||||
|
expect(title.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const deckPath = new RegExp(`/slides/${slug}/1/?$`)
|
||||||
|
await expect(page.getByRole('link', { name: 'Voir les slides' })).toHaveAttribute('href', deckPath)
|
||||||
|
await expect(page.getByRole('link', { name: 'Ouvrir dans un nouvel onglet' })).toHaveAttribute('href', deckPath)
|
||||||
|
await expect(page.locator('.qr-deck canvas')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`deck Slidev ${slug} : #app + titre`, async ({ page }) => {
|
||||||
|
await page.goto(`/slides/${slug}/1`)
|
||||||
|
await expect(page.locator('#app')).toBeAttached()
|
||||||
|
const t = await page.title()
|
||||||
|
expect(t.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`liste : carte ${slug} avec actions Fiche / Slides`, async ({ page }) => {
|
||||||
|
await page.goto('/presentations/')
|
||||||
|
const card = page
|
||||||
|
.locator('article.talk-card--list')
|
||||||
|
.filter({ has: page.locator(`a[href="/presentations/${slug}/"], a[href$="/presentations/${slug}/"]`) })
|
||||||
|
await expect(card.getByRole('link', { name: 'Fiche' })).toHaveAttribute('href', new RegExp(`/presentations/${slug}/`))
|
||||||
|
await expect(card.getByRole('link', { name: 'Slides' })).toHaveAttribute('href', new RegExp(`/slides/${slug}/1`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Exemple Flux — à intégrer dans le dépôt cluster (adapter namespaces / secrets).
|
|
||||||
# Chart poussé par la CI : helm push talks-slides-chart-<version>.tgz oci://git.specificat.io/specificat.io
|
|
||||||
---
|
|
||||||
apiVersion: source.toolkit.fluxcd.io/v1
|
|
||||||
kind: OCIRepository
|
|
||||||
metadata:
|
|
||||||
name: talks-slides-chart
|
|
||||||
namespace: flux-system
|
|
||||||
spec:
|
|
||||||
interval: 10m
|
|
||||||
url: oci://git.specificat.io/specificat.io/talks-slides-chart
|
|
||||||
secretRef:
|
|
||||||
name: gitea-registry-oci
|
|
||||||
ref:
|
|
||||||
semver: ">=0.0.1"
|
|
||||||
---
|
|
||||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
|
||||||
kind: HelmRelease
|
|
||||||
metadata:
|
|
||||||
name: talks-slides
|
|
||||||
namespace: product
|
|
||||||
spec:
|
|
||||||
interval: 10m
|
|
||||||
targetNamespace: product
|
|
||||||
chartRef:
|
|
||||||
kind: OCIRepository
|
|
||||||
name: talks-slides-chart
|
|
||||||
namespace: flux-system
|
|
||||||
install:
|
|
||||||
remediation:
|
|
||||||
retries: 3
|
|
||||||
upgrade:
|
|
||||||
remediation:
|
|
||||||
retries: 3
|
|
||||||
values:
|
|
||||||
slides:
|
|
||||||
image:
|
|
||||||
repository: git.specificat.io/specificat.io/talks-slides
|
|
||||||
tag: latest # {"$imagepolicy": "product:talks-slides"}
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: prd-gitea-registry-secret
|
|
||||||
ingress:
|
|
||||||
enabled: true
|
|
||||||
className: nginx
|
|
||||||
hosts:
|
|
||||||
- host: slides.specificat.io
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
# TLS via cert-manager (annotation + bloc tls pour le secret à créer)
|
|
||||||
certManager:
|
|
||||||
enabled: true
|
|
||||||
clusterIssuer: letsencrypt-prod
|
|
||||||
tls:
|
|
||||||
secretName: ""
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# Optionnel : Image Automation pour l’image applicative (pas le chart).
|
|
||||||
# Adapte sourceRef GitRepository, update.path et la branche à ton dépôt cluster.
|
|
||||||
# Alternative souvent plus simple : ne pousser que l’image en CI et laisser le chart
|
|
||||||
# en OCI avec semver ; Flux réconcilie le chart ; le contenu des slides est déjà
|
|
||||||
# mis à jour par git pull dans le conteneur (pas besoin de redéployer à chaque commit).
|
|
||||||
# Prérequis : image-reflector-controller + image-automation-controller.
|
|
||||||
---
|
|
||||||
apiVersion: image.toolkit.fluxcd.io/v1beta2
|
|
||||||
kind: ImageRepository
|
|
||||||
metadata:
|
|
||||||
name: talks-slides
|
|
||||||
namespace: product
|
|
||||||
spec:
|
|
||||||
image: git.specificat.io/specificat.io/talks-slides
|
|
||||||
interval: 1m
|
|
||||||
secretRef:
|
|
||||||
name: prd-gitea-registry-secret
|
|
||||||
---
|
|
||||||
apiVersion: image.toolkit.fluxcd.io/v1beta2
|
|
||||||
kind: ImagePolicy
|
|
||||||
metadata:
|
|
||||||
name: talks-slides
|
|
||||||
namespace: product
|
|
||||||
spec:
|
|
||||||
imageRepositoryRef:
|
|
||||||
name: talks-slides
|
|
||||||
policy:
|
|
||||||
semver:
|
|
||||||
range: ">=0.0.1-0"
|
|
||||||
---
|
|
||||||
apiVersion: image.toolkit.fluxcd.io/v1beta2
|
|
||||||
kind: ImageUpdateAutomation
|
|
||||||
metadata:
|
|
||||||
name: talks-slides
|
|
||||||
namespace: product
|
|
||||||
spec:
|
|
||||||
interval: 1m
|
|
||||||
sourceRef:
|
|
||||||
kind: GitRepository
|
|
||||||
name: flux-system
|
|
||||||
namespace: flux-system
|
|
||||||
git:
|
|
||||||
commit:
|
|
||||||
author:
|
|
||||||
name: Flux
|
|
||||||
email: flux@specificat.io
|
|
||||||
push:
|
|
||||||
branch: main
|
|
||||||
update:
|
|
||||||
path: ./clusters/product
|
|
||||||
strategy: Setters
|
|
||||||
Generated
+12945
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "tech-talks-portal",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Portail VitePress + decks Slidev",
|
||||||
|
"scripts": {
|
||||||
|
"sync": "node tools/sync-portal.mjs",
|
||||||
|
"start": "node tools/run.mjs",
|
||||||
|
"build": "node tools/sync-portal.mjs && vitepress build portal && node tools/build-all-decks.mjs",
|
||||||
|
"preview:static": "node tools/static-preview.mjs",
|
||||||
|
"dev:portal": "node tools/dev-portal.mjs",
|
||||||
|
"dev:slides:kubernetes-operators": "slidev slides/kubernetes-operators/slides.md --open",
|
||||||
|
"dev:slides:spring-ai": "slidev slides/spring-ai/slides.md --open",
|
||||||
|
"test": "npm run test:node && npm run test:e2e",
|
||||||
|
"test:node": "node --test tests/site-base.test.mjs tests/presentation-contracts.test.mjs tests/repo-consistency.test.mjs tests/sync-tmp-repo.test.mjs tests/dist-layout.test.mjs tests/vitepress-slides-route.test.mjs",
|
||||||
|
"test:unit": "npm run test:node",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@slidev/cli": "^52.14.2",
|
||||||
|
"@slidev/theme-default": "^0.25.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"chokidar": "^4.0.3",
|
||||||
|
"serve": "^14.2.6",
|
||||||
|
"sirv": "^3.0.2",
|
||||||
|
"vitepress": "^1.6.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
const port = Number(process.env.PW_PORT || 4174)
|
||||||
|
const baseURL = `http://127.0.0.1:${port}`
|
||||||
|
|
||||||
|
const skipWebServer = !!process.env.PW_SKIP_WEBSERVER
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'e2e',
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
},
|
||||||
|
projects: [{ name: 'chromium', use: {} }],
|
||||||
|
webServer: skipWebServer
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
command: `npm run build && PREVIEW_PORT=${port} node tools/static-preview.mjs`,
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 300_000,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { withBase } from 'vitepress'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
const props = defineProps<{ path: string }>()
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
const href = computed(() => withBase(props.path))
|
||||||
|
|
||||||
|
const displayHost = computed(() => {
|
||||||
|
if (typeof window === 'undefined') return href.value
|
||||||
|
const abs = new URL(href.value, window.location.origin).href
|
||||||
|
return abs.replace(/^https?:\/\//, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const fullHref = computed(() => {
|
||||||
|
if (typeof window === 'undefined') return href.value
|
||||||
|
return new URL(href.value, window.location.origin).href
|
||||||
|
})
|
||||||
|
|
||||||
|
async function draw() {
|
||||||
|
if (!canvas.value || typeof window === 'undefined') return
|
||||||
|
const url = new URL(href.value, window.location.origin).href
|
||||||
|
await QRCode.toCanvas(canvas.value, url, {
|
||||||
|
width: 200,
|
||||||
|
margin: 1,
|
||||||
|
color: { dark: '#0d1115ff', light: '#ffffffff' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(draw)
|
||||||
|
watch(href, draw)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="qr-deck">
|
||||||
|
<a class="qr-link" :href="href">{{ displayHost }}</a>
|
||||||
|
<div class="qrcode-wrap">
|
||||||
|
<canvas ref="canvas" role="img" :aria-label="`QR code vers ${fullHref}`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useData } from 'vitepress'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
const { theme } = useData()
|
||||||
|
|
||||||
|
const repoUrl = computed(() => (theme.value as Record<string, string>).talkRepoUrl || '')
|
||||||
|
const repoTitle = computed(() => (theme.value as Record<string, string>).talkRepoTitle || 'Dépôt de code')
|
||||||
|
const repoDesc = computed(
|
||||||
|
() => (theme.value as Record<string, string>).talkRepoDesc || 'Sources, CI et configuration du site.',
|
||||||
|
)
|
||||||
|
const siteTitle = computed(() => (theme.value as Record<string, string>).talkSiteTitle || 'Ce site (slides)')
|
||||||
|
const siteDesc = computed(
|
||||||
|
() =>
|
||||||
|
(theme.value as Record<string, string>).talkSiteDesc ||
|
||||||
|
'URL à projeter ou à partager pendant une présentation.',
|
||||||
|
)
|
||||||
|
|
||||||
|
const siteUrlText = ref('…')
|
||||||
|
const siteUrlHref = ref('#')
|
||||||
|
const canvasRepo = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const canvasSite = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
width: 200,
|
||||||
|
margin: 1,
|
||||||
|
color: { dark: '#0d1115ff', light: '#ffffffff' },
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const u = new URL(window.location.href)
|
||||||
|
u.hash = ''
|
||||||
|
const siteUrl = u.href
|
||||||
|
siteUrlHref.value = siteUrl
|
||||||
|
siteUrlText.value = siteUrl.replace(/^https?:\/\//, '')
|
||||||
|
|
||||||
|
if (canvasRepo.value && repoUrl.value) {
|
||||||
|
await QRCode.toCanvas(canvasRepo.value, repoUrl.value, opts)
|
||||||
|
}
|
||||||
|
if (canvasSite.value) {
|
||||||
|
await QRCode.toCanvas(canvasSite.value, siteUrl, opts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="qr-section" aria-labelledby="qr-heading">
|
||||||
|
<h2 id="qr-heading" class="section-title">Liens rapides</h2>
|
||||||
|
<div class="qr-grid">
|
||||||
|
<article v-if="repoUrl" class="qr-card">
|
||||||
|
<h3>{{ repoTitle }}</h3>
|
||||||
|
<p class="qr-card-desc">{{ repoDesc }}</p>
|
||||||
|
<a class="qr-link" :href="repoUrl" target="_blank" rel="noopener noreferrer">{{ repoUrl.replace(/^https?:\/\//, '') }}</a>
|
||||||
|
<div class="qrcode-wrap" aria-hidden="true">
|
||||||
|
<canvas ref="canvasRepo" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="qr-card">
|
||||||
|
<h3>{{ siteTitle }}</h3>
|
||||||
|
<p class="qr-card-desc">{{ siteDesc }}</p>
|
||||||
|
<a class="qr-link" id="qr-site-link" :href="siteUrlHref">{{ siteUrlText }}</a>
|
||||||
|
<div class="qrcode-wrap" aria-hidden="true">
|
||||||
|
<canvas ref="canvasSite" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig } from 'vitepress'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { vitepressAppBase } from '../../tools/site-base.mjs'
|
||||||
|
import { vitePluginSlideDists } from './vite-plugin-slide-dists.mjs'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repoRoot = path.resolve(__dirname, '../..')
|
||||||
|
|
||||||
|
const vpBase = vitepressAppBase()
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
lang: 'fr-FR',
|
||||||
|
title: 'Talks — Specificat',
|
||||||
|
description: 'Présentations techniques (Slidev) — portail VitePress',
|
||||||
|
base: vpBase,
|
||||||
|
appearance: 'force-dark',
|
||||||
|
cleanUrls: true,
|
||||||
|
srcDir: '.',
|
||||||
|
/** Build portail seul — les decks sont dans dist-<slug>/ à la racine du dépôt. */
|
||||||
|
outDir: path.resolve(repoRoot, 'dist-portal'),
|
||||||
|
vite: {
|
||||||
|
// Écoute sur toutes les interfaces (ex. 127.0.0.1 + ::1). Sinon Node peut
|
||||||
|
// ne lier que ::1 et http://127.0.0.1:5173/… refuse la connexion (pas le même que localhost).
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
plugins: [vitePluginSlideDists({ base: vpBase })],
|
||||||
|
},
|
||||||
|
themeConfig: {
|
||||||
|
nav: [
|
||||||
|
{ text: 'Accueil', link: '/' },
|
||||||
|
{ text: 'Présentations', link: '/presentations/' },
|
||||||
|
],
|
||||||
|
search: { provider: 'local' },
|
||||||
|
sidebar: false,
|
||||||
|
footer: {
|
||||||
|
message: 'Talks — VitePress + Slidev.',
|
||||||
|
},
|
||||||
|
talkRepoUrl: '',
|
||||||
|
talkRepoTitle: '',
|
||||||
|
talkRepoDesc: '',
|
||||||
|
talkSiteTitle: 'Ce site (slides)',
|
||||||
|
talkSiteDesc: 'URL à projeter ou partager pendant une présentation.',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"slug": "kubernetes-operators",
|
||||||
|
"title": "Kubernetes Operators — De l’enfer au paradis",
|
||||||
|
"description": "Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.",
|
||||||
|
"tags": [
|
||||||
|
"kubernetes",
|
||||||
|
"operators",
|
||||||
|
"devops",
|
||||||
|
"controllers"
|
||||||
|
],
|
||||||
|
"order": 1,
|
||||||
|
"featured": true,
|
||||||
|
"folder": "kubernetes-operators"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "spring-ai",
|
||||||
|
"title": "Spring AI",
|
||||||
|
"description": "Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.",
|
||||||
|
"tags": [
|
||||||
|
"java",
|
||||||
|
"spring",
|
||||||
|
"llm",
|
||||||
|
"ai"
|
||||||
|
],
|
||||||
|
"order": 2,
|
||||||
|
"featured": true,
|
||||||
|
"folder": "spring-ai"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
/**
|
||||||
|
* Look & feel aligné sur talk.specificat.io (palette sombre + accent cyan).
|
||||||
|
* Variables VitePress ajustées pour cohérence avec la nav et le contenu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--talk-bg-dark: #050709;
|
||||||
|
--talk-bg-medium: #0d1115;
|
||||||
|
--talk-bg-light: #1b1f22;
|
||||||
|
--talk-bg-gradient-start: #10151a;
|
||||||
|
--talk-text-primary: #f5f5f5;
|
||||||
|
--talk-text-muted: rgba(245, 245, 245, 0.9);
|
||||||
|
--talk-text-subtle: rgba(245, 245, 245, 0.75);
|
||||||
|
--talk-accent-cyan: #2bc9c6;
|
||||||
|
--talk-accent-cyan-bg: rgba(43, 201, 198, 0.08);
|
||||||
|
--talk-accent-cyan-border: rgba(43, 201, 198, 0.55);
|
||||||
|
--talk-border-subtle: rgba(255, 255, 255, 0.12);
|
||||||
|
--talk-border-very-subtle: rgba(255, 255, 255, 0.07);
|
||||||
|
--talk-panel-glow: 0 0 0 1px rgba(43, 201, 198, 0.12);
|
||||||
|
|
||||||
|
--vp-c-bg: var(--talk-bg-dark);
|
||||||
|
--vp-c-bg-alt: var(--talk-bg-medium);
|
||||||
|
--vp-c-bg-elv: var(--talk-bg-light);
|
||||||
|
--vp-c-text-1: var(--talk-text-primary);
|
||||||
|
--vp-c-text-2: var(--talk-text-muted);
|
||||||
|
--vp-c-text-3: var(--talk-text-subtle);
|
||||||
|
--vp-c-divider: var(--talk-border-subtle);
|
||||||
|
--vp-c-brand-1: var(--talk-accent-cyan);
|
||||||
|
--vp-c-brand-2: var(--talk-accent-cyan);
|
||||||
|
--vp-c-brand-3: var(--talk-accent-cyan);
|
||||||
|
--vp-c-brand-soft: var(--talk-accent-cyan-bg);
|
||||||
|
--vp-button-brand-bg: transparent;
|
||||||
|
--vp-button-brand-border: var(--talk-accent-cyan);
|
||||||
|
--vp-button-brand-text: var(--talk-accent-cyan);
|
||||||
|
--vp-nav-bg-color: rgba(5, 7, 9, 0.85);
|
||||||
|
--vp-sidebar-bg-color: var(--talk-bg-medium);
|
||||||
|
--vp-code-bg: var(--talk-accent-cyan-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--vp-c-bg: var(--talk-bg-dark);
|
||||||
|
--vp-c-bg-alt: var(--talk-bg-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
||||||
|
color: var(--talk-text-primary);
|
||||||
|
background-color: var(--talk-bg-dark);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 10% 0%, var(--talk-bg-gradient-start) 0%, var(--talk-bg-dark) 52%, var(--talk-bg-light) 100%),
|
||||||
|
radial-gradient(ellipse 100% 80% at 50% -20%, rgba(43, 201, 198, 0.12), transparent 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(ellipse 95% 75% at 50% 50%, transparent 35%, rgba(5, 7, 9, 0.35) 100%);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app,
|
||||||
|
.VPContent,
|
||||||
|
.VPDoc,
|
||||||
|
.VPDoc .content-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.VPNav {
|
||||||
|
border-bottom: 1px solid var(--talk-border-very-subtle);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Page d’accueil (structure legacy) --- */
|
||||||
|
.talk-page {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead h1 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.25rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead-lead {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--talk-text-subtle);
|
||||||
|
max-width: 42em;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead-lead strong {
|
||||||
|
color: var(--talk-accent-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead a {
|
||||||
|
color: var(--talk-accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masthead a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--talk-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
margin-bottom: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--talk-text-muted);
|
||||||
|
max-width: 52em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro code {
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.12em 0.35em;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--talk-accent-cyan-bg);
|
||||||
|
border: 1px solid var(--talk-border-very-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- QR (accueil) --- */
|
||||||
|
.qr-section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.qr-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1.25rem 1.35rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--talk-border-subtle);
|
||||||
|
background: rgba(13, 17, 21, 0.55);
|
||||||
|
box-shadow: var(--talk-panel-glow), inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card h3 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--talk-accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card-desc {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--talk-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--talk-accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-link:hover,
|
||||||
|
.qr-link:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-link:focus-visible {
|
||||||
|
outline: 2px solid var(--talk-accent-cyan);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-wrap {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cartes accueil : .qr-card est en align-items: flex-start */
|
||||||
|
.qr-card .qrcode-wrap {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-wrap canvas,
|
||||||
|
.qrcode-wrap img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-deck {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-deck .qr-link {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-more {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Grille présentations (cartes type legacy) --- */
|
||||||
|
.talks-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talks-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.talks-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.talk-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.15rem 1.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--talk-border-subtle);
|
||||||
|
background: rgba(13, 17, 21, 0.55);
|
||||||
|
box-shadow: var(--talk-panel-glow), inset 0 1px 0 0 rgba(255, 255, 255, 0.03);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease,
|
||||||
|
transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.talk-card:hover,
|
||||||
|
a.talk-card:focus-visible {
|
||||||
|
border-color: var(--talk-accent-cyan-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--talk-panel-glow),
|
||||||
|
0 0 0 1px rgba(43, 201, 198, 0.18),
|
||||||
|
inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.talk-card:focus-visible {
|
||||||
|
outline: 2px solid var(--talk-accent-cyan);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talk-card h2 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--talk-accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.talk-card p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--talk-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.talk-card .meta {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--talk-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Liste présentations : carte avec actions */
|
||||||
|
.talk-card--list h2 a {
|
||||||
|
color: var(--talk-accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.talk-card--list h2 a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
article.talk-card--list {
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
article.talk-card--list:hover {
|
||||||
|
border-color: var(--talk-accent-cyan-border);
|
||||||
|
box-shadow:
|
||||||
|
var(--talk-panel-glow),
|
||||||
|
0 0 0 1px rgba(43, 201, 198, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.talk-card__actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boutons (fiches + détail) */
|
||||||
|
.vp-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--talk-accent-cyan);
|
||||||
|
color: var(--talk-accent-cyan);
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-button:hover {
|
||||||
|
background: var(--talk-accent-cyan-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-button--alt {
|
||||||
|
border-color: var(--talk-border-subtle);
|
||||||
|
color: var(--talk-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pres-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titres pages intérieures */
|
||||||
|
.vp-doc h1 {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer VitePress */
|
||||||
|
.VPFooter {
|
||||||
|
border-top: 1px solid var(--talk-border-very-subtle);
|
||||||
|
color: var(--talk-text-subtle);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Local search / algolia si activé */
|
||||||
|
.VPLocalSearchBackdrop {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Theme } from 'vitepress'
|
||||||
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import QrHomeSection from '../components/QrHomeSection.vue'
|
||||||
|
import QrEncode from '../components/QrEncode.vue'
|
||||||
|
import './custom.css'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: DefaultTheme,
|
||||||
|
enhanceApp({ app }) {
|
||||||
|
app.component('QrHomeSection', QrHomeSection)
|
||||||
|
app.component('QrEncode', QrEncode)
|
||||||
|
},
|
||||||
|
} satisfies Theme
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Dev : sert chaque dist-<slug>/ sous /slides/<slug>/ (sources = dossiers à la racine du dépôt).
|
||||||
|
* La racine `/slides/<slug>/` (et `/slides/<slug>`) redirige en 302 vers `/slides/<slug>/1` (route Slidev).
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import sirv from 'sirv'
|
||||||
|
import { listPresentationSlugs } from '../../tools/list-slugs.mjs'
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||||
|
|
||||||
|
function slidesUrlPrefix(vpBase) {
|
||||||
|
if (!vpBase || vpBase === '/') return '/slides'
|
||||||
|
return `${String(vpBase).replace(/\/+$/, '')}/slides`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requêtes que le middleware ne doit pas confondre avec la SPA Slidev (sinon HTML → erreur MIME sur les modules). */
|
||||||
|
function isViteOrSourceLikeRequest(rawUrl) {
|
||||||
|
let u
|
||||||
|
try {
|
||||||
|
u = new URL(rawUrl, 'http://vitepress.local')
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const q = u.searchParams
|
||||||
|
if (q.has('import') || q.has('raw') || q.has('url') || q.has('worker') || q.has('sharedworker')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const p = u.pathname
|
||||||
|
return /\.(md|mdx|vue|ts|mts|cts|tsx|jsx)($|\?)/i.test(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Après échec sirv : ne pas laisser VitePress répondre en HTML pour des scripts / modules.
|
||||||
|
* Pour une navigation document, servir le index.html du deck (router Slidev en history).
|
||||||
|
*/
|
||||||
|
function send404(res) {
|
||||||
|
res.statusCode = 404
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
res.end('Not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
function wantsAssetNotHtml(req) {
|
||||||
|
const dest = req.headers['sec-fetch-dest']
|
||||||
|
if (dest === 'script' || dest === 'style' || dest === 'image' || dest === 'font') return true
|
||||||
|
const accept = req.headers.accept || ''
|
||||||
|
if (accept.includes('text/javascript') || accept.includes('application/javascript')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Racine du deck Slidev (`/` ou `/index.html`) : le routeur attend `/1` pour la première planche. */
|
||||||
|
function isSlideDeckEntrySubpath(sub) {
|
||||||
|
const n = sub.replace(/\/+$/, '') || '/'
|
||||||
|
return n === '/' || n === '/index.html'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vitePluginSlideDists({ base: vpBase }) {
|
||||||
|
const prefix = slidesUrlPrefix(vpBase)
|
||||||
|
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const re = new RegExp(`^${escaped}\\/([^/]+)(.*)$`)
|
||||||
|
/** @type {Map<string, ReturnType<typeof sirv>>} */
|
||||||
|
const sirvBySlug = new Map()
|
||||||
|
/** @type {Map<string, string>} */
|
||||||
|
const indexHtmlBySlug = new Map()
|
||||||
|
const warnedMissing = new Set()
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'talks-slide-dists',
|
||||||
|
enforce: 'pre',
|
||||||
|
apply: 'serve',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const raw = req.originalUrl || req.url || '/'
|
||||||
|
const pathPart = raw.split('?')[0]
|
||||||
|
const m = pathPart.match(re)
|
||||||
|
if (!m) return next()
|
||||||
|
const slug = m[1]
|
||||||
|
const subRaw = m[2] || '/'
|
||||||
|
if (!listPresentationSlugs(repoRoot).includes(slug)) return next()
|
||||||
|
|
||||||
|
const distDir = path.join(repoRoot, `dist-${slug}`)
|
||||||
|
if (!fs.existsSync(path.join(distDir, 'index.html'))) {
|
||||||
|
if (!warnedMissing.has(slug)) {
|
||||||
|
warnedMissing.add(slug)
|
||||||
|
server.config.logger.warn(
|
||||||
|
`[talks-slide-dists] dist-${slug}/ manquant ou sans index.html — exécutez « make run » ou « npm run build » puis réessayez.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sub = subRaw.startsWith('/') ? subRaw : `/${subRaw}`
|
||||||
|
const query = raw.includes('?') ? `?${raw.split('?')[1]}` : ''
|
||||||
|
|
||||||
|
if (
|
||||||
|
(req.method === 'GET' || req.method === 'HEAD') &&
|
||||||
|
isSlideDeckEntrySubpath(sub) &&
|
||||||
|
!isViteOrSourceLikeRequest(raw)
|
||||||
|
) {
|
||||||
|
res.statusCode = 302
|
||||||
|
res.setHeader('Location', `${prefix}/${slug}/1${query}`)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isViteOrSourceLikeRequest(raw)) {
|
||||||
|
return send404(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
const origUrl = req.url
|
||||||
|
delete req._parsedUrl
|
||||||
|
req.url = sub + query
|
||||||
|
|
||||||
|
let handler = sirvBySlug.get(slug)
|
||||||
|
if (!handler) {
|
||||||
|
handler = sirv(distDir, { dev: true, etag: true, maxAge: 0 })
|
||||||
|
sirvBySlug.set(slug, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(req, res, () => {
|
||||||
|
req.url = origUrl
|
||||||
|
delete req._parsedUrl
|
||||||
|
|
||||||
|
if (isViteOrSourceLikeRequest(raw)) {
|
||||||
|
return send404(res)
|
||||||
|
}
|
||||||
|
if (wantsAssetNotHtml(req)) {
|
||||||
|
return send404(res)
|
||||||
|
}
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
let html = indexHtmlBySlug.get(slug)
|
||||||
|
if (html === undefined) {
|
||||||
|
const p = path.join(distDir, 'index.html')
|
||||||
|
html = fs.readFileSync(p, 'utf8')
|
||||||
|
indexHtmlBySlug.set(slug, html)
|
||||||
|
}
|
||||||
|
res.statusCode = 200
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
res.end(html)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
layout: doc
|
||||||
|
aside: false
|
||||||
|
outline: false
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="talk-page">
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<h1>Talks</h1>
|
||||||
|
<p class="masthead-lead">
|
||||||
|
Présentations techniques au format
|
||||||
|
<strong><a href="https://sli.dev" target="_blank" rel="noopener noreferrer">Slidev</a></strong>
|
||||||
|
— portail
|
||||||
|
<a href="https://vitepress.dev" target="_blank" rel="noopener noreferrer">VitePress</a>,
|
||||||
|
déployable en statique (CI, Pages, Nginx).
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="intro" aria-labelledby="intro-heading">
|
||||||
|
<h2 id="intro-heading" class="section-title">À propos de ce site</h2>
|
||||||
|
<p>
|
||||||
|
Ce site regroupe les slides utilisées pour nos conférences et ateliers : navigation clavier, mode plein écran,
|
||||||
|
export PDF selon les thèmes Slidev. Le contenu vit dans le dépôt Git <em>Talks</em> ; les mises à jour sous
|
||||||
|
<code>slides/</code> et le portail <code>portal/</code> peuvent être déployées automatiquement.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
En salle ou en visio, partagez l’URL des slides ou le dépôt source — les QR codes ci-dessous facilitent l’accès
|
||||||
|
depuis un téléphone.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<QrHomeSection />
|
||||||
|
|
||||||
|
<!-- FEATURED_START -->
|
||||||
|
|
||||||
|
<section class="talks-section talks-section--home" aria-labelledby="featured-heading">
|
||||||
|
<h2 id="featured-heading" class="section-title">Présentations</h2>
|
||||||
|
<div class="talks-grid">
|
||||||
|
<a class="talk-card" href="/presentations/kubernetes-operators/">
|
||||||
|
<h2>Kubernetes Operators — De l’enfer au paradis</h2>
|
||||||
|
<p>Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.</p>
|
||||||
|
<span class="meta">Présentation · kubernetes-operators</span>
|
||||||
|
</a>
|
||||||
|
<a class="talk-card" href="/presentations/spring-ai/">
|
||||||
|
<h2>Spring AI</h2>
|
||||||
|
<p>Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.</p>
|
||||||
|
<span class="meta">Présentation · spring-ai</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FEATURED_END -->
|
||||||
|
|
||||||
|
<section class="home-more" aria-label="Navigation">
|
||||||
|
<p><a class="qr-link" href="/presentations/">Voir toutes les présentations →</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: Présentations
|
||||||
|
outline: deep
|
||||||
|
---
|
||||||
|
|
||||||
|
# Présentations
|
||||||
|
|
||||||
|
Decks disponibles — fiche portail et build Slidev statique.
|
||||||
|
|
||||||
|
<div class="talks-grid">
|
||||||
|
<article class="talk-card talk-card--list">
|
||||||
|
<h2><a href="/presentations/kubernetes-operators/">Kubernetes Operators — De l’enfer au paradis</a></h2>
|
||||||
|
<p>Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.</p>
|
||||||
|
<p class="meta">`kubernetes` · `operators` · `devops` · `controllers`</p>
|
||||||
|
<div class="talk-card__actions">
|
||||||
|
<a class="vp-button" href="/presentations/kubernetes-operators/">Fiche</a>
|
||||||
|
<a class="vp-button vp-button--alt vp-raw" href="/slides/kubernetes-operators/1" rel="noopener">Slides</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="talk-card talk-card--list">
|
||||||
|
<h2><a href="/presentations/spring-ai/">Spring AI</a></h2>
|
||||||
|
<p>Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.</p>
|
||||||
|
<p class="meta">`java` · `spring` · `llm` · `ai`</p>
|
||||||
|
<div class="talk-card__actions">
|
||||||
|
<a class="vp-button" href="/presentations/spring-ai/">Fiche</a>
|
||||||
|
<a class="vp-button vp-button--alt vp-raw" href="/slides/spring-ai/1" rel="noopener">Slides</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: Kubernetes Operators — De l’enfer au paradis
|
||||||
|
outline: deep
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kubernetes Operators — De l’enfer au paradis
|
||||||
|
|
||||||
|
**Tags :** `kubernetes` · `operators` · `devops` · `controllers`
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
<p class="pres-actions">
|
||||||
|
<a class="vp-button vp-raw" href="/slides/kubernetes-operators/1">Voir les slides</a>
|
||||||
|
<a class="vp-button vp-button--alt vp-raw" href="/slides/kubernetes-operators/1" target="_blank" rel="noopener noreferrer">Ouvrir dans un nouvel onglet</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Partager les slides
|
||||||
|
|
||||||
|
Depuis la salle, scannez le code pour ouvrir les slides sur mobile.
|
||||||
|
|
||||||
|
<QrEncode path="/slides/kubernetes-operators/1" />
|
||||||
|
|
||||||
|
::: info À propos
|
||||||
|
Les slides sont servies en SPA statique (Slidev). Les chemins d’assets suivent le `base` défini au build.
|
||||||
|
:::
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
title: Spring AI
|
||||||
|
outline: deep
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spring AI
|
||||||
|
|
||||||
|
**Tags :** `java` · `spring` · `llm` · `ai`
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
<p class="pres-actions">
|
||||||
|
<a class="vp-button vp-raw" href="/slides/spring-ai/1">Voir les slides</a>
|
||||||
|
<a class="vp-button vp-button--alt vp-raw" href="/slides/spring-ai/1" target="_blank" rel="noopener noreferrer">Ouvrir dans un nouvel onglet</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Partager les slides
|
||||||
|
|
||||||
|
Depuis la salle, scannez le code pour ouvrir les slides sur mobile.
|
||||||
|
|
||||||
|
<QrEncode path="/slides/spring-ai/1" />
|
||||||
|
|
||||||
|
::: info À propos
|
||||||
|
Les slides sont servies en SPA statique (Slidev). Les chemins d’assets suivent le `base` défini au build.
|
||||||
|
:::
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Contexte minimal : seuls Dockerfile, nginx/ et refresh.sh sont nécessaires.
|
|
||||||
# Le dépôt applicatif est cloné dans l'image (voir Dockerfile).
|
|
||||||
+28
-35
@@ -1,44 +1,37 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# BuildKit / buildx : cache apk + permissions posées au build (moins de travail / capabilities au runtime).
|
# Image statique : build npm (VitePress + Slidev) puis nginx non-root.
|
||||||
FROM nginx:alpine
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apk \
|
COPY package.json package-lock.json ./
|
||||||
apk add --no-cache git
|
# npm 10 (image officielle) et npm 11 (lock généré localement) divergent sur la validation du lock ; `install` reste déterministe via package-lock.
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
# Image figée au clone ; le conteneur met à jour via refresh.sh (git pull origin main).
|
# Même arborescence que tools/static-preview.mjs : portail à la racine, decks sous slides/<slug>/
|
||||||
# Seul le dossier content/ est extrait (sparse checkout) : léger et aligné sur la racine Nginx.
|
RUN set -eux \
|
||||||
# L’image nginx:alpine place déjà des fichiers ici — il faut vider le répertoire avant git clone vers « . ».
|
&& mkdir -p /opt/site \
|
||||||
ARG TALKS_REPO_URL=https://git.specificat.io/arnault/Talks.git
|
&& cp -a dist-portal/. /opt/site/ \
|
||||||
ARG TALKS_BRANCH=main
|
&& for d in dist-*; do \
|
||||||
ARG TALKS_SPARSE_DIR=content
|
[ -d "$d" ] || continue; \
|
||||||
|
[ "$d" = "dist-portal" ] && continue; \
|
||||||
|
slug="${d#dist-}"; \
|
||||||
|
mkdir -p "/opt/site/slides/${slug}"; \
|
||||||
|
cp -a "${d}/." "/opt/site/slides/${slug}/"; \
|
||||||
|
done
|
||||||
|
|
||||||
RUN find . -mindepth 1 -delete \
|
FROM nginxinc/nginx-unprivileged:stable-alpine
|
||||||
&& git clone --filter=blob:none --sparse --branch "${TALKS_BRANCH}" --single-branch "${TALKS_REPO_URL}" . \
|
|
||||||
&& git sparse-checkout init --cone \
|
|
||||||
&& git sparse-checkout set "${TALKS_SPARSE_DIR}" \
|
|
||||||
&& git config --global --add safe.directory /usr/share/nginx/html
|
|
||||||
|
|
||||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
ARG UID=101
|
||||||
COPY refresh.sh /refresh.sh
|
ARG GID=101
|
||||||
|
|
||||||
# Caches et logs : créés ici avec le même schéma que l’entrypoint nginx (évite le chown au démarrage).
|
USER root
|
||||||
# Propriétaire nginx (cf. /etc/nginx/nginx.conf user) → l’entrypoint ne refait pas chown si tout est déjà cohérent.
|
COPY --from=builder /opt/site /usr/share/nginx/html
|
||||||
RUN chmod +x /refresh.sh \
|
COPY server/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
&& mkdir -p \
|
RUN chown -R "${UID}:${GID}" /usr/share/nginx/html
|
||||||
/var/cache/nginx/client_temp \
|
|
||||||
/var/cache/nginx/proxy_temp \
|
|
||||||
/var/cache/nginx/fastcgi_temp \
|
|
||||||
/var/cache/nginx/uwsgi_temp \
|
|
||||||
/var/cache/nginx/scgi_temp \
|
|
||||||
/var/log/nginx \
|
|
||||||
&& chown -R nginx:nginx \
|
|
||||||
/var/cache/nginx \
|
|
||||||
/var/log/nginx \
|
|
||||||
/usr/share/nginx/html
|
|
||||||
|
|
||||||
# Port non privilégié (pas de CAP_NET_BIND_SERVICE) ; le Service K8s mappe souvent 80 → 8080.
|
USER ${UID}
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
CMD sh -c "/refresh.sh & exec nginx -g 'daemon off;'"
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
server {
|
server {
|
||||||
listen 8080;
|
listen 8080;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
# Racine web = contenu statique sparse (dossier content/ du dépôt).
|
root /usr/share/nginx/html;
|
||||||
root /usr/share/nginx/html/content;
|
index index.html;
|
||||||
index index.html index.htm;
|
|
||||||
|
|
||||||
|
# Portail VitePress (fichiers statiques + clean URLs)
|
||||||
location / {
|
location / {
|
||||||
add_header Cache-Control "no-cache";
|
try_files $uri $uri/ $uri.html =404;
|
||||||
try_files $uri $uri/ =404;
|
}
|
||||||
|
|
||||||
|
# Slidev : /slides/<slug> ou /slides/<slug>/ → première planche (comme static-preview.mjs)
|
||||||
|
# $http_host conserve le port client (reverse proxy, docker -p host:8080).
|
||||||
|
location ~ ^/slides/([^/]+)/?$ {
|
||||||
|
return 302 $scheme://$http_host/slides/$1/1$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Decks Slidev : assets et routes SPA (/1, /2, …)
|
||||||
|
location ~ ^/slides/(?<slug>[^/]+)(?<rest>/.*)$ {
|
||||||
|
try_files $uri $uri/ /slides/$slug/index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Répertoire git : /usr/share/nginx/html (sparse checkout limité à content/).
|
|
||||||
cd /usr/share/nginx/html || exit 1
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
echo "Pulling latest changes from origin/main (sparse content/)..."
|
|
||||||
git pull origin main || echo "git pull failed, will retry in 300s"
|
|
||||||
sleep 300
|
|
||||||
done
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Pied de page fixe pour tous les decks Slidev : retour portail + lien Specificat.
|
||||||
|
* Lien « Accueil » en relatif ../../../ pour fonctionner avec --base …/slides/<slug>/
|
||||||
|
* (ex. /slides/foo/12 → racine du site).
|
||||||
|
*
|
||||||
|
* Ne pas cibler .slidev-page en CSS global (padding, box-sizing, etc.) : Slidev centre
|
||||||
|
* la planche avec transform + dimensions ; modifier cette boîte casse le centrage.
|
||||||
|
*/
|
||||||
|
const specificat = 'https://specificat.io/'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="deck-footer" aria-label="Navigation du portail">
|
||||||
|
<a class="deck-footer__link" href="../../../">Accueil</a>
|
||||||
|
<span class="deck-footer__sep" aria-hidden="true">·</span>
|
||||||
|
<a
|
||||||
|
class="deck-footer__link"
|
||||||
|
:href="specificat"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>Specificat.io</a>
|
||||||
|
<span class="deck-footer__sep" aria-hidden="true">·</span>
|
||||||
|
<span class="deck-footer__rights">All rights reserved</span>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.deck-footer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
/*
|
||||||
|
* Slidev place la webcam sur .z-camera (= z-index 15, voir @slidev/client/uno.config.ts).
|
||||||
|
* Un footer plus haut (ex. 30) passait au-dessus et masquait la miniature.
|
||||||
|
*/
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35em 0.5em;
|
||||||
|
padding: 0.35rem 2.5rem 0.55rem;
|
||||||
|
font-size: clamp(10px, 1.5vw, 12px);
|
||||||
|
line-height: 1.3;
|
||||||
|
color: rgba(245, 245, 245, 0.75);
|
||||||
|
pointer-events: none;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
background: linear-gradient(to top, rgba(5, 7, 9, 0.72), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-footer__link {
|
||||||
|
pointer-events: auto;
|
||||||
|
color: #2bc9c6;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-footer__link:hover,
|
||||||
|
.deck-footer__link:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-footer__sep {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-footer__rights {
|
||||||
|
opacity: 0.85;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
* Reveal : center: true — le thème Slidev « default » aligne le bloc en haut-gauche.
|
||||||
|
* Si une planche reste en layout default (cache, ancien bundle, etc.), on centre
|
||||||
|
* le bloc comme le layout intégré « center ». Ne cible pas .slidev-layout.center.
|
||||||
|
*/
|
||||||
|
.slidev-layout.default {
|
||||||
|
min-height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
const REPO = 'https://git.specificat.io/arnault/Talks'
|
||||||
|
|
||||||
|
const canvasRepo = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const canvasHome = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
/** Accueil portail : même logique que l’ancien Reveal (parent depuis /slides/<slug>/<n>). */
|
||||||
|
function portalHomeHref(): string {
|
||||||
|
if (typeof window === 'undefined') return 'https://specificat.io/'
|
||||||
|
try {
|
||||||
|
const u = new URL(window.location.href)
|
||||||
|
if (u.pathname.includes('/slides/')) {
|
||||||
|
u.pathname = '/'
|
||||||
|
u.hash = ''
|
||||||
|
return u.href
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return `${window.location.origin}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
width: 150,
|
||||||
|
margin: 1,
|
||||||
|
color: { dark: '#0d1115ff', light: '#ffffffff' },
|
||||||
|
}
|
||||||
|
|
||||||
|
async function draw() {
|
||||||
|
if (canvasRepo.value)
|
||||||
|
await QRCode.toCanvas(canvasRepo.value, REPO, opts)
|
||||||
|
if (canvasHome.value)
|
||||||
|
await QRCode.toCanvas(canvasHome.value, portalHomeHref(), opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(draw)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="closing-qr" aria-label="QR codes dépôt et accueil">
|
||||||
|
<figure class="closing-qr__block">
|
||||||
|
<figcaption class="closing-qr__cap">Dépôt</figcaption>
|
||||||
|
<div class="closing-qr__wrap">
|
||||||
|
<canvas
|
||||||
|
ref="canvasRepo"
|
||||||
|
role="img"
|
||||||
|
width="150"
|
||||||
|
height="150"
|
||||||
|
aria-label="QR code vers le dépôt Git"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
<figure class="closing-qr__block">
|
||||||
|
<figcaption class="closing-qr__cap">Slides — accueil</figcaption>
|
||||||
|
<div class="closing-qr__wrap">
|
||||||
|
<canvas
|
||||||
|
ref="canvasHome"
|
||||||
|
role="img"
|
||||||
|
width="150"
|
||||||
|
height="150"
|
||||||
|
aria-label="QR code vers l’accueil du portail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.closing-qr {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1.75rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closing-qr__block {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closing-qr__cap {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(245, 245, 245, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closing-qr__wrap {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2em;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 0;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closing-qr__wrap canvas {
|
||||||
|
display: block;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DeckFooter from '../_shared/DeckFooter.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DeckFooter />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"slug": "kubernetes-operators",
|
||||||
|
"title": "Kubernetes Operators — De l’enfer au paradis",
|
||||||
|
"description": "Mental model (API, etcd, boucle), controllers et opérateurs, exemples concrets (Reflector, Reloader, cert-manager, Postgres) et pièges à éviter.",
|
||||||
|
"tags": ["kubernetes", "operators", "devops", "controllers"],
|
||||||
|
"order": 1,
|
||||||
|
"featured": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
---
|
||||||
|
theme: default
|
||||||
|
# Par défaut Slidev : record: "dev" → pas d’UI caméra/enregistrement dans `slidev build`. true = inclus en prod.
|
||||||
|
record: true
|
||||||
|
title: Kubernetes Operators — De l'enfer au paradis
|
||||||
|
info: |
|
||||||
|
Mental model Kubernetes, boucles de contrôle, opérateurs, exemples et pièges.
|
||||||
|
highlighter: shiki
|
||||||
|
# Comme Reveal « center: true » : planche centrée (layout intégré Slidev).
|
||||||
|
defaults:
|
||||||
|
layout: center
|
||||||
|
---
|
||||||
|
|
||||||
|
# Kubernetes Operators — De l'enfer… au paradis
|
||||||
|
|
||||||
|
> Kubernetes n'est **pas** compliqué.
|
||||||
|
> Mais **mal compris**… c'est un enfer.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
On parle surtout de pourquoi les opérateurs changent la manière de travailler — puissants mais dangereux si le mental model est faux.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes : délire d'Ops ?
|
||||||
|
|
||||||
|
> On nous l'a surtout vendu comme un truc d'infra.
|
||||||
|
> *Moi aussi, je suis passé par ce réflexe.*
|
||||||
|
|
||||||
|
- « Trop complexe »
|
||||||
|
- Réservé aux spécialistes plateforme
|
||||||
|
- Magie noire / pas « mon » métier
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Souvent présenté comme réservé aux spécialistes — en réalité proche de ce qu'on fait déjà en dev.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes c'est un délire de dev
|
||||||
|
|
||||||
|
> Kubernetes c'est un délire de dev
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Transition : contraste avec la slide précédente.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes = API + état + boucle
|
||||||
|
|
||||||
|
- API REST
|
||||||
|
- Base d'état (etcd)
|
||||||
|
- Boucle de réconciliation → events
|
||||||
|
- Pas de mystère → mécanismes explicites (controllers, kubelet, containerd, etc.)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Pas de magie — des patterns qu'on retrouve ailleurs en dev.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## De vos outils au noyau Linux
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
B[kube-api]
|
||||||
|
subgraph Clients["Clients"]
|
||||||
|
A01[kubectl]
|
||||||
|
A02[Helm]
|
||||||
|
A03[Kustomize]
|
||||||
|
end
|
||||||
|
A01 --> B
|
||||||
|
A02 --> B
|
||||||
|
A03 --> B
|
||||||
|
B --> C[(etcd)]
|
||||||
|
B --> D[Controllers]
|
||||||
|
D --> B
|
||||||
|
D --> E[kubelet]
|
||||||
|
E --> F[containerd]
|
||||||
|
F --> G[Kernel Linux]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **kube-api** — point d'entrée
|
||||||
|
- **etcd** — source de vérité
|
||||||
|
- **kubelet** — agent sur chaque nœud
|
||||||
|
- **container runtime** (ex. containerd) — exécution des conteneurs
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Helm, kubectl, Flux… appellent l'API. L'API parle à etcd, les controllers poussent vers les kubelets, le runtime lance les conteneurs.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes ne fait pas tourner vos apps : la boucle de contrôle
|
||||||
|
|
||||||
|
Il **orchestre** · il **délègue** · il **observe**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
DS["Spec · désir"] --> AP[kube-apiserver]
|
||||||
|
AP <-->|objets cluster| ET[(etcd)]
|
||||||
|
AP --> EV["Watch · événements"]
|
||||||
|
EV --> CTRL[Controllers]
|
||||||
|
CTRL -->|réconciliation · requêtes API| AP
|
||||||
|
subgraph NODE["Nœud"]
|
||||||
|
K[kubelet] -->|CRI| CD[containerd]
|
||||||
|
end
|
||||||
|
AP -->|spec Pods · synchro| K
|
||||||
|
K -->|status| AP
|
||||||
|
```
|
||||||
|
|
||||||
|
**↻** Les changements d'objets vivent dans l'API ; le **kubelet** **synchronise** depuis l'API, traduit en **CRI** vers **containerd**. Le **status** remonte par le kubelet.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Aligné sur la doc Kubernetes : observer, rapprocher le courant du spec ; informers / watches → files d'événements. etcd derrière l'API.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sous containerd : cgroups et namespaces
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
F[containerd] --> K[Linux Kernel]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **cgroups**
|
||||||
|
- **namespaces**
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Derrière containerd, le noyau — vous ne voulez pas manipuler les fichiers à la main au quotidien.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes vous évite de vivre dans le noyau
|
||||||
|
|
||||||
|
- Manipuler les **cgroups** à la main
|
||||||
|
- Gérer **isolation**, **CPU**, **mémoire** sur vos nœuds
|
||||||
|
|
||||||
|
> À côté de ça, un cluster qui râle, c'est presque du repos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CNI, CSI, CRI : de l'infra branchable
|
||||||
|
|
||||||
|
- **CNI** → réseau (ex. Istio, Cilium, Calico, Flannel…)
|
||||||
|
- **CSI** → stockage (ex. Ceph, Longhorn, Amazon…)
|
||||||
|
- **CRI** → runtime (ex. containerd, CRI-O…)
|
||||||
|
- Ici : brancher l'infra — pas encoder la logique métier (*≠* opérateur)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brancher l'infra ou coder le métier ?
|
||||||
|
|
||||||
|
| Type | Rôle |
|
||||||
|
|------|------|
|
||||||
|
| **CNI / CSI / CRI** | Infrastructure |
|
||||||
|
| **Opérateur** | Logique métier |
|
||||||
|
|
||||||
|
<!--
|
||||||
|
D'un côté briques branchables ; de l'autre du code qui encode le comportement dans le cluster. Icônes : icônes K8s officielles, CNCF Landscape, Simple Icons.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qui écrit la logique du cluster ?
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Qui décide quand l'état réel diverge ? (pause)
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## La logique vit dans les controllers
|
||||||
|
|
||||||
|
*Ils ferment la boucle : observation, décision, action.*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
L'intelligence du cluster est dans les controllers.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les opérateurs : des controllers spécialisés
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Même mécanisme, focalisé sur un domaine métier ou un logiciel (base, queue…).
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sans opérateur : scripts, crons, humains dans la boucle
|
||||||
|
|
||||||
|
- Scripts bash
|
||||||
|
- Cron jobs
|
||||||
|
- Interventions humaines
|
||||||
|
- État incohérent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Automatisation éclatée : la logique reste dans les têtes
|
||||||
|
|
||||||
|
- Runbooks Confluence
|
||||||
|
- Scripts bash « magiques »
|
||||||
|
- Cron jobs oubliés
|
||||||
|
- Interventions humaines
|
||||||
|
|
||||||
|
**La logique est dans la tête des gens, pas dans le système.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Drift, pas de reproductibilité, 3 h du matin
|
||||||
|
|
||||||
|
- Drift de configuration
|
||||||
|
- Actions non reproductibles
|
||||||
|
- Dépendance aux humains
|
||||||
|
- Incidents à 3 h du matin
|
||||||
|
|
||||||
|
- Pas **reproductible** / pas **observable** cluster / pas **fiable** dans le temps
|
||||||
|
- Impossible de **raisonner globalement** sur l'état
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vous déclarez ; le système réconcilie
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: Database
|
||||||
|
spec:
|
||||||
|
size: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
<!--
|
||||||
|
État voulu → le contrôleur fait les transitions répétitives.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec, opérateur, cluster : ça tourne en boucle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Spec] --> B[Operator]
|
||||||
|
B --> C[Cluster]
|
||||||
|
C --> B
|
||||||
|
```
|
||||||
|
|
||||||
|
*Dès que le réel diverge, la réconciliation repart.*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Exemples : annotations sur ressources natives, puis vrais CRD ; projet maison pour ancrer le discours.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reflector & Reloader : la puissance des annotations
|
||||||
|
|
||||||
|
| Outil | Idée | Déclencheur typique |
|
||||||
|
|-------|------|---------------------|
|
||||||
|
| **Reflector** (EmberStack) | Copie / réplique **Secrets** et **ConfigMaps** (autres ns, miroirs) | Annotations sur la ressource source ou la copie |
|
||||||
|
| **Reloader** (Stakater) | **Redémarre** les Pods si Secret / ConfigMap référencé change | Annotation sur **Deployment** / **StatefulSet** / etc. |
|
||||||
|
|
||||||
|
- Reflector : `reflector.v1.k8s.emberstack.com/*`
|
||||||
|
- Reloader : `reloader.stakater.com/auto: "true"`
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Pas toujours un gros opérateur CRD — parfois contrôleur sur objets standards + annotations. Liens : kubernetes-reflector, Reloader sur GitHub.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exemple Reflector : secret DB → namespace applicatif
|
||||||
|
|
||||||
|
*Le secret « source de vérité » vit avec la base ; Reflector recrée un miroir là où l'app tourne.*
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Namespace database — secret maître
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: db-credentials
|
||||||
|
namespace: database
|
||||||
|
annotations:
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
|
||||||
|
reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: "app-shop"
|
||||||
|
stringData:
|
||||||
|
DATABASE_URL: "postgres://…"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Reflector maintient un **Secret du même nom** dans `app-shop`, à jour quand la source change.
|
||||||
|
- Alternative : `reflector.v1.k8s.emberstack.com/reflects: "database/db-credentials"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exemple Reloader : rollout quand le secret miroir change
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: shop-api
|
||||||
|
namespace: app-shop
|
||||||
|
annotations:
|
||||||
|
reloader.stakater.com/auto: "true"
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: api
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: db-credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
- Rotation dans `database/db-credentials` → Reflector met à jour `app-shop/db-credentials` → Reloader déclenche un **rollout**.
|
||||||
|
- Sans Reloader, les Pods gardent l'ancienne valeur en mémoire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cert-manager, Postgres, Keycloak : la spec devient une entité
|
||||||
|
|
||||||
|
| Opérateur | CRD (exemples) | Ce que ça matérialise |
|
||||||
|
|-----------|----------------|----------------------|
|
||||||
|
| **cert-manager** | `Certificate`, `Issuer`, `ClusterIssuer` | Secrets TLS, ACME / PKI |
|
||||||
|
| **Postgres Operator** (Zalando) | `postgresql` (`acid.zalan.do`) | Cluster PG (Patroni, volumes, users…) |
|
||||||
|
| **Keycloak Operator** | `Keycloak`, `KeycloakRealm`, `KeycloakClient`… | Instances, royaumes, clients |
|
||||||
|
|
||||||
|
La spec décrit l'état métier ; le contrôleur crée / met à jour Deployments, Services, Secrets…
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Trois domaines, même mécanisme CRD + réconciliation. Liens cert-manager.io, Zalando postgres-operator, Keycloak operator.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Un certificat déclaratif, tout le cycle TLS en dessous
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: api-example
|
||||||
|
spec:
|
||||||
|
dnsNames:
|
||||||
|
- api.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Suite dérivée automatiquement : `CertificateRequest`, challenge ACME, Secret TLS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Certificats : du Certbot au renouvellement automatique
|
||||||
|
|
||||||
|
*Avant — sans opérateur*
|
||||||
|
|
||||||
|
- Certbot manuel
|
||||||
|
- Scripts cron
|
||||||
|
- Cert expiré = prod down
|
||||||
|
|
||||||
|
*Avec cert-manager*
|
||||||
|
|
||||||
|
- Déclaratif
|
||||||
|
- Auto-renouvellement
|
||||||
|
- Observabilité native (`Certificate`, conditions, events)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Complexité énorme encapsulée — pertinent quand le domaine est clair, répétitif et critique.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Postgres répliqué sans bricoler le failover à la main
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: acid.zalan.do/v1
|
||||||
|
kind: postgresql
|
||||||
|
metadata:
|
||||||
|
name: my-cluster
|
||||||
|
spec:
|
||||||
|
numberOfInstances: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Derrière : réplication, failover, backup, users / rôles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quelle techno pour développer votre opérateur ?
|
||||||
|
|
||||||
|
| Piste | Outils typiques | À retenir |
|
||||||
|
|-------|-----------------|-----------|
|
||||||
|
| **Go** (référence) | **Kubebuilder**, `controller-runtime`, client-go | Même patterns upstream ; sous charge : goroutines, files, back-off — peu de RAM en plus. |
|
||||||
|
| **Operator SDK** | CLI Red Hat · Go · Ansible / Helm | Go solide ; Ansible/Helm = wrapper — court si la logique métier épaissit. |
|
||||||
|
| **Autres** | Python **Kopf**, Rust **kube-rs**, Java… | Selon l'équipe ; moins d'exemples core. |
|
||||||
|
|
||||||
|
**Go + controller-runtime** pour un opérateur maison sérieux ; sinon celui que l'équipe **maintient** le mieux.
|
||||||
|
|
||||||
|
*Charge ↑ : un runtime qui gonfle la RAM par événement mange votre quota.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quand un opérateur est légitime
|
||||||
|
|
||||||
|
- Système **complexe**
|
||||||
|
- **Règles** claires et stables
|
||||||
|
- **État** critique pour l'activité
|
||||||
|
- Opérations **répétitives**
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Si tu ne peux pas formaliser « si X alors Y » proprement — très mauvaise idée en opérateur.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keycloak Credential Manager — le même schéma, chez moi
|
||||||
|
|
||||||
|
- Objectif : **réconcilier** des **credentials Keycloak** avec ce qui est **déclaré dans le cluster** (CRD, Secrets annotés…).
|
||||||
|
- Montrer le **même schéma** : *observe → compare → corrige* sur **ton** domaine.
|
||||||
|
- **Incident / compromission** : reload Keycloak → événements côté IdP ; l'opérateur **interroge** (ex. toutes les minutes), repère l'écart et **propage** la correction.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Pas pour vendre un produit — prouver que la logique est la même : API K8s, état désiré, code à la place du runbook.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exemple — `KeycloakClientCredential`
|
||||||
|
|
||||||
|
*CRD* `secrets.carbogame.com/v1alpha1` — `spec.source` (realm, client) → `spec.target` (Secret, rafraîchissement, mapping).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: secrets.carbogame.com/v1alpha1
|
||||||
|
kind: KeycloakClientCredential
|
||||||
|
metadata:
|
||||||
|
name: gitea-creds
|
||||||
|
namespace: keycloak
|
||||||
|
spec:
|
||||||
|
source:
|
||||||
|
keycloakServer: prod-keycloak
|
||||||
|
realm: operator
|
||||||
|
clientId: gitea
|
||||||
|
target:
|
||||||
|
secretName: gitea-secret-creds
|
||||||
|
namespace:
|
||||||
|
- gitea
|
||||||
|
refreshPeriod: 10m
|
||||||
|
fields:
|
||||||
|
clientId: key
|
||||||
|
clientSecret: secret
|
||||||
|
```
|
||||||
|
|
||||||
|
*En chart Helm, `namespace` et `keycloakServer` sont souvent injectés via templates.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Développer son opérateur : réutiliser les autres
|
||||||
|
|
||||||
|
- **Ne pas tout recoder** : s'appuyer sur des contrôleurs existants pour ce qu'ils font déjà bien.
|
||||||
|
- Ex. **Secrets / ConfigMaps** vers d'autres namespaces : brancher **Reflector** plutôt que dupliquer la logique — une politique de réflexion, **un RBAC** à raisonner.
|
||||||
|
- Votre code se concentre sur **le métier**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce qu'un opérateur bien choisi vous apporte
|
||||||
|
|
||||||
|
- Automatiser du **répétitif**
|
||||||
|
- **Encapsuler** de la complexité
|
||||||
|
- **Stabiliser** un système
|
||||||
|
|
||||||
|
… mais les opérateurs peuvent aussi devenir un enfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quand ne pas écrire d'opérateur
|
||||||
|
|
||||||
|
- Logique **floue** ou en perpétuelle négociation métier
|
||||||
|
- Processus **instables**
|
||||||
|
- Dépendances **externes** imprévisibles
|
||||||
|
- **One-shot** sans cycle de vie
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cinq signaux qu'un opérateur part en vrille
|
||||||
|
|
||||||
|
- Spec **incompréhensible** sans lire le code
|
||||||
|
- Effets **implicites** (créations ailleurs invisibles)
|
||||||
|
- **Couplage** externe fort
|
||||||
|
- **Debug** impossible sans les mainteneurs
|
||||||
|
- **État** non observable (pas de conditions / status utiles)
|
||||||
|
|
||||||
|
> Un opérateur mal conçu, c'est un microservice dont plus personne ne tient les règles du jeu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Débugger un opérateur : describe, events, status, logs
|
||||||
|
|
||||||
|
- `kubectl describe` (resource + events)
|
||||||
|
- **Events** du namespace
|
||||||
|
- **Status / conditions** sur la CRD
|
||||||
|
- **Logs** du contrôleur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Un opérateur, c'est un produit logiciel — pas un script
|
||||||
|
|
||||||
|
- **Code** et revues
|
||||||
|
- **Tests** (unitaires, intégration, e2e si possible)
|
||||||
|
- **Maintenance** dans la durée
|
||||||
|
- **Compatibilité** avec les versions Kubernetes
|
||||||
|
- **Migrations** de CRD / spec
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quand l'opérateur devient l'enfer
|
||||||
|
|
||||||
|
- Logique **implicite**, **effets de bord**, **debugging** difficile, **couplage** fort
|
||||||
|
|
||||||
|
En pratique :
|
||||||
|
|
||||||
|
- Tout mettre en opérateur
|
||||||
|
- Logique métier **floue**
|
||||||
|
- Effets **cachés**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes, plateforme de programmation
|
||||||
|
|
||||||
|
- Kubernetes évite **l'enfer de l'infrastructure** bas niveau.
|
||||||
|
- Les **bons** opérateurs évitent **l'enfer du manuel** — les **mauvais** y renvoient autrement.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Fil : pas magique → API + boucle → controllers / opérateurs → avant/après → exemples → bon usage / pièges → conclusion.
|
||||||
|
-->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Avant d'écrire un opérateur : la checklist
|
||||||
|
|
||||||
|
- Répétitif
|
||||||
|
- Règles claires
|
||||||
|
- Déclaratif possible
|
||||||
|
- Idempotent
|
||||||
|
- Observable
|
||||||
|
|
||||||
|
Si une case importante manque : **reculez**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Votre logique, exécutée par le cluster au lieu d'un humain
|
||||||
|
|
||||||
|
- « OK, je comprends enfin le cadre Kubernetes. »
|
||||||
|
- « Les opérateurs : puissants, à manier avec précision. »
|
||||||
|
|
||||||
|
Un opérateur, c'est votre logique métier — **lisible**, **testable**, **bornée**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes simplifie l'infra — les opérateurs, ça peut simplifier ou empirer
|
||||||
|
|
||||||
|
Les **bons** **encapsulent** une complexité maîtrisée ; les **mauvais** recréent un enfer **plus sophistiqué**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Merci
|
||||||
|
|
||||||
|
Merci pour votre attention.
|
||||||
|
|
||||||
|
**Des questions ?**
|
||||||
|
|
||||||
|
<ClosingQr />
|
||||||
|
|
||||||
|
- Dépôt : [git.specificat.io/arnault/Talks](https://git.specificat.io/arnault/Talks)
|
||||||
|
- Site : [specificat.io](https://specificat.io/)
|
||||||
|
- Index des slides : portail talks (page d'accueil des présentations)
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Laisser le temps pour les questions ; rappeler l'URL du dépôt à l'oral.
|
||||||
|
-->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "../_shared/center-default-like-reveal.css";
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DeckFooter from '../_shared/DeckFooter.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DeckFooter />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"slug": "spring-ai",
|
||||||
|
"title": "Spring AI",
|
||||||
|
"description": "Intégrer des modèles LLM dans l’écosystème Spring avec des API cohérentes.",
|
||||||
|
"tags": ["java", "spring", "llm", "ai"],
|
||||||
|
"order": 2,
|
||||||
|
"featured": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
theme: default
|
||||||
|
record: true
|
||||||
|
title: Spring AI
|
||||||
|
info: |
|
||||||
|
Démo technique — portail talks
|
||||||
|
highlighter: shiki
|
||||||
|
defaults:
|
||||||
|
layout: center
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spring AI
|
||||||
|
|
||||||
|
Intégration **LLM** dans une stack Java familière.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
- Abstractions stables (prompts, chat, embeddings)
|
||||||
|
- Choix de backends (OpenAI, Ollama, etc.)
|
||||||
|
- Testabilité et observabilité
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exemple de flux
|
||||||
|
|
||||||
|
```text
|
||||||
|
Controller → ChatClient → Model (provider)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bonnes pratiques
|
||||||
|
|
||||||
|
- Isoler les prompts et versions
|
||||||
|
- Mesurer latence et coûts
|
||||||
|
- Prévoir reprise et timeouts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Merci
|
||||||
|
|
||||||
|
[Documentation Spring AI](https://docs.spring.io/spring-ai/reference/)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "../_shared/center-default-like-reveal.css";
|
||||||
@@ -24,15 +24,14 @@ spec:
|
|||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
securityContext:
|
securityContext:
|
||||||
seccompProfile:
|
{{- toYaml .Values.slides.podSecurityContext | nindent 8 }}
|
||||||
type: RuntimeDefault
|
|
||||||
containers:
|
containers:
|
||||||
- name: nginx
|
- name: nginx
|
||||||
image: "{{ .Values.slides.image.repository }}:{{ .Values.slides.image.tag | default .Chart.AppVersion }}"
|
image: "{{ .Values.slides.image.repository }}:{{ .Values.slides.image.tag | default .Chart.AppVersion }}"
|
||||||
imagePullPolicy: {{ .Values.slides.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.slides.image.pullPolicy }}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
# Port 8080 dans le conteneur : pas besoin de NET_BIND_SERVICE (ports privilégiés).
|
# Image : USER nginx + master non-root → pas de SETUID/SETGID ; port 8080 → pas de NET_BIND_SERVICE.
|
||||||
capabilities:
|
capabilities:
|
||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
readOnlyRootFilesystem: false
|
readOnlyRootFilesystem: false
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ slides:
|
|||||||
# Port d’écoute dans le conteneur (doit correspondre à server/nginx/default.conf, ex. 8080).
|
# Port d’écoute dans le conteneur (doit correspondre à server/nginx/default.conf, ex. 8080).
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
|
|
||||||
|
# Aligné sur l’utilisateur nginx de l’image (UID/GID 101). L’image utilise USER nginx.
|
||||||
|
podSecurityContext:
|
||||||
|
runAsUser: 101
|
||||||
|
runAsGroup: 101
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
# Port du Service (Ingress pointe ici) ; le trafic est envoyé vers containerPort sur les pods.
|
# Port du Service (Ingress pointe ici) ; le trafic est envoyé vers containerPort sur les pods.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Après npm run build : dist-portal + dist-<slug>/ à la racine.
|
||||||
|
*/
|
||||||
|
import { test, describe } from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repoRoot = path.resolve(__dirname, '..')
|
||||||
|
const portal = path.join(repoRoot, 'dist-portal')
|
||||||
|
const hasPortal = fs.existsSync(portal)
|
||||||
|
|
||||||
|
describe('Artefacts build (dist-portal + dist-<slug>)', () => {
|
||||||
|
test('portail + chaque deck', { skip: !hasPortal }, () => {
|
||||||
|
assert.ok(fs.existsSync(path.join(portal, 'index.html')))
|
||||||
|
assert.ok(fs.existsSync(path.join(portal, 'presentations', 'index.html')))
|
||||||
|
|
||||||
|
const data = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json'), 'utf8'),
|
||||||
|
)
|
||||||
|
for (const p of data) {
|
||||||
|
const slideRoot = path.join(repoRoot, `dist-${p.slug}`)
|
||||||
|
const slideIndex = path.join(slideRoot, 'index.html')
|
||||||
|
assert.ok(fs.existsSync(slideIndex), `deck manquant: dist-${p.slug}/index.html`)
|
||||||
|
const presIndex = path.join(portal, 'presentations', p.slug, 'index.html')
|
||||||
|
assert.ok(fs.existsSync(presIndex), `fiche manquante: dist-portal/presentations/${p.slug}/index.html`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { test, describe } from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {
|
||||||
|
escHtml,
|
||||||
|
escMd,
|
||||||
|
pickFeatured,
|
||||||
|
buildFeaturedHtml,
|
||||||
|
readAllMeta,
|
||||||
|
generateListPageMarkdown,
|
||||||
|
generateDetailPageMarkdown,
|
||||||
|
patchIndexFeaturedContent,
|
||||||
|
FEATURED_START,
|
||||||
|
FEATURED_END,
|
||||||
|
} from '../tools/presentation-contracts.mjs'
|
||||||
|
|
||||||
|
function mockSitePath(p) {
|
||||||
|
return p.startsWith('/') ? p : `/${p}`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('escHtml / escMd', () => {
|
||||||
|
test('escHtml neutralise < > & "', () => {
|
||||||
|
assert.strictEqual(escHtml('<a href="x">'), '<a href="x">')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('escMd échappe les pipes tableaux markdown', () => {
|
||||||
|
assert.strictEqual(escMd('a|b'), 'a\\|b')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pickFeatured', () => {
|
||||||
|
test('priorité aux entrées featured puis order', () => {
|
||||||
|
const items = [
|
||||||
|
{ slug: 'c', title: 'C', order: 3, featured: false },
|
||||||
|
{ slug: 'a', title: 'A', order: 1, featured: true },
|
||||||
|
{ slug: 'b', title: 'B', order: 2, featured: true },
|
||||||
|
]
|
||||||
|
const got = pickFeatured(items)
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
got.map((x) => x.slug),
|
||||||
|
['a', 'b'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sans featured : trois premiers par order', () => {
|
||||||
|
const items = [
|
||||||
|
{ slug: 'z', title: 'Z', order: 10 },
|
||||||
|
{ slug: 'a', title: 'A', order: 1 },
|
||||||
|
{ slug: 'b', title: 'B', order: 2 },
|
||||||
|
{ slug: 'c', title: 'C', order: 3 },
|
||||||
|
]
|
||||||
|
const got = pickFeatured(items)
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
got.map((x) => x.slug),
|
||||||
|
['a', 'b', 'c'],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('liste vide', () => {
|
||||||
|
assert.deepStrictEqual(pickFeatured([]), [])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildFeaturedHtml', () => {
|
||||||
|
test('génère des liens et échappe le titre', () => {
|
||||||
|
const html = buildFeaturedHtml(
|
||||||
|
[{ slug: 'x', title: '<evil>', description: 'd & co', order: 1 }],
|
||||||
|
mockSitePath,
|
||||||
|
)
|
||||||
|
assert.match(html, /href="\/presentations\/x\/"/)
|
||||||
|
assert.match(html, /<evil>/)
|
||||||
|
assert.match(html, /d & co/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aucune présentation : commentaire', () => {
|
||||||
|
assert.strictEqual(buildFeaturedHtml([], mockSitePath), '<!-- (aucune présentation) -->')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readAllMeta (répertoire temporaire)', () => {
|
||||||
|
test('ignore dossier sans meta.json', () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'talks-slides-'))
|
||||||
|
fs.mkdirSync(path.join(dir, 'empty'), { recursive: true })
|
||||||
|
const okDir = path.join(dir, 'ok')
|
||||||
|
fs.mkdirSync(okDir, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(okDir, 'meta.json'), JSON.stringify({ title: 'T', description: '', order: 1 }))
|
||||||
|
const list = readAllMeta(dir)
|
||||||
|
assert.strictEqual(list.length, 1)
|
||||||
|
assert.strictEqual(list[0].slug, 'ok')
|
||||||
|
assert.strictEqual(list[0].title, 'T')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateListPageMarkdown / generateDetailPageMarkdown', () => {
|
||||||
|
const p = {
|
||||||
|
slug: 'demo',
|
||||||
|
title: 'Démo',
|
||||||
|
description: 'Desc',
|
||||||
|
tags: ['a', 'b'],
|
||||||
|
order: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
test('liste contient fiche + slides + tags', () => {
|
||||||
|
const md = generateListPageMarkdown([p], mockSitePath)
|
||||||
|
assert.match(md, /href="\/presentations\/demo\/"/)
|
||||||
|
assert.match(md, /href="\/slides\/demo\/1"/)
|
||||||
|
assert.match(md, /vp-raw/)
|
||||||
|
assert.match(md, /`a` · `b`/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('détail contient QrEncode et boutons', () => {
|
||||||
|
const md = generateDetailPageMarkdown(p, mockSitePath)
|
||||||
|
assert.match(md, /<QrEncode path="\/slides\/demo\/1" \/>/)
|
||||||
|
assert.match(md, /class="vp-button vp-raw"/)
|
||||||
|
assert.match(md, /Voir les slides/)
|
||||||
|
assert.match(md, /## Partager les slides/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('détail sans tags : pas de ligne Tags', () => {
|
||||||
|
const md = generateDetailPageMarkdown({ ...p, tags: [] }, mockSitePath)
|
||||||
|
assert.ok(!md.includes('**Tags :**'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('patchIndexFeaturedContent', () => {
|
||||||
|
test('remplace la zone entre marqueurs', () => {
|
||||||
|
const raw = `head\n${FEATURED_START}\nold\n${FEATURED_END}\ntail`
|
||||||
|
const { ok, content } = patchIndexFeaturedContent(
|
||||||
|
raw,
|
||||||
|
[{ slug: 's', title: 'T', description: 'D', order: 1 }],
|
||||||
|
mockSitePath,
|
||||||
|
)
|
||||||
|
assert.strictEqual(ok, true)
|
||||||
|
assert.ok(content.includes('talk-card'))
|
||||||
|
assert.ok(content.includes('head'))
|
||||||
|
assert.ok(content.includes('tail'))
|
||||||
|
assert.ok(!content.includes('old'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('marqueurs absents : ok false', () => {
|
||||||
|
const { ok } = patchIndexFeaturedContent('nope', [], mockSitePath)
|
||||||
|
assert.strictEqual(ok, false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Invariant du dépôt : chaque slides/<slug>/meta.json a une entrée et des fichiers générés.
|
||||||
|
* Exécuter après `npm run sync` (la CI lance sync avant les tests).
|
||||||
|
*/
|
||||||
|
import { test, describe, before } from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repoRoot = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
const r = spawnSync(process.execPath, [path.join(repoRoot, 'tools', 'sync-portal.mjs')], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
assert.strictEqual(r.status, 0, r.stderr || r.stdout)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cohérence slides ↔ portail (après sync)', () => {
|
||||||
|
test('presentations.json existe et liste les dossiers slides', () => {
|
||||||
|
const jsonPath = path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json')
|
||||||
|
assert.ok(fs.existsSync(jsonPath), 'presentations.json manquant')
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
|
||||||
|
assert.ok(Array.isArray(data))
|
||||||
|
assert.ok(data.length >= 1, 'au moins une présentation attendue dans ce dépôt')
|
||||||
|
|
||||||
|
const slideDirs = fs
|
||||||
|
.readdirSync(path.join(repoRoot, 'slides'), { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
|
||||||
|
.map((d) => d.name)
|
||||||
|
.filter((name) => fs.existsSync(path.join(repoRoot, 'slides', name, 'meta.json')))
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
data.length,
|
||||||
|
slideDirs.length,
|
||||||
|
'nombre d’entrées JSON = nombre de dossiers slides avec meta.json',
|
||||||
|
)
|
||||||
|
|
||||||
|
const slugs = new Set(data.map((p) => p.slug))
|
||||||
|
for (const dir of slideDirs) {
|
||||||
|
assert.ok(slugs.has(dir), `slug manquant pour le dossier slides/${dir}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('chaque entrée a title, description, slug', () => {
|
||||||
|
const jsonPath = path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json')
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
|
||||||
|
for (const p of data) {
|
||||||
|
assert.ok(typeof p.title === 'string' && p.title.length > 0, `title requis: ${p.slug}`)
|
||||||
|
assert.ok(typeof p.description === 'string', `description requise: ${p.slug}`)
|
||||||
|
assert.ok(typeof p.slug === 'string' && p.slug.length > 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('chaque deck a un global-bottom.vue (pied de page portail)', () => {
|
||||||
|
const data = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json'), 'utf8'),
|
||||||
|
)
|
||||||
|
for (const p of data) {
|
||||||
|
const gb = path.join(repoRoot, 'slides', p.slug, 'global-bottom.vue')
|
||||||
|
assert.ok(fs.existsSync(gb), `slides/${p.slug}/global-bottom.vue manquant (pied de page)`)
|
||||||
|
const raw = fs.readFileSync(gb, 'utf8')
|
||||||
|
assert.ok(
|
||||||
|
raw.includes('_shared/DeckFooter'),
|
||||||
|
`global-bottom doit importer le pied de page partagé: ${p.slug}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fiche et liste générées pour chaque slug', () => {
|
||||||
|
const data = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repoRoot, 'portal', '.vitepress', 'data', 'presentations.json'), 'utf8'),
|
||||||
|
)
|
||||||
|
const listMd = path.join(repoRoot, 'portal', 'presentations', 'index.md')
|
||||||
|
const listContent = fs.readFileSync(listMd, 'utf8')
|
||||||
|
|
||||||
|
for (const p of data) {
|
||||||
|
const detail = path.join(repoRoot, 'portal', 'presentations', p.slug, 'index.md')
|
||||||
|
assert.ok(fs.existsSync(detail), `fiche manquante: presentations/${p.slug}/index.md`)
|
||||||
|
const body = fs.readFileSync(detail, 'utf8')
|
||||||
|
assert.match(body, new RegExp(`slides/${p.slug}/`, ''), 'lien deck dans la fiche')
|
||||||
|
assert.match(body, /<QrEncode path="/, 'composant QR présent')
|
||||||
|
assert.ok(listContent.includes(p.title), 'titre présent dans la liste')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { test, describe, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import { getSiteBase, sitePath, vitepressAppBase } from '../tools/site-base.mjs'
|
||||||
|
|
||||||
|
describe('site-base (SITE_BASE / chemins statiques)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env.SITE_BASE
|
||||||
|
delete process.env.TALKS_PORTAL_DEV
|
||||||
|
})
|
||||||
|
|
||||||
|
test('défaut : même comportement que la racine du site', () => {
|
||||||
|
assert.strictEqual(getSiteBase(), '')
|
||||||
|
assert.strictEqual(sitePath('/slides/foo/'), '/slides/foo/')
|
||||||
|
assert.strictEqual(sitePath('/presentations/bar/'), '/presentations/bar/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sous-chemin sans slash final', () => {
|
||||||
|
process.env.SITE_BASE = '/mon-depot'
|
||||||
|
assert.strictEqual(getSiteBase(), '/mon-depot')
|
||||||
|
assert.strictEqual(sitePath('/slides/foo/'), '/mon-depot/slides/foo/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sous-chemin avec slash final', () => {
|
||||||
|
process.env.SITE_BASE = '/repo/'
|
||||||
|
assert.strictEqual(getSiteBase(), '/repo')
|
||||||
|
assert.strictEqual(sitePath('/slides/x/'), '/repo/slides/x/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('chemins relatifs normalisés', () => {
|
||||||
|
delete process.env.SITE_BASE
|
||||||
|
assert.strictEqual(sitePath('slides/x/'), '/slides/x/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SITE_BASE vide explicite : traité comme /', () => {
|
||||||
|
process.env.SITE_BASE = ''
|
||||||
|
assert.strictEqual(getSiteBase(), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('TALKS_PORTAL_DEV=1 : ignore SITE_BASE (dev local)', () => {
|
||||||
|
process.env.TALKS_PORTAL_DEV = '1'
|
||||||
|
process.env.SITE_BASE = '/pages-projet/'
|
||||||
|
assert.strictEqual(getSiteBase(), '')
|
||||||
|
assert.strictEqual(sitePath('/slides/a/'), '/slides/a/')
|
||||||
|
assert.strictEqual(vitepressAppBase(), '/')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Intégration : sync-portal sur un dépôt minimal (TALKS_ROOT) sans toucher au vrai portail.
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repoRoot = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
test('sync sur dépôt temporaire (TALKS_ROOT)', () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'talks-tmp-'))
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(tmp, 'portal'), { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmp, 'portal', 'index.md'),
|
||||||
|
['---', 'layout: doc', '---', '', '<!-- FEATURED_START -->', '<!-- FEATURED_END -->', ''].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hello = path.join(tmp, 'slides', 'hello-world')
|
||||||
|
fs.mkdirSync(hello, { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(hello, 'meta.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
title: 'Hello World',
|
||||||
|
description: 'Test tmp',
|
||||||
|
order: 1,
|
||||||
|
featured: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fs.writeFileSync(path.join(hello, 'slides.md'), '---\n---\n\n# Slide\n')
|
||||||
|
|
||||||
|
const r = spawnSync(process.execPath, [path.join(repoRoot, 'tools', 'sync-portal.mjs')], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: { ...process.env, TALKS_ROOT: tmp },
|
||||||
|
encoding: 'utf8',
|
||||||
|
})
|
||||||
|
assert.strictEqual(r.status, 0, r.stderr || r.stdout)
|
||||||
|
|
||||||
|
const detail = path.join(tmp, 'portal', 'presentations', 'hello-world', 'index.md')
|
||||||
|
assert.ok(fs.existsSync(detail), 'fiche générée')
|
||||||
|
const body = fs.readFileSync(detail, 'utf8')
|
||||||
|
assert.ok(body.includes('Hello World'))
|
||||||
|
assert.ok(body.includes('<QrEncode path="/slides/hello-world/1"'))
|
||||||
|
|
||||||
|
const list = path.join(tmp, 'portal', 'presentations', 'index.md')
|
||||||
|
assert.ok(fs.readFileSync(list, 'utf8').includes('Hello World'))
|
||||||
|
|
||||||
|
const featured = fs.readFileSync(path.join(tmp, 'portal', 'index.md'), 'utf8')
|
||||||
|
assert.ok(featured.includes('talk-card'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Tests du plugin slide-dists en dev VitePress (`/slides/<slug>/` → dist-<slug>/).
|
||||||
|
*
|
||||||
|
* Régression couverte (soumission initiale) :
|
||||||
|
* En dev, des requêtes du type `GET /slides/<slug>/1.md?import&…` recevaient la page HTML
|
||||||
|
* du portail VitePress (Content-Type: text/html) au lieu d’être ignorées ou traitées
|
||||||
|
* comme des modules JS. Le navigateur affichait alors :
|
||||||
|
* « Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of text/html »
|
||||||
|
* (middleware sirv trop large + repli SPA VitePress sur du HTML pour chemins inconnus.)
|
||||||
|
*
|
||||||
|
* Contrat : pour ces URLs « module Vite » sous /slides/, la réponse ne doit **jamais**
|
||||||
|
* être du `text/html` (404 texte ou autre non-HTML est acceptable).
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import http from 'node:http'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { createServer } from 'vitepress'
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const deckIndex = path.join(root, 'dist-kubernetes-operators', 'index.html')
|
||||||
|
if (!fs.existsSync(deckIndex)) {
|
||||||
|
const e = { ...process.env, TALKS_PORTAL_DEV: '1' }
|
||||||
|
delete e.SITE_BASE
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'tools', 'build-deck.mjs'), 'kubernetes-operators'], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(url, headers = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const u = new URL(url)
|
||||||
|
const opts = { hostname: u.hostname, port: u.port, path: u.pathname + u.search, headers }
|
||||||
|
http
|
||||||
|
.get(opts, (res) => {
|
||||||
|
let body = ''
|
||||||
|
res.on('data', (c) => {
|
||||||
|
body += c
|
||||||
|
})
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }))
|
||||||
|
})
|
||||||
|
.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lance VitePress en mode dev local (TALKS_PORTAL_DEV), exécute le callback, puis ferme. */
|
||||||
|
async function withDevPortal(callback) {
|
||||||
|
const prevDev = process.env.TALKS_PORTAL_DEV
|
||||||
|
const prevSite = process.env.SITE_BASE
|
||||||
|
process.env.TALKS_PORTAL_DEV = '1'
|
||||||
|
delete process.env.SITE_BASE
|
||||||
|
const server = await createServer('portal', {
|
||||||
|
server: { strictPort: true, host: true },
|
||||||
|
})
|
||||||
|
const port = 28740 + Math.floor(Math.random() * 200)
|
||||||
|
await server.listen(port)
|
||||||
|
try {
|
||||||
|
await callback(port)
|
||||||
|
} finally {
|
||||||
|
await server.close()
|
||||||
|
if (prevDev !== undefined) process.env.TALKS_PORTAL_DEV = prevDev
|
||||||
|
else delete process.env.TALKS_PORTAL_DEV
|
||||||
|
if (prevSite !== undefined) process.env.SITE_BASE = prevSite
|
||||||
|
else delete process.env.SITE_BASE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('VitePress dev : racine deck → 302 vers …/1 (première planche Slidev)', async () => {
|
||||||
|
await withDevPortal(async (port) => {
|
||||||
|
const base = `http://127.0.0.1:${port}`
|
||||||
|
for (const path of ['/slides/kubernetes-operators/', '/slides/kubernetes-operators']) {
|
||||||
|
const { status, headers } = await get(`${base}${path}`)
|
||||||
|
assert.equal(status, 302, `${path} → attendu 302`)
|
||||||
|
assert.equal(headers.location, '/slides/kubernetes-operators/1', `Location pour ${path}`)
|
||||||
|
}
|
||||||
|
const q = await get(`${base}/slides/kubernetes-operators/?embedded=1`)
|
||||||
|
assert.equal(q.status, 302)
|
||||||
|
assert.equal(q.headers.location, '/slides/kubernetes-operators/1?embedded=1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('VitePress dev : GET /slides/kubernetes-operators/1 → 200 (HTML Slidev)', async () => {
|
||||||
|
await withDevPortal(async (port) => {
|
||||||
|
const { status, body } = await get(`http://127.0.0.1:${port}/slides/kubernetes-operators/1`)
|
||||||
|
assert.equal(status, 200, `attendu 200, reçu ${status}`)
|
||||||
|
assert.match(body, /slidev|Slidev|#app/i, 'corps doit ressembler à une page Slidev')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Régression MIME module : *.md?import sous /slides/<slug>/ ne renvoie pas text/html (HAR)', async () => {
|
||||||
|
const paths = [
|
||||||
|
'/slides/kubernetes-operators/1.md?import&t=1775912428995',
|
||||||
|
'/slides/kubernetes-operators/index.md?import',
|
||||||
|
'/slides/kubernetes-operators/slides.md?import',
|
||||||
|
]
|
||||||
|
await withDevPortal(async (port) => {
|
||||||
|
for (const p of paths) {
|
||||||
|
const { status, headers, body } = await get(`http://127.0.0.1:${port}${p}`, {
|
||||||
|
Accept: '*/*',
|
||||||
|
'Sec-Fetch-Dest': 'script',
|
||||||
|
'Sec-Fetch-Mode': 'cors',
|
||||||
|
})
|
||||||
|
const ct = String(headers['content-type'] || '')
|
||||||
|
assert.ok(
|
||||||
|
!ct.toLowerCase().includes('text/html'),
|
||||||
|
`${p} : interdit text/html (reçu ${status}, Content-Type: ${ct || '(vide)'}) — régression MIME module`,
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
!/^<\s*!DOCTYPE\s+html/i.test(body.trim()),
|
||||||
|
`${p} : le corps ne doit pas être un document HTML (ex. shell VitePress)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('VitePress dev : GET …/2 (route client Slidev) → 200 HTML deck', async () => {
|
||||||
|
await withDevPortal(async (port) => {
|
||||||
|
const { status, body } = await get(`http://127.0.0.1:${port}/slides/kubernetes-operators/2`, {
|
||||||
|
Accept: 'text/html',
|
||||||
|
})
|
||||||
|
assert.equal(status, 200)
|
||||||
|
assert.match(body, /slidev|Slidev|#app/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { listPresentationSlugs } from './list-slugs.mjs'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
const slugs = listPresentationSlugs(root)
|
||||||
|
if (!slugs.length) {
|
||||||
|
console.log('[build-all-decks] aucune présentation')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of slugs) {
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'tools', 'build-deck.mjs'), slug], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: root,
|
||||||
|
env: process.env,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log('[build-all-decks] terminé')
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Build Slidev pour un slug → dossier dist-<slug>/ à la racine du dépôt.
|
||||||
|
* Usage : node tools/build-deck.mjs <slug>
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { sitePath } from './site-base.mjs'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
function publishBuild(wipDir, finalDir) {
|
||||||
|
fs.mkdirSync(finalDir, { recursive: true })
|
||||||
|
fs.cpSync(wipDir, finalDir, { recursive: true, force: true })
|
||||||
|
fs.rmSync(wipDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function slidevBin() {
|
||||||
|
const p = path.join(root, 'node_modules', '.bin', 'slidev')
|
||||||
|
if (fs.existsSync(p)) return p
|
||||||
|
return 'slidev'
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = process.argv[2]
|
||||||
|
if (!slug || slug.startsWith('wip_')) {
|
||||||
|
console.error('usage: node tools/build-deck.mjs <slug>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = path.join(root, 'slides', slug, 'slides.md')
|
||||||
|
const metaPath = path.join(root, 'slides', slug, 'meta.json')
|
||||||
|
if (!fs.existsSync(entry) || !fs.existsSync(metaPath)) {
|
||||||
|
console.error(`[build-deck] slides/${slug}/ incomplet (slides.md + meta.json requis)`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDir = path.join(root, `dist-${slug}`)
|
||||||
|
const wipDir = path.join(root, '.slide-wip', slug)
|
||||||
|
const base = sitePath(`/slides/${slug}/`)
|
||||||
|
if (!base.endsWith('/')) throw new Error(`base invalide: ${base}`)
|
||||||
|
|
||||||
|
fs.rmSync(wipDir, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(wipDir, { recursive: true })
|
||||||
|
/* Ne pas supprimer slides/<slug>/index.html avant le build : Slidev s’attend à pouvoir unlink ce fichier en fin de build. */
|
||||||
|
console.log(`[build-deck] ${slug} → dist-${slug}/ (base=${base})`)
|
||||||
|
try {
|
||||||
|
execFileSync(
|
||||||
|
slidevBin(),
|
||||||
|
['build', entry, '--out', wipDir, '--base', base],
|
||||||
|
{ stdio: 'inherit', cwd: root, env: process.env },
|
||||||
|
)
|
||||||
|
publishBuild(wipDir, finalDir)
|
||||||
|
/* Slidev peut écrire un index.html à côté de slides.md — inutile et bruyant au build suivant */
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(root, 'slides', slug, 'index.html'), { force: true })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(wipDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* npm run dev:portal — sync + VitePress dev avec base=/ (ignore SITE_BASE sauf SKIP_PORTAL_DEV=1).
|
||||||
|
*/
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { spawn, execFileSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const runEnv =
|
||||||
|
process.env.SKIP_PORTAL_DEV === '1'
|
||||||
|
? process.env
|
||||||
|
: { ...process.env, TALKS_PORTAL_DEV: '1' }
|
||||||
|
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'tools', 'sync-portal.mjs')], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: root,
|
||||||
|
env: runEnv,
|
||||||
|
})
|
||||||
|
|
||||||
|
const vitepressBin = path.join(root, 'node_modules', '.bin', 'vitepress')
|
||||||
|
const child = spawn(vitepressBin, ['dev', 'portal'], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: runEnv,
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
})
|
||||||
|
|
||||||
|
function die() {
|
||||||
|
try {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGINT', die)
|
||||||
|
process.on('SIGTERM', die)
|
||||||
|
child.on('exit', (code) => process.exit(code ?? 0))
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Dossiers de présentation sous slides/ (hors wip_*), avec meta.json + slides.md.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export function listPresentationSlugs(root) {
|
||||||
|
const slides = path.join(root, 'slides')
|
||||||
|
if (!fs.existsSync(slides)) return []
|
||||||
|
return fs
|
||||||
|
.readdirSync(slides, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && !d.name.startsWith('wip_') && !d.name.startsWith('_'))
|
||||||
|
.map((d) => d.name)
|
||||||
|
.filter((slug) => {
|
||||||
|
return (
|
||||||
|
fs.existsSync(path.join(slides, slug, 'meta.json')) &&
|
||||||
|
fs.existsSync(path.join(slides, slug, 'slides.md'))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* create | delete | clone | rename — cohérence slides + portail (sync).
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { execFileSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'tools', 'sync-portal.mjs')], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: root,
|
||||||
|
env: process.env,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSlug(s, label = 'slug') {
|
||||||
|
if (!s || typeof s !== 'string') throw new Error(`${label} manquant`)
|
||||||
|
if (!/^[a-z0-9-]+$/.test(s)) throw new Error(`${label} invalide (a-z 0-9 -): ${s}`)
|
||||||
|
if (s.length < 2) throw new Error(`${label} trop court`)
|
||||||
|
if (s === 'portal') throw new Error('slug réservé')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = process.argv[2]
|
||||||
|
const a = process.argv[3]
|
||||||
|
const b = process.argv[4]
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cmd === 'create') {
|
||||||
|
assertSlug(a)
|
||||||
|
const dir = path.join(root, 'slides', a)
|
||||||
|
if (fs.existsSync(dir)) throw new Error(`existe déjà: slides/${a}/`)
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'meta.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
title: 'Nouvelle présentation',
|
||||||
|
description: '',
|
||||||
|
order: 99,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'slides.md'),
|
||||||
|
['---', 'title: Nouvelle présentation', '---', '', '# Bienvenue', ''].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
sync()
|
||||||
|
console.log(`[create] slides/${a}/ — lancez make run ou make build`)
|
||||||
|
} else if (cmd === 'delete') {
|
||||||
|
assertSlug(a)
|
||||||
|
fs.rmSync(path.join(root, 'slides', a), { recursive: true, force: true })
|
||||||
|
fs.rmSync(path.join(root, `dist-${a}`), { recursive: true, force: true })
|
||||||
|
sync()
|
||||||
|
console.log(`[delete] ${a} retiré`)
|
||||||
|
} else if (cmd === 'clone') {
|
||||||
|
assertSlug(a, 'source')
|
||||||
|
assertSlug(b, 'dest')
|
||||||
|
const src = path.join(root, 'slides', a)
|
||||||
|
const dst = path.join(root, 'slides', b)
|
||||||
|
if (!fs.existsSync(src)) throw new Error(`source absente: slides/${a}/`)
|
||||||
|
if (fs.existsSync(dst)) throw new Error(`dest existe: slides/${b}/`)
|
||||||
|
fs.cpSync(src, dst, { recursive: true })
|
||||||
|
const metaPath = path.join(dst, 'meta.json')
|
||||||
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
||||||
|
meta.title = `${meta.title || a} (copie)`
|
||||||
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
|
||||||
|
sync()
|
||||||
|
console.log(`[clone] ${a} → ${b} — build: node tools/build-deck.mjs ${b}`)
|
||||||
|
} else if (cmd === 'rename') {
|
||||||
|
assertSlug(a, 'ancien')
|
||||||
|
assertSlug(b, 'nouveau')
|
||||||
|
const src = path.join(root, 'slides', a)
|
||||||
|
const dst = path.join(root, 'slides', b)
|
||||||
|
if (!fs.existsSync(src)) throw new Error(`slides/${a}/ absent`)
|
||||||
|
if (fs.existsSync(dst)) throw new Error(`slides/${b}/ existe déjà`)
|
||||||
|
fs.renameSync(src, dst)
|
||||||
|
const metaPath = path.join(dst, 'meta.json')
|
||||||
|
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
||||||
|
delete meta.slug
|
||||||
|
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
|
||||||
|
const oldDist = path.join(root, `dist-${a}`)
|
||||||
|
const newDist = path.join(root, `dist-${b}`)
|
||||||
|
if (fs.existsSync(oldDist)) {
|
||||||
|
fs.renameSync(oldDist, newDist)
|
||||||
|
}
|
||||||
|
sync()
|
||||||
|
console.log(`[rename] ${a} → ${b}`)
|
||||||
|
} else {
|
||||||
|
console.error('usage: node tools/presentation-cli.mjs <create|delete|clone|rename> [args]')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(String(e.message || e))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Logique pure partagée : génération markdown, lecture meta, échappements.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export const FEATURED_START = '<!-- FEATURED_START -->'
|
||||||
|
export const FEATURED_END = '<!-- FEATURED_END -->'
|
||||||
|
|
||||||
|
export function escMd(s) {
|
||||||
|
if (typeof s !== 'string') return ''
|
||||||
|
return s.replace(/\|/g, '\\|')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickFeatured(items) {
|
||||||
|
const marked = items.filter((p) => p.featured).sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
if (marked.length) return marked.slice(0, 6)
|
||||||
|
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Function} sitePath - ex. import { sitePath } from './site-base.mjs' */
|
||||||
|
export function buildFeaturedHtml(items, sitePath) {
|
||||||
|
const featured = pickFeatured(items)
|
||||||
|
if (!featured.length) return '<!-- (aucune présentation) -->'
|
||||||
|
const lines = [
|
||||||
|
'<section class="talks-section talks-section--home" aria-labelledby="featured-heading">',
|
||||||
|
' <h2 id="featured-heading" class="section-title">Présentations</h2>',
|
||||||
|
' <div class="talks-grid">',
|
||||||
|
]
|
||||||
|
for (const p of featured) {
|
||||||
|
const href = sitePath(`/presentations/${p.slug}/`)
|
||||||
|
const meta = `Présentation · ${p.slug}`
|
||||||
|
lines.push(` <a class="talk-card" href="${escHtml(href)}">`)
|
||||||
|
lines.push(` <h2>${escHtml(p.title)}</h2>`)
|
||||||
|
lines.push(` <p>${escHtml(p.description || '')}</p>`)
|
||||||
|
lines.push(` <span class="meta">${escHtml(meta)}</span>`)
|
||||||
|
lines.push(' </a>')
|
||||||
|
}
|
||||||
|
lines.push(' </div>', '</section>')
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAllMeta(slidesRoot) {
|
||||||
|
if (!fs.existsSync(slidesRoot)) return []
|
||||||
|
const dirs = fs
|
||||||
|
.readdirSync(slidesRoot, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && !d.name.startsWith('wip_') && !d.name.startsWith('_'))
|
||||||
|
.map((d) => d.name)
|
||||||
|
|
||||||
|
const list = []
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const metaPath = path.join(slidesRoot, dir, 'meta.json')
|
||||||
|
if (!fs.existsSync(metaPath)) continue
|
||||||
|
const raw = fs.readFileSync(metaPath, 'utf8')
|
||||||
|
const meta = JSON.parse(raw)
|
||||||
|
if (meta.slug && meta.slug !== dir) {
|
||||||
|
console.warn(`[sync] slug "${meta.slug}" ≠ dossier "${dir}", le dossier fait foi pour les URLs Slidev`)
|
||||||
|
}
|
||||||
|
list.push({ ...meta, slug: dir, folder: dir })
|
||||||
|
}
|
||||||
|
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Function} sitePath */
|
||||||
|
export function generateListPageMarkdown(items, sitePath) {
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
'title: Présentations',
|
||||||
|
'outline: deep',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# Présentations',
|
||||||
|
'',
|
||||||
|
'Decks disponibles — fiche portail et build Slidev statique.',
|
||||||
|
'',
|
||||||
|
'<div class="talks-grid">',
|
||||||
|
]
|
||||||
|
for (const p of items) {
|
||||||
|
const detail = sitePath(`/presentations/${p.slug}/`)
|
||||||
|
/** Première planche (`/1`) + classe `vp-raw` : le routeur VitePress ignore ces liens (navigation réelle vers Slidev). */
|
||||||
|
const deck = sitePath(`/slides/${p.slug}/1`)
|
||||||
|
const tags = (p.tags || []).map((t) => `\`${escMd(t)}\``).join(' · ')
|
||||||
|
lines.push(' <article class="talk-card talk-card--list">')
|
||||||
|
lines.push(` <h2><a href="${detail}">${escMd(p.title)}</a></h2>`)
|
||||||
|
lines.push(` <p>${escMd(p.description || '')}</p>`)
|
||||||
|
if (tags) lines.push(` <p class="meta">${tags}</p>`)
|
||||||
|
lines.push(' <div class="talk-card__actions">')
|
||||||
|
lines.push(` <a class="vp-button" href="${detail}">Fiche</a>`)
|
||||||
|
lines.push(
|
||||||
|
` <a class="vp-button vp-button--alt vp-raw" href="${deck}" rel="noopener">Slides</a>`,
|
||||||
|
)
|
||||||
|
lines.push(' </div>')
|
||||||
|
lines.push(' </article>')
|
||||||
|
}
|
||||||
|
lines.push('</div>', '')
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Function} sitePath */
|
||||||
|
export function generateDetailPageMarkdown(p, sitePath) {
|
||||||
|
/** `vp-raw` : VitePress ne intercepte pas le clic (sinon 404 SPA sur /slides/…). */
|
||||||
|
const deck = sitePath(`/slides/${p.slug}/1`)
|
||||||
|
const tags = (p.tags || []).map((t) => `\`${t}\``).join(' · ')
|
||||||
|
const lines = [
|
||||||
|
'---',
|
||||||
|
`title: ${p.title.replace(/"/g, '\\"')}`,
|
||||||
|
'outline: deep',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
`# ${p.title}`,
|
||||||
|
'',
|
||||||
|
...(tags ? [`**Tags :** ${tags}`, ''] : []),
|
||||||
|
'## Résumé',
|
||||||
|
'',
|
||||||
|
p.description || '_Pas de description._',
|
||||||
|
'',
|
||||||
|
'## Accès',
|
||||||
|
'',
|
||||||
|
`<p class="pres-actions">`,
|
||||||
|
` <a class="vp-button vp-raw" href="${deck}">Voir les slides</a>`,
|
||||||
|
` <a class="vp-button vp-button--alt vp-raw" href="${deck}" target="_blank" rel="noopener noreferrer">Ouvrir dans un nouvel onglet</a>`,
|
||||||
|
`</p>`,
|
||||||
|
'',
|
||||||
|
'## Partager les slides',
|
||||||
|
'',
|
||||||
|
'Depuis la salle, scannez le code pour ouvrir les slides sur mobile.',
|
||||||
|
'',
|
||||||
|
`<QrEncode path="${deck}" />`,
|
||||||
|
'',
|
||||||
|
'::: info À propos',
|
||||||
|
'Les slides sont servies en SPA statique (Slidev). Les chemins d’assets suivent le `base` défini au build.',
|
||||||
|
':::',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
return lines.filter((l) => l !== undefined).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function patchIndexFeaturedContent(indexContent, items, sitePath) {
|
||||||
|
const i0 = indexContent.indexOf(FEATURED_START)
|
||||||
|
const i1 = indexContent.indexOf(FEATURED_END)
|
||||||
|
if (i0 === -1 || i1 === -1 || i1 <= i0) {
|
||||||
|
return { ok: false, content: indexContent }
|
||||||
|
}
|
||||||
|
const before = indexContent.slice(0, i0 + FEATURED_START.length)
|
||||||
|
const after = indexContent.slice(i1)
|
||||||
|
const block = buildFeaturedHtml(items, sitePath)
|
||||||
|
const content = `${before}\n\n${block}\n\n${after}`
|
||||||
|
return { ok: true, content }
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* make run : sync + build tous les decks, puis VitePress dev + watch slides/
|
||||||
|
* → rebuild ciblé + sync pour refléter les sources.
|
||||||
|
*/
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { spawn, execFileSync } from 'node:child_process'
|
||||||
|
import chokidar from 'chokidar'
|
||||||
|
import { listPresentationSlugs } from './list-slugs.mjs'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const slidesDir = path.join(root, 'slides')
|
||||||
|
|
||||||
|
/** Aligne sync, decks et VitePress sur base=/ (ignore SITE_BASE du .env). Voir tools/site-base.mjs */
|
||||||
|
const runEnv =
|
||||||
|
process.env.SKIP_PORTAL_DEV === '1'
|
||||||
|
? process.env
|
||||||
|
: { ...process.env, TALKS_PORTAL_DEV: '1' }
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'tools', 'sync-portal.mjs')], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: root,
|
||||||
|
env: runEnv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeck(slug) {
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'tools', 'build-deck.mjs'), slug], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: root,
|
||||||
|
env: runEnv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAllDecks() {
|
||||||
|
const slugs = listPresentationSlugs(root)
|
||||||
|
for (const slug of slugs) buildDeck(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
sync()
|
||||||
|
buildAllDecks()
|
||||||
|
|
||||||
|
const pending = new Set()
|
||||||
|
let flushTimer = null
|
||||||
|
function flush() {
|
||||||
|
flushTimer = null
|
||||||
|
sync()
|
||||||
|
for (const slug of pending) {
|
||||||
|
try {
|
||||||
|
buildDeck(slug)
|
||||||
|
} catch {
|
||||||
|
/* build-deck a déjà loggé */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
chokidar
|
||||||
|
.watch(slidesDir, {
|
||||||
|
ignoreInitial: true,
|
||||||
|
ignored: (p) => path.basename(p).startsWith('.') || p.includes(`${path.sep}wip_`),
|
||||||
|
})
|
||||||
|
.on('all', (_evt, fp) => {
|
||||||
|
const rel = path.relative(slidesDir, fp)
|
||||||
|
const slug = rel.split(path.sep)[0]
|
||||||
|
if (!slug || slug.startsWith('wip_')) return
|
||||||
|
pending.add(slug)
|
||||||
|
clearTimeout(flushTimer)
|
||||||
|
flushTimer = setTimeout(flush, 450)
|
||||||
|
})
|
||||||
|
|
||||||
|
const vitepressBin = path.join(root, 'node_modules', '.bin', 'vitepress')
|
||||||
|
const child = spawn(vitepressBin, ['dev', 'portal'], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: runEnv,
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
})
|
||||||
|
|
||||||
|
function die() {
|
||||||
|
try {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGINT', die)
|
||||||
|
process.on('SIGTERM', die)
|
||||||
|
child.on('exit', (code) => process.exit(code ?? 0))
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Préfixe URL du site, aligné sur VitePress `base`.
|
||||||
|
* Racine : SITE_BASE=/ ou absent
|
||||||
|
* Sous-chemin (ex. GitHub Pages) : SITE_BASE=/mon-repo/
|
||||||
|
*
|
||||||
|
* En dev local (`TALKS_PORTAL_DEV=1`, ex. make run), SITE_BASE est ignoré pour
|
||||||
|
* éviter les modules .md servis en text/html (mismatch base / URLs).
|
||||||
|
* Pour tester un sous-chemin en dev : SKIP_PORTAL_DEV=1 SITE_BASE=/repo/ npm run dev:portal
|
||||||
|
*/
|
||||||
|
export function getSiteBase() {
|
||||||
|
if (process.env.TALKS_PORTAL_DEV === '1') return ''
|
||||||
|
const raw = process.env.SITE_BASE?.trim() || '/'
|
||||||
|
if (raw === '/') return ''
|
||||||
|
return raw.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valeur `base` VitePress : `/` ou `/mon-projet/` */
|
||||||
|
export function vitepressAppBase() {
|
||||||
|
if (process.env.TALKS_PORTAL_DEV === '1') return '/'
|
||||||
|
const raw = process.env.SITE_BASE?.trim()
|
||||||
|
if (!raw || raw === '/') return '/'
|
||||||
|
return raw.endsWith('/') ? raw : `${raw}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chemin absolu du site : /slides/foo/ */
|
||||||
|
export function sitePath(absolutePath) {
|
||||||
|
const p = absolutePath.startsWith('/') ? absolutePath : `/${absolutePath}`
|
||||||
|
const b = getSiteBase()
|
||||||
|
return b ? `${b}${p}` : p
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Sert dist-portal + chaque dist-<slug> sous /slides/<slug>/ (tests E2E, prod locale).
|
||||||
|
* Comme le plugin Vite en dev : racine du deck → 302 vers /slides/<slug>/1 ; routes Slidev (/1, /2…) via SPA (sirv single).
|
||||||
|
*/
|
||||||
|
import http from 'node:http'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import sirv from 'sirv'
|
||||||
|
import { listPresentationSlugs } from './list-slugs.mjs'
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const port = Number(process.env.PREVIEW_PORT || 4174)
|
||||||
|
|
||||||
|
const portalStatic = sirv(path.join(root, 'dist-portal'), { etag: true })
|
||||||
|
const slideSirv = new Map()
|
||||||
|
|
||||||
|
function getSirv(slug) {
|
||||||
|
if (!slideSirv.has(slug)) {
|
||||||
|
slideSirv.set(
|
||||||
|
slug,
|
||||||
|
sirv(path.join(root, `dist-${slug}`), {
|
||||||
|
etag: true,
|
||||||
|
single: 'index.html',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return slideSirv.get(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url || '/', 'http://127.0.0.1')
|
||||||
|
const pathname = u.pathname
|
||||||
|
const m = pathname.match(/^\/slides\/([^/]+)(.*)$/)
|
||||||
|
if (m) {
|
||||||
|
const slug = m[1]
|
||||||
|
const rest = m[2] || '/'
|
||||||
|
const slugs = new Set(listPresentationSlugs(root))
|
||||||
|
if (!slugs.has(slug)) {
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end('not found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const atDeckRoot = rest === '/' || rest === ''
|
||||||
|
if (atDeckRoot && (req.method === 'GET' || req.method === 'HEAD')) {
|
||||||
|
res.statusCode = 302
|
||||||
|
res.setHeader('Location', `/slides/${slug}/1${u.search || ''}`)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const orig = req.url
|
||||||
|
req.url = rest + (u.search || '')
|
||||||
|
getSirv(slug)(req, res, () => {
|
||||||
|
req.url = orig
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
portalStatic(req, res, () => {
|
||||||
|
res.statusCode = 404
|
||||||
|
res.end('not found')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, '127.0.0.1', () => {
|
||||||
|
console.log(`[static-preview] http://127.0.0.1:${port}/`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Lit chaque slides/<slug>/meta.json, écrit presentations.json, index featured, liste + fiches.
|
||||||
|
* Retire les fiches portal obsolètes.
|
||||||
|
*
|
||||||
|
* TALKS_ROOT (optionnel) : racine du dépôt — tests.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { sitePath } from './site-base.mjs'
|
||||||
|
import {
|
||||||
|
readAllMeta,
|
||||||
|
generateListPageMarkdown,
|
||||||
|
generateDetailPageMarkdown,
|
||||||
|
patchIndexFeaturedContent,
|
||||||
|
} from './presentation-contracts.mjs'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = process.env.TALKS_ROOT ? path.resolve(process.env.TALKS_ROOT) : path.resolve(__dirname, '..')
|
||||||
|
const slidesRoot = path.join(root, 'slides')
|
||||||
|
const portalData = path.join(root, 'portal', '.vitepress', 'data')
|
||||||
|
const presentationsDir = path.join(root, 'portal', 'presentations')
|
||||||
|
|
||||||
|
function pruneStalePresentationDirs(items) {
|
||||||
|
const keep = new Set(items.map((p) => p.slug))
|
||||||
|
if (!fs.existsSync(presentationsDir)) return
|
||||||
|
for (const d of fs.readdirSync(presentationsDir, { withFileTypes: true })) {
|
||||||
|
if (!d.isDirectory()) continue
|
||||||
|
if (!keep.has(d.name)) {
|
||||||
|
fs.rmSync(path.join(presentationsDir, d.name), { recursive: true, force: true })
|
||||||
|
console.warn(`[sync] fiche retirée (plus de slides/${d.name}/): presentations/${d.name}/`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const items = readAllMeta(slidesRoot)
|
||||||
|
fs.mkdirSync(portalData, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(portalData, 'presentations.json'), JSON.stringify(items, null, 2), 'utf8')
|
||||||
|
|
||||||
|
const indexPath = path.join(root, 'portal', 'index.md')
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
const raw = fs.readFileSync(indexPath, 'utf8')
|
||||||
|
const { ok, content } = patchIndexFeaturedContent(raw, items, sitePath)
|
||||||
|
if (!ok) {
|
||||||
|
console.warn('[sync] marqueurs FEATURED_START / FEATURED_END absents ou invalides dans portal/index.md')
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(indexPath, content, 'utf8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(presentationsDir, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(presentationsDir, 'index.md'), generateListPageMarkdown(items, sitePath), 'utf8')
|
||||||
|
|
||||||
|
for (const p of items) {
|
||||||
|
const dir = path.join(presentationsDir, p.slug)
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(dir, 'index.md'), generateDetailPageMarkdown(p, sitePath), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneStalePresentationDirs(items)
|
||||||
|
|
||||||
|
console.log(`[sync] ${items.length} présentation(s) — portal mis à jour`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user