Compare commits

...
12 Commits
Author SHA1 Message Date
Le Prévost-Corvellec Arnault c004da7b20 Update image repository references in build-and-push workflow and values.yaml
Talks slides — image & chart / vars (push) Successful in 2s
Talks slides — image & chart / Helm chart (push) Successful in 8s
Talks slides — image & chart / Build container image (push) Successful in 1m12s
- Changed the IMAGE_NAME and HELM_OCI_REPOSITORY in build-and-push.yaml to reflect the new repository structure.
- Updated the image repository path in values.yaml to align with the new naming convention for the talks-slides project.
2026-05-01 02:01:34 +02:00
Le Prévost-Corvellec Arnault 84e5c70c0d Update Helm download URL in build-and-push workflow for correct package source
Talks slides — image & chart / vars (push) Successful in 2s
Talks slides — image & chart / Helm chart (push) Failing after 8s
Talks slides — image & chart / Build container image (push) Failing after 1m28s
- Changed the Helm download URL in the build-and-push.yaml workflow to point to the correct repository for package retrieval, ensuring successful builds and deployments.
2026-05-01 01:58:10 +02:00
Le Prévost-Corvellec Arnault 777e74f04b Add record configuration to Slidev presentations for improved recording functionality
Talks slides — image & chart / vars (push) Successful in 1s
Talks slides — image & chart / Helm chart (push) Failing after 4s
Talks slides — image & chart / Build container image (push) Failing after 1m27s
- Enabled recording feature in slides for Kubernetes Operators and Spring AI presentations.
- Updated slides.md files to include 'record: true' for both presentations, allowing for camera and recording options during development.
2026-04-11 17:05:19 +02:00
Le Prévost-Corvellec Arnault 2f5065bf37 Add Docker and Gitea workflow configurations for Talks slides deployment
- Introduced .dockerignore to exclude unnecessary files from Docker builds.
- Added build-and-push.yaml workflow for automated image building and Helm chart deployment on changes to specific paths.
- Created Dockerfile for building a static site with Nginx, including configuration for serving slides.
- Established Helm chart structure in talks-slides-dist for managing deployment configurations and resources.
- Removed outdated GitHub Actions workflows to streamline CI/CD processes.
2026-04-11 16:53:09 +02:00
Le Prévost-Corvellec Arnault 354c8cd9ef Add Makefile, package.json, and initial configuration for VitePress and Slidev integration
- Introduced a Makefile for streamlined commands related to building, running, and managing presentations.
- Created package.json to define project metadata, dependencies, and scripts for development and testing.
- Added .gitignore entries to exclude build artifacts and temporary files from version control.
- Included playwright.config.mjs for end-to-end testing setup with Playwright.
- Added README.md for project documentation, outlining setup instructions and development guidelines.
2026-04-11 15:59:20 +02:00
Le Prévost-Corvellec Arnault 10f7798afb Enhance index.html and custom.css for improved content presentation and new assets
- Updated index.html to refine content structure, including enhanced blockquotes and lists for better readability.
- Added a new section to emphasize Kubernetes as a developer-centric tool.
- Improved CSS styles in custom.css for blockquote presentation and slide layout adjustments.
- Introduced new SVG and WEBP images for better visual representation of tools used in Kubernetes.
2026-04-09 02:52:44 +02:00
Le Prévost-Corvellec Arnault 3a52131d1e Add closing slide section with QR codes and styling enhancements
- Introduced a new closing slide section in index.html to thank the audience and provide QR codes for easy access to the repository and slides.
- Added corresponding CSS styles in custom.css to enhance the presentation of the closing slide elements, including layout and typography adjustments.
- Implemented QR code generation for the repository and slides using JavaScript for improved user engagement.
2026-04-09 01:07:41 +02:00
Le Prévost-Corvellec Arnault 4b45521f01 Enhance index.html and home.css for improved presentation layout and functionality
- Updated index.html to include an introductory section, quick links with QR codes, and a structured layout for presentations.
- Added styling in home.css for new sections, including .masthead-lead, .intro, and .qr-section, to improve visual hierarchy and user experience.
- Implemented QR code generation for easy access to the code repository and presentation slides.
2026-04-08 23:44:26 +02:00
Le Prévost-Corvellec Arnault da748c2cae Refactor Dockerfile to improve user configuration and permissions
- Removed the creation of a home directory for the nginx user, simplifying the setup for a stateless image.
- Updated git configuration to use a temporary home directory, enhancing compatibility with non-root user practices.
- Adjusted ownership of the temporary git configuration file to align with UID and GID parameters.
2026-04-08 22:12:44 +02:00
Le Prévost-Corvellec Arnault 4a5cbb950b Refactor Dockerfile to parameterize user and group IDs for enhanced flexibility
- Introduced ARG variables for UID and GID to allow dynamic user and group configuration.
- Updated ownership commands to utilize the new UID and GID parameters, improving compatibility with different environments.
- Maintained non-root user practices while ensuring proper permissions for Nginx and associated files.
2026-04-08 22:06:28 +02:00
Le Prévost-Corvellec Arnault 600c07e358 Update Dockerfile to use nginxinc/nginx-unprivileged image and enhance user permissions
- Switched base image to nginxinc/nginx-unprivileged for improved security.
- Adjusted user permissions and ownership settings for better compliance with non-root user practices.
- Removed unnecessary commands related to pid and user configuration in nginx.conf.
2026-04-08 21:51:44 +02:00
Le Prévost-Corvellec Arnault 9a4942daad Refactor Dockerfile and Helm chart to enhance security and user permissions
- Updated Dockerfile to run as non-root user 'nginx' and adjusted Nginx configuration for improved security.
- Added pod security context in values.yaml to align with the non-root user setup.
- Refined deployment.yaml to utilize the new pod security context for better compliance with Kubernetes security standards.
2026-04-08 21:36:09 +02:00
61 changed files with 16369 additions and 1456 deletions
+14
View File
@@ -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
+20 -8
View File
@@ -1,29 +1,41 @@
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:
push:
branches: [main, develop, cicd]
branches: [main, master, develop, cicd]
tags: ['v*']
paths:
- 'server/**'
- 'talks-slides-dist/**'
- '.gitea/workflows/**'
- 'portal/**'
- 'slides/**'
- 'tools/**'
- 'package.json'
- 'package-lock.json'
- '.dockerignore'
pull_request:
branches: [main, develop]
branches: [main, master, develop]
paths:
- 'server/**'
- 'talks-slides-dist/**'
- '.gitea/workflows/**'
- 'portal/**'
- 'slides/**'
- 'tools/**'
- 'package.json'
- 'package-lock.json'
- '.dockerignore'
env:
# GIT_DEFAULT_HASH: sha256
DOCKER_HOST: 'unix:///var/run/docker.sock'
DOCKER_CERT_PATH: '/certs/client'
IMAGE_REGISTRY: git.specificat.io
IMAGE_NAME: specificat.io/talks-slides
IMAGE_NAME: arnault/talks-slides
# Même registre OCI Helm que les autres charts (ex. knowledge-mcp).
HELM_OCI_REPOSITORY: oci://git.specificat.io/specificat.io
HELM_OCI_REPOSITORY: oci://git.specificat.io/arnault
HELM_DIR: ./talks-slides-dist
jobs:
@@ -65,7 +77,7 @@ jobs:
run: |
set -euo pipefail
IMAGE="${IMAGE_REGISTRY}/${IMAGE_NAME}:${VERSION}"
docker build -f server/Dockerfile -t "${IMAGE}" server/
docker build -f server/Dockerfile -t "${IMAGE}" .
docker push "${IMAGE}"
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
docker tag "${IMAGE}" "${IMAGE_REGISTRY}/${IMAGE_NAME}:latest"
@@ -75,7 +87,7 @@ jobs:
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
docker build -f server/Dockerfile -t talks-slides:ci server/
docker build -f server/Dockerfile -t talks-slides:ci .
helm:
runs-on: ubuntu-latest
@@ -87,7 +99,7 @@ jobs:
run: |
set -euo pipefail
curl -fsSL -o helm.tgz --user "${{ secrets.CI_USER }}:${{ secrets.CI_TOKEN }}" \
"https://git.specificat.io/api/packages/Specificat.io/generic/helm/4.1.1/helm-v4.1.1-linux-amd64.tar.gz"
"https://git.specificat.io/api/packages/arnault/generic/helm/4.1.1/helm-v4.1.1-linux-amd64.tar.gz"
tar -xzf helm.tgz
sudo mv linux-amd64/helm /usr/local/bin/helm
helm version
+13
View File
@@ -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
*.log
test-results/
playwright-report/
blob-report/
playwright/.cache/
+74
View File
@@ -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
+132
View File
@@ -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`.
-165
View File
@@ -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;
}
-32
View File
@@ -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] --&gt; B[kube-api]
B --&gt; C[etcd]
B --&gt; D[Controllers]
D --&gt; E[kubelet]
E --&gt; F[containerd]
F --&gt; 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é] --&gt; B[API] --&gt; C[Controllers] --&gt; 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] --&gt; B[Operator]
B --&gt; C[Cluster]
C --&gt; 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 &amp; 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 &amp;
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>
+69
View File
@@ -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\/?$/)
})
})
+25
View File
@@ -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()
}
})
})
+39
View File
@@ -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`))
})
}
})
-55
View File
@@ -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: ""
-51
View File
@@ -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
+12945
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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"
}
}
+33
View File
@@ -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',
},
})
+43
View File
@@ -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>
+46
View File
@@ -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.',
},
})
+30
View File
@@ -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"
}
]
+401
View File
@@ -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;
}
+13
View File
@@ -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()
})
})
},
}
}
+59
View File
@@ -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>
+29
View File
@@ -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.
:::
+29
View File
@@ -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.
:::
-2
View File
@@ -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
View File
@@ -1,44 +1,37 @@
# syntax=docker/dockerfile:1
# BuildKit / buildx : cache apk + permissions posées au build (moins de travail / capabilities au runtime).
FROM nginx:alpine
# Image statique : build npm (VitePress + Slidev) puis nginx non-root.
FROM node:22-bookworm-slim AS builder
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apk \
apk add --no-cache git
COPY package.json package-lock.json ./
# 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).
# Seul le dossier content/ est extrait (sparse checkout) : léger et aligné sur la racine Nginx.
# L’image nginx:alpine place déjà des fichiers ici — il faut vider le répertoire avant git clone vers « . ».
ARG TALKS_REPO_URL=https://git.specificat.io/arnault/Talks.git
ARG TALKS_BRANCH=main
ARG TALKS_SPARSE_DIR=content
# Même arborescence que tools/static-preview.mjs : portail à la racine, decks sous slides/<slug>/
RUN set -eux \
&& mkdir -p /opt/site \
&& cp -a dist-portal/. /opt/site/ \
&& for d in dist-*; do \
[ -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 \
&& 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
FROM nginxinc/nginx-unprivileged:stable-alpine
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY refresh.sh /refresh.sh
ARG UID=101
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).
# Propriétaire nginx (cf. /etc/nginx/nginx.conf user) → l’entrypoint ne refait pas chown si tout est déjà cohérent.
RUN chmod +x /refresh.sh \
&& mkdir -p \
/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
USER root
COPY --from=builder /opt/site /usr/share/nginx/html
COPY server/nginx/default.conf /etc/nginx/conf.d/default.conf
RUN chown -R "${UID}:${GID}" /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
CMD sh -c "/refresh.sh & exec nginx -g 'daemon off;'"
CMD ["nginx", "-g", "daemon off;"]
+15 -5
View File
@@ -1,12 +1,22 @@
server {
listen 8080;
server_name localhost;
# Racine web = contenu statique sparse (dossier content/ du dépôt).
root /usr/share/nginx/html/content;
index index.html index.htm;
root /usr/share/nginx/html;
index index.html;
# Portail VitePress (fichiers statiques + clean URLs)
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ =404;
try_files $uri $uri/ $uri.html =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;
}
}
-9
View File
@@ -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
+74
View File
@@ -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>
+8
View File
@@ -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
}
+579
View File
@@ -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.
-->
+1
View File
@@ -0,0 +1 @@
@import "../_shared/center-default-like-reveal.css";
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import DeckFooter from '../_shared/DeckFooter.vue'
</script>
<template>
<DeckFooter />
</template>
+8
View File
@@ -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
}
+44
View File
@@ -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/)
+1
View File
@@ -0,0 +1 @@
@import "../_shared/center-default-like-reveal.css";
+2 -3
View File
@@ -24,15 +24,14 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
seccompProfile:
type: RuntimeDefault
{{- toYaml .Values.slides.podSecurityContext | nindent 8 }}
containers:
- name: nginx
image: "{{ .Values.slides.image.repository }}:{{ .Values.slides.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.slides.image.pullPolicy }}
securityContext:
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:
drop: ["ALL"]
readOnlyRootFilesystem: false
+9 -1
View File
@@ -5,7 +5,7 @@ fullnameOverride: ""
slides:
image:
repository: git.specificat.io/specificat.io/talks-slides
repository: git.specificat.io/arnault/talks-slides
tag: "latest"
pullPolicy: IfNotPresent
@@ -17,6 +17,14 @@ slides:
# Port d’écoute dans le conteneur (doit correspondre à server/nginx/default.conf, ex. 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:
type: ClusterIP
# Port du Service (Ingress pointe ici) ; le trafic est envoyé vers containerPort sur les pods.
+31
View File
@@ -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`)
}
})
})
+146
View File
@@ -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">'), '&lt;a href=&quot;x&quot;&gt;')
})
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, /&lt;evil&gt;/)
assert.match(html, /d &amp; 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)
})
})
+90
View File
@@ -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')
}
})
})
+46
View File
@@ -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(), '/')
})
})
+55
View File
@@ -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'))
})
+129
View File
@@ -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)
})
})
+23
View File
@@ -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é')
+69
View File
@@ -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)
}
+40
View File
@@ -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))
+20
View File
@@ -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'))
)
})
}
+105
View File
@@ -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)
}
+158
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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 }
}
+93
View File
@@ -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))
+30
View File
@@ -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
}
+68
View File
@@ -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}/`)
})
+67
View File
@@ -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()