feat(infra): provisionnement Hetzner, k3s et secrets SOPS
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
parent
9a3e1db048
commit
44713e4ca0
43
infra/prod/.gitignore
vendored
Normal file
43
infra/prod/.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
# === Garde-fou : rien de dechiffre ne doit entrer dans Git ===================
|
||||
# Tout secret est versionne UNIQUEMENT sous forme chiffree SOPS (*.sops.yaml).
|
||||
|
||||
# Fichiers d'environnement en clair
|
||||
*.env
|
||||
.env
|
||||
.env.*
|
||||
!*.env.example
|
||||
!.env.example
|
||||
|
||||
# Secrets Kubernetes dechiffres (sortie de `sops -d`)
|
||||
*.dec.yaml
|
||||
*.decrypted.yaml
|
||||
secrets-plain.yaml
|
||||
|
||||
# Cles
|
||||
*.key
|
||||
*.pem
|
||||
*.age
|
||||
age.key
|
||||
age-key.txt
|
||||
id_ed25519*
|
||||
id_rsa*
|
||||
|
||||
# Kubeconfig
|
||||
kubeconfig*
|
||||
*.kubeconfig
|
||||
|
||||
# Terraform
|
||||
.terraform/
|
||||
.terraform.lock.hcl
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfvars
|
||||
!*.tfvars.example
|
||||
crash.log
|
||||
tfplan*
|
||||
|
||||
# Dumps et backups locaux
|
||||
*.dump
|
||||
*.sql.gz
|
||||
*.tar.zst
|
||||
backups/
|
||||
40
infra/prod/.sops.yaml
Normal file
40
infra/prod/.sops.yaml
Normal file
@ -0,0 +1,40 @@
|
||||
# =============================================================================
|
||||
# Regles de chiffrement SOPS (age)
|
||||
# =============================================================================
|
||||
#
|
||||
# Un seul mecanisme de secrets pour toute la prod : SOPS + age.
|
||||
# Il couvre a la fois les Secrets Kubernetes (noeud app) ET les fichiers .env
|
||||
# du noeud de donnees (Docker Compose) -- ce qu'un controleur type Sealed
|
||||
# Secrets ne saurait pas faire.
|
||||
#
|
||||
# Generation de la cle (UNE SEULE FOIS, cf. docs/mise-en-prod/06-secrets-sops.md) :
|
||||
# age-keygen -o ~/.config/sops/age/keys.txt
|
||||
# grep 'public key' ~/.config/sops/age/keys.txt
|
||||
#
|
||||
# Puis remplacer AGE_RECIPIENT_PRIMARY ci-dessous par la cle publique (age1...).
|
||||
# La cle PRIVEE ne quitte jamais : votre poste + le coffre-fort (1Password /
|
||||
# Bitwarden) + le secret GitHub Actions SOPS_AGE_KEY.
|
||||
#
|
||||
# Ajoutez une 2e recipient (cle de secours, stockee hors ligne, sur papier ou
|
||||
# YubiKey) : sans elle, perdre le poste = perdre tous les secrets de prod.
|
||||
|
||||
creation_rules:
|
||||
# Secrets Kubernetes : on ne chiffre QUE les valeurs sous `data`/`stringData`,
|
||||
# les metadonnees restent lisibles pour pouvoir relire un diff en revue.
|
||||
- path_regex: k8s/.*\.sops\.yaml$
|
||||
encrypted_regex: '^(data|stringData)$'
|
||||
age: >-
|
||||
AGE_RECIPIENT_PRIMARY,
|
||||
AGE_RECIPIENT_BACKUP
|
||||
|
||||
# Fichiers d'environnement du noeud de donnees (docker compose) : tout chiffre.
|
||||
- path_regex: (data-node|env)/.*\.sops\.(yaml|env)$
|
||||
age: >-
|
||||
AGE_RECIPIENT_PRIMARY,
|
||||
AGE_RECIPIENT_BACKUP
|
||||
|
||||
# Filet de securite : tout autre .sops.yaml du dossier prod.
|
||||
- path_regex: \.sops\.yaml$
|
||||
age: >-
|
||||
AGE_RECIPIENT_PRIMARY,
|
||||
AGE_RECIPIENT_BACKUP
|
||||
140
infra/prod/Makefile
Normal file
140
infra/prod/Makefile
Normal file
@ -0,0 +1,140 @@
|
||||
# =============================================================================
|
||||
# Xpeditis - production Hetzner
|
||||
# =============================================================================
|
||||
# Toutes les cibles s'executent depuis infra/prod/.
|
||||
# make help
|
||||
#
|
||||
# Les cibles qui touchent la production affichent ce qu'elles vont faire et
|
||||
# demandent confirmation. Aucune ne s'execute par accident.
|
||||
|
||||
SHELL := /bin/bash
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
KUBECONFIG ?= $(HOME)/.kube/xpeditis-prod.yaml
|
||||
NAMESPACE ?= xpeditis-prod
|
||||
REGISTRY ?= rg.fr-par.scw.cloud/weworkstudio
|
||||
export KUBECONFIG
|
||||
|
||||
BOLD := \033[1m
|
||||
RESET := \033[0m
|
||||
|
||||
.PHONY: help
|
||||
help: ## Affiche cette aide
|
||||
@printf '$(BOLD)Xpeditis - production$(RESET)\n\n'
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||
@printf '\nKUBECONFIG : $(KUBECONFIG)\n'
|
||||
|
||||
# --- Infrastructure ---------------------------------------------------------
|
||||
|
||||
.PHONY: tf-init
|
||||
tf-init: ## Terraform : initialisation
|
||||
cd terraform && terraform init
|
||||
|
||||
.PHONY: tf-plan
|
||||
tf-plan: ## Terraform : previsualise les changements d'infrastructure
|
||||
cd terraform && terraform plan
|
||||
|
||||
.PHONY: tf-apply
|
||||
tf-apply: ## Terraform : applique (cree/modifie les serveurs)
|
||||
@printf '$(BOLD)Cette commande modifie l infrastructure de PRODUCTION.$(RESET)\n'
|
||||
@read -p 'Continuer ? [oui/non] ' r; [ "$$r" = oui ]
|
||||
cd terraform && terraform apply
|
||||
|
||||
.PHONY: tf-output
|
||||
tf-output: ## Terraform : affiche les IP et les enregistrements DNS a creer
|
||||
cd terraform && terraform output
|
||||
|
||||
.PHONY: cloudflare-ips
|
||||
cloudflare-ips: ## Compare les rangs IP Cloudflare avec firewall.tf
|
||||
bash scripts/refresh-cloudflare-ips.sh
|
||||
|
||||
# --- Secrets ----------------------------------------------------------------
|
||||
|
||||
.PHONY: secrets-edit
|
||||
secrets-edit: ## Edite les secrets chiffres (SOPS ouvre votre editeur)
|
||||
sops k8s/base/03-secrets.sops.yaml
|
||||
|
||||
.PHONY: secrets-apply
|
||||
secrets-apply: ## Applique les secrets sur le cluster
|
||||
bash scripts/secrets-apply.sh
|
||||
|
||||
.PHONY: secrets-check
|
||||
secrets-check: ## Verifie qu'aucun secret en clair n'est pret a etre commite
|
||||
@echo '>>> Fichiers suspects dans infra/prod :'
|
||||
@! git ls-files --others --cached --exclude-standard . \
|
||||
| grep -E '\.(env|key|pem)$$|secrets\.yaml$$|tfvars$$' \
|
||||
| grep -v '\.example$$' \
|
||||
| grep -v '\.sops\.' \
|
||||
|| (echo 'ARRET : des fichiers sensibles sont suivis ou non ignores.'; exit 1)
|
||||
@echo 'Aucun fichier sensible detecte.'
|
||||
|
||||
# --- Deploiement ------------------------------------------------------------
|
||||
|
||||
.PHONY: deploy
|
||||
deploy: ## Deploie une version (make deploy TAG=prod-a1b2c3d)
|
||||
@[ -n "$(TAG)" ] || (echo 'Usage: make deploy TAG=prod-a1b2c3d'; exit 1)
|
||||
bash scripts/deploy.sh $(TAG)
|
||||
|
||||
.PHONY: deploy-monitoring
|
||||
deploy-monitoring: ## Deploie ou met a jour la pile d'observabilite
|
||||
bash scripts/deploy-monitoring.sh
|
||||
|
||||
.PHONY: rollback
|
||||
rollback: ## Revient a la version precedente (backend + frontend)
|
||||
@printf '$(BOLD)Retour arriere de la PRODUCTION.$(RESET)\n'
|
||||
@read -p 'Continuer ? [oui/non] ' r; [ "$$r" = oui ]
|
||||
kubectl -n $(NAMESPACE) rollout undo deploy/xpeditis-backend
|
||||
kubectl -n $(NAMESPACE) rollout undo deploy/xpeditis-frontend
|
||||
kubectl -n $(NAMESPACE) rollout status deploy/xpeditis-backend
|
||||
kubectl -n $(NAMESPACE) rollout status deploy/xpeditis-frontend
|
||||
|
||||
.PHONY: restart
|
||||
restart: ## Redemarre les pods applicatifs (prise en compte des secrets)
|
||||
kubectl -n $(NAMESPACE) rollout restart deploy/xpeditis-backend deploy/xpeditis-frontend
|
||||
|
||||
# --- Controles --------------------------------------------------------------
|
||||
|
||||
.PHONY: preflight
|
||||
preflight: ## Controle go / no-go avant ouverture au public
|
||||
bash scripts/preflight-check.sh
|
||||
|
||||
.PHONY: smoke
|
||||
smoke: ## Tests de fumee sur les URLs publiques
|
||||
bash scripts/smoke-test.sh
|
||||
|
||||
.PHONY: status
|
||||
status: ## Vue d'ensemble de la production
|
||||
@printf '\n$(BOLD)Noeuds$(RESET)\n'; kubectl get nodes -o wide
|
||||
@printf '\n$(BOLD)Application$(RESET)\n'; kubectl -n $(NAMESPACE) get pods,deploy,hpa
|
||||
@printf '\n$(BOLD)Ingress$(RESET)\n'; kubectl -n $(NAMESPACE) get ingress
|
||||
@printf '\n$(BOLD)Certificats$(RESET)\n'; kubectl get certificate -A
|
||||
@printf '\n$(BOLD)Observabilite$(RESET)\n'; kubectl -n monitoring get pods
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## Journaux du backend en direct
|
||||
kubectl -n $(NAMESPACE) logs -l app.kubernetes.io/name=xpeditis-backend -f --tail=100 --max-log-requests=6
|
||||
|
||||
.PHONY: logs-frontend
|
||||
logs-frontend: ## Journaux du frontend en direct
|
||||
kubectl -n $(NAMESPACE) logs -l app.kubernetes.io/name=xpeditis-frontend -f --tail=100 --max-log-requests=6
|
||||
|
||||
.PHONY: events
|
||||
events: ## Derniers evenements Kubernetes (diagnostic)
|
||||
kubectl -n $(NAMESPACE) get events --sort-by=.lastTimestamp | tail -40
|
||||
|
||||
# --- Validation locale ------------------------------------------------------
|
||||
|
||||
.PHONY: validate
|
||||
validate: ## Valide les manifests sans rien appliquer
|
||||
@echo '>>> Validation cote serveur (dry-run)'
|
||||
@for f in k8s/base/*.yaml k8s/monitoring/*.yaml k8s/cluster/*.yaml; do \
|
||||
case "$$f" in *secrets.template.yaml|*migration-job.yaml) continue;; esac; \
|
||||
printf ' %s\n' "$$f"; \
|
||||
kubectl apply --dry-run=server -f "$$f" >/dev/null || exit 1; \
|
||||
done
|
||||
@echo '>>> Terraform'
|
||||
@cd terraform && terraform validate
|
||||
@echo '>>> Scripts shell'
|
||||
@command -v shellcheck >/dev/null && shellcheck -S warning scripts/*.sh data-node/backup/*.sh || echo ' shellcheck absent, ignore'
|
||||
@echo 'Validation terminee.'
|
||||
152
infra/prod/README.md
Normal file
152
infra/prod/README.md
Normal file
@ -0,0 +1,152 @@
|
||||
# infra/prod — Production Hetzner
|
||||
|
||||
Tout ce qui est nécessaire pour déployer et exploiter Xpeditis en production.
|
||||
|
||||
> **La procédure pas à pas est dans [`docs/mise-en-prod/`](../../docs/mise-en-prod/README.md).**
|
||||
> Ce README décrit *ce que contient le dossier* ; la doc décrit *quoi faire, dans quel ordre*.
|
||||
|
||||
---
|
||||
|
||||
## Architecture cible
|
||||
|
||||
```
|
||||
Internet
|
||||
|
|
||||
Cloudflare (DNS + WAF + anti-DDoS)
|
||||
| seules les IP Cloudflare passent le firewall
|
||||
v
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ app-01 CPX41 8 vCPU / 16 Go fsn1 │
|
||||
│ │
|
||||
│ k3s (mono-nœud) │
|
||||
│ Traefik ──┬── api.xpeditis.com → backend │
|
||||
│ ├── app / www / apex → frontend│
|
||||
│ └── grafana.xpeditis.com │
|
||||
│ backend NestJS x2 (HPA 2→4) │
|
||||
│ frontend Next.js x2 │
|
||||
│ log-exporter │
|
||||
│ monitoring : Loki, Promtail, Prometheus, │
|
||||
│ Alertmanager, Grafana │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│ réseau privé 10.10.1.0/24
|
||||
│ TLS obligatoire (hostssl)
|
||||
v
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ db-01 CPX31 4 vCPU / 8 Go fsn1 │
|
||||
│ │
|
||||
│ PostgreSQL 15 + WAL-G (Docker Compose) │
|
||||
│ Redis 7 │
|
||||
│ postgres-exporter │
|
||||
│ volume dédié 50 Go │
|
||||
└──────────────────┬──────────────────────────┘
|
||||
│
|
||||
┌───────────┴────────────┐
|
||||
v v
|
||||
Hetzner Object Storage Hetzner Storage Box
|
||||
(documents + WAL-G) (dumps chiffrés age)
|
||||
```
|
||||
|
||||
**Pourquoi la base hors de Kubernetes** — PostgreSQL en StatefulSet ajoute des
|
||||
volumes persistants, un ordre de démarrage et des montées de version délicates,
|
||||
sans aucun gain à cette échelle. Hors cluster, les sauvegardes, la restauration
|
||||
à un instant T et les tests de restauration sont triviaux, et le nœud
|
||||
applicatif reste entièrement reconstructible sans toucher aux données.
|
||||
|
||||
---
|
||||
|
||||
## Contenu du dossier
|
||||
|
||||
| Chemin | Rôle |
|
||||
|---|---|
|
||||
| `terraform/` | Serveurs, réseau privé, firewalls, volume, clé SSH. La seule source de vérité de l'infrastructure. |
|
||||
| `scripts/00-bootstrap-common.sh` | Durcissement système commun (SSH, UFW, fail2ban, auditd, sysctl, mises à jour auto). |
|
||||
| `scripts/01-setup-data-node.sh` | Installe db-01 : volume, Docker, certificat TLS Postgres, timers de sauvegarde. |
|
||||
| `scripts/02-setup-k3s-server.sh` | Installe k3s durci (secrets chiffrés au repos, audit API, Traefik configuré). |
|
||||
| `scripts/03-install-cluster-addons.sh` | cert-manager, namespaces, accès registre, ClusterIssuer. |
|
||||
| `scripts/secrets-apply.sh` | Déchiffre SOPS → applique sur le cluster, sans passer par le disque. |
|
||||
| `scripts/harden-seed-data.sh` | Secours et audit : neutralise les comptes de démonstration (`admin@xpeditis.com` / `Password123!`) sur une base migrée avant l'ajout de la garde `NODE_ENV`. Le cas nominal est traité par les migrations. |
|
||||
| `scripts/deploy.sh` | Déploiement complet : migrations → images → attente → tests → retour arrière. |
|
||||
| `scripts/ssh-deploy-wrapper.sh` | Restreint la clé SSH de la CI à quatre commandes. Une clé volée ne donne pas un shell. |
|
||||
| `scripts/deploy-monitoring.sh` | Pile d'observabilité. |
|
||||
| `scripts/smoke-test.sh` | Vérifie ce qu'un utilisateur constate réellement, de l'extérieur. |
|
||||
| `scripts/preflight-check.sh` | Contrôle go / no-go avant ouverture au public. |
|
||||
| `scripts/refresh-cloudflare-ips.sh` | Met à jour les rangs IP Cloudflare (firewall + Traefik). |
|
||||
| `data-node/` | Docker Compose, `postgresql.conf`, `pg_hba.conf`, `redis.conf`, sauvegardes WAL-G. |
|
||||
| `k8s/base/` | Namespaces, quotas, ConfigMap, gabarit de secrets, déploiements, Ingress, middlewares, politiques réseau, certificats. |
|
||||
| `k8s/cluster/` | ClusterIssuer Let's Encrypt (DNS-01 Cloudflare). |
|
||||
| `k8s/monitoring/` | Loki, Promtail, Prometheus, Alertmanager, Grafana. |
|
||||
| `env/` | Gabarits de variables d'environnement et liste des secrets GitHub. |
|
||||
| `cloudflare/` | Enregistrements DNS et règles WAF à créer côté Cloudflare. |
|
||||
| `Makefile` | Raccourcis d'exploitation (`make help`). |
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
```bash
|
||||
cd infra/prod
|
||||
make help
|
||||
|
||||
# Infrastructure
|
||||
cp terraform/terraform.tfvars.example terraform/terraform.tfvars
|
||||
$EDITOR terraform/terraform.tfvars
|
||||
make tf-init && make tf-plan && make tf-apply
|
||||
make tf-output # IP + enregistrements DNS à créer
|
||||
|
||||
# ... provisioning des serveurs : voir docs/mise-en-prod/03 et 04 ...
|
||||
|
||||
# Secrets
|
||||
cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml
|
||||
$EDITOR /tmp/secrets.yaml
|
||||
sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml && shred -u /tmp/secrets.yaml
|
||||
make secrets-apply
|
||||
|
||||
# Déploiement
|
||||
make deploy TAG=prod-a1b2c3d
|
||||
make deploy-monitoring
|
||||
|
||||
# Avant d'ouvrir au public
|
||||
make preflight
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Règles de sécurité non négociables
|
||||
|
||||
1. **Aucun secret en clair dans Git.** Uniquement des fichiers `*.sops.yaml`
|
||||
chiffrés avec age. `make secrets-check` refuse le contraire.
|
||||
2. **La base de données n'est jamais joignable depuis Internet.** Réseau privé,
|
||||
bind explicite sur l'IP privée, UFW, et `pg_hba` en `hostssl` seulement.
|
||||
3. **Personne ne contourne Cloudflare.** Le firewall Hetzner n'accepte 80/443
|
||||
que depuis les rangs Cloudflare.
|
||||
4. **SSH et l'API Kubernetes ne sont ouverts qu'à vos IP d'administration.**
|
||||
Jamais `0.0.0.0/0` — Terraform refuse cette valeur.
|
||||
5. **Une sauvegarde non restaurée n'est pas une sauvegarde.** Le test de
|
||||
restauration tourne toutes les semaines et doit être passé avant l'ouverture.
|
||||
6. **Les migrations passent par un Job**, jamais en concurrence entre replicas.
|
||||
7. **L'image frontend est reconstruite pour la production**, jamais promue
|
||||
depuis la preprod : `NEXT_PUBLIC_API_URL` est figée au build.
|
||||
8. **Aucun mot de passe d'administrateur n'est stocké nulle part.**
|
||||
`SeedTestUsers` ne s'exécute plus en production, une migration de secours
|
||||
neutralise ses comptes s'ils existent, et le premier administrateur est créé
|
||||
sans mot de passe utilisable — vous définissez le vôtre via « mot de passe
|
||||
oublié ».
|
||||
|
||||
---
|
||||
|
||||
## Coûts (référence : `Xpeditis_Previsions_Couts.xlsx`, feuille « Hetzner »)
|
||||
|
||||
| Poste | € HT / mois |
|
||||
|---|---|
|
||||
| app-01 CPX41 | 29 |
|
||||
| db-01 CPX31 | 15 |
|
||||
| Volume 50 Go | 2,40 |
|
||||
| Sauvegardes Hetzner (20 %) | 8,80 |
|
||||
| Storage Box BX11 (1 To) | 3,90 |
|
||||
| Object Storage (documents + WAL-G, < 1 To) | ~6 |
|
||||
| IPv4 supplémentaire | 1 |
|
||||
| Cloudflare Free | 0 |
|
||||
| **Total infrastructure** | **≈ 66 €** |
|
||||
|
||||
Les services tiers (Brevo, Stripe, Sentry, Pappers, assurance) s'ajoutent :
|
||||
voir la feuille « Synthèse comparatif » du fichier de prévisions.
|
||||
171
infra/prod/cloudflare/README.md
Normal file
171
infra/prod/cloudflare/README.md
Normal file
@ -0,0 +1,171 @@
|
||||
# Configuration Cloudflare — production
|
||||
|
||||
Cloudflare est la première ligne de défense : DNS, anti-DDoS, WAF, cache.
|
||||
Le firewall Hetzner n'accepte 80/443 **que** depuis les rangs Cloudflare, donc
|
||||
si le proxy est désactivé sur un enregistrement, ce domaine devient
|
||||
inaccessible. C'est voulu : personne ne doit pouvoir taper l'IP d'origine.
|
||||
|
||||
Le plan Free suffit en phase 1 (≈ 100 utilisateurs). Le plan Pro (20 €/mois,
|
||||
prévu au budget à partir de 1 000 utilisateurs) ajoute le WAF managé et
|
||||
l'analyse de bots.
|
||||
|
||||
---
|
||||
|
||||
## 1. Enregistrements DNS
|
||||
|
||||
Toutes les valeurs `<IP_APP_01>` viennent de `make tf-output`.
|
||||
|
||||
| Type | Nom | Contenu | Proxy | TTL |
|
||||
|---|---|---|---|---|
|
||||
| A | `xpeditis.com` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||
| A | `www` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||
| A | `app` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||
| A | `api` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||
| A | `grafana` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||
| AAAA | idem si IPv6 activée | `<IPv6_APP_01>` | **Proxifié** | Auto |
|
||||
|
||||
**Aucun enregistrement ne pointe vers db-01.** Son IP publique ne sert qu'au
|
||||
SSH d'administration et ne doit apparaître nulle part en DNS.
|
||||
|
||||
### Messagerie (obligatoire pour que les e-mails arrivent)
|
||||
|
||||
| Type | Nom | Contenu |
|
||||
|---|---|---|
|
||||
| TXT | `xpeditis.com` | `v=spf1 include:spf.brevo.com -all` |
|
||||
| TXT | `mail._domainkey` | clé DKIM fournie par Brevo |
|
||||
| TXT | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@xpeditis.com; pct=100` |
|
||||
|
||||
Sans SPF/DKIM/DMARC, les confirmations de réservation et les liens magiques
|
||||
transporteurs partent en spam — un défaut de production silencieux et coûteux.
|
||||
Commencez à `p=quarantine`, passez à `p=reject` après deux semaines de rapports
|
||||
propres.
|
||||
|
||||
---
|
||||
|
||||
## 2. Réglages SSL/TLS
|
||||
|
||||
| Réglage | Valeur | Pourquoi |
|
||||
|---|---|---|
|
||||
| Mode de chiffrement | **Full (strict)** | Cloudflare vérifie le certificat Let's Encrypt du serveur. En « Flexible », le trafic Cloudflare → origine serait en clair. |
|
||||
| Always Use HTTPS | Activé | |
|
||||
| Minimum TLS Version | **1.2** | TLS 1.0/1.1 sont cassés et refusés par la plupart des référentiels de conformité. |
|
||||
| TLS 1.3 | Activé | |
|
||||
| Automatic HTTPS Rewrites | Activé | |
|
||||
| HSTS | **Activé après vérification** | Voir l'avertissement ci-dessous. |
|
||||
|
||||
> **HSTS est irréversible pendant un an.** Ne l'activez qu'une fois certain que
|
||||
> *tous* les sous-domaines servent bien du HTTPS valide. Traefik pose déjà
|
||||
> l'en-tête (`k8s/base/08-traefik-middlewares.yaml`) ; l'activer aussi côté
|
||||
> Cloudflare ajoute la protection avant même que la requête n'atteigne le
|
||||
> serveur.
|
||||
|
||||
---
|
||||
|
||||
## 3. Règles WAF
|
||||
|
||||
### 3.1 Bloquer l'accès direct aux chemins d'administration
|
||||
|
||||
```
|
||||
(http.host eq "api.xpeditis.com" and starts_with(http.request.uri.path, "/api/docs"))
|
||||
```
|
||||
→ Action : **Block**
|
||||
|
||||
La documentation Swagger est déjà désactivée en production par `main.ts`, mais
|
||||
une règle de bordure protège même en cas d'erreur de configuration.
|
||||
|
||||
### 3.2 Limiter les tentatives de connexion
|
||||
|
||||
Rate limiting (Security → WAF → Rate limiting rules) :
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Expression | `starts_with(http.request.uri.path, "/api/v1/auth/login")` |
|
||||
| Requêtes | 10 |
|
||||
| Période | 1 minute |
|
||||
| Par | Adresse IP |
|
||||
| Action | Block, 10 minutes |
|
||||
|
||||
Cette limite s'ajoute à celle de Traefik (`rate-limit-auth`) et à celle de
|
||||
NestJS (`CustomThrottlerGuard`). Trois couches indépendantes : une erreur de
|
||||
configuration sur l'une ne laisse pas la porte ouverte.
|
||||
|
||||
### 3.3 Protéger le webhook Stripe
|
||||
|
||||
```
|
||||
(http.host eq "api.xpeditis.com" and starts_with(http.request.uri.path, "/api/v1/subscriptions/webhook") and not ip.src in {IP_STRIPE})
|
||||
```
|
||||
→ Action : **Block**
|
||||
|
||||
Les rangs d'IP Stripe sont publiés sur
|
||||
`https://stripe.com/files/ips/ips_webhooks.txt`. La signature du webhook est
|
||||
déjà vérifiée côté applicatif ; ceci évite simplement que du bruit atteigne
|
||||
l'application.
|
||||
|
||||
### 3.4 Filtrage géographique — à décider consciemment
|
||||
|
||||
Xpeditis sert des transitaires européens. Bloquer tout sauf l'Europe réduit
|
||||
massivement le bruit, mais coupe aussi les transitaires asiatiques légitimes
|
||||
(les connecteurs transporteurs partent du serveur, ils ne sont pas concernés).
|
||||
|
||||
**Recommandation** : ne pas bloquer par pays au lancement. Activer plutôt
|
||||
« Bot Fight Mode » (gratuit) et surveiller les journaux un mois avant de
|
||||
décider.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cache
|
||||
|
||||
| Chemin | Règle |
|
||||
|---|---|
|
||||
| `api.xpeditis.com/*` | **Bypass cache** — une réponse d'API mise en cache servirait les données d'un utilisateur à un autre. |
|
||||
| `app.xpeditis.com/_next/static/*` | Cache Everything, Edge TTL 1 an (contenu au nom versionné). |
|
||||
| `app.xpeditis.com/*` | Standard (Next.js gère ses propres en-têtes). |
|
||||
|
||||
> La règle de bypass sur l'API est **critique**. Un cache mal placé sur une
|
||||
> route authentifiée est une fuite de données, pas un problème de performance.
|
||||
|
||||
---
|
||||
|
||||
## 5. Jeton API pour cert-manager
|
||||
|
||||
`My Profile → API Tokens → Create Token → Edit zone DNS`
|
||||
|
||||
| Permission | Portée |
|
||||
|---|---|
|
||||
| Zone / DNS / Edit | Zone **xpeditis.com** uniquement |
|
||||
| Zone / Zone / Read | Zone **xpeditis.com** uniquement |
|
||||
|
||||
N'utilisez **jamais** la clé globale du compte : elle permettrait à
|
||||
cert-manager — ou à quiconque lirait le Secret — de rediriger tout votre trafic.
|
||||
|
||||
Le jeton alimente le Secret `cert-manager/cloudflare-api-token`
|
||||
(`k8s/base/03-secrets.template.yaml`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Accès à Grafana
|
||||
|
||||
Deux options, au choix :
|
||||
|
||||
- **Liste d'IP** (par défaut) — middleware Traefik `admin-ip-allowlist`,
|
||||
à renseigner dans `k8s/base/08-traefik-middlewares.yaml`.
|
||||
- **Cloudflare Access** (Zero Trust, gratuit jusqu'à 50 utilisateurs) —
|
||||
authentification par e-mail à usage unique devant `grafana.xpeditis.com`.
|
||||
Préférable si votre IP est dynamique.
|
||||
|
||||
---
|
||||
|
||||
## 7. Vérification
|
||||
|
||||
```bash
|
||||
# Le proxy est-il bien actif ? (l'IP retournée doit être une IP Cloudflare)
|
||||
dig +short app.xpeditis.com
|
||||
|
||||
# L'origine est-elle injoignable en direct ?
|
||||
curl -sS --max-time 5 --connect-to app.xpeditis.com:443:<IP_APP_01>:443 \
|
||||
https://app.xpeditis.com/ # doit échouer par timeout
|
||||
|
||||
# SPF / DKIM / DMARC
|
||||
dig +short TXT xpeditis.com
|
||||
dig +short TXT _dmarc.xpeditis.com
|
||||
```
|
||||
29
infra/prod/data-node/Dockerfile.postgres
Normal file
29
infra/prod/data-node/Dockerfile.postgres
Normal file
@ -0,0 +1,29 @@
|
||||
# =============================================================================
|
||||
# PostgreSQL 15 + WAL-G
|
||||
# =============================================================================
|
||||
# Image postgres officielle enrichie de WAL-G, qui assure :
|
||||
# - l'archivage continu des WAL vers Hetzner Object Storage (archive_command)
|
||||
# - les sauvegardes physiques completes (basebackup)
|
||||
# - la restauration a un instant T (PITR)
|
||||
#
|
||||
# On epingle une version precise de WAL-G : une mise a jour silencieuse de
|
||||
# l'outil de sauvegarde est exactement ce qu'on ne veut pas decouvrir un jour
|
||||
# de restauration.
|
||||
|
||||
FROM postgres:15-bookworm
|
||||
|
||||
ARG WALG_VERSION=v3.0.5
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends ca-certificates curl; \
|
||||
curl -fsSL -o /tmp/wal-g.tar.gz \
|
||||
"https://github.com/wal-g/wal-g/releases/download/${WALG_VERSION}/wal-g-pg-ubuntu-22.04-${TARGETARCH}.tar.gz"; \
|
||||
tar -xzf /tmp/wal-g.tar.gz -C /tmp; \
|
||||
mv "/tmp/wal-g-pg-ubuntu-22.04-${TARGETARCH}" /usr/local/bin/wal-g; \
|
||||
chmod 0755 /usr/local/bin/wal-g; \
|
||||
rm -rf /tmp/wal-g.tar.gz /var/lib/apt/lists/*; \
|
||||
wal-g --version
|
||||
|
||||
# Les scripts de sauvegarde / restauration sont montes depuis backup/.
|
||||
134
infra/prod/data-node/backup/pg-backup.sh
Executable file
134
infra/prod/data-node/backup/pg-backup.sh
Executable file
@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Sauvegarde PostgreSQL - production Xpeditis
|
||||
# =============================================================================
|
||||
# Deux mecanismes complementaires, volontairement redondants :
|
||||
#
|
||||
# 1. WAL-G basebackup + archivage WAL continu -> Hetzner Object Storage
|
||||
# Objectif : restauration a un instant T (PITR). RPO <= 5 min.
|
||||
# C'est la sauvegarde qui compte en cas de corruption ou d'erreur humaine
|
||||
# ("j'ai supprime les bookings de mars").
|
||||
#
|
||||
# 2. pg_dump logique chiffre -> Hetzner Storage Box (autre service, autre
|
||||
# credential, autre protocole).
|
||||
# Objectif : survivre a une compromission du compte Object Storage, a un
|
||||
# bug WAL-G, ou a une montee de version majeure de PostgreSQL.
|
||||
#
|
||||
# Regle des 3-2-1 : 3 copies (volume + Object Storage + Storage Box), 2 supports
|
||||
# distincts, 1 hors du perimetre de la VM. Cf. 12-sauvegardes-restauration.md.
|
||||
#
|
||||
# Execution : timer systemd `xpeditis-backup.timer`, tous les jours a 02h30.
|
||||
set -euo pipefail
|
||||
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
|
||||
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
|
||||
DUMP_DIR="${DUMP_DIR:-/var/lib/xpeditis/dumps}"
|
||||
LOG_TAG="xpeditis-backup"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
|
||||
log() { logger -t "$LOG_TAG" -- "$*"; printf '[%s] %s\n' "$(date -Is)" "$*"; }
|
||||
fail() { log "ECHEC: $*"; notify "ECHEC sauvegarde PostgreSQL" "$*"; exit 1; }
|
||||
|
||||
notify() {
|
||||
# Alerte Discord (meme webhook que la CI/CD). Une sauvegarde qui echoue en
|
||||
# silence est pire que pas de sauvegarde du tout.
|
||||
local title="$1" body="$2"
|
||||
[[ -n "${DISCORD_WEBHOOK_URL:-}" ]] || return 0
|
||||
curl -sf -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg t "$title" --arg d "$body" \
|
||||
'{embeds:[{title:$t,description:$d,color:15158332}]}')" \
|
||||
"$DISCORD_WEBHOOK_URL" >/dev/null || true
|
||||
}
|
||||
|
||||
dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; }
|
||||
|
||||
# --- 0. Preconditions --------------------------------------------------------
|
||||
command -v age >/dev/null || fail "age n'est pas installe (chiffrement des dumps)"
|
||||
[[ -n "${BACKUP_AGE_RECIPIENT:-}" ]] || fail "BACKUP_AGE_RECIPIENT absent de .env.data"
|
||||
mkdir -p "$DUMP_DIR"
|
||||
|
||||
dc ps --status running --services | grep -qx postgres \
|
||||
|| fail "le conteneur postgres n'est pas demarre"
|
||||
|
||||
# --- 1. Sauvegarde physique WAL-G (base de la PITR) --------------------------
|
||||
log "WAL-G basebackup : demarrage"
|
||||
if ! dc exec -T -u postgres postgres wal-g backup-push /var/lib/postgresql/data/pgdata; then
|
||||
fail "wal-g backup-push a echoue"
|
||||
fi
|
||||
log "WAL-G basebackup : termine"
|
||||
|
||||
# --- 2. Retention WAL-G ------------------------------------------------------
|
||||
# 7 sauvegardes completes conservees (soit ~1 semaine de PITR a raison d'une
|
||||
# par jour), les WAL anterieurs sont purges avec elles.
|
||||
log "WAL-G : purge des sauvegardes au-dela de 7 retentions"
|
||||
dc exec -T -u postgres postgres wal-g delete retain FULL 7 --confirm \
|
||||
|| log "AVERTISSEMENT: la purge WAL-G a echoue (non bloquant)"
|
||||
|
||||
# --- 3. Dump logique chiffre -------------------------------------------------
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
DUMP_NAME="xpeditis-${STAMP}.dump"
|
||||
DUMP_PATH="${DUMP_DIR}/${DUMP_NAME}"
|
||||
|
||||
log "pg_dump logique : demarrage"
|
||||
# -Fc = format custom : compresse, restaurable table par table avec pg_restore.
|
||||
if ! dc exec -T -u postgres postgres \
|
||||
pg_dump -Fc -Z6 --no-owner --no-privileges \
|
||||
-d "$POSTGRES_DB" > "$DUMP_PATH"; then
|
||||
rm -f "$DUMP_PATH"
|
||||
fail "pg_dump a echoue"
|
||||
fi
|
||||
|
||||
DUMP_SIZE=$(stat -c%s "$DUMP_PATH")
|
||||
# Un dump anormalement petit signale une base vide ou une erreur silencieuse.
|
||||
if (( DUMP_SIZE < 51200 )); then
|
||||
rm -f "$DUMP_PATH"
|
||||
fail "dump suspect : ${DUMP_SIZE} octets (< 50 Ko)"
|
||||
fi
|
||||
log "pg_dump : ${DUMP_SIZE} octets"
|
||||
|
||||
log "Chiffrement age"
|
||||
age -r "$BACKUP_AGE_RECIPIENT" -o "${DUMP_PATH}.age" "$DUMP_PATH" \
|
||||
|| fail "chiffrement age echoue"
|
||||
# Le dump en clair ne survit pas a la minute : seule la version chiffree part
|
||||
# sur le reseau et reste sur disque.
|
||||
shred -u "$DUMP_PATH" 2>/dev/null || rm -f "$DUMP_PATH"
|
||||
sha256sum "${DUMP_PATH}.age" > "${DUMP_PATH}.age.sha256"
|
||||
|
||||
# --- 4. Copie vers la Storage Box -------------------------------------------
|
||||
if [[ -n "${STORAGEBOX_HOST:-}" ]]; then
|
||||
log "Envoi vers la Storage Box ${STORAGEBOX_HOST}"
|
||||
rsync -a --protect-args \
|
||||
-e "ssh -p ${STORAGEBOX_PORT:-23} -i ${STORAGEBOX_SSH_KEY:-/root/.ssh/storagebox} -o StrictHostKeyChecking=yes" \
|
||||
"${DUMP_PATH}.age" "${DUMP_PATH}.age.sha256" \
|
||||
"${STORAGEBOX_USER}@${STORAGEBOX_HOST}:./xpeditis-prod/dumps/" \
|
||||
|| fail "rsync vers la Storage Box a echoue"
|
||||
else
|
||||
log "AVERTISSEMENT: STORAGEBOX_HOST non configure, pas de seconde copie hors VM"
|
||||
fi
|
||||
|
||||
# --- 5. Retention locale -----------------------------------------------------
|
||||
# 7 jours en local : de quoi restaurer vite sans re-telecharger. La retention
|
||||
# longue (30 jours + 12 mensuels) vit sur la Storage Box.
|
||||
find "$DUMP_DIR" -name 'xpeditis-*.dump.age*' -mtime +7 -delete
|
||||
log "Retention locale appliquee (7 jours)"
|
||||
|
||||
# --- 6. Compte rendu ---------------------------------------------------------
|
||||
LATEST=$(dc exec -T -u postgres postgres wal-g backup-list --detail 2>/dev/null | tail -n 1 || echo "?")
|
||||
log "Sauvegarde terminee. Derniere sauvegarde WAL-G: ${LATEST}"
|
||||
|
||||
# Battement de coeur externe : c'est le SILENCE qui declenche l'alerte. Une
|
||||
# supervision hebergee sur cette meme machine ne dirait rien si la machine
|
||||
# etait eteinte -- exactement le cas ou l'alerte compte.
|
||||
if [[ -n "${BACKUP_HEARTBEAT_URL:-}" ]]; then
|
||||
curl -fsS --max-time 10 --retry 3 "$BACKUP_HEARTBEAT_URL" >/dev/null \
|
||||
|| log "AVERTISSEMENT: le battement de coeur n'a pas pu etre envoye"
|
||||
fi
|
||||
|
||||
if [[ -n "${DISCORD_WEBHOOK_URL:-}" ]]; then
|
||||
curl -sf -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg d "Dump: ${DUMP_NAME}.age ($(numfmt --to=iec "$DUMP_SIZE"))" \
|
||||
'{embeds:[{title:"Sauvegarde PostgreSQL OK",description:$d,color:3066993}]}')" \
|
||||
"$DISCORD_WEBHOOK_URL" >/dev/null || true
|
||||
fi
|
||||
192
infra/prod/data-node/backup/pg-restore.sh
Executable file
192
infra/prod/data-node/backup/pg-restore.sh
Executable file
@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Restauration PostgreSQL - production Xpeditis
|
||||
# =============================================================================
|
||||
# Trois modes, du moins au plus destructeur :
|
||||
#
|
||||
# verify <fichier.dump.age> Restaure dans une base jetable et controle
|
||||
# la coherence. AUCUN impact sur la prod.
|
||||
# C'est ce que lance le timer hebdomadaire.
|
||||
#
|
||||
# logical <fichier.dump.age> Restaure un dump logique dans une base
|
||||
# nommee (par defaut une base de secours).
|
||||
#
|
||||
# pitr <YYYY-MM-DD HH:MM:SS> Restauration a un instant T via WAL-G.
|
||||
# DETRUIT le repertoire de donnees courant.
|
||||
# Reserve aux incidents majeurs.
|
||||
#
|
||||
# Lire docs/mise-en-prod/12-sauvegardes-restauration.md AVANT d'utiliser `pitr`.
|
||||
set -euo pipefail
|
||||
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
|
||||
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
|
||||
PGDATA_HOST="${PGDATA_HOST:-/var/lib/xpeditis/pgdata}"
|
||||
|
||||
# shellcheck disable=SC1090
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
|
||||
log() { printf '\n[%s] %s\n' "$(date -Is)" "$*"; }
|
||||
fail() { printf '\nECHEC: %s\n' "$*" >&2; exit 1; }
|
||||
dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; }
|
||||
|
||||
confirm() {
|
||||
local prompt="$1" expected="$2" answer
|
||||
printf '\n%s\nTapez exactement "%s" pour confirmer : ' "$prompt" "$expected"
|
||||
read -r answer
|
||||
[[ "$answer" == "$expected" ]] || fail "confirmation refusee"
|
||||
}
|
||||
|
||||
decrypt_dump() {
|
||||
local encrypted="$1" out="$2"
|
||||
[[ -f "$encrypted" ]] || fail "fichier introuvable : $encrypted"
|
||||
[[ -f "${BACKUP_AGE_KEY_FILE:-/root/.config/xpeditis/backup-age.key}" ]] \
|
||||
|| fail "cle de dechiffrement absente (BACKUP_AGE_KEY_FILE)"
|
||||
age -d -i "${BACKUP_AGE_KEY_FILE:-/root/.config/xpeditis/backup-age.key}" \
|
||||
-o "$out" "$encrypted" || fail "dechiffrement age echoue"
|
||||
}
|
||||
|
||||
MODE="${1:-}"
|
||||
|
||||
case "$MODE" in
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
verify)
|
||||
ENCRYPTED="${2:-$(ls -1t /var/lib/xpeditis/dumps/xpeditis-*.dump.age 2>/dev/null | head -1)}"
|
||||
[[ -n "$ENCRYPTED" ]] || fail "aucun dump trouve"
|
||||
SCRATCH_DB="xpeditis_restore_check"
|
||||
TMP_DUMP="/tmp/xpeditis-verify-$$.dump"
|
||||
|
||||
log "Verification de : $ENCRYPTED"
|
||||
decrypt_dump "$ENCRYPTED" "$TMP_DUMP"
|
||||
trap 'rm -f "$TMP_DUMP"' EXIT
|
||||
|
||||
log "Creation de la base jetable ${SCRATCH_DB}"
|
||||
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE IF EXISTS ${SCRATCH_DB};"
|
||||
dc exec -T -u postgres postgres psql -q -c "CREATE DATABASE ${SCRATCH_DB};"
|
||||
|
||||
log "pg_restore dans la base jetable"
|
||||
dc exec -T -u postgres postgres pg_restore \
|
||||
--no-owner --no-privileges --exit-on-error -d "$SCRATCH_DB" < "$TMP_DUMP" \
|
||||
|| fail "pg_restore a echoue : LA SAUVEGARDE EST INEXPLOITABLE"
|
||||
|
||||
log "Controles de coherence"
|
||||
# Une sauvegarde qui se restaure mais ne contient rien ne vaut rien : on
|
||||
# verifie que les tables metier critiques sont peuplees.
|
||||
dc exec -T -u postgres postgres psql -d "$SCRATCH_DB" -v ON_ERROR_STOP=1 <<'SQL'
|
||||
\pset pager off
|
||||
SELECT 'tables' AS objet, count(*) AS n FROM information_schema.tables WHERE table_schema='public';
|
||||
SELECT 'users' AS objet, count(*) AS n FROM users;
|
||||
SELECT 'organizations' AS objet, count(*) AS n FROM organizations;
|
||||
SELECT 'csv_bookings' AS objet, count(*) AS n FROM csv_bookings;
|
||||
SELECT 'migrations' AS objet, count(*) AS n FROM migrations;
|
||||
DO $$
|
||||
DECLARE t int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO t FROM information_schema.tables WHERE table_schema='public';
|
||||
IF t < 10 THEN
|
||||
RAISE EXCEPTION 'Sauvegarde suspecte : seulement % tables restaurees', t;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
log "Nettoyage"
|
||||
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE ${SCRATCH_DB};"
|
||||
log "SAUVEGARDE VALIDE : $ENCRYPTED"
|
||||
;;
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
logical)
|
||||
ENCRYPTED="${2:?usage: $0 logical <fichier.dump.age> [base_cible]}"
|
||||
TARGET_DB="${3:-${POSTGRES_DB}_restored}"
|
||||
TMP_DUMP="/tmp/xpeditis-restore-$$.dump"
|
||||
|
||||
confirm "Restauration logique de $ENCRYPTED dans la base '${TARGET_DB}'. La base cible sera SUPPRIMEE si elle existe." "RESTAURER ${TARGET_DB}"
|
||||
|
||||
decrypt_dump "$ENCRYPTED" "$TMP_DUMP"
|
||||
trap 'rm -f "$TMP_DUMP"' EXIT
|
||||
|
||||
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE IF EXISTS ${TARGET_DB};"
|
||||
dc exec -T -u postgres postgres psql -q -c "CREATE DATABASE ${TARGET_DB};"
|
||||
dc exec -T -u postgres postgres pg_restore \
|
||||
--no-owner --no-privileges --exit-on-error -j 2 -d "$TARGET_DB" < "$TMP_DUMP" \
|
||||
|| fail "pg_restore a echoue"
|
||||
|
||||
log "Restauration terminee dans '${TARGET_DB}'."
|
||||
log "La base de production n'a PAS ete touchee. Pour basculer :"
|
||||
log " 1. arreter le backend : kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0"
|
||||
log " 2. renommer les bases : ALTER DATABASE ${POSTGRES_DB} RENAME TO ${POSTGRES_DB}_old;"
|
||||
log " ALTER DATABASE ${TARGET_DB} RENAME TO ${POSTGRES_DB};"
|
||||
log " 3. relancer le backend : kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2"
|
||||
;;
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
pitr)
|
||||
TARGET_TIME="${2:?usage: $0 pitr \"YYYY-MM-DD HH:MM:SS\"}"
|
||||
|
||||
cat <<BANNER
|
||||
|
||||
############################################################
|
||||
# RESTAURATION A UN INSTANT T (PITR) #
|
||||
# #
|
||||
# Cette operation DETRUIT le repertoire de donnees actuel #
|
||||
# et rejoue les WAL jusqu'a : #
|
||||
# ${TARGET_TIME}
|
||||
# #
|
||||
# Prerequis : #
|
||||
# - backend arrete (replicas=0) : sinon ecritures perdues #
|
||||
# - un dump logique frais pris AVANT (voir plus bas) #
|
||||
############################################################
|
||||
|
||||
BANNER
|
||||
confirm "Confirmez-vous la destruction de ${PGDATA_HOST} ?" "DETRUIRE ET RESTAURER"
|
||||
|
||||
log "Sauvegarde de securite du repertoire courant (au cas ou)"
|
||||
SAFETY="/var/lib/xpeditis/pgdata-avant-pitr-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
dc stop postgres
|
||||
mv "${PGDATA_HOST}/pgdata" "$SAFETY" || fail "impossible de deplacer le repertoire courant"
|
||||
mkdir -p "${PGDATA_HOST}/pgdata"
|
||||
chown 999:999 "${PGDATA_HOST}/pgdata"
|
||||
chmod 700 "${PGDATA_HOST}/pgdata"
|
||||
log "Ancien repertoire conserve dans ${SAFETY} (a supprimer une fois la restauration validee)"
|
||||
|
||||
log "wal-g backup-fetch (derniere sauvegarde complete)"
|
||||
dc run --rm --no-deps -u postgres postgres \
|
||||
wal-g backup-fetch /var/lib/postgresql/data/pgdata LATEST \
|
||||
|| fail "backup-fetch a echoue"
|
||||
|
||||
log "Configuration du rejeu des WAL jusqu'a ${TARGET_TIME}"
|
||||
dc run --rm --no-deps -u postgres postgres bash -c "
|
||||
set -e
|
||||
D=/var/lib/postgresql/data/pgdata
|
||||
touch \$D/recovery.signal
|
||||
cat >> \$D/postgresql.auto.conf <<CONF
|
||||
restore_command = 'wal-g wal-fetch \"%f\" \"%p\"'
|
||||
recovery_target_time = '${TARGET_TIME}'
|
||||
recovery_target_action = 'promote'
|
||||
recovery_target_timeline = 'latest'
|
||||
CONF
|
||||
" || fail "configuration de la restauration echouee"
|
||||
|
||||
log "Redemarrage de PostgreSQL : le rejeu des WAL commence"
|
||||
dc up -d postgres
|
||||
|
||||
log "Suivez la progression : docker compose logs -f postgres"
|
||||
log "La base sort du mode restauration quand 'database system is ready to accept connections' apparait."
|
||||
log "Verifiez ENSUITE les donnees avant de relancer le backend."
|
||||
;;
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
*)
|
||||
cat <<USAGE
|
||||
Usage:
|
||||
$0 verify [fichier.dump.age] Test de restauration sans impact
|
||||
$0 logical <fichier.dump.age> [base] Restauration logique dans une base a part
|
||||
$0 pitr "YYYY-MM-DD HH:MM:SS" Restauration a un instant T (DESTRUCTIF)
|
||||
|
||||
Lister les sauvegardes disponibles :
|
||||
ls -lht /var/lib/xpeditis/dumps/
|
||||
docker compose -f ${COMPOSE_DIR}/docker-compose.data.yml exec -u postgres postgres wal-g backup-list --detail
|
||||
USAGE
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
26
infra/prod/data-node/backup/xpeditis-backup-verify.service
Normal file
26
infra/prod/data-node/backup/xpeditis-backup-verify.service
Normal file
@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=Verification de restaurabilite des sauvegardes Xpeditis
|
||||
Documentation=file:///opt/xpeditis/data-node/backup/pg-restore.sh
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Environment=COMPOSE_DIR=/opt/xpeditis/data-node
|
||||
Environment=ENV_FILE=/opt/xpeditis/data-node/.env.data
|
||||
# `verify` restaure le dernier dump dans une base jetable puis la supprime.
|
||||
# Une sauvegarde jamais restauree n'est pas une sauvegarde.
|
||||
ExecStart=/opt/xpeditis/data-node/backup/pg-restore.sh verify
|
||||
TimeoutStartSec=2h
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=xpeditis-backup-verify
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
12
infra/prod/data-node/backup/xpeditis-backup-verify.timer
Normal file
12
infra/prod/data-node/backup/xpeditis-backup-verify.timer
Normal file
@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Verification hebdomadaire des sauvegardes Xpeditis
|
||||
|
||||
[Timer]
|
||||
# Dimanche 04h00, apres la sauvegarde de la nuit.
|
||||
OnCalendar=Sun *-*-* 04:00:00
|
||||
RandomizedDelaySec=600
|
||||
Persistent=true
|
||||
Unit=xpeditis-backup-verify.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
29
infra/prod/data-node/backup/xpeditis-backup.service
Normal file
29
infra/prod/data-node/backup/xpeditis-backup.service
Normal file
@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=Sauvegarde PostgreSQL Xpeditis (WAL-G + dump logique chiffre)
|
||||
Documentation=file:///opt/xpeditis/data-node/backup/pg-backup.sh
|
||||
After=docker.service network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Environment=COMPOSE_DIR=/opt/xpeditis/data-node
|
||||
Environment=ENV_FILE=/opt/xpeditis/data-node/.env.data
|
||||
ExecStart=/opt/xpeditis/data-node/backup/pg-backup.sh
|
||||
TimeoutStartSec=3h
|
||||
|
||||
# Le script ecrit dans /var/lib/xpeditis/dumps et pilote Docker : on ne peut pas
|
||||
# aller beaucoup plus loin dans le confinement sans le casser.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=xpeditis-backup
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
16
infra/prod/data-node/backup/xpeditis-backup.timer
Normal file
16
infra/prod/data-node/backup/xpeditis-backup.timer
Normal file
@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Sauvegarde PostgreSQL Xpeditis - quotidienne
|
||||
Documentation=file:///opt/xpeditis/data-node/backup/pg-backup.sh
|
||||
|
||||
[Timer]
|
||||
# 02h30 heure de Paris : creux d'activite des transitaires europeens, et avant
|
||||
# la fenetre de redemarrage automatique des mises a jour de securite (04h30).
|
||||
OnCalendar=*-*-* 02:30:00
|
||||
# Etale le declenchement pour ne pas taper l'Object Storage a la seconde pres.
|
||||
RandomizedDelaySec=300
|
||||
# Rattrape la sauvegarde si le serveur etait eteint a l'heure prevue.
|
||||
Persistent=true
|
||||
Unit=xpeditis-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
30
infra/prod/data-node/conf/pg_hba.conf
Normal file
30
infra/prod/data-node/conf/pg_hba.conf
Normal file
@ -0,0 +1,30 @@
|
||||
# =============================================================================
|
||||
# pg_hba.conf - production Xpeditis
|
||||
# =============================================================================
|
||||
# Principe : `hostssl` uniquement. Aucune connexion reseau en clair n'est
|
||||
# possible, meme depuis le reseau prive Hetzner.
|
||||
#
|
||||
# Consequence a connaitre : toute application se connectant a cette base DOIT
|
||||
# negocier TLS. Cote Xpeditis cela signifie DATABASE_SSL=true, honore par
|
||||
# data-source.ts, par le pool applicatif et par scripts/setup/startup.js.
|
||||
#
|
||||
# TYPE DATABASE USER ADDRESS METHOD
|
||||
|
||||
# --- Socket local (administration dans le conteneur, wal-g, pg_dump) ---------
|
||||
local all postgres peer
|
||||
local all all scram-sha-256
|
||||
|
||||
# --- Noeud applicatif (k3s) --------------------------------------------------
|
||||
hostssl all all 10.10.1.10/32 scram-sha-256
|
||||
|
||||
# --- Reseau interne du compose (postgres-exporter) ---------------------------
|
||||
hostssl all all 172.28.0.0/24 scram-sha-256
|
||||
|
||||
# --- Replication (standby futur, phase 2 du plan de charge) ------------------
|
||||
hostssl replication all 10.10.1.0/24 scram-sha-256
|
||||
|
||||
# --- Tout le reste est refuse explicitement ---------------------------------
|
||||
# `reject` plutot qu'une absence de regle : le refus est immediat et journalise,
|
||||
# ce qui rend les tentatives visibles dans Loki.
|
||||
host all all 0.0.0.0/0 reject
|
||||
host all all ::/0 reject
|
||||
108
infra/prod/data-node/conf/postgresql.conf
Normal file
108
infra/prod/data-node/conf/postgresql.conf
Normal file
@ -0,0 +1,108 @@
|
||||
# =============================================================================
|
||||
# PostgreSQL 15 - production Xpeditis
|
||||
# Cible : Hetzner CPX31 (4 vCPU / 8 Go), partage avec Redis (1,5 Go plafonne).
|
||||
# Budget memoire retenu pour PostgreSQL : ~6 Go.
|
||||
# =============================================================================
|
||||
|
||||
# --- Connexions --------------------------------------------------------------
|
||||
listen_addresses = '*' # le confinement reseau est assure par le bind
|
||||
# Docker sur l'IP privee + UFW + pg_hba
|
||||
port = 5432
|
||||
max_connections = 150 # 2 replicas backend x pool TypeORM + marge ops
|
||||
superuser_reserved_connections = 5
|
||||
|
||||
# --- TLS ---------------------------------------------------------------------
|
||||
# Obligatoire : pg_hba n'accepte que des lignes `hostssl`. Le reseau prive
|
||||
# Hetzner n'est pas chiffre par l'hyperviseur, c'est nous qui le chiffrons.
|
||||
ssl = on
|
||||
ssl_cert_file = '/var/lib/xpeditis/certs/server.crt'
|
||||
ssl_key_file = '/var/lib/xpeditis/certs/server.key'
|
||||
ssl_min_protocol_version = 'TLSv1.2'
|
||||
ssl_prefer_server_ciphers = on
|
||||
|
||||
# --- Authentification --------------------------------------------------------
|
||||
password_encryption = scram-sha-256
|
||||
|
||||
# --- Memoire -----------------------------------------------------------------
|
||||
shared_buffers = 2GB
|
||||
effective_cache_size = 5GB
|
||||
maintenance_work_mem = 512MB
|
||||
work_mem = 16MB # par operation de tri/hash, pas par connexion
|
||||
huge_pages = try
|
||||
|
||||
# --- Disque (SSD NVMe Hetzner) ----------------------------------------------
|
||||
random_page_cost = 1.1
|
||||
effective_io_concurrency = 200
|
||||
max_worker_processes = 4
|
||||
max_parallel_workers = 4
|
||||
max_parallel_workers_per_gather = 2
|
||||
max_parallel_maintenance_workers = 2
|
||||
|
||||
# --- WAL / archivage ---------------------------------------------------------
|
||||
wal_level = replica # suffisant pour PITR + futur standby physique
|
||||
wal_compression = on
|
||||
max_wal_size = 4GB
|
||||
min_wal_size = 1GB
|
||||
checkpoint_completion_target = 0.9
|
||||
checkpoint_timeout = 15min
|
||||
|
||||
archive_mode = on
|
||||
archive_command = 'wal-g wal-push %p'
|
||||
# Force la cloture d'un segment WAL toutes les 5 min meme sans activite :
|
||||
# c'est ce qui plafonne le RPO a 5 minutes de donnees perdues au maximum.
|
||||
archive_timeout = 300
|
||||
|
||||
# Conserve de quoi rattacher un standby sans repartir d'un basebackup.
|
||||
max_wal_senders = 5
|
||||
wal_keep_size = 1GB
|
||||
max_replication_slots = 5
|
||||
|
||||
# --- Autovacuum --------------------------------------------------------------
|
||||
# audit_logs et csv_rates subissent beaucoup d'ecritures : on vacuume plus tot
|
||||
# que les valeurs par defaut, sinon les index gonflent et les recherches
|
||||
# tarifaires ralentissent.
|
||||
autovacuum = on
|
||||
autovacuum_max_workers = 3
|
||||
autovacuum_naptime = 30s
|
||||
autovacuum_vacuum_scale_factor = 0.05
|
||||
autovacuum_analyze_scale_factor = 0.02
|
||||
autovacuum_vacuum_cost_limit = 1000
|
||||
|
||||
# --- Delais de garde ---------------------------------------------------------
|
||||
statement_timeout = 300000 # 5 min : borne les requetes folles
|
||||
# tout en laissant passer les
|
||||
# imports CSV de grilles
|
||||
idle_in_transaction_session_timeout = 300000 # 5 min : libere les verrous oublies
|
||||
lock_timeout = 30000
|
||||
tcp_keepalives_idle = 60
|
||||
tcp_keepalives_interval = 10
|
||||
tcp_keepalives_count = 6
|
||||
|
||||
# --- Observabilite -----------------------------------------------------------
|
||||
shared_preload_libraries = 'pg_stat_statements'
|
||||
pg_stat_statements.max = 5000
|
||||
pg_stat_statements.track = top
|
||||
track_io_timing = on
|
||||
track_functions = pl
|
||||
|
||||
logging_collector = off # les logs partent sur stdout -> Docker -> Loki
|
||||
log_destination = 'stderr'
|
||||
log_min_duration_statement = 500 # trace toute requete > 500 ms
|
||||
log_line_prefix = '%m [%p] %q%u@%d %a '
|
||||
log_checkpoints = on
|
||||
log_connections = on
|
||||
log_disconnections = on
|
||||
log_lock_waits = on
|
||||
log_temp_files = 0
|
||||
log_autovacuum_min_duration = 1000
|
||||
log_statement = 'ddl' # trace les changements de schema (migrations)
|
||||
log_timezone = 'Europe/Paris'
|
||||
|
||||
# --- Localisation ------------------------------------------------------------
|
||||
timezone = 'Europe/Paris'
|
||||
datestyle = 'iso, mdy'
|
||||
lc_messages = 'C'
|
||||
lc_monetary = 'C'
|
||||
lc_numeric = 'C'
|
||||
lc_time = 'C'
|
||||
default_text_search_config = 'pg_catalog.french'
|
||||
85
infra/prod/data-node/conf/redis.conf
Normal file
85
infra/prod/data-node/conf/redis.conf
Normal file
@ -0,0 +1,85 @@
|
||||
# =============================================================================
|
||||
# Redis 7 - production Xpeditis
|
||||
# =============================================================================
|
||||
# Le mot de passe n'est PAS ici : il est injecte par --requirepass depuis
|
||||
# .env.data (cf. docker-compose.data.yml). Ce fichier est versionne, pas lui.
|
||||
|
||||
# --- Reseau ------------------------------------------------------------------
|
||||
# Ecoute sur toutes les interfaces DU CONTENEUR ; l'exposition reelle est
|
||||
# limitee par Docker (bind sur l'IP privee) puis par UFW.
|
||||
bind 0.0.0.0
|
||||
protected-mode yes
|
||||
port 6379
|
||||
tcp-backlog 511
|
||||
tcp-keepalive 300
|
||||
timeout 300
|
||||
|
||||
# --- Persistance -------------------------------------------------------------
|
||||
# Redis ne sert pas qu'a cacher les cotations : il porte aussi des donnees de
|
||||
# session et de revocation de jetons. Un redemarrage ne doit pas les perdre,
|
||||
# sinon des jetons revoques redeviendraient valides.
|
||||
dir /data
|
||||
appendonly yes
|
||||
appendfilename "appendonly.aof"
|
||||
appendfsync everysec
|
||||
no-appendfsync-on-rewrite no
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
aof-use-rdb-preamble yes
|
||||
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
dbfilename dump.rdb
|
||||
rdbcompression yes
|
||||
rdbchecksum yes
|
||||
stop-writes-on-bgsave-error yes
|
||||
|
||||
# --- Memoire -----------------------------------------------------------------
|
||||
# Estimation Excel : 0,5 Go a 100 utilisateurs. On plafonne a 1 Go, soit 2x de
|
||||
# marge, sous la limite conteneur de 1,5 Go.
|
||||
#
|
||||
# volatile-lru et non allkeys-lru : seules les cles portant un TTL peuvent etre
|
||||
# evincees. Les cotations tarifaires (TTL 15 min) sont donc sacrifiables, ce qui
|
||||
# est le comportement voulu. Surveiller quand meme used_memory : une eviction
|
||||
# subie sur une cle de revocation prolongerait la validite d'un jeton revoque.
|
||||
# Alerte Grafana a 75 % (cf. k8s/monitoring/).
|
||||
maxmemory 1gb
|
||||
maxmemory-policy volatile-lru
|
||||
maxmemory-samples 5
|
||||
|
||||
lazyfree-lazy-eviction yes
|
||||
lazyfree-lazy-expire yes
|
||||
lazyfree-lazy-server-del yes
|
||||
|
||||
# --- Durcissement ------------------------------------------------------------
|
||||
# Les commandes destructrices ou de reconnaissance sont RENOMMEES, pas
|
||||
# supprimees : une injection cote application ne peut plus les atteindre, mais
|
||||
# un operateur qui connait le nom garde une porte de sortie en incident.
|
||||
#
|
||||
# Consequence connue : RedisCacheAdapter.clear() (redis-cache.adapter.ts:133)
|
||||
# appelle FLUSHDB et echouera donc en production. C'est volontaire -- cette
|
||||
# methode n'a aucun appelant dans le code applicatif et vider le cache de prod
|
||||
# depuis une requete HTTP ne doit jamais etre possible. Pour vider le cache
|
||||
# manuellement, utiliser le nom renomme ci-dessous depuis db-01.
|
||||
#
|
||||
# Ces noms sont publics dans Git : ils protegent d'une injection, pas d'un
|
||||
# attaquant ayant deja le mot de passe Redis ET l'acces reseau. Si vous voulez
|
||||
# une vraie defense, remplacez-les par des chaines aleatoires et notez-les dans
|
||||
# le coffre-fort.
|
||||
rename-command FLUSHALL XPD_FLUSHALL_9c1f
|
||||
rename-command FLUSHDB XPD_FLUSHDB_9c1f
|
||||
rename-command KEYS XPD_KEYS_9c1f
|
||||
rename-command CONFIG XPD_CONFIG_9c1f
|
||||
rename-command DEBUG ""
|
||||
rename-command MODULE ""
|
||||
|
||||
# --- Journalisation ----------------------------------------------------------
|
||||
logfile ""
|
||||
loglevel notice
|
||||
|
||||
# --- Divers ------------------------------------------------------------------
|
||||
databases 16
|
||||
slowlog-log-slower-than 10000
|
||||
slowlog-max-len 128
|
||||
latency-monitor-threshold 100
|
||||
187
infra/prod/data-node/docker-compose.data.yml
Normal file
187
infra/prod/data-node/docker-compose.data.yml
Normal file
@ -0,0 +1,187 @@
|
||||
# =============================================================================
|
||||
# Noeud de donnees Xpeditis - production
|
||||
# =============================================================================
|
||||
# Deploye sur db-01 uniquement, dans /opt/xpeditis/data-node.
|
||||
#
|
||||
# docker compose -f docker-compose.data.yml --env-file .env.data up -d
|
||||
#
|
||||
# Regles non negociables appliquees ici :
|
||||
# - Aucun port publie sur 0.0.0.0 : bind explicite sur l'IP privee.
|
||||
# - Aucun mot de passe en dur : tout vient de .env.data (chiffre SOPS en Git).
|
||||
# - PostgreSQL exige TLS (hostssl) pour toute connexion venant du reseau.
|
||||
# - Les conteneurs ne peuvent pas escalader leurs privileges.
|
||||
|
||||
name: xpeditis-data
|
||||
|
||||
services:
|
||||
postgres:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.postgres
|
||||
image: xpeditis/postgres-walg:15
|
||||
container_name: xpeditis-postgres
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 60s
|
||||
|
||||
# Bind sur l'IP privee : injoignable depuis Internet, meme si UFW tombe.
|
||||
ports:
|
||||
- "${PRIVATE_IP}:5432:5432"
|
||||
|
||||
environment:
|
||||
POSTGRES_DB: "${POSTGRES_DB}"
|
||||
POSTGRES_USER: "${POSTGRES_USER}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
# scram-sha-256 pour tous les mots de passe (jamais md5).
|
||||
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256 --data-checksums"
|
||||
TZ: Europe/Paris
|
||||
|
||||
# --- WAL-G : archivage continu vers Hetzner Object Storage --------------
|
||||
WALG_S3_PREFIX: "${WALG_S3_PREFIX}"
|
||||
AWS_ACCESS_KEY_ID: "${WALG_ACCESS_KEY_ID}"
|
||||
AWS_SECRET_ACCESS_KEY: "${WALG_SECRET_ACCESS_KEY}"
|
||||
AWS_ENDPOINT: "${WALG_S3_ENDPOINT}"
|
||||
AWS_REGION: "${WALG_S3_REGION}"
|
||||
AWS_S3_FORCE_PATH_STYLE: "true"
|
||||
# Chiffrement cote client : meme si le bucket fuite, les sauvegardes
|
||||
# restent illisibles sans la cle privee libsodium.
|
||||
WALG_LIBSODIUM_KEY: "${WALG_LIBSODIUM_KEY}"
|
||||
WALG_COMPRESSION_METHOD: brotli
|
||||
WALG_DELTA_MAX_STEPS: "6"
|
||||
WALG_UPLOAD_CONCURRENCY: "2"
|
||||
PGHOST: /var/run/postgresql
|
||||
|
||||
volumes:
|
||||
- /var/lib/xpeditis/pgdata:/var/lib/postgresql/data
|
||||
- ./conf/postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
- ./conf/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
|
||||
- /var/lib/xpeditis/certs:/var/lib/xpeditis/certs:ro
|
||||
- ./backup:/opt/backup:ro
|
||||
- /var/lib/xpeditis/dumps:/var/lib/xpeditis/dumps
|
||||
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- config_file=/etc/postgresql/postgresql.conf
|
||||
- -c
|
||||
- hba_file=/etc/postgresql/pg_hba.conf
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h /var/run/postgresql"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 30s
|
||||
|
||||
networks:
|
||||
- internal
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
shm_size: 512mb
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "3.0"
|
||||
memory: 6g
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
redis:
|
||||
image: redis:7.4-alpine
|
||||
container_name: xpeditis-redis
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
|
||||
ports:
|
||||
- "${PRIVATE_IP}:6379:6379"
|
||||
|
||||
# requirepass passe en ligne de commande : redis.conf ne sait pas lire
|
||||
# de variable d'environnement, et on refuse d'ecrire le mot de passe
|
||||
# dans un fichier versionne.
|
||||
command:
|
||||
- redis-server
|
||||
- /usr/local/etc/redis/redis.conf
|
||||
- --requirepass
|
||||
- "${REDIS_PASSWORD}"
|
||||
|
||||
volumes:
|
||||
- ./conf/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||
- /var/lib/xpeditis/redis:/data
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" --no-auth-warning ping | grep -q PONG"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 10s
|
||||
environment:
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
TZ: Europe/Paris
|
||||
|
||||
networks:
|
||||
- internal
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
sysctls:
|
||||
# Evite les pertes de connexion sous charge sur le backlog TCP.
|
||||
net.core.somaxconn: 1024
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 1500m
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
# Exportateur Prometheus PostgreSQL : alimente les tableaux de bord Grafana
|
||||
# heberges sur app-01 (scrape via le reseau prive).
|
||||
postgres-exporter:
|
||||
image: prometheuscommunity/postgres-exporter:v0.15.0
|
||||
container_name: xpeditis-postgres-exporter
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PRIVATE_IP}:9187:9187"
|
||||
environment:
|
||||
# Connexion par le reseau interne du compose (jamais par l'IP publiee),
|
||||
# en TLS obligatoire comme toute autre connexion reseau.
|
||||
DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=require"
|
||||
networks:
|
||||
- internal
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "0.25"
|
||||
memory: 128m
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "2"
|
||||
|
||||
# Sous-reseau fixe : il est reference explicitement dans pg_hba.conf, il ne doit
|
||||
# donc pas changer d'un `docker compose up` a l'autre.
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/24
|
||||
69
infra/prod/env/data-node.env.example
vendored
Normal file
69
infra/prod/env/data-node.env.example
vendored
Normal file
@ -0,0 +1,69 @@
|
||||
# =============================================================================
|
||||
# Noeud de donnees (db-01) - variables d'environnement
|
||||
# =============================================================================
|
||||
# Sur le serveur : /opt/xpeditis/data-node/.env.data (chmod 600, root:root)
|
||||
# Dans Git : infra/prod/data-node/data-node.sops.env (chiffre SOPS)
|
||||
#
|
||||
# sops -e --input-type dotenv --output-type dotenv \
|
||||
# /opt/xpeditis/data-node/.env.data > data-node/data-node.sops.env
|
||||
# sops -d --input-type dotenv --output-type dotenv \
|
||||
# data-node/data-node.sops.env > /opt/xpeditis/data-node/.env.data
|
||||
#
|
||||
# Generer un secret : openssl rand -base64 32 | tr -d '\n/+=' | head -c 40
|
||||
# Ne JAMAIS reutiliser un secret de preprod : ceux presents dans le depot sont
|
||||
# compromis (cf. docs/mise-en-prod/13-securite-durcissement.md, "Rotation
|
||||
# obligatoire avant la mise en production").
|
||||
|
||||
# --- Reseau ------------------------------------------------------------------
|
||||
# IP privee de db-01 : seule interface sur laquelle Postgres et Redis sont
|
||||
# publies. Doit correspondre a var.db_private_ip cote Terraform.
|
||||
PRIVATE_IP=10.10.1.20
|
||||
|
||||
# --- PostgreSQL --------------------------------------------------------------
|
||||
POSTGRES_DB=xpeditis_prod
|
||||
POSTGRES_USER=xpeditis
|
||||
POSTGRES_PASSWORD=REMPLACER_40_CARACTERES_ALEATOIRES
|
||||
|
||||
# --- Redis -------------------------------------------------------------------
|
||||
REDIS_PASSWORD=REMPLACER_40_CARACTERES_ALEATOIRES
|
||||
|
||||
# --- WAL-G : archivage continu vers Hetzner Object Storage -------------------
|
||||
# Bucket DEDIE aux sauvegardes, distinct du bucket des documents applicatifs,
|
||||
# avec son propre jeu de cles S3. Si les cles applicatives fuitent, les
|
||||
# sauvegardes restent hors de portee.
|
||||
WALG_S3_PREFIX=s3://xpeditis-prod-pgbackup
|
||||
WALG_S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
WALG_S3_REGION=fsn1
|
||||
WALG_ACCESS_KEY_ID=REMPLACER
|
||||
WALG_SECRET_ACCESS_KEY=REMPLACER
|
||||
|
||||
# Chiffrement cote client des sauvegardes physiques.
|
||||
# WAL-G attend une cle de 32 octets encodee en HEXADECIMAL (64 caracteres) :
|
||||
# openssl rand -hex 32
|
||||
# Sans cette cle, aucune restauration n'est possible : conservez-la dans le
|
||||
# coffre-fort ET hors ligne.
|
||||
WALG_LIBSODIUM_KEY=REMPLACER_64_CARACTERES_HEXADECIMAUX
|
||||
|
||||
# --- Chiffrement des dumps logiques (age) ------------------------------------
|
||||
# Cle publique age destinataire des dumps quotidiens.
|
||||
# age-keygen -o backup-age.key && grep 'public key' backup-age.key
|
||||
BACKUP_AGE_RECIPIENT=age1REMPLACER
|
||||
# Chemin de la cle PRIVEE sur db-01, lue par pg-restore.sh.
|
||||
BACKUP_AGE_KEY_FILE=/root/.config/xpeditis/backup-age.key
|
||||
|
||||
# --- Storage Box Hetzner (seconde copie des dumps) ---------------------------
|
||||
# Souscrite separement (BX11 ~4 EUR/mois). Cf. 12-sauvegardes-restauration.md.
|
||||
STORAGEBOX_HOST=uXXXXXX.your-storagebox.de
|
||||
STORAGEBOX_USER=uXXXXXX
|
||||
STORAGEBOX_PORT=23
|
||||
STORAGEBOX_SSH_KEY=/root/.ssh/storagebox
|
||||
|
||||
# --- Alertes -----------------------------------------------------------------
|
||||
# Meme webhook que la CI/CD : une sauvegarde en echec doit se voir.
|
||||
DISCORD_WEBHOOK_URL=
|
||||
|
||||
# Battement de coeur externe appele a chaque sauvegarde reussie
|
||||
# (BetterStack Heartbeats ou healthchecks.io, gratuit). Le service alerte quand
|
||||
# l'appel n'arrive PAS -- c'est ce qui detecte un serveur eteint ou un timer
|
||||
# systemd desactive, ce qu'une supervision interne ne verrait jamais.
|
||||
BACKUP_HEARTBEAT_URL=
|
||||
69
infra/prod/env/github-secrets.md
vendored
Normal file
69
infra/prod/env/github-secrets.md
vendored
Normal file
@ -0,0 +1,69 @@
|
||||
# Secrets et variables GitHub Actions — production
|
||||
|
||||
Dépôt → **Settings → Environments → `production`**.
|
||||
|
||||
Utiliser un *Environment* et non des secrets de dépôt permet d'exiger une
|
||||
approbation manuelle avant tout déploiement, et empêche une branche non
|
||||
protégée d'accéder à ces valeurs.
|
||||
|
||||
> **Activez « Required reviewers » sur l'environnement `production`.**
|
||||
> Sans cela, n'importe quel `push` sur `main` déploie sans validation humaine.
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
| Nom | Contenu | Comment l'obtenir |
|
||||
|---|---|---|
|
||||
| `REGISTRY_TOKEN` | Jeton du registre Scaleway (lecture + écriture) | Console Scaleway → Container Registry → jetons. Déjà utilisé par la preprod. |
|
||||
| `HCLOUD_TOKEN_CICD` | Jeton API Hetzner Cloud | Console Hetzner → Security → API tokens. **Read & Write** (nécessaire pour ouvrir/fermer le firewall CI). Jeton **distinct** de celui de Terraform, pour pouvoir le révoquer seul. |
|
||||
| `PROD_SSH_HOST` | IP publique de app-01 | `make tf-output` |
|
||||
| `PROD_SSH_USER` | `deploy` | |
|
||||
| `PROD_SSH_KEY` | Clé privée SSH **dédiée à la CI** | `ssh-keygen -t ed25519 -a 100 -C "github-actions-prod" -f ci_deploy` puis ajouter la clé publique dans `~deploy/.ssh/authorized_keys` sur app-01. Ne réutilisez pas votre clé personnelle. |
|
||||
| `PROD_SSH_KNOWN_HOSTS` | Empreinte du serveur | `ssh-keyscan -H <IP_APP_01>` — évite qu'un détournement DNS/BGP redirige le déploiement vers une machine tierce. |
|
||||
| `NEXT_PUBLIC_API_URL_PROD` | `https://api.xpeditis.com` | Figé dans l'image frontend au build. |
|
||||
| `NEXT_PUBLIC_APP_URL_PROD` | `https://app.xpeditis.com` | idem |
|
||||
| `DISCORD_WEBHOOK_URL` | Webhook du canal de déploiement | Déjà utilisé par la preprod. |
|
||||
|
||||
---
|
||||
|
||||
## Variables (non sensibles)
|
||||
|
||||
| Nom | Valeur |
|
||||
|---|---|
|
||||
| `PROD_API_URL` | `https://api.xpeditis.com` |
|
||||
| `PROD_APP_URL` | `https://app.xpeditis.com` |
|
||||
| `HCLOUD_CICD_FIREWALL` | `xpeditis-prod-fw-cicd` |
|
||||
|
||||
---
|
||||
|
||||
## Restreindre la clé SSH de la CI
|
||||
|
||||
La clé de déploiement n'a besoin que de lancer un script. Limitez-la dans
|
||||
`~deploy/.ssh/authorized_keys` sur app-01 :
|
||||
|
||||
```
|
||||
restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
|
||||
```
|
||||
|
||||
`restrict` désactive le transfert de ports, d'agent et X11. Le `command=` force
|
||||
l'exécution du wrapper quelle que soit la commande demandée : une clé volée ne
|
||||
donne pas un shell.
|
||||
|
||||
> `rsync` a besoin d'exécuter sa propre commande. Le wrapper l'autorise
|
||||
> explicitement en inspectant `SSH_ORIGINAL_COMMAND` — voir
|
||||
> `scripts/ssh-deploy-wrapper.sh`.
|
||||
|
||||
---
|
||||
|
||||
## Ce qui ne doit PAS être un secret GitHub
|
||||
|
||||
- **La clé privée age / SOPS.** Elle déchiffre *tous* les secrets de production.
|
||||
Les secrets sont appliqués depuis votre poste (`make secrets-apply`), pas
|
||||
depuis la CI. Un dépôt compromis ne doit pas donner les mots de passe de la
|
||||
base.
|
||||
- **Le `kubeconfig`.** L'API Kubernetes n'est ouverte qu'à vos IP
|
||||
d'administration ; la CI passe par SSH et utilise le kubeconfig local du
|
||||
serveur.
|
||||
- **Le token Terraform.** Il peut détruire l'infrastructure. Il reste sur votre
|
||||
poste.
|
||||
30
infra/prod/k8s/base/00-namespaces.yaml
Normal file
30
infra/prod/k8s/base/00-namespaces.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
environment: production
|
||||
# Pod Security Admission en mode "restricted" : refuse tout pod privilegie,
|
||||
# tout conteneur root, toute capability. C'est une barriere du cluster,
|
||||
# independante de ce que contiennent les manifests applicatifs.
|
||||
pod-security.kubernetes.io/enforce: restricted
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
environment: production
|
||||
# "baseline" et non "restricted" : Promtail doit lire les journaux des
|
||||
# conteneurs de l'hote (hostPath), ce que "restricted" interdit.
|
||||
# Le compromis est assume et confine a ce seul namespace.
|
||||
pod-security.kubernetes.io/enforce: baseline
|
||||
pod-security.kubernetes.io/enforce-version: latest
|
||||
pod-security.kubernetes.io/audit: restricted
|
||||
pod-security.kubernetes.io/warn: restricted
|
||||
59
infra/prod/k8s/base/01-limits.yaml
Normal file
59
infra/prod/k8s/base/01-limits.yaml
Normal file
@ -0,0 +1,59 @@
|
||||
# =============================================================================
|
||||
# Garde-fous de consommation
|
||||
# =============================================================================
|
||||
# Le noeud applicatif est un CPX41 (8 vCPU / 16 Go) partage entre l'application,
|
||||
# l'ingress et la supervision. Sans quota, une fuite memoire du backend fait
|
||||
# tomber Traefik et Grafana avec lui : plus d'application ET plus de moyen de
|
||||
# comprendre pourquoi.
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: xpeditis-prod-quota
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "3"
|
||||
requests.memory: 5Gi
|
||||
limits.cpu: "7"
|
||||
limits.memory: 9Gi
|
||||
pods: "20"
|
||||
persistentvolumeclaims: "4"
|
||||
services.loadbalancers: "0" # aucun LB supplementaire facture par erreur
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: LimitRange
|
||||
metadata:
|
||||
name: xpeditis-prod-defaults
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
limits:
|
||||
- type: Container
|
||||
# Un conteneur deploye sans requests/limits (debug, job ponctuel) herite
|
||||
# de valeurs raisonnables au lieu de pouvoir tout consommer.
|
||||
default:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
defaultRequest:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
max:
|
||||
cpu: "4"
|
||||
memory: 4Gi
|
||||
min:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ResourceQuota
|
||||
metadata:
|
||||
name: monitoring-quota
|
||||
namespace: monitoring
|
||||
spec:
|
||||
hard:
|
||||
requests.cpu: "1"
|
||||
requests.memory: 1536Mi
|
||||
limits.cpu: "3"
|
||||
limits.memory: 3Gi
|
||||
pods: "10"
|
||||
99
infra/prod/k8s/base/02-configmap-backend.yaml
Normal file
99
infra/prod/k8s/base/02-configmap-backend.yaml
Normal file
@ -0,0 +1,99 @@
|
||||
# =============================================================================
|
||||
# Configuration NON SECRETE du backend
|
||||
# =============================================================================
|
||||
# Seules figurent ici les variables reellement lues par le code
|
||||
# (verifie dans app.module.ts, security.config.ts et les adaptateurs).
|
||||
# Les identifiants et cles vivent dans le Secret xpeditis-backend-secrets.
|
||||
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: xpeditis-backend-config
|
||||
namespace: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
PORT: "4000"
|
||||
API_PREFIX: "api/v1"
|
||||
# Force la sortie pino en JSON : c'est ce que Promtail sait decouper en
|
||||
# champs (level, context, reqId) pour Loki.
|
||||
LOG_FORMAT: "json"
|
||||
|
||||
# --- URLs publiques --------------------------------------------------------
|
||||
APP_URL: "https://app.xpeditis.com"
|
||||
FRONTEND_URL: "https://app.xpeditis.com"
|
||||
BACKEND_URL: "https://api.xpeditis.com"
|
||||
|
||||
# Liste blanche CORS. `credentials: true` interdit le joker `*` : chaque
|
||||
# origine autorisee doit etre enumeree (security.config.ts).
|
||||
CORS_ORIGIN: "https://app.xpeditis.com,https://www.xpeditis.com,https://xpeditis.com"
|
||||
|
||||
# --- Cookies d'authentification -------------------------------------------
|
||||
# Le point initial rend le cookie valide pour tous les sous-domaines : l'API
|
||||
# (api.xpeditis.com) le pose, le middleware Next.js (app.xpeditis.com) le lit.
|
||||
# Sans cela, la connexion reussit mais l'utilisateur est renvoye sur /login.
|
||||
COOKIE_DOMAIN: ".xpeditis.com"
|
||||
# app et api partagent le meme site : 'lax' suffit et reste le plus sur.
|
||||
# Ne passer a 'none' que si le front devient reellement cross-site (et 'none'
|
||||
# impose Secure).
|
||||
COOKIE_SAMESITE: "lax"
|
||||
|
||||
# --- PostgreSQL (db-01, reseau prive) --------------------------------------
|
||||
DATABASE_HOST: "10.10.1.20"
|
||||
DATABASE_PORT: "5432"
|
||||
DATABASE_NAME: "xpeditis_prod"
|
||||
# pg_hba n'accepte que des lignes `hostssl` : sans cette valeur a "true",
|
||||
# le backend ne peut tout simplement pas se connecter.
|
||||
DATABASE_SSL: "true"
|
||||
DATABASE_LOGGING: "false"
|
||||
|
||||
# --- Redis (db-01, reseau prive) -------------------------------------------
|
||||
REDIS_HOST: "10.10.1.20"
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_DB: "0"
|
||||
|
||||
# --- JWT -------------------------------------------------------------------
|
||||
JWT_ACCESS_EXPIRATION: "15m"
|
||||
JWT_REFRESH_EXPIRATION: "7d"
|
||||
|
||||
# --- SMTP (Brevo) ----------------------------------------------------------
|
||||
SMTP_HOST: "smtp-relay.brevo.com"
|
||||
SMTP_PORT: "587"
|
||||
SMTP_SECURE: "false"
|
||||
SMTP_FROM: "noreply@xpeditis.com"
|
||||
|
||||
# --- Stockage objet (Hetzner Object Storage, compatible S3) ----------------
|
||||
# Aucun changement de code : le SDK AWS parle a Hetzner via AWS_S3_ENDPOINT.
|
||||
AWS_REGION: "fsn1"
|
||||
AWS_S3_ENDPOINT: "https://fsn1.your-objectstorage.com"
|
||||
AWS_S3_BUCKET: "xpeditis-prod-documents"
|
||||
|
||||
# --- Collecteur de logs applicatifs ---------------------------------------
|
||||
LOG_EXPORTER_URL: "http://xpeditis-log-exporter:3200"
|
||||
|
||||
# --- Premier administrateur ------------------------------------------------
|
||||
# Lu par la migration 1756000000001-BootstrapAdminFromEnv, qui remplace le
|
||||
# compte de demonstration admin@xpeditis.com / Password123!.
|
||||
#
|
||||
# Renseigne SEUL (sans BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret), il cree
|
||||
# un compte ADMIN actif SANS mot de passe utilisable : vous definissez le
|
||||
# votre via « mot de passe oublie ». Aucun secret n'existe alors nulle part.
|
||||
#
|
||||
# La migration ne fait rien s'il existe deja un administrateur actif : elle ne
|
||||
# peut donc pas en creer un second lors d'un deploiement ulterieur.
|
||||
#
|
||||
# Cette adresse doit etre RELEVABLE : c'est par elle que passe le lien de
|
||||
# definition du mot de passe.
|
||||
BOOTSTRAP_ADMIN_EMAIL: "ops@xpeditis.com"
|
||||
BOOTSTRAP_ADMIN_FIRST_NAME: "Admin"
|
||||
BOOTSTRAP_ADMIN_LAST_NAME: "Xpeditis"
|
||||
|
||||
# Organisation de rattachement (users.organization_id est NOT NULL).
|
||||
# Creee si elle n'existe pas ; a completer ensuite dans l'interface.
|
||||
BOOTSTRAP_ADMIN_ORG_NAME: "Xpeditis"
|
||||
BOOTSTRAP_ADMIN_ORG_STREET: "A completer"
|
||||
BOOTSTRAP_ADMIN_ORG_CITY: "A completer"
|
||||
BOOTSTRAP_ADMIN_ORG_POSTAL_CODE: "00000"
|
||||
BOOTSTRAP_ADMIN_ORG_COUNTRY: "FR"
|
||||
165
infra/prod/k8s/base/03-secrets.template.yaml
Normal file
165
infra/prod/k8s/base/03-secrets.template.yaml
Normal file
@ -0,0 +1,165 @@
|
||||
# =============================================================================
|
||||
# GABARIT de secrets -- NE JAMAIS COMMITTER CE FICHIER RENSEIGNE
|
||||
# =============================================================================
|
||||
# Mode d'emploi :
|
||||
#
|
||||
# 1. cp 03-secrets.template.yaml /tmp/secrets.yaml
|
||||
# 2. renseigner chaque REMPLACER (voir 06-secrets-sops.md)
|
||||
# 3. sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml
|
||||
# 4. shred -u /tmp/secrets.yaml
|
||||
# 5. git add k8s/base/03-secrets.sops.yaml <-- celui-la est chiffre, il se commit
|
||||
#
|
||||
# Application sur le cluster : bash scripts/secrets-apply.sh
|
||||
#
|
||||
# Generation d'une valeur aleatoire :
|
||||
# openssl rand -base64 48 | tr -d '\n'
|
||||
#
|
||||
# RAPPEL : aucune valeur de preprod ne doit etre reprise. Les secrets presents
|
||||
# dans infra/preprod/docker-stack.preprod.yml sont dans l'historique Git, donc
|
||||
# compromis (cf. 13-securite-durcissement.md).
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: xpeditis-backend-secrets
|
||||
namespace: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
type: Opaque
|
||||
stringData:
|
||||
# --- Base de donnees -------------------------------------------------------
|
||||
DATABASE_USER: "xpeditis"
|
||||
DATABASE_PASSWORD: "REMPLACER" # identique a POSTGRES_PASSWORD de db-01
|
||||
|
||||
# --- Redis -----------------------------------------------------------------
|
||||
REDIS_PASSWORD: "REMPLACER" # identique a REDIS_PASSWORD de db-01
|
||||
|
||||
# --- JWT -------------------------------------------------------------------
|
||||
# Minimum 32 caracteres (valide par Joi au demarrage). Le changer invalide
|
||||
# toutes les sessions en cours : a faire en dehors des heures ouvrees.
|
||||
JWT_SECRET: "REMPLACER_64_CARACTERES_MINIMUM"
|
||||
|
||||
# Derive les mots de passe des documents transporteurs. S'il est absent, le
|
||||
# code retombe sur JWT_SECRET -- et une rotation du JWT rendrait alors
|
||||
# illisibles tous les documents deja emis. On le definit donc explicitement,
|
||||
# et on ne le change JAMAIS sans plan de migration.
|
||||
DOCUMENT_PASSWORD_SECRET: "REMPLACER_32_CARACTERES_MINIMUM"
|
||||
|
||||
# --- SMTP (Brevo) ----------------------------------------------------------
|
||||
# Creez une cle SMTP dediee a la production dans Brevo. Celle de la preprod
|
||||
# est publiee dans le depot : elle doit etre revoquee.
|
||||
SMTP_USER: "REMPLACER"
|
||||
SMTP_PASS: "REMPLACER"
|
||||
|
||||
# --- Stockage objet Hetzner (documents applicatifs) ------------------------
|
||||
# Jeu de cles DISTINCT de celui utilise par WAL-G pour les sauvegardes :
|
||||
# une fuite cote application ne doit pas donner acces aux sauvegardes.
|
||||
AWS_ACCESS_KEY_ID: "REMPLACER"
|
||||
AWS_SECRET_ACCESS_KEY: "REMPLACER"
|
||||
|
||||
# --- Stripe ----------------------------------------------------------------
|
||||
# ATTENTION : cles LIVE en production (sk_live_..., whsec_...).
|
||||
STRIPE_SECRET_KEY: "REMPLACER_sk_live"
|
||||
STRIPE_WEBHOOK_SECRET: "REMPLACER_whsec"
|
||||
|
||||
# Noms exacts attendus par le code (app.module.ts / subscriptions).
|
||||
# Le stack de preprod utilise STARTER/PRO/ENTERPRISE : ces noms-la ne sont lus
|
||||
# par personne, les identifiants de tarif y sont donc silencieusement absents.
|
||||
# Ne pas reproduire l'erreur ici.
|
||||
STRIPE_SILVER_MONTHLY_PRICE_ID: "REMPLACER_price_"
|
||||
STRIPE_SILVER_YEARLY_PRICE_ID: "REMPLACER_price_"
|
||||
STRIPE_GOLD_MONTHLY_PRICE_ID: "REMPLACER_price_"
|
||||
STRIPE_GOLD_YEARLY_PRICE_ID: "REMPLACER_price_"
|
||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: "REMPLACER_price_"
|
||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID: "REMPLACER_price_"
|
||||
|
||||
# --- Premier administrateur (FACULTATIF) -----------------------------------
|
||||
# LAISSEZ VIDE dans le cas nominal.
|
||||
#
|
||||
# Vide + BOOTSTRAP_ADMIN_EMAIL renseigne dans le ConfigMap : le compte ADMIN
|
||||
# est cree sans mot de passe utilisable, et vous definissez le votre via
|
||||
# « mot de passe oublie ». Aucun secret n'existe nulle part -- rien a faire
|
||||
# fuiter, et la reception du courriel prouve au passage que SMTP fonctionne.
|
||||
#
|
||||
# Ne renseignez ce champ que si vous devez pouvoir vous connecter AVANT que
|
||||
# l'envoi de courriels ne soit operationnel. Dans ce cas :
|
||||
# cd apps/backend && node scripts/setup/generate-admin-hash.js
|
||||
# Le hash reste attaquable hors ligne : changez le mot de passe des la
|
||||
# premiere connexion, puis RETIREZ cette valeur et reappliquez le Secret.
|
||||
#
|
||||
# Un mot de passe en clair place ici fait echouer la migration : la valeur
|
||||
# doit commencer par "$argon2".
|
||||
BOOTSTRAP_ADMIN_PASSWORD_HASH: ""
|
||||
|
||||
# --- Pappers (registre SIRET) ----------------------------------------------
|
||||
# Facultatif : sans cle, la verification SIRET est simplement ignoree
|
||||
# (pappers-siret.adapter.ts emet un avertissement au demarrage).
|
||||
PAPPERS_API_KEY: "REMPLACER"
|
||||
|
||||
# --- Documentation Swagger -------------------------------------------------
|
||||
# Laisser les deux valeurs VIDES en production : main.ts desactive alors
|
||||
# completement /api/docs. Ne les renseigner que si vous avez vraiment besoin
|
||||
# d'exposer la documentation, auquel cas elle passe derriere Basic Auth.
|
||||
SWAGGER_USERNAME: ""
|
||||
SWAGGER_PASSWORD: ""
|
||||
|
||||
# --- Connecteurs transporteurs (facultatifs) -------------------------------
|
||||
# A renseigner uniquement quand les contrats API sont signes. Un connecteur
|
||||
# sans identifiants est ignore, il ne fait pas planter le demarrage.
|
||||
MAERSK_API_KEY: ""
|
||||
MAERSK_API_BASE_URL: ""
|
||||
MSC_API_KEY: ""
|
||||
MSC_API_URL: ""
|
||||
CMACGM_CLIENT_ID: ""
|
||||
CMACGM_CLIENT_SECRET: ""
|
||||
CMACGM_API_URL: ""
|
||||
HAPAG_API_KEY: ""
|
||||
HAPAG_API_URL: ""
|
||||
ONE_USERNAME: ""
|
||||
ONE_PASSWORD: ""
|
||||
ONE_API_URL: ""
|
||||
|
||||
---
|
||||
# Token API Cloudflare utilise par cert-manager pour le defi DNS-01.
|
||||
# Permissions minimales : Zone / DNS / Edit sur la seule zone xpeditis.com.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: cloudflare-api-token
|
||||
namespace: cert-manager
|
||||
labels:
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
type: Opaque
|
||||
stringData:
|
||||
api-token: "REMPLACER"
|
||||
|
||||
---
|
||||
# Acces a Grafana. Mot de passe long : l'interface est exposee sur Internet,
|
||||
# meme si elle est filtree par IP en amont.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: grafana-admin
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
type: Opaque
|
||||
stringData:
|
||||
admin-user: "REMPLACER"
|
||||
admin-password: "REMPLACER"
|
||||
|
||||
---
|
||||
# Webhook Discord utilise par Alertmanager. Dans un Secret et non dans le
|
||||
# ConfigMap : une URL de webhook permet de publier n'importe quoi dans le canal.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: alertmanager-secrets
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
type: Opaque
|
||||
stringData:
|
||||
discord-webhook: "https://discord.com/api/webhooks/REMPLACER"
|
||||
216
infra/prod/k8s/base/04-backend.yaml
Normal file
216
infra/prod/k8s/base/04-backend.yaml
Normal file
@ -0,0 +1,216 @@
|
||||
# =============================================================================
|
||||
# Backend NestJS
|
||||
# =============================================================================
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: xpeditis-backend
|
||||
namespace: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
app.kubernetes.io/component: api
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 5
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
# Aucun pod retire avant qu'un remplacant ne soit pret : zero coupure.
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
app.kubernetes.io/component: api
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
annotations:
|
||||
# Force le redemarrage des pods quand la configuration change : sans
|
||||
# cela, modifier le ConfigMap ne produit aucun effet visible.
|
||||
# La valeur est recalculee par scripts/deploy.sh.
|
||||
xpeditis.com/config-checksum: "PLACEHOLDER"
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
# 2 replicas sur 1 noeud : la contrainte est "preferred", sinon le second
|
||||
# pod resterait indefiniment en Pending. Elle deviendra effective le jour
|
||||
# ou un second noeud sera ajoute (phase 2 du plan de charge).
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
fsGroup: 1001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
# 60 s : laisse le temps aux requetes en cours et aux connexions
|
||||
# WebSocket de se fermer proprement.
|
||||
terminationGracePeriodSeconds: 60
|
||||
containers:
|
||||
- name: backend
|
||||
# Le tag est remplace au deploiement (kubectl set image).
|
||||
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 4000
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: xpeditis-backend-config
|
||||
- secretRef:
|
||||
name: xpeditis-backend-secrets
|
||||
env:
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
|
||||
# Le demarrage inclut l'attente de PostgreSQL puis la verification
|
||||
# des migrations : la sonde de demarrage laisse jusqu'a 150 s avant
|
||||
# de declarer le pod perdu, sans penaliser les redemarrages rapides.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health/live
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
failureThreshold: 30
|
||||
timeoutSeconds: 3
|
||||
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health/live
|
||||
port: http
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# LIMITE CONNUE : /health/ready renvoie toujours "ready" sans tester
|
||||
# PostgreSQL ni Redis (health.controller.ts). Un pod incapable de
|
||||
# joindre la base sera donc declare pret. Correctif recommande apres
|
||||
# la mise en ligne : @nestjs/terminus avec des indicateurs reels.
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/health/ready
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 300m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1500m
|
||||
memory: 1536Mi
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
# NON active volontairement : le conteneur ecrit dans /app/logs et
|
||||
# dans /app/src/infrastructure/storage/csv-storage/rates (chemin
|
||||
# resolu au runtime par le chargeur de grilles CSV). Monter des
|
||||
# emptyDir par-dessus masquerait les fichiers livres dans l'image.
|
||||
readOnlyRootFilesystem: false
|
||||
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
# Laisse Traefik retirer le pod de son pool avant que le
|
||||
# processus ne commence a refuser des connexions.
|
||||
command: ["sh", "-c", "sleep 10"]
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: xpeditis-backend
|
||||
namespace: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
annotations:
|
||||
# Sessions collantes : obligatoires pour Socket.IO. La negociation
|
||||
# long-polling echoue si deux requetes d'un meme handshake atterrissent sur
|
||||
# des replicas differents.
|
||||
#
|
||||
# ATTENTION -- limite non resolue par ce reglage : le gateway
|
||||
# (notifications.gateway.ts) garde la carte userId -> sockets EN MEMOIRE et
|
||||
# n'utilise pas @socket.io/redis-adapter. Une notification emise par le
|
||||
# replica A n'atteint donc pas un utilisateur connecte au replica B.
|
||||
# Cf. docs/mise-en-prod/15-exploitation-incidents.md, "Points de vigilance".
|
||||
traefik.ingress.kubernetes.io/service.sticky.cookie: "true"
|
||||
traefik.ingress.kubernetes.io/service.sticky.cookie.name: "xpd_be"
|
||||
traefik.ingress.kubernetes.io/service.sticky.cookie.secure: "true"
|
||||
traefik.ingress.kubernetes.io/service.sticky.cookie.httponly: "true"
|
||||
traefik.ingress.kubernetes.io/service.sticky.cookie.samesite: "lax"
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
ports:
|
||||
- name: http
|
||||
port: 4000
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: xpeditis-backend
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
|
||||
---
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: xpeditis-backend
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: xpeditis-backend
|
||||
minReplicas: 2
|
||||
# Plafond a 4 : au-dela, le CPX41 sature. Passer a 3 noeuds avant d'augmenter
|
||||
# (cf. 15-exploitation-incidents.md, section montee en charge).
|
||||
maxReplicas: 4
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior:
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 60
|
||||
scaleDown:
|
||||
# Descente lente : evite de retirer un pod juste avant un nouveau pic.
|
||||
stabilizationWindowSeconds: 600
|
||||
163
infra/prod/k8s/base/05-frontend.yaml
Normal file
163
infra/prod/k8s/base/05-frontend.yaml
Normal file
@ -0,0 +1,163 @@
|
||||
# =============================================================================
|
||||
# Frontend Next.js 14 (sortie standalone)
|
||||
# =============================================================================
|
||||
# RAPPEL CRITIQUE : NEXT_PUBLIC_API_URL est fige au moment du BUILD de l'image
|
||||
# (next.config.js, bloc `env`). Une image construite pour la preprod pointera
|
||||
# toujours vers api.preprod.xpeditis.com, quelles que soient les variables
|
||||
# injectees ici. L'image du frontend doit donc etre RECONSTRUITE pour la prod,
|
||||
# jamais promue depuis la preprod. Le workflow cd-main.yml applique cette regle.
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: xpeditis-frontend
|
||||
namespace: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-frontend
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 5
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-frontend
|
||||
app.kubernetes.io/component: web
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
topologySpreadConstraints:
|
||||
- maxSkew: 1
|
||||
topologyKey: kubernetes.io/hostname
|
||||
whenUnsatisfiable: ScheduleAnyway
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-frontend
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
fsGroup: 1001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
terminationGracePeriodSeconds: 30
|
||||
containers:
|
||||
- name: frontend
|
||||
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: HOSTNAME
|
||||
value: "0.0.0.0"
|
||||
# Lues cote serveur uniquement (route handlers, server actions).
|
||||
# Le code client, lui, a deja la valeur figee au build.
|
||||
- name: NEXT_PUBLIC_API_URL
|
||||
value: "https://api.xpeditis.com"
|
||||
- name: NEXT_PUBLIC_APP_URL
|
||||
value: "https://app.xpeditis.com"
|
||||
- name: NEXT_TELEMETRY_DISABLED
|
||||
value: "1"
|
||||
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 3
|
||||
failureThreshold: 30
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 384Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
# Le build standalone n'ecrit qu'en cache : on peut donc verrouiller
|
||||
# la racine et n'ouvrir que les deux repertoires necessaires.
|
||||
readOnlyRootFilesystem: true
|
||||
|
||||
volumeMounts:
|
||||
- name: next-cache
|
||||
mountPath: /app/.next/cache
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sh", "-c", "sleep 5"]
|
||||
|
||||
volumes:
|
||||
- name: next-cache
|
||||
emptyDir:
|
||||
sizeLimit: 512Mi
|
||||
- name: tmp
|
||||
emptyDir:
|
||||
sizeLimit: 128Mi
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: xpeditis-frontend
|
||||
namespace: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-frontend
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: xpeditis-frontend
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: xpeditis-frontend
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-frontend
|
||||
73
infra/prod/k8s/base/06-log-exporter.yaml
Normal file
73
infra/prod/k8s/base/06-log-exporter.yaml
Normal file
@ -0,0 +1,73 @@
|
||||
# =============================================================================
|
||||
# Log exporter -- API interne qui pousse des logs structures vers Loki
|
||||
# =============================================================================
|
||||
# Jamais expose par l'Ingress : uniquement joignable depuis le namespace
|
||||
# (le backend l'appelle via LOG_EXPORTER_URL).
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: xpeditis-log-exporter
|
||||
namespace: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-log-exporter
|
||||
app.kubernetes.io/component: observability
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-log-exporter
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-log-exporter
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: log-exporter
|
||||
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-log-exporter:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3200
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3200"
|
||||
- name: LOKI_URL
|
||||
value: "http://loki.monitoring.svc.cluster.local:3100"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 192Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
readOnlyRootFilesystem: true
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: xpeditis-log-exporter
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: xpeditis-log-exporter
|
||||
ports:
|
||||
- name: http
|
||||
port: 3200
|
||||
targetPort: http
|
||||
76
infra/prod/k8s/base/07-migration-job.yaml
Normal file
76
infra/prod/k8s/base/07-migration-job.yaml
Normal file
@ -0,0 +1,76 @@
|
||||
# =============================================================================
|
||||
# Migrations de base de donnees -- Job execute AVANT chaque deploiement
|
||||
# =============================================================================
|
||||
# Pourquoi un Job separe alors que l'image lance deja les migrations au
|
||||
# demarrage (scripts/setup/startup.js) :
|
||||
#
|
||||
# Avec 2 replicas, deux pods appellent runMigrations() simultanement. TypeORM
|
||||
# ne serialise pas les migrations entre processus : l'un des deux echoue et
|
||||
# part en CrashLoopBackOff. En passant par un Job (parallelisme 1) execute
|
||||
# avant le `kubectl set image`, les migrations sont deja appliquees quand les
|
||||
# pods demarrent : startup.js constate "no pending migrations" et laisse la
|
||||
# main a l'application. Le comportement de l'image reste inchange, il devient
|
||||
# simplement un filet de securite.
|
||||
#
|
||||
# Le nom du Job porte le tag de l'image : chaque deploiement cree son propre
|
||||
# Job, ce qui laisse une trace consultable (kubectl get jobs).
|
||||
# Le tag et le nom sont substitues par scripts/deploy.sh / cd-main.yml.
|
||||
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: xpeditis-migrate-__IMAGE_TAG__
|
||||
namespace: xpeditis-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-migrate
|
||||
app.kubernetes.io/part-of: xpeditis
|
||||
spec:
|
||||
# Les Jobs termines s'effacent au bout d'une heure : pas d'accumulation.
|
||||
ttlSecondsAfterFinished: 3600
|
||||
backoffLimit: 2
|
||||
activeDeadlineSeconds: 900
|
||||
parallelism: 1
|
||||
completions: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: xpeditis-migrate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
runAsGroup: 1001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: migrate
|
||||
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:__IMAGE_TAG__
|
||||
imagePullPolicy: IfNotPresent
|
||||
# CLI TypeORM sur la source de donnees compilee. Elle honore
|
||||
# DATABASE_SSL, contrairement au client de secours de startup.js.
|
||||
command:
|
||||
- node
|
||||
- ./node_modules/typeorm/cli.js
|
||||
- migration:run
|
||||
- -d
|
||||
- dist/infrastructure/persistence/typeorm/data-source.js
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: xpeditis-backend-config
|
||||
- secretRef:
|
||||
name: xpeditis-backend-secrets
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 384Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
readOnlyRootFilesystem: false
|
||||
148
infra/prod/k8s/base/08-traefik-middlewares.yaml
Normal file
148
infra/prod/k8s/base/08-traefik-middlewares.yaml
Normal file
@ -0,0 +1,148 @@
|
||||
# =============================================================================
|
||||
# Middlewares Traefik
|
||||
# =============================================================================
|
||||
# Reference dans les Ingress via l'annotation :
|
||||
# traefik.ingress.kubernetes.io/router.middlewares:
|
||||
# xpeditis-prod-<nom>@kubernetescrd
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: security-headers
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
headers:
|
||||
# HSTS : 1 an, sous-domaines inclus, eligible au preload.
|
||||
# A n'activer qu'une fois certain que TOUS les sous-domaines sont en HTTPS,
|
||||
# car la decision est irreversible cote navigateur pendant un an.
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
forceSTSHeader: true
|
||||
|
||||
contentTypeNosniff: true
|
||||
browserXssFilter: true
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
|
||||
customResponseHeaders:
|
||||
# DENY et non SAMEORIGIN : l'application n'a aucun usage legitime d'une
|
||||
# iframe, et c'est la meilleure protection contre le clickjacking.
|
||||
X-Frame-Options: "DENY"
|
||||
Permissions-Policy: "camera=(), microphone=(), geolocation=(), payment=(self), interest-cohort=()"
|
||||
Cross-Origin-Opener-Policy: "same-origin"
|
||||
Cross-Origin-Resource-Policy: "same-site"
|
||||
# Masque la stack technique.
|
||||
Server: ""
|
||||
X-Powered-By: ""
|
||||
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: rate-limit
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
rateLimit:
|
||||
# Limite de bordure, volontairement large : elle protege l'infrastructure.
|
||||
# La limite metier fine reste celle de CustomThrottlerGuard cote NestJS.
|
||||
average: 50
|
||||
period: 1s
|
||||
burst: 100
|
||||
sourceCriterion:
|
||||
ipStrategy:
|
||||
# 1 = on prend l'avant-derniere IP de X-Forwarded-For, c'est-a-dire
|
||||
# celle que Cloudflare a inscrite : la vraie IP du visiteur.
|
||||
# Sans cela toutes les requetes seraient comptees sur l'IP Cloudflare
|
||||
# et un seul abuseur ferait tomber la limite pour tout le monde.
|
||||
depth: 1
|
||||
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: rate-limit-auth
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
rateLimit:
|
||||
# Beaucoup plus stricte sur /api/v1/auth : connexion, inscription,
|
||||
# reinitialisation de mot de passe, liens magiques transporteurs.
|
||||
# 5 req/s en pointe suffit largement a un usage humain.
|
||||
average: 5
|
||||
period: 1s
|
||||
burst: 10
|
||||
sourceCriterion:
|
||||
ipStrategy:
|
||||
depth: 1
|
||||
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: compress
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
compress:
|
||||
minResponseBodyBytes: 1024
|
||||
excludedContentTypes:
|
||||
- image/png
|
||||
- image/jpeg
|
||||
- image/webp
|
||||
- application/pdf
|
||||
|
||||
---
|
||||
# Chaine appliquee au trafic public standard.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: public-chain
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
chain:
|
||||
middlewares:
|
||||
- name: security-headers
|
||||
- name: rate-limit
|
||||
- name: compress
|
||||
|
||||
---
|
||||
# Chaine appliquee aux routes d'authentification.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: auth-chain
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
chain:
|
||||
middlewares:
|
||||
- name: security-headers
|
||||
- name: rate-limit-auth
|
||||
- name: compress
|
||||
|
||||
---
|
||||
# Restriction par IP, utilisee pour Grafana. Remplacez les valeurs par vos IP
|
||||
# d'administration -- les memes que admin_ip_allowlist cote Terraform.
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: admin-ip-allowlist
|
||||
namespace: monitoring
|
||||
spec:
|
||||
ipAllowList:
|
||||
sourceRange:
|
||||
- 203.0.113.7/32
|
||||
ipStrategy:
|
||||
depth: 1
|
||||
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: security-headers
|
||||
namespace: monitoring
|
||||
spec:
|
||||
headers:
|
||||
stsSeconds: 31536000
|
||||
stsIncludeSubdomains: true
|
||||
contentTypeNosniff: true
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
customResponseHeaders:
|
||||
X-Frame-Options: "DENY"
|
||||
128
infra/prod/k8s/base/09-ingress.yaml
Normal file
128
infra/prod/k8s/base/09-ingress.yaml
Normal file
@ -0,0 +1,128 @@
|
||||
# =============================================================================
|
||||
# Exposition publique
|
||||
# =============================================================================
|
||||
# Cartographie des domaines :
|
||||
#
|
||||
# xpeditis.com -> frontend (vitrine, pages publiques)
|
||||
# www.xpeditis.com -> frontend
|
||||
# app.xpeditis.com -> frontend (dashboard ; c'est l'origine des cookies)
|
||||
# api.xpeditis.com -> backend NestJS
|
||||
# grafana.xpeditis.com-> Grafana (voir k8s/monitoring/)
|
||||
#
|
||||
# Le certificat est un wildcard *.xpeditis.com + xpeditis.com, obtenu par
|
||||
# cert-manager en DNS-01 Cloudflare (cf. 11-certificate.yaml).
|
||||
# La redirection HTTP -> HTTPS est faite au niveau de l'entrypoint Traefik,
|
||||
# aucun Ingress ne peut l'oublier.
|
||||
|
||||
---
|
||||
# --- API : routes d'authentification (limitation stricte) --------------------
|
||||
# Ingress separe et plus specifique que celui de l'API : Traefik privilegie la
|
||||
# regle au chemin le plus long, cette limite s'applique donc bien en priorite.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: xpeditis-api-auth
|
||||
namespace: xpeditis-prod
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-auth-chain@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- api.xpeditis.com
|
||||
secretName: xpeditis-wildcard-tls
|
||||
rules:
|
||||
- host: api.xpeditis.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api/v1/auth
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: xpeditis-backend
|
||||
port:
|
||||
name: http
|
||||
|
||||
---
|
||||
# --- API : tout le reste -----------------------------------------------------
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: xpeditis-api
|
||||
namespace: xpeditis-prod
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-public-chain@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- api.xpeditis.com
|
||||
secretName: xpeditis-wildcard-tls
|
||||
rules:
|
||||
- host: api.xpeditis.com
|
||||
http:
|
||||
paths:
|
||||
# Couvre l'API REST, le webhook Stripe et le handshake Socket.IO
|
||||
# (/socket.io/...), Traefik gerant l'upgrade WebSocket nativement.
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: xpeditis-backend
|
||||
port:
|
||||
name: http
|
||||
|
||||
---
|
||||
# --- Frontend ----------------------------------------------------------------
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: xpeditis-app
|
||||
namespace: xpeditis-prod
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-public-chain@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- xpeditis.com
|
||||
- www.xpeditis.com
|
||||
- app.xpeditis.com
|
||||
secretName: xpeditis-wildcard-tls
|
||||
rules:
|
||||
- host: app.xpeditis.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: xpeditis-frontend
|
||||
port:
|
||||
name: http
|
||||
- host: www.xpeditis.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: xpeditis-frontend
|
||||
port:
|
||||
name: http
|
||||
- host: xpeditis.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: xpeditis-frontend
|
||||
port:
|
||||
name: http
|
||||
239
infra/prod/k8s/base/10-network-policies.yaml
Normal file
239
infra/prod/k8s/base/10-network-policies.yaml
Normal file
@ -0,0 +1,239 @@
|
||||
# =============================================================================
|
||||
# Politiques reseau
|
||||
# =============================================================================
|
||||
# k3s applique nativement les NetworkPolicy (controleur kube-router embarque).
|
||||
#
|
||||
# Objectif : qu'un pod compromis ne puisse ni etre joint par n'importe qui, ni
|
||||
# se servir du cluster comme point de rebond vers le reseau prive Hetzner.
|
||||
# Sans ces regles, tout pod peut joindre tout pod ET toute IP privee.
|
||||
|
||||
---
|
||||
# --- Refus par defaut, entrant ET sortant -----------------------------------
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: [Ingress, Egress]
|
||||
|
||||
---
|
||||
# --- Resolution DNS (indispensable, sinon plus rien ne fonctionne) ----------
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-dns
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: [Egress]
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
|
||||
---
|
||||
# --- Trafic entrant depuis l'ingress ----------------------------------------
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-ingress-from-traefik
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
podSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values: [xpeditis-backend, xpeditis-frontend]
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 4000
|
||||
- protocol: TCP
|
||||
port: 3000
|
||||
|
||||
---
|
||||
# --- Le backend ecrit dans le log-exporter ----------------------------------
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-backend-to-log-exporter
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-log-exporter
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3200
|
||||
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-to-log-exporter
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-backend
|
||||
policyTypes: [Egress]
|
||||
egress:
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-log-exporter
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3200
|
||||
|
||||
---
|
||||
# --- Le log-exporter pousse vers Loki ---------------------------------------
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-log-exporter-to-loki
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: xpeditis-log-exporter
|
||||
policyTypes: [Egress]
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: monitoring
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3100
|
||||
|
||||
---
|
||||
# --- Acces a PostgreSQL et Redis sur db-01 ----------------------------------
|
||||
# Une seule IP, deux ports. Tout autre acces au reseau prive est refuse : un
|
||||
# backend compromis ne peut pas scanner 10.10.0.0/16.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-to-data-node
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
podSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values: [xpeditis-backend, xpeditis-migrate]
|
||||
policyTypes: [Egress]
|
||||
egress:
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 10.10.1.20/32
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 5432
|
||||
- protocol: TCP
|
||||
port: 6379
|
||||
|
||||
---
|
||||
# --- Sorties Internet (Stripe, Brevo, Object Storage, Pappers, carriers) ----
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-egress-internet
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
podSelector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/name
|
||||
operator: In
|
||||
values: [xpeditis-backend, xpeditis-frontend, xpeditis-migrate]
|
||||
policyTypes: [Egress]
|
||||
egress:
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
# Tout l'espace prive est exclu : la sortie ne sert qu'a joindre
|
||||
# des services publics, jamais l'infrastructure interne.
|
||||
except:
|
||||
- 10.0.0.0/8
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
- 169.254.0.0/16 # metadonnees cloud
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
- protocol: TCP
|
||||
port: 587 # SMTP soumission (Brevo)
|
||||
|
||||
---
|
||||
# --- Namespace monitoring ----------------------------------------------------
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: monitoring
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: [Ingress]
|
||||
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-loki-writes
|
||||
namespace: monitoring
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: loki
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
# Promtail (meme namespace) et le log-exporter applicatif.
|
||||
- from:
|
||||
- podSelector: {}
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: xpeditis-prod
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3100
|
||||
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-grafana-from-traefik
|
||||
namespace: monitoring
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: grafana
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 3000
|
||||
59
infra/prod/k8s/base/11-certificate.yaml
Normal file
59
infra/prod/k8s/base/11-certificate.yaml
Normal file
@ -0,0 +1,59 @@
|
||||
# =============================================================================
|
||||
# Certificats TLS (Let's Encrypt via cert-manager, defi DNS-01 Cloudflare)
|
||||
# =============================================================================
|
||||
# Pourquoi DNS-01 et non HTTP-01 :
|
||||
# - il fonctionne meme quand le proxy Cloudflare (nuage orange) est actif et
|
||||
# que le firewall Hetzner n'accepte que les IP Cloudflare ;
|
||||
# - il permet un certificat wildcard, donc un seul certificat pour tous les
|
||||
# sous-domaines presents et futurs ;
|
||||
# - il ne depend pas de la joignabilite du port 80.
|
||||
#
|
||||
# Un certificat est renouvele 30 jours avant expiration. Surveillez l'alerte
|
||||
# "certificat expirant" (cf. k8s/monitoring/) : un renouvellement casse passe
|
||||
# inapercu jusqu'au jour ou le site devient inaccessible.
|
||||
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: xpeditis-wildcard
|
||||
namespace: xpeditis-prod
|
||||
spec:
|
||||
secretName: xpeditis-wildcard-tls
|
||||
issuerRef:
|
||||
name: letsencrypt-prod
|
||||
kind: ClusterIssuer
|
||||
commonName: xpeditis.com
|
||||
dnsNames:
|
||||
# Le wildcard ne couvre PAS l'apex : les deux doivent etre listes.
|
||||
- xpeditis.com
|
||||
- "*.xpeditis.com"
|
||||
duration: 2160h # 90 jours
|
||||
renewBefore: 720h # 30 jours
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
# Nouvelle cle a chaque renouvellement : limite la fenetre d'exploitation
|
||||
# d'une cle qui aurait fuite.
|
||||
rotationPolicy: Always
|
||||
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: grafana-tls
|
||||
namespace: monitoring
|
||||
spec:
|
||||
secretName: grafana-tls
|
||||
issuerRef:
|
||||
name: letsencrypt-prod
|
||||
kind: ClusterIssuer
|
||||
commonName: grafana.xpeditis.com
|
||||
dnsNames:
|
||||
- grafana.xpeditis.com
|
||||
duration: 2160h
|
||||
renewBefore: 720h
|
||||
privateKey:
|
||||
algorithm: ECDSA
|
||||
size: 256
|
||||
rotationPolicy: Always
|
||||
56
infra/prod/k8s/cluster/cluster-issuer.yaml
Normal file
56
infra/prod/k8s/cluster/cluster-issuer.yaml
Normal file
@ -0,0 +1,56 @@
|
||||
# =============================================================================
|
||||
# Emetteurs ACME
|
||||
# =============================================================================
|
||||
# Prerequis : le Secret cert-manager/cloudflare-api-token doit exister
|
||||
# (bash scripts/secrets-apply.sh).
|
||||
#
|
||||
# Le token Cloudflare doit avoir la permission MINIMALE :
|
||||
# Zone > DNS > Edit, limite a la seule zone xpeditis.com.
|
||||
# Un token global de compte donnerait a cert-manager le pouvoir de rediriger
|
||||
# tout votre trafic.
|
||||
|
||||
---
|
||||
# Emetteur de TEST. Ses certificats ne sont pas reconnus par les navigateurs,
|
||||
# mais il n'a pas de quota serre : utilisez-le pour valider la chaine DNS-01
|
||||
# AVANT de basculer sur l'emetteur de production.
|
||||
# Let's Encrypt limite la production a 5 echecs/heure et 50 certificats/semaine
|
||||
# par domaine : on ne debogue pas dessus.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-staging
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
email: ops@xpeditis.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-staging-account-key
|
||||
solvers:
|
||||
- dns01:
|
||||
cloudflare:
|
||||
apiTokenSecretRef:
|
||||
name: cloudflare-api-token
|
||||
key: api-token
|
||||
|
||||
---
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
# Adresse REELLE et surveillee : Let's Encrypt y envoie les avertissements
|
||||
# d'expiration si le renouvellement automatique cesse de fonctionner.
|
||||
email: ops@xpeditis.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod-account-key
|
||||
solvers:
|
||||
- dns01:
|
||||
cloudflare:
|
||||
apiTokenSecretRef:
|
||||
name: cloudflare-api-token
|
||||
key: api-token
|
||||
selector:
|
||||
dnsZones:
|
||||
- xpeditis.com
|
||||
172
infra/prod/k8s/monitoring/01-loki.yaml
Normal file
172
infra/prod/k8s/monitoring/01-loki.yaml
Normal file
@ -0,0 +1,172 @@
|
||||
# =============================================================================
|
||||
# Loki -- agregation des journaux
|
||||
# =============================================================================
|
||||
# Mode "single binary", stockage sur le disque local du noeud (local-path de
|
||||
# k3s). Suffisant jusqu'a ~10 Go/mois de logs, ce qui couvre tres largement la
|
||||
# phase 1. Au-dela, basculer le stockage sur Hetzner Object Storage.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: loki-config
|
||||
namespace: monitoring
|
||||
data:
|
||||
loki.yaml: |
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
grpc_listen_port: 9096
|
||||
log_level: warn
|
||||
|
||||
common:
|
||||
instance_addr: 127.0.0.1
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
limits_config:
|
||||
allow_structured_metadata: true
|
||||
volume_enabled: true
|
||||
# 31 jours : couvre l'analyse d'incident et reste sous le seuil ou les
|
||||
# journaux applicatifs deviendraient une base de donnees personnelles
|
||||
# a part entiere au sens du RGPD (cf. 16 dans la doc de mise en prod).
|
||||
retention_period: 744h
|
||||
reject_old_samples: true
|
||||
reject_old_samples_max_age: 168h
|
||||
ingestion_rate_mb: 8
|
||||
ingestion_burst_size_mb: 16
|
||||
max_entries_limit_per_query: 5000
|
||||
max_query_series: 500
|
||||
|
||||
compactor:
|
||||
working_directory: /loki/compactor
|
||||
compaction_interval: 10m
|
||||
retention_enabled: true
|
||||
retention_delete_delay: 2h
|
||||
retention_delete_worker_count: 50
|
||||
delete_request_store: filesystem
|
||||
|
||||
query_range:
|
||||
results_cache:
|
||||
cache:
|
||||
embedded_cache:
|
||||
enabled: true
|
||||
max_size_mb: 100
|
||||
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: loki-data
|
||||
namespace: monitoring
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: loki
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: loki
|
||||
spec:
|
||||
replicas: 1
|
||||
# Un seul volume RWO : on remplace avant de recreer, jamais l'inverse.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: loki
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: loki
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
fsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: loki
|
||||
image: grafana/loki:3.3.2
|
||||
args: ["-config.file=/etc/loki/loki.yaml"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3100
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: http
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/loki
|
||||
- name: data
|
||||
mountPath: /loki
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: loki-config
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: loki-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: loki
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: loki
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: loki
|
||||
ports:
|
||||
- name: http
|
||||
port: 3100
|
||||
targetPort: http
|
||||
185
infra/prod/k8s/monitoring/02-promtail.yaml
Normal file
185
infra/prod/k8s/monitoring/02-promtail.yaml
Normal file
@ -0,0 +1,185 @@
|
||||
# =============================================================================
|
||||
# Promtail -- collecte des journaux de conteneurs
|
||||
# =============================================================================
|
||||
# k3s utilise containerd, pas Docker : la decouverte se fait via l'API
|
||||
# Kubernetes et la lecture de /var/log/pods, et non via le socket Docker comme
|
||||
# en preprod.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: promtail
|
||||
namespace: monitoring
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: promtail
|
||||
rules:
|
||||
# Lecture seule, strictement ce qu'exige la decouverte de pods.
|
||||
- apiGroups: [""]
|
||||
resources: [nodes, nodes/proxy, services, endpoints, pods]
|
||||
verbs: [get, list, watch]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: promtail
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: promtail
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: promtail
|
||||
namespace: monitoring
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: promtail-config
|
||||
namespace: monitoring
|
||||
data:
|
||||
promtail.yaml: |
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
log_level: warn
|
||||
|
||||
positions:
|
||||
filename: /run/promtail/positions.yaml
|
||||
|
||||
clients:
|
||||
- url: http://loki:3100/loki/api/v1/push
|
||||
batchwait: 1s
|
||||
batchsize: 1048576
|
||||
timeout: 10s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: kubernetes-pods
|
||||
kubernetes_sd_configs:
|
||||
- role: pod
|
||||
pipeline_stages:
|
||||
# Format CRI de containerd : "<ts> <stream> <flags> <message>".
|
||||
- cri: {}
|
||||
|
||||
- drop:
|
||||
older_than: 15m
|
||||
drop_counter_reason: entry_too_old
|
||||
|
||||
# Les sondes de sante representent l'essentiel du volume et n'ont
|
||||
# aucune valeur d'analyse : on les jette avant ingestion.
|
||||
- drop:
|
||||
expression: '(GET /api/v1/health|GET /api/health|/ready|/metrics)'
|
||||
drop_counter_reason: healthcheck
|
||||
|
||||
# Journaux pino du backend (LOG_FORMAT=json).
|
||||
- json:
|
||||
expressions:
|
||||
level: level
|
||||
msg: msg
|
||||
context: context
|
||||
reqId: reqId
|
||||
- template:
|
||||
source: level
|
||||
template: >-
|
||||
{{ if eq .Value "10" }}trace{{ else if eq .Value "20" }}debug{{ else if eq .Value "30" }}info{{ else if eq .Value "40" }}warn{{ else if eq .Value "50" }}error{{ else if eq .Value "60" }}fatal{{ else }}{{ .Value }}{{ end }}
|
||||
- labels:
|
||||
level:
|
||||
context:
|
||||
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_kubernetes_pod_node_name]
|
||||
target_label: node
|
||||
- source_labels: [__meta_kubernetes_namespace]
|
||||
target_label: namespace
|
||||
- source_labels: [__meta_kubernetes_pod_name]
|
||||
target_label: pod
|
||||
- source_labels: [__meta_kubernetes_pod_container_name]
|
||||
target_label: container
|
||||
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||
target_label: service
|
||||
# Chemin reel des journaux sur l'hote.
|
||||
- source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
|
||||
target_label: __path__
|
||||
separator: /
|
||||
replacement: /var/log/pods/*$1/*.log
|
||||
# On ne collecte que les namespaces utiles : pas de bruit kube-system
|
||||
# hormis l'ingress.
|
||||
- source_labels: [__meta_kubernetes_namespace]
|
||||
regex: (xpeditis-prod|monitoring|kube-system)
|
||||
action: keep
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: promtail
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: promtail
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: promtail
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: promtail
|
||||
spec:
|
||||
serviceAccountName: promtail
|
||||
securityContext:
|
||||
# Les journaux de /var/log/pods appartiennent a root : Promtail doit
|
||||
# pouvoir les lire. C'est la raison pour laquelle le namespace
|
||||
# monitoring est en "baseline" et non "restricted".
|
||||
runAsUser: 0
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: promtail
|
||||
image: grafana/promtail:3.3.2
|
||||
args: ["-config.file=/etc/promtail/promtail.yaml"]
|
||||
env:
|
||||
- name: HOSTNAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 9080
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 300m
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/promtail
|
||||
- name: positions
|
||||
mountPath: /run/promtail
|
||||
- name: pods
|
||||
mountPath: /var/log/pods
|
||||
readOnly: true
|
||||
- name: containers
|
||||
mountPath: /var/lib/rancher/k3s/agent/containerd
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: promtail-config
|
||||
- name: positions
|
||||
emptyDir: {}
|
||||
- name: pods
|
||||
hostPath:
|
||||
path: /var/log/pods
|
||||
- name: containers
|
||||
hostPath:
|
||||
path: /var/lib/rancher/k3s/agent/containerd
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
operator: Exists
|
||||
351
infra/prod/k8s/monitoring/03-prometheus.yaml
Normal file
351
infra/prod/k8s/monitoring/03-prometheus.yaml
Normal file
@ -0,0 +1,351 @@
|
||||
# =============================================================================
|
||||
# Prometheus -- metriques et regles d'alerte
|
||||
# =============================================================================
|
||||
# Perimetre volontairement reduit : kubelet/cAdvisor, node-exporter, Traefik,
|
||||
# cert-manager et postgres-exporter. Pas de kube-state-metrics ni d'operateur :
|
||||
# a un noeud et une dizaine de pods, ils coutent plus de RAM qu'ils n'apportent.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: prometheus
|
||||
namespace: monitoring
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: prometheus
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
|
||||
verbs: [get, list, watch]
|
||||
- nonResourceURLs: ["/metrics", "/metrics/cadvisor"]
|
||||
verbs: [get]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: prometheus
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: prometheus
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: prometheus
|
||||
namespace: monitoring
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: prometheus-config
|
||||
namespace: monitoring
|
||||
data:
|
||||
prometheus.yml: |
|
||||
global:
|
||||
scrape_interval: 30s
|
||||
evaluation_interval: 30s
|
||||
external_labels:
|
||||
cluster: xpeditis-prod
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/rules/*.yml
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: ['alertmanager:9093']
|
||||
|
||||
scrape_configs:
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
- job_name: node-exporter
|
||||
kubernetes_sd_configs:
|
||||
- role: endpoints
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_kubernetes_service_name]
|
||||
regex: node-exporter
|
||||
action: keep
|
||||
|
||||
# cAdvisor : consommation CPU/memoire par conteneur.
|
||||
- job_name: kubelet-cadvisor
|
||||
scheme: https
|
||||
tls_config:
|
||||
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||
insecure_skip_verify: true
|
||||
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||
kubernetes_sd_configs:
|
||||
- role: node
|
||||
relabel_configs:
|
||||
- action: labelmap
|
||||
regex: __meta_kubernetes_node_label_(.+)
|
||||
- target_label: __address__
|
||||
replacement: kubernetes.default.svc:443
|
||||
- source_labels: [__meta_kubernetes_node_name]
|
||||
regex: (.+)
|
||||
target_label: __metrics_path__
|
||||
replacement: /api/v1/nodes/${1}/proxy/metrics/cadvisor
|
||||
|
||||
- job_name: traefik
|
||||
kubernetes_sd_configs:
|
||||
- role: pod
|
||||
namespaces:
|
||||
names: [kube-system]
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||
regex: traefik
|
||||
action: keep
|
||||
- source_labels: [__address__]
|
||||
regex: '(.+?)(?::\d+)?'
|
||||
target_label: __address__
|
||||
replacement: '${1}:9100'
|
||||
|
||||
- job_name: cert-manager
|
||||
kubernetes_sd_configs:
|
||||
- role: pod
|
||||
namespaces:
|
||||
names: [cert-manager]
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_kubernetes_pod_container_port_number]
|
||||
regex: "9402"
|
||||
action: keep
|
||||
|
||||
# PostgreSQL : l'exportateur tourne sur db-01, hors du cluster.
|
||||
- job_name: postgres
|
||||
static_configs:
|
||||
- targets: ['10.10.1.20:9187']
|
||||
labels:
|
||||
instance: xpeditis-prod-db-01
|
||||
|
||||
- job_name: loki
|
||||
static_configs:
|
||||
- targets: ['loki:3100']
|
||||
|
||||
rules.yml: |
|
||||
groups:
|
||||
- name: xpeditis-disponibilite
|
||||
rules:
|
||||
- alert: BackendIndisponible
|
||||
# Traefik ne voit plus aucune instance saine derriere le service :
|
||||
# l'API est hors ligne pour les utilisateurs, quoi que dise k8s.
|
||||
expr: |
|
||||
sum by (service) (traefik_service_server_up{service=~"xpeditis-prod-xpeditis-backend.*"}) == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critique
|
||||
annotations:
|
||||
summary: "Aucune instance backend saine derriere l'ingress"
|
||||
description: "L'API Xpeditis ne repond plus. Verifier : kubectl -n xpeditis-prod get pods"
|
||||
|
||||
- alert: FrontendIndisponible
|
||||
expr: |
|
||||
sum by (service) (traefik_service_server_up{service=~"xpeditis-prod-xpeditis-frontend.*"}) == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critique
|
||||
annotations:
|
||||
summary: "Aucune instance frontend saine derriere l'ingress"
|
||||
|
||||
- alert: TauxErreur5xxEleve
|
||||
expr: |
|
||||
sum(rate(traefik_service_requests_total{code=~"5.."}[5m]))
|
||||
/ clamp_min(sum(rate(traefik_service_requests_total[5m])), 0.001) > 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critique
|
||||
annotations:
|
||||
summary: "Plus de 5% de reponses 5xx"
|
||||
description: "Taux d'erreur serveur anormal sur les 5 dernieres minutes."
|
||||
|
||||
- alert: LatenceElevee
|
||||
expr: |
|
||||
histogram_quantile(0.95,
|
||||
sum(rate(traefik_service_request_duration_seconds_bucket[5m])) by (le, service)) > 2
|
||||
for: 10m
|
||||
labels:
|
||||
severity: avertissement
|
||||
annotations:
|
||||
summary: "P95 au-dessus de 2 s sur {{ $labels.service }}"
|
||||
|
||||
- name: xpeditis-ressources
|
||||
rules:
|
||||
- alert: DisqueBientotPlein
|
||||
# Un disque plein arrete PostgreSQL et k3s. Seuil a 15% restants
|
||||
# pour laisser le temps d'intervenir.
|
||||
expr: |
|
||||
node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"}
|
||||
/ node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"} < 0.15
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critique
|
||||
annotations:
|
||||
summary: "Moins de 15% d'espace disque libre sur {{ $labels.instance }}"
|
||||
|
||||
- alert: MemoireNoeudSaturee
|
||||
expr: |
|
||||
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
|
||||
for: 10m
|
||||
labels:
|
||||
severity: avertissement
|
||||
annotations:
|
||||
summary: "Memoire du noeud a plus de 90%"
|
||||
|
||||
- alert: ConteneurRedemarreEnBoucle
|
||||
expr: |
|
||||
increase(container_start_time_seconds{namespace="xpeditis-prod"}[15m]) > 3
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critique
|
||||
annotations:
|
||||
summary: "Le conteneur {{ $labels.container }} redemarre en boucle"
|
||||
|
||||
- name: xpeditis-donnees
|
||||
rules:
|
||||
- alert: PostgresInjoignable
|
||||
expr: pg_up == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critique
|
||||
annotations:
|
||||
summary: "PostgreSQL ne repond plus sur db-01"
|
||||
description: "Verifier db-01 : docker compose ps, journalctl -u docker"
|
||||
|
||||
- alert: ConnexionsPostgresSaturees
|
||||
expr: |
|
||||
sum(pg_stat_database_numbackends) / on() pg_settings_max_connections > 0.80
|
||||
for: 5m
|
||||
labels:
|
||||
severity: avertissement
|
||||
annotations:
|
||||
summary: "Plus de 80% des connexions PostgreSQL consommees"
|
||||
|
||||
# NOTE -- la surveillance des sauvegardes ne passe PAS par Prometheus.
|
||||
# Une alerte "la sauvegarde n'a pas tourne" evaluee par un composant
|
||||
# heberge sur la meme machine ne se declenche pas quand la machine est
|
||||
# eteinte, c'est-a-dire precisement quand elle serait utile.
|
||||
# Elle repose donc sur un battement de coeur externe : pg-backup.sh
|
||||
# appelle BACKUP_HEARTBEAT_URL a chaque succes, et le service externe
|
||||
# (BetterStack / healthchecks.io) alerte en cas de silence.
|
||||
# Cf. docs/mise-en-prod/12-sauvegardes-restauration.md.
|
||||
|
||||
- name: xpeditis-tls
|
||||
rules:
|
||||
- alert: CertificatBientotExpire
|
||||
expr: |
|
||||
(certmanager_certificate_expiration_timestamp_seconds - time()) / 86400 < 15
|
||||
for: 1h
|
||||
labels:
|
||||
severity: critique
|
||||
annotations:
|
||||
summary: "Certificat {{ $labels.name }} expire dans moins de 15 jours"
|
||||
description: "Le renouvellement automatique ne fonctionne plus. Verifier : kubectl describe certificate -A"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: prometheus-data
|
||||
namespace: monitoring
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 15Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: prometheus
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: prometheus
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: prometheus
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: prometheus
|
||||
annotations:
|
||||
# Redemarre Prometheus quand les regles changent.
|
||||
checksum/config: "PLACEHOLDER"
|
||||
spec:
|
||||
serviceAccountName: prometheus
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
fsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: prometheus
|
||||
image: prom/prometheus:v3.1.0
|
||||
args:
|
||||
- --config.file=/etc/prometheus/prometheus.yml
|
||||
- --storage.tsdb.path=/prometheus
|
||||
# 15 jours : suffisant pour l'analyse d'incident, tient dans 15 Go.
|
||||
- --storage.tsdb.retention.time=15d
|
||||
- --web.enable-lifecycle
|
||||
- --web.listen-address=:9090
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 9090
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /-/ready
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /-/healthy
|
||||
port: http
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 150m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1536Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/prometheus/prometheus.yml
|
||||
subPath: prometheus.yml
|
||||
- name: config
|
||||
mountPath: /etc/prometheus/rules/rules.yml
|
||||
subPath: rules.yml
|
||||
- name: data
|
||||
mountPath: /prometheus
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: prometheus-config
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: prometheus-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: prometheus
|
||||
namespace: monitoring
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: prometheus
|
||||
ports:
|
||||
- name: http
|
||||
port: 9090
|
||||
targetPort: http
|
||||
95
infra/prod/k8s/monitoring/04-node-exporter.yaml
Normal file
95
infra/prod/k8s/monitoring/04-node-exporter.yaml
Normal file
@ -0,0 +1,95 @@
|
||||
# =============================================================================
|
||||
# node-exporter -- metriques systeme du noeud
|
||||
# =============================================================================
|
||||
# Presence justifiee par une seule alerte, mais la plus importante de toutes :
|
||||
# le disque plein. Un disque sature arrete PostgreSQL, k3s et les sauvegardes
|
||||
# en meme temps, et c'est la panne la plus frequente d'un serveur laisse seul.
|
||||
#
|
||||
# Port 9101 et non 9100 : 9100 est deja l'entrypoint metrics de Traefik, et
|
||||
# confondre les deux cibles rend le diagnostic penible.
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: node-exporter
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
spec:
|
||||
hostNetwork: true
|
||||
hostPID: true
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: node-exporter
|
||||
image: prom/node-exporter:v1.8.2
|
||||
args:
|
||||
- --path.procfs=/host/proc
|
||||
- --path.sysfs=/host/sys
|
||||
- --path.rootfs=/host/root
|
||||
- --web.listen-address=:9101
|
||||
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+|var/lib/kubelet/.+|var/lib/rancher/.+)($|/)
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 9101
|
||||
hostPort: 9101
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: proc
|
||||
mountPath: /host/proc
|
||||
readOnly: true
|
||||
- name: sys
|
||||
mountPath: /host/sys
|
||||
readOnly: true
|
||||
- name: root
|
||||
mountPath: /host/root
|
||||
mountPropagation: HostToContainer
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: proc
|
||||
hostPath: { path: /proc }
|
||||
- name: sys
|
||||
hostPath: { path: /sys }
|
||||
- name: root
|
||||
hostPath: { path: / }
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
operator: Exists
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: node-exporter
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app.kubernetes.io/name: node-exporter
|
||||
ports:
|
||||
- name: metrics
|
||||
port: 9101
|
||||
targetPort: metrics
|
||||
150
infra/prod/k8s/monitoring/05-alertmanager.yaml
Normal file
150
infra/prod/k8s/monitoring/05-alertmanager.yaml
Normal file
@ -0,0 +1,150 @@
|
||||
# =============================================================================
|
||||
# Alertmanager -- routage des alertes vers Discord
|
||||
# =============================================================================
|
||||
# L'URL du webhook n'est PAS dans ce ConfigMap : elle est lue depuis un fichier
|
||||
# monte depuis un Secret (webhook_url_file). Un ConfigMap est lisible par tout
|
||||
# ce qui a un acces lecture au namespace, un webhook Discord permet de publier
|
||||
# n'importe quoi dans votre canal d'exploitation.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: alertmanager-config
|
||||
namespace: monitoring
|
||||
data:
|
||||
alertmanager.yml: |
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
route:
|
||||
receiver: discord
|
||||
group_by: ['alertname', 'severity']
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
# Une alerte critique non traitee est repetee toutes les heures : elle ne
|
||||
# doit pas disparaitre du fil de discussion.
|
||||
repeat_interval: 1h
|
||||
routes:
|
||||
- matchers:
|
||||
- severity = "avertissement"
|
||||
receiver: discord
|
||||
repeat_interval: 12h
|
||||
|
||||
inhibit_rules:
|
||||
# Si le backend est totalement indisponible, inutile d'inonder le canal
|
||||
# avec les alertes de latence et de taux d'erreur qui en decoulent.
|
||||
- source_matchers:
|
||||
- alertname = "BackendIndisponible"
|
||||
target_matchers:
|
||||
- severity =~ "avertissement|critique"
|
||||
equal: ['cluster']
|
||||
|
||||
receivers:
|
||||
- name: discord
|
||||
discord_configs:
|
||||
- webhook_url_file: /etc/alertmanager/secrets/discord-webhook
|
||||
send_resolved: true
|
||||
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}'
|
||||
message: |-
|
||||
{{ range .Alerts }}{{ .Annotations.summary }}
|
||||
{{ .Annotations.description }}
|
||||
{{ end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: alertmanager-data
|
||||
namespace: monitoring
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: alertmanager
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: alertmanager
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: alertmanager
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: alertmanager
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65534
|
||||
runAsGroup: 65534
|
||||
fsGroup: 65534
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: alertmanager
|
||||
image: prom/alertmanager:v0.28.0
|
||||
args:
|
||||
- --config.file=/etc/alertmanager/alertmanager.yml
|
||||
- --storage.path=/alertmanager
|
||||
- --web.listen-address=:9093
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 9093
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /-/ready
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 20m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 192Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/alertmanager/alertmanager.yml
|
||||
subPath: alertmanager.yml
|
||||
- name: secrets
|
||||
mountPath: /etc/alertmanager/secrets
|
||||
readOnly: true
|
||||
- name: data
|
||||
mountPath: /alertmanager
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: alertmanager-config
|
||||
- name: secrets
|
||||
secret:
|
||||
secretName: alertmanager-secrets
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: alertmanager-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: alertmanager
|
||||
namespace: monitoring
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: alertmanager
|
||||
ports:
|
||||
- name: http
|
||||
port: 9093
|
||||
targetPort: http
|
||||
219
infra/prod/k8s/monitoring/06-grafana.yaml
Normal file
219
infra/prod/k8s/monitoring/06-grafana.yaml
Normal file
@ -0,0 +1,219 @@
|
||||
# =============================================================================
|
||||
# Grafana -- tableaux de bord (logs Loki + metriques Prometheus)
|
||||
# =============================================================================
|
||||
# Expose sur grafana.xpeditis.com, protege par TROIS couches :
|
||||
# 1. le firewall Hetzner (seules les IP Cloudflare atteignent le serveur)
|
||||
# 2. le middleware Traefik admin-ip-allowlist (vos IP d'administration)
|
||||
# 3. l'authentification Grafana (Secret grafana-admin)
|
||||
# L'inscription et l'acces anonyme sont desactives.
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: grafana-provisioning
|
||||
namespace: monitoring
|
||||
data:
|
||||
datasources.yaml: |
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
isDefault: false
|
||||
jsonData:
|
||||
maxLines: 2000
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
jsonData:
|
||||
timeInterval: 30s
|
||||
|
||||
dashboards.yaml: |
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: xpeditis
|
||||
orgId: 1
|
||||
folder: Xpeditis
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 60
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: grafana-data
|
||||
namespace: monitoring
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: grafana
|
||||
namespace: monitoring
|
||||
labels:
|
||||
app.kubernetes.io/name: grafana
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: grafana
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: grafana
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 472
|
||||
runAsGroup: 472
|
||||
fsGroup: 472
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: grafana
|
||||
image: grafana/grafana:11.4.0
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
env:
|
||||
- name: GF_SECURITY_ADMIN_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: grafana-admin
|
||||
key: admin-user
|
||||
- name: GF_SECURITY_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: grafana-admin
|
||||
key: admin-password
|
||||
- name: GF_SERVER_ROOT_URL
|
||||
value: "https://grafana.xpeditis.com"
|
||||
- name: GF_USERS_ALLOW_SIGN_UP
|
||||
value: "false"
|
||||
- name: GF_AUTH_ANONYMOUS_ENABLED
|
||||
value: "false"
|
||||
# Empeche l'integration de Grafana dans une iframe tierce.
|
||||
- name: GF_SECURITY_ALLOW_EMBEDDING
|
||||
value: "false"
|
||||
- name: GF_SECURITY_COOKIE_SECURE
|
||||
value: "true"
|
||||
- name: GF_SECURITY_COOKIE_SAMESITE
|
||||
value: "strict"
|
||||
- name: GF_SECURITY_STRICT_TRANSPORT_SECURITY
|
||||
value: "true"
|
||||
- name: GF_ANALYTICS_REPORTING_ENABLED
|
||||
value: "false"
|
||||
- name: GF_ANALYTICS_CHECK_FOR_UPDATES
|
||||
value: "false"
|
||||
# Les alertes sont evaluees par Prometheus et routees par
|
||||
# Alertmanager : l'alerting Grafana ferait doublon.
|
||||
- name: GF_UNIFIED_ALERTING_ENABLED
|
||||
value: "false"
|
||||
- name: GF_ALERTING_ENABLED
|
||||
value: "false"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: http
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: provisioning-datasources
|
||||
mountPath: /etc/grafana/provisioning/datasources
|
||||
- name: provisioning-dashboards
|
||||
mountPath: /etc/grafana/provisioning/dashboards
|
||||
- name: dashboards
|
||||
mountPath: /var/lib/grafana/dashboards
|
||||
- name: data
|
||||
mountPath: /var/lib/grafana
|
||||
volumes:
|
||||
- name: provisioning-datasources
|
||||
configMap:
|
||||
name: grafana-provisioning
|
||||
items:
|
||||
- key: datasources.yaml
|
||||
path: datasources.yaml
|
||||
- name: provisioning-dashboards
|
||||
configMap:
|
||||
name: grafana-provisioning
|
||||
items:
|
||||
- key: dashboards.yaml
|
||||
path: dashboards.yaml
|
||||
# Cree par scripts/deploy-monitoring.sh depuis infra/logging/grafana/.
|
||||
- name: dashboards
|
||||
configMap:
|
||||
name: grafana-dashboards
|
||||
optional: true
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: grafana-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: grafana
|
||||
namespace: monitoring
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: grafana
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: grafana
|
||||
namespace: monitoring
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
traefik.ingress.kubernetes.io/router.middlewares: monitoring-admin-ip-allowlist@kubernetescrd,monitoring-security-headers@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- grafana.xpeditis.com
|
||||
secretName: grafana-tls
|
||||
rules:
|
||||
- host: grafana.xpeditis.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: grafana
|
||||
port:
|
||||
name: http
|
||||
227
infra/prod/scripts/00-bootstrap-common.sh
Executable file
227
infra/prod/scripts/00-bootstrap-common.sh
Executable file
@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 00 - Durcissement commun aux deux noeuds (app-01 et db-01)
|
||||
# =============================================================================
|
||||
# A executer EN PREMIER sur chaque serveur, en sudo :
|
||||
# scp infra/prod/scripts/00-bootstrap-common.sh deploy@<ip>:/tmp/
|
||||
# ssh deploy@<ip> 'sudo bash /tmp/00-bootstrap-common.sh <role>'
|
||||
#
|
||||
# <role> = app | data
|
||||
#
|
||||
# Idempotent : peut etre relance sans risque.
|
||||
set -euo pipefail
|
||||
|
||||
ROLE="${1:-}"
|
||||
if [[ "$ROLE" != "app" && "$ROLE" != "data" ]]; then
|
||||
echo "Usage: $0 <app|data>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$EUID" -ne 0 ]]; then
|
||||
echo "Ce script doit tourner en root (sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log() { printf '\n>>> %s\n' "$*"; }
|
||||
|
||||
# --- 1. Paquets de base ------------------------------------------------------
|
||||
log "Mise a jour du systeme"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get upgrade -y -qq
|
||||
apt-get install -y -qq \
|
||||
ufw fail2ban unattended-upgrades apt-listchanges \
|
||||
chrony auditd audispd-plugins \
|
||||
curl gnupg ca-certificates jq git rsync htop ncdu \
|
||||
needrestart
|
||||
|
||||
# --- 2. Mises a jour de securite automatiques --------------------------------
|
||||
log "Activation des mises a jour de securite automatiques"
|
||||
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'CONF'
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
CONF
|
||||
|
||||
cat > /etc/apt/apt.conf.d/50unattended-upgrades <<'CONF'
|
||||
Unattended-Upgrade::Allowed-Origins {
|
||||
"${distro_id}:${distro_codename}-security";
|
||||
"${distro_id}ESMApps:${distro_codename}-apps-security";
|
||||
"${distro_id}ESM:${distro_codename}-infra-security";
|
||||
};
|
||||
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
|
||||
Unattended-Upgrade::Remove-Unused-Dependencies "true";
|
||||
// Redemarrage automatique la nuit SI un paquet noyau l'exige.
|
||||
// Fenetre choisie hors des heures ouvrees des transitaires europeens.
|
||||
Unattended-Upgrade::Automatic-Reboot "true";
|
||||
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
|
||||
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
|
||||
Unattended-Upgrade::Mail "";
|
||||
CONF
|
||||
|
||||
systemctl enable --now unattended-upgrades
|
||||
|
||||
# --- 3. SSH ------------------------------------------------------------------
|
||||
log "Durcissement SSH"
|
||||
cat > /etc/ssh/sshd_config.d/99-xpeditis-hardening.conf <<'CONF'
|
||||
# Authentification par cle uniquement.
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
PermitEmptyPasswords no
|
||||
PubkeyAuthentication yes
|
||||
AuthenticationMethods publickey
|
||||
|
||||
# Reduction de surface.
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
PermitTunnel no
|
||||
GatewayPorts no
|
||||
|
||||
# Anti brute-force / sessions fantomes.
|
||||
MaxAuthTries 3
|
||||
MaxSessions 5
|
||||
LoginGraceTime 20
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
|
||||
# Cryptographie moderne uniquement.
|
||||
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
|
||||
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
|
||||
Macs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
|
||||
|
||||
AllowUsers deploy
|
||||
CONF
|
||||
|
||||
# Retire les cles d'hote faibles si presentes.
|
||||
rm -f /etc/ssh/ssh_host_dsa_key* /etc/ssh/ssh_host_ecdsa_key* || true
|
||||
|
||||
sshd -t
|
||||
systemctl restart ssh 2>/dev/null || systemctl restart sshd
|
||||
|
||||
# --- 4. fail2ban -------------------------------------------------------------
|
||||
log "Configuration fail2ban"
|
||||
cat > /etc/fail2ban/jail.d/xpeditis.local <<'CONF'
|
||||
[DEFAULT]
|
||||
bantime = 1h
|
||||
findtime = 10m
|
||||
maxretry = 4
|
||||
backend = systemd
|
||||
# Ne jamais se bannir soi-meme depuis le reseau prive.
|
||||
ignoreip = 127.0.0.1/8 ::1 10.10.0.0/16
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
mode = aggressive
|
||||
maxretry = 3
|
||||
bantime = 24h
|
||||
CONF
|
||||
|
||||
systemctl enable --now fail2ban
|
||||
systemctl restart fail2ban
|
||||
|
||||
# --- 5. Parametres noyau -----------------------------------------------------
|
||||
log "Durcissement sysctl"
|
||||
cat > /etc/sysctl.d/99-xpeditis-hardening.conf <<'CONF'
|
||||
# Reseau
|
||||
net.ipv4.conf.all.rp_filter = 1
|
||||
net.ipv4.conf.default.rp_filter = 1
|
||||
net.ipv4.conf.all.accept_redirects = 0
|
||||
net.ipv4.conf.all.send_redirects = 0
|
||||
net.ipv4.conf.all.accept_source_route = 0
|
||||
net.ipv4.conf.all.log_martians = 1
|
||||
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
net.ipv6.conf.all.accept_redirects = 0
|
||||
net.ipv6.conf.all.accept_source_route = 0
|
||||
|
||||
# Memoire / noyau
|
||||
kernel.randomize_va_space = 2
|
||||
kernel.kptr_restrict = 2
|
||||
kernel.dmesg_restrict = 1
|
||||
kernel.yama.ptrace_scope = 1
|
||||
fs.protected_hardlinks = 1
|
||||
fs.protected_symlinks = 1
|
||||
fs.suid_dumpable = 0
|
||||
|
||||
# Capacite : k3s et PostgreSQL ouvrent beaucoup de descripteurs.
|
||||
fs.file-max = 2097152
|
||||
fs.inotify.max_user_instances = 8192
|
||||
fs.inotify.max_user_watches = 524288
|
||||
CONF
|
||||
sysctl --system >/dev/null
|
||||
|
||||
# --- 6. Journalisation -------------------------------------------------------
|
||||
log "Limitation des journaux systemd (evite de saturer le disque)"
|
||||
mkdir -p /etc/systemd/journald.conf.d
|
||||
cat > /etc/systemd/journald.conf.d/99-xpeditis.conf <<'CONF'
|
||||
[Journal]
|
||||
SystemMaxUse=2G
|
||||
SystemMaxFileSize=200M
|
||||
MaxRetentionSec=30day
|
||||
Compress=yes
|
||||
CONF
|
||||
systemctl restart systemd-journald
|
||||
|
||||
# --- 7. Horloge --------------------------------------------------------------
|
||||
log "Synchronisation horaire (obligatoire : JWT, TLS, audit_logs)"
|
||||
timedatectl set-timezone Europe/Paris
|
||||
systemctl enable --now chrony
|
||||
|
||||
# --- 8. Audit ----------------------------------------------------------------
|
||||
log "Regles auditd minimales"
|
||||
cat > /etc/audit/rules.d/99-xpeditis.rules <<'CONF'
|
||||
-w /etc/passwd -p wa -k identity
|
||||
-w /etc/shadow -p wa -k identity
|
||||
-w /etc/ssh/sshd_config -p wa -k sshd
|
||||
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
|
||||
-w /etc/sudoers -p wa -k sudoers
|
||||
-w /etc/sudoers.d/ -p wa -k sudoers
|
||||
-w /var/log/auth.log -p wa -k authlog
|
||||
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=4294967295 -k rootcmd
|
||||
CONF
|
||||
augenrules --load >/dev/null 2>&1 || true
|
||||
systemctl enable --now auditd
|
||||
|
||||
# --- 9. Pare-feu local -------------------------------------------------------
|
||||
# Le firewall Hetzner Cloud ne filtre QUE les interfaces publiques. UFW prend
|
||||
# en charge le reseau prive, ou transite le trafic PostgreSQL/Redis.
|
||||
log "Configuration UFW (role: $ROLE)"
|
||||
ufw --force reset >/dev/null
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow 22/tcp comment 'SSH'
|
||||
|
||||
if [[ "$ROLE" == "app" ]]; then
|
||||
ufw allow 80/tcp comment 'HTTP (redirection + ACME)'
|
||||
ufw allow 443/tcp comment 'HTTPS'
|
||||
ufw allow 6443/tcp comment 'API k3s'
|
||||
# Reseau prive : le noeud app doit joindre db-01, pas l'inverse.
|
||||
ufw allow from 10.10.0.0/16 to any port 10250 proto tcp comment 'kubelet metrics'
|
||||
else
|
||||
# Seul app-01 (IP privee) peut atteindre PostgreSQL et Redis.
|
||||
APP_PRIVATE_IP="${APP_PRIVATE_IP:-10.10.1.10}"
|
||||
ufw allow from "${APP_PRIVATE_IP}" to any port 5432 proto tcp comment 'PostgreSQL <- app-01'
|
||||
ufw allow from "${APP_PRIVATE_IP}" to any port 6379 proto tcp comment 'Redis <- app-01'
|
||||
fi
|
||||
|
||||
ufw --force enable
|
||||
ufw status verbose
|
||||
|
||||
# --- 10. Verifications finales ----------------------------------------------
|
||||
log "Verifications"
|
||||
echo " SSH root login : $(sshd -T 2>/dev/null | grep -i '^permitrootlogin' || echo '?')"
|
||||
echo " Password auth : $(sshd -T 2>/dev/null | grep -i '^passwordauthentication' || echo '?')"
|
||||
echo " fail2ban : $(systemctl is-active fail2ban)"
|
||||
echo " unattended-upgr. : $(systemctl is-active unattended-upgrades)"
|
||||
echo " auditd : $(systemctl is-active auditd)"
|
||||
echo " chrony : $(systemctl is-active chrony)"
|
||||
|
||||
log "Durcissement commun termine pour le role '$ROLE'."
|
||||
echo "Etape suivante :"
|
||||
if [[ "$ROLE" == "app" ]]; then
|
||||
echo " sudo bash 02-setup-k3s-server.sh"
|
||||
else
|
||||
echo " sudo bash 01-setup-data-node.sh"
|
||||
fi
|
||||
170
infra/prod/scripts/01-setup-data-node.sh
Executable file
170
infra/prod/scripts/01-setup-data-node.sh
Executable file
@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 01 - Installation du noeud de donnees (db-01)
|
||||
# =============================================================================
|
||||
# Prerequis : 00-bootstrap-common.sh data deja execute sur ce serveur.
|
||||
#
|
||||
# sudo bash 01-setup-data-node.sh
|
||||
#
|
||||
# Ce script :
|
||||
# 1. monte le volume Hetzner sur /var/lib/xpeditis/pgdata
|
||||
# 2. installe Docker Engine depuis le depot officiel
|
||||
# 3. genere le certificat TLS interne de PostgreSQL
|
||||
# 4. installe age (chiffrement des dumps)
|
||||
# 5. installe les unites systemd de sauvegarde
|
||||
#
|
||||
# Il ne demarre PAS la base : il faut d'abord deposer .env.data (SOPS).
|
||||
set -euo pipefail
|
||||
|
||||
[[ "$EUID" -eq 0 ]] || { echo "Executer en root (sudo)." >&2; exit 1; }
|
||||
|
||||
APP_PRIVATE_IP="${APP_PRIVATE_IP:-10.10.1.10}"
|
||||
DB_PRIVATE_IP="${DB_PRIVATE_IP:-10.10.1.20}"
|
||||
BASE_DIR=/opt/xpeditis/data-node
|
||||
DATA_ROOT=/var/lib/xpeditis
|
||||
|
||||
log() { printf '\n>>> %s\n' "$*"; }
|
||||
|
||||
# --- 1. Volume de donnees ----------------------------------------------------
|
||||
log "Montage du volume PostgreSQL"
|
||||
mkdir -p "${DATA_ROOT}"/{pgdata,redis,certs,dumps}
|
||||
|
||||
# Hetzner expose les volumes sous /dev/disk/by-id/scsi-0HC_Volume_<id>.
|
||||
VOLUME_DEV="$(ls /dev/disk/by-id/scsi-0HC_Volume_* 2>/dev/null | head -1 || true)"
|
||||
|
||||
if [[ -n "$VOLUME_DEV" ]]; then
|
||||
if ! blkid "$VOLUME_DEV" >/dev/null 2>&1; then
|
||||
log "Formatage de $VOLUME_DEV en ext4 (volume vierge)"
|
||||
mkfs.ext4 -F -L xpeditis-pgdata "$VOLUME_DEV"
|
||||
else
|
||||
log "Volume deja formate, conservation des donnees existantes"
|
||||
fi
|
||||
|
||||
if ! grep -q "${DATA_ROOT}/pgdata" /etc/fstab; then
|
||||
# `nofail` : si le volume est absent au boot, le serveur demarre quand meme
|
||||
# et on diagnostique en SSH plutot que de se retrouver hors ligne.
|
||||
echo "${VOLUME_DEV} ${DATA_ROOT}/pgdata ext4 discard,nofail,defaults 0 2" >> /etc/fstab
|
||||
fi
|
||||
mount -a
|
||||
df -h "${DATA_ROOT}/pgdata"
|
||||
else
|
||||
echo "AVERTISSEMENT: aucun volume Hetzner detecte. Les donnees resteront sur le disque systeme."
|
||||
fi
|
||||
|
||||
# L'UID/GID 999 est celui de l'utilisateur postgres dans l'image officielle.
|
||||
chown -R 999:999 "${DATA_ROOT}/pgdata" "${DATA_ROOT}/redis" "${DATA_ROOT}/dumps"
|
||||
chmod 700 "${DATA_ROOT}/pgdata"
|
||||
|
||||
# --- 2. Docker Engine --------------------------------------------------------
|
||||
if ! command -v docker >/dev/null; then
|
||||
log "Installation de Docker Engine (depot officiel)"
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
fi
|
||||
|
||||
log "Durcissement du demon Docker"
|
||||
cat > /etc/docker/daemon.json <<'JSON'
|
||||
{
|
||||
"log-driver": "json-file",
|
||||
"log-opts": { "max-size": "50m", "max-file": "5" },
|
||||
"live-restore": true,
|
||||
"userland-proxy": false,
|
||||
"no-new-privileges": true,
|
||||
"icc": false,
|
||||
"default-address-pools": [
|
||||
{ "base": "172.28.0.0/16", "size": 24 }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
systemctl enable --now docker
|
||||
systemctl restart docker
|
||||
|
||||
# --- 3. Certificat TLS de PostgreSQL ----------------------------------------
|
||||
# Certificat auto-signe : PostgreSQL n'est joignable que depuis app-01 sur un
|
||||
# reseau prive, il n'y a pas de tiers a authentifier. Ce certificat sert a
|
||||
# CHIFFRER le transport, pas a prouver une identite publique.
|
||||
# Cote client, DATABASE_SSL=true avec rejectUnauthorized:false accepte ce
|
||||
# certificat : c'est coherent, et documente dans 04-noeud-donnees.md.
|
||||
if [[ ! -f "${DATA_ROOT}/certs/server.key" ]]; then
|
||||
log "Generation du certificat TLS PostgreSQL (10 ans)"
|
||||
openssl req -new -x509 -days 3650 -nodes \
|
||||
-newkey rsa:4096 \
|
||||
-out "${DATA_ROOT}/certs/server.crt" \
|
||||
-keyout "${DATA_ROOT}/certs/server.key" \
|
||||
-subj "/CN=xpeditis-prod-db-01/O=Xpeditis/C=FR" \
|
||||
-addext "subjectAltName=IP:${DB_PRIVATE_IP},DNS:postgres"
|
||||
# PostgreSQL refuse de demarrer si la cle privee est lisible par d'autres.
|
||||
chown 999:999 "${DATA_ROOT}/certs/server.key" "${DATA_ROOT}/certs/server.crt"
|
||||
chmod 600 "${DATA_ROOT}/certs/server.key"
|
||||
chmod 644 "${DATA_ROOT}/certs/server.crt"
|
||||
else
|
||||
log "Certificat TLS deja present, conserve"
|
||||
fi
|
||||
|
||||
# --- 4. age (chiffrement des dumps) -----------------------------------------
|
||||
if ! command -v age >/dev/null; then
|
||||
log "Installation de age"
|
||||
apt-get install -y -qq age
|
||||
fi
|
||||
mkdir -p /root/.config/xpeditis
|
||||
chmod 700 /root/.config/xpeditis
|
||||
|
||||
# --- 5. Arborescence applicative --------------------------------------------
|
||||
log "Preparation de ${BASE_DIR}"
|
||||
mkdir -p "${BASE_DIR}"/{conf,backup}
|
||||
chmod 750 "${BASE_DIR}"
|
||||
|
||||
# --- 6. Unites systemd de sauvegarde ----------------------------------------
|
||||
if [[ -f "${BASE_DIR}/backup/xpeditis-backup.service" ]]; then
|
||||
log "Installation des timers de sauvegarde"
|
||||
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup.service" /etc/systemd/system/
|
||||
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup.timer" /etc/systemd/system/
|
||||
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup-verify.service" /etc/systemd/system/
|
||||
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup-verify.timer" /etc/systemd/system/
|
||||
chmod +x "${BASE_DIR}/backup/"*.sh
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now xpeditis-backup.timer xpeditis-backup-verify.timer
|
||||
systemctl list-timers 'xpeditis-*' --no-pager
|
||||
else
|
||||
echo "AVERTISSEMENT: ${BASE_DIR}/backup/ vide. Copiez infra/prod/data-node/ puis relancez ce script."
|
||||
fi
|
||||
|
||||
# --- 7. Rappel du filtrage reseau -------------------------------------------
|
||||
log "Regles UFW effectives"
|
||||
ufw status numbered
|
||||
|
||||
cat <<NEXT
|
||||
|
||||
=============================================================================
|
||||
Noeud de donnees pret.
|
||||
=============================================================================
|
||||
Etapes suivantes (depuis votre poste) :
|
||||
|
||||
1. Copier la configuration :
|
||||
rsync -a infra/prod/data-node/ deploy@${DB_PRIVATE_IP}:/tmp/data-node/
|
||||
ssh deploy@<ip-publique-db> 'sudo rsync -a /tmp/data-node/ ${BASE_DIR}/'
|
||||
|
||||
2. Dechiffrer et deposer les secrets :
|
||||
sops -d --input-type dotenv --output-type dotenv \\
|
||||
infra/prod/data-node/data-node.sops.env > /tmp/.env.data
|
||||
scp /tmp/.env.data deploy@<ip-publique-db>:/tmp/
|
||||
ssh deploy@<ip> 'sudo install -m600 -o root -g root /tmp/.env.data ${BASE_DIR}/.env.data && shred -u /tmp/.env.data'
|
||||
|
||||
3. Demarrer :
|
||||
cd ${BASE_DIR} && sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build
|
||||
|
||||
4. Premiere sauvegarde complete (obligatoire avant d'ouvrir au public) :
|
||||
sudo systemctl start xpeditis-backup.service
|
||||
sudo journalctl -u xpeditis-backup -f
|
||||
|
||||
Verifier que rien n'est expose publiquement, depuis un autre reseau :
|
||||
nmap -Pn -p 5432,6379 <ip-publique-db> # doit repondre filtered
|
||||
=============================================================================
|
||||
NEXT
|
||||
245
infra/prod/scripts/02-setup-k3s-server.sh
Executable file
245
infra/prod/scripts/02-setup-k3s-server.sh
Executable file
@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 02 - Installation du cluster k3s (app-01)
|
||||
# =============================================================================
|
||||
# Prerequis : 00-bootstrap-common.sh app deja execute sur ce serveur.
|
||||
#
|
||||
# sudo K3S_VERSION=v1.31.5+k3s1 PUBLIC_IP=<ip> bash 02-setup-k3s-server.sh
|
||||
#
|
||||
# Choix structurants et pourquoi :
|
||||
# --secrets-encryption les Secrets sont chiffres au repos dans la base
|
||||
# d'etat de k3s. Sans ce drapeau, un acces disque
|
||||
# (snapshot, vol de volume) livre tous les secrets.
|
||||
# --protect-kernel-defaults refuse de demarrer si les sysctl attendus par
|
||||
# kubelet ne sont pas poses : echec bruyant plutot
|
||||
# que derive silencieuse.
|
||||
# audit-log journal d'audit de l'API : indispensable pour
|
||||
# repondre a "qui a supprime ce deploiement".
|
||||
# Traefik est CONSERVE (celui livre par k3s) et reconfigure via
|
||||
# HelmChartConfig : entrypoints, en-tetes de
|
||||
# securite, IPs de confiance Cloudflare.
|
||||
set -euo pipefail
|
||||
|
||||
[[ "$EUID" -eq 0 ]] || { echo "Executer en root (sudo)." >&2; exit 1; }
|
||||
|
||||
K3S_VERSION="${K3S_VERSION:-v1.31.5+k3s1}"
|
||||
PUBLIC_IP="${PUBLIC_IP:-$(curl -s --max-time 5 https://ifconfig.me || true)}"
|
||||
PRIVATE_IP="${PRIVATE_IP:-10.10.1.10}"
|
||||
MANIFEST_DIR=/var/lib/rancher/k3s/server/manifests
|
||||
|
||||
log() { printf '\n>>> %s\n' "$*"; }
|
||||
|
||||
[[ -n "$PUBLIC_IP" ]] || { echo "PUBLIC_IP introuvable, passez-la en variable." >&2; exit 1; }
|
||||
|
||||
# --- 1. Prerequis noyau ------------------------------------------------------
|
||||
log "Parametres noyau exiges par kubelet (--protect-kernel-defaults)"
|
||||
cat > /etc/sysctl.d/90-kubelet.conf <<'CONF'
|
||||
vm.panic_on_oom=0
|
||||
vm.overcommit_memory=1
|
||||
kernel.panic=10
|
||||
kernel.panic_on_oops=1
|
||||
CONF
|
||||
sysctl --system >/dev/null
|
||||
|
||||
log "Desactivation du swap (exige par kubelet)"
|
||||
swapoff -a || true
|
||||
sed -i '/\sswap\s/s/^/#/' /etc/fstab || true
|
||||
|
||||
# --- 2. Politique d'audit de l'API Kubernetes -------------------------------
|
||||
log "Politique d'audit"
|
||||
mkdir -p /var/lib/rancher/k3s/server /var/log/k3s
|
||||
cat > /var/lib/rancher/k3s/server/audit-policy.yaml <<'YAML'
|
||||
apiVersion: audit.k8s.io/v1
|
||||
kind: Policy
|
||||
# Ne jamais journaliser le contenu des Secrets : le journal d'audit deviendrait
|
||||
# lui-meme un coffre-fort en clair.
|
||||
omitStages:
|
||||
- RequestReceived
|
||||
rules:
|
||||
- level: None
|
||||
resources:
|
||||
- group: ""
|
||||
resources: ["secrets", "configmaps"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
|
||||
# Bruit de fond : sondes et lectures d'etat.
|
||||
- level: None
|
||||
users: ["system:kube-proxy", "system:apiserver"]
|
||||
verbs: ["watch", "list"]
|
||||
- level: None
|
||||
nonResourceURLs: ["/healthz*", "/readyz*", "/livez*", "/version", "/metrics"]
|
||||
|
||||
# Toute ecriture est tracee avec ses metadonnees (qui, quoi, quand).
|
||||
- level: Metadata
|
||||
verbs: ["create", "update", "patch", "delete", "deletecollection"]
|
||||
|
||||
# Acces aux Secrets : trace, sans le contenu.
|
||||
- level: Metadata
|
||||
resources:
|
||||
- group: ""
|
||||
resources: ["secrets"]
|
||||
|
||||
# Escalades de privileges : trace complet cote requete.
|
||||
- level: Request
|
||||
resources:
|
||||
- group: "rbac.authorization.k8s.io"
|
||||
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
|
||||
|
||||
- level: Metadata
|
||||
YAML
|
||||
chmod 600 /var/lib/rancher/k3s/server/audit-policy.yaml
|
||||
|
||||
# --- 3. Configuration Traefik (avant l'installation : k3s l'applique au boot) -
|
||||
log "Configuration Traefik (HelmChartConfig)"
|
||||
mkdir -p "$MANIFEST_DIR"
|
||||
cat > "${MANIFEST_DIR}/traefik-config.yaml" <<'YAML'
|
||||
apiVersion: helm.cattle.io/v1
|
||||
kind: HelmChartConfig
|
||||
metadata:
|
||||
name: traefik
|
||||
namespace: kube-system
|
||||
spec:
|
||||
valuesContent: |-
|
||||
# Les vraies IP des visiteurs arrivent dans X-Forwarded-For, pose par
|
||||
# Cloudflare. Sans cette liste de confiance, la limitation de debit et les
|
||||
# audit_logs verraient tous l'IP de Cloudflare : inexploitable.
|
||||
# Rafraichir avec scripts/refresh-cloudflare-ips.sh.
|
||||
additionalArguments:
|
||||
- "--entrypoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,2400:cb00::/32,2606:4700::/32,2803:f800::/32,2405:b500::/32,2405:8100::/32,2a06:98c0::/29,2c0f:f248::/32"
|
||||
- "--entrypoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,2400:cb00::/32,2606:4700::/32,2803:f800::/32,2405:b500::/32,2405:8100::/32,2a06:98c0::/29,2c0f:f248::/32"
|
||||
# Tout le trafic HTTP est redirige en HTTPS au niveau de l'entrypoint :
|
||||
# aucun Ingress ne peut oublier de le faire.
|
||||
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
||||
- "--entrypoints.web.http.redirections.entryPoint.permanent=true"
|
||||
# Delais de garde : borne les connexions lentes (slowloris).
|
||||
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=60s"
|
||||
- "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0s"
|
||||
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=180s"
|
||||
- "--serversTransport.maxIdleConnsPerHost=100"
|
||||
# WebSocket (Socket.IO) : pas de timeout d'ecriture, sinon les
|
||||
# notifications temps reel sont coupees toutes les 60 s.
|
||||
- "--metrics.prometheus=true"
|
||||
- "--metrics.prometheus.addEntryPointsLabels=true"
|
||||
- "--metrics.prometheus.addServicesLabels=true"
|
||||
- "--accesslog=true"
|
||||
- "--accesslog.format=json"
|
||||
- "--accesslog.fields.headers.defaultMode=drop"
|
||||
- "--accesslog.fields.headers.names.User-Agent=keep"
|
||||
- "--accesslog.fields.headers.names.Cf-Connecting-Ip=keep"
|
||||
- "--ping=true"
|
||||
|
||||
# Le tableau de bord Traefik n'est jamais expose.
|
||||
ingressRoute:
|
||||
dashboard:
|
||||
enabled: false
|
||||
|
||||
logs:
|
||||
general:
|
||||
level: WARN
|
||||
access:
|
||||
enabled: true
|
||||
|
||||
# Le service est en LoadBalancer (klipper-lb de k3s) : il ecoute
|
||||
# directement sur les ports 80/443 de l'hote, filtres par le firewall
|
||||
# Hetzner qui n'accepte que les IP Cloudflare.
|
||||
service:
|
||||
spec:
|
||||
externalTrafficPolicy: Local
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
YAML
|
||||
|
||||
# --- 4. Installation de k3s --------------------------------------------------
|
||||
if ! command -v k3s >/dev/null; then
|
||||
log "Installation de k3s ${K3S_VERSION}"
|
||||
curl -sfL https://get.k3s.io | \
|
||||
INSTALL_K3S_VERSION="${K3S_VERSION}" \
|
||||
INSTALL_K3S_EXEC="server \
|
||||
--node-name=xpeditis-prod-app-01 \
|
||||
--node-ip=${PRIVATE_IP} \
|
||||
--advertise-address=${PRIVATE_IP} \
|
||||
--tls-san=${PUBLIC_IP} \
|
||||
--tls-san=${PRIVATE_IP} \
|
||||
--write-kubeconfig-mode=0600 \
|
||||
--secrets-encryption \
|
||||
--protect-kernel-defaults \
|
||||
--kube-apiserver-arg=audit-log-path=/var/log/k3s/audit.log \
|
||||
--kube-apiserver-arg=audit-policy-file=/var/lib/rancher/k3s/server/audit-policy.yaml \
|
||||
--kube-apiserver-arg=audit-log-maxage=30 \
|
||||
--kube-apiserver-arg=audit-log-maxbackup=10 \
|
||||
--kube-apiserver-arg=audit-log-maxsize=100 \
|
||||
--kube-apiserver-arg=request-timeout=300s \
|
||||
--kubelet-arg=streaming-connection-idle-timeout=5m \
|
||||
--kubelet-arg=event-qps=0 \
|
||||
--etcd-expose-metrics=false" \
|
||||
sh -
|
||||
else
|
||||
log "k3s deja installe : $(k3s --version | head -1)"
|
||||
fi
|
||||
|
||||
systemctl enable --now k3s
|
||||
log "Attente de la disponibilite du noeud"
|
||||
for _ in $(seq 1 60); do
|
||||
k3s kubectl get node >/dev/null 2>&1 && break
|
||||
sleep 5
|
||||
done
|
||||
k3s kubectl get node -o wide
|
||||
|
||||
# --- 5. kubectl pour l'utilisateur deploy -----------------------------------
|
||||
log "Configuration de kubectl pour l'utilisateur deploy"
|
||||
DEPLOY_USER="${DEPLOY_USER:-deploy}"
|
||||
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/${DEPLOY_USER}/.kube"
|
||||
install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
|
||||
/etc/rancher/k3s/k3s.yaml "/home/${DEPLOY_USER}/.kube/config"
|
||||
grep -q 'KUBECONFIG' "/home/${DEPLOY_USER}/.bashrc" || \
|
||||
echo 'export KUBECONFIG=$HOME/.kube/config' >> "/home/${DEPLOY_USER}/.bashrc"
|
||||
|
||||
# `k3s kubectl` reste reserve a root ; deploy passe par son kubeconfig.
|
||||
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl
|
||||
|
||||
# --- 6. Rotation des journaux d'audit ---------------------------------------
|
||||
cat > /etc/logrotate.d/k3s-audit <<'CONF'
|
||||
/var/log/k3s/audit.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
su root root
|
||||
}
|
||||
CONF
|
||||
|
||||
# --- 7. Verifications --------------------------------------------------------
|
||||
log "Etat du cluster"
|
||||
k3s kubectl get nodes
|
||||
k3s kubectl -n kube-system get pods
|
||||
|
||||
cat <<NEXT
|
||||
|
||||
=============================================================================
|
||||
Cluster k3s pret.
|
||||
=============================================================================
|
||||
Le chiffrement des Secrets au repos est actif :
|
||||
sudo k3s secrets-encrypt status
|
||||
|
||||
Recuperer le kubeconfig sur votre poste (l'API n'est ouverte qu'a vos IPs
|
||||
d'administration, cf. firewall Terraform) :
|
||||
|
||||
ssh deploy@${PUBLIC_IP} 'cat ~/.kube/config' \\
|
||||
| sed "s/127.0.0.1/${PUBLIC_IP}/" > ~/.kube/xpeditis-prod.yaml
|
||||
chmod 600 ~/.kube/xpeditis-prod.yaml
|
||||
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||
kubectl get nodes
|
||||
|
||||
Etape suivante :
|
||||
sudo bash 03-install-cluster-addons.sh
|
||||
=============================================================================
|
||||
NEXT
|
||||
86
infra/prod/scripts/03-install-cluster-addons.sh
Executable file
86
infra/prod/scripts/03-install-cluster-addons.sh
Executable file
@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# 03 - Composants du cluster (cert-manager, namespaces, acces registre)
|
||||
# =============================================================================
|
||||
# A executer depuis VOTRE POSTE, kubeconfig de prod charge :
|
||||
#
|
||||
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||
# REGISTRY_TOKEN=... bash 03-install-cluster-addons.sh
|
||||
#
|
||||
# cert-manager est installe depuis son manifeste statique officiel : pas de
|
||||
# Helm a maintenir sur le cluster, une seule version epinglee, un seul fichier
|
||||
# a relire en cas de doute.
|
||||
set -euo pipefail
|
||||
|
||||
CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.16.2}"
|
||||
REGISTRY_SERVER="${REGISTRY_SERVER:-rg.fr-par.scw.cloud}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
NAMESPACE="${NAMESPACE:-xpeditis-prod}"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
K8S_DIR="${HERE}/../k8s"
|
||||
|
||||
log() { printf '\n>>> %s\n' "$*"; }
|
||||
|
||||
kubectl version --output=yaml >/dev/null || { echo "kubectl ne joint pas le cluster." >&2; exit 1; }
|
||||
|
||||
# --- 1. Namespaces -----------------------------------------------------------
|
||||
log "Namespaces"
|
||||
kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
|
||||
|
||||
# --- 2. cert-manager ---------------------------------------------------------
|
||||
if ! kubectl get ns cert-manager >/dev/null 2>&1; then
|
||||
log "Installation de cert-manager ${CERT_MANAGER_VERSION}"
|
||||
kubectl apply -f \
|
||||
"https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
|
||||
else
|
||||
log "cert-manager deja present"
|
||||
fi
|
||||
|
||||
log "Attente de cert-manager"
|
||||
kubectl -n cert-manager rollout status deploy/cert-manager --timeout=180s
|
||||
kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=180s
|
||||
kubectl -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=180s
|
||||
|
||||
# --- 3. Acces au registre Scaleway ------------------------------------------
|
||||
# Le registre est prive : sans ce Secret, les pods restent en ImagePullBackOff.
|
||||
if [[ -n "$REGISTRY_TOKEN" ]]; then
|
||||
log "Secret d'acces au registre (${REGISTRY_SERVER})"
|
||||
kubectl -n "$NAMESPACE" create secret docker-registry regcred \
|
||||
--docker-server="$REGISTRY_SERVER" \
|
||||
--docker-username=nologin \
|
||||
--docker-password="$REGISTRY_TOKEN" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
else
|
||||
echo "AVERTISSEMENT: REGISTRY_TOKEN absent. Creez 'regcred' avant de deployer :"
|
||||
echo " kubectl -n ${NAMESPACE} create secret docker-registry regcred \\"
|
||||
echo " --docker-server=${REGISTRY_SERVER} --docker-username=nologin --docker-password=<token>"
|
||||
fi
|
||||
|
||||
# --- 4. Emetteur ACME --------------------------------------------------------
|
||||
# Le ClusterIssuer depend d'un Secret contenant le token API Cloudflare :
|
||||
# il est chiffre SOPS et doit etre applique avant (secrets-apply.sh).
|
||||
if kubectl -n cert-manager get secret cloudflare-api-token >/dev/null 2>&1; then
|
||||
log "ClusterIssuer Let's Encrypt (DNS-01 Cloudflare)"
|
||||
kubectl apply -f "${K8S_DIR}/cluster/cluster-issuer.yaml"
|
||||
else
|
||||
echo "AVERTISSEMENT: le Secret cert-manager/cloudflare-api-token est absent."
|
||||
echo " Appliquez d'abord les secrets : bash scripts/secrets-apply.sh"
|
||||
echo " puis : kubectl apply -f ${K8S_DIR}/cluster/cluster-issuer.yaml"
|
||||
fi
|
||||
|
||||
# --- 5. Verifications --------------------------------------------------------
|
||||
log "Etat"
|
||||
kubectl get ns
|
||||
kubectl -n cert-manager get pods
|
||||
kubectl get clusterissuer 2>/dev/null || true
|
||||
|
||||
cat <<NEXT
|
||||
|
||||
=============================================================================
|
||||
Composants du cluster installes.
|
||||
=============================================================================
|
||||
Etapes suivantes :
|
||||
1. bash scripts/secrets-apply.sh # secrets SOPS -> cluster
|
||||
2. bash scripts/deploy.sh <tag-image> # premier deploiement
|
||||
=============================================================================
|
||||
NEXT
|
||||
76
infra/prod/scripts/deploy-monitoring.sh
Executable file
76
infra/prod/scripts/deploy-monitoring.sh
Executable file
@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Deploiement de la pile d'observabilite
|
||||
# =============================================================================
|
||||
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||
# bash scripts/deploy-monitoring.sh
|
||||
#
|
||||
# Loki (journaux) + Promtail (collecte) + Prometheus (metriques) +
|
||||
# Alertmanager (Discord) + Grafana (consultation).
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
K8S_DIR="${HERE}/../k8s"
|
||||
REPO_ROOT="$(cd "${HERE}/../../.." && pwd)"
|
||||
|
||||
log() { printf '\n>>> %s\n' "$*"; }
|
||||
|
||||
kubectl get ns monitoring >/dev/null 2>&1 || kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
|
||||
|
||||
kubectl -n monitoring get secret grafana-admin >/dev/null 2>&1 \
|
||||
|| { echo "Secret grafana-admin absent : lancez d'abord scripts/secrets-apply.sh" >&2; exit 1; }
|
||||
kubectl -n monitoring get secret alertmanager-secrets >/dev/null 2>&1 \
|
||||
|| { echo "Secret alertmanager-secrets absent : lancez d'abord scripts/secrets-apply.sh" >&2; exit 1; }
|
||||
|
||||
# --- Tableaux de bord --------------------------------------------------------
|
||||
# Reutilise les tableaux de bord deja ecrits pour la preprod plutot que d'en
|
||||
# maintenir un second jeu.
|
||||
DASHBOARD_SRC="${REPO_ROOT}/infra/logging/grafana/provisioning/dashboards"
|
||||
if [[ -d "$DASHBOARD_SRC" ]]; then
|
||||
log "Tableaux de bord depuis ${DASHBOARD_SRC}"
|
||||
kubectl -n monitoring create configmap grafana-dashboards \
|
||||
$(find "$DASHBOARD_SRC" -name '*.json' -exec printf -- '--from-file=%s ' {} +) \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
else
|
||||
echo "AVERTISSEMENT: aucun tableau de bord trouve dans ${DASHBOARD_SRC}"
|
||||
fi
|
||||
|
||||
log "Loki"
|
||||
kubectl apply -f "${K8S_DIR}/monitoring/01-loki.yaml"
|
||||
log "Promtail"
|
||||
kubectl apply -f "${K8S_DIR}/monitoring/02-promtail.yaml"
|
||||
log "Prometheus"
|
||||
kubectl apply -f "${K8S_DIR}/monitoring/03-prometheus.yaml"
|
||||
log "node-exporter"
|
||||
kubectl apply -f "${K8S_DIR}/monitoring/04-node-exporter.yaml"
|
||||
log "Alertmanager"
|
||||
kubectl apply -f "${K8S_DIR}/monitoring/05-alertmanager.yaml"
|
||||
log "Grafana"
|
||||
kubectl apply -f "${K8S_DIR}/monitoring/06-grafana.yaml"
|
||||
|
||||
log "Politiques reseau du namespace monitoring"
|
||||
kubectl apply -f "${K8S_DIR}/base/10-network-policies.yaml"
|
||||
kubectl apply -f "${K8S_DIR}/base/08-traefik-middlewares.yaml"
|
||||
|
||||
log "Attente du demarrage"
|
||||
kubectl -n monitoring rollout status deploy/loki --timeout=300s
|
||||
kubectl -n monitoring rollout status deploy/prometheus --timeout=300s
|
||||
kubectl -n monitoring rollout status deploy/alertmanager --timeout=180s
|
||||
kubectl -n monitoring rollout status deploy/grafana --timeout=300s
|
||||
|
||||
log "Etat"
|
||||
kubectl -n monitoring get pods,svc,pvc
|
||||
|
||||
cat <<'NEXT'
|
||||
|
||||
Grafana : https://grafana.xpeditis.com
|
||||
Identifiants : Secret monitoring/grafana-admin
|
||||
L'acces est filtre par IP (middleware monitoring-admin-ip-allowlist).
|
||||
Adaptez la liste dans k8s/base/08-traefik-middlewares.yaml, sinon vous
|
||||
obtiendrez un 403 depuis votre poste.
|
||||
|
||||
Verifier que les alertes partent bien, AVANT d'en avoir besoin :
|
||||
kubectl -n monitoring port-forward svc/alertmanager 9093:9093
|
||||
curl -XPOST http://localhost:9093/api/v2/alerts -H 'Content-Type: application/json' \
|
||||
-d '[{"labels":{"alertname":"TestDeRoutage","severity":"avertissement"}}]'
|
||||
NEXT
|
||||
126
infra/prod/scripts/deploy.sh
Executable file
126
infra/prod/scripts/deploy.sh
Executable file
@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Deploiement d'une version en production
|
||||
# =============================================================================
|
||||
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||
# bash scripts/deploy.sh prod-a1b2c3d
|
||||
#
|
||||
# Sequence :
|
||||
# 1. verification que les images existent dans le registre
|
||||
# 2. Job de migration (parallelisme 1) -- bloquant
|
||||
# 3. mise a jour des images backend et frontend
|
||||
# 4. attente du deploiement complet
|
||||
# 5. tests de fumee sur les URLs publiques
|
||||
# 6. retour arriere automatique si l'une des etapes echoue
|
||||
#
|
||||
# C'est la meme sequence que cd-main.yml : ce script est le chemin manuel de
|
||||
# secours quand GitHub Actions est indisponible.
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?usage: $0 <tag-image> ex: prod-a1b2c3d}"
|
||||
NAMESPACE="${NAMESPACE:-xpeditis-prod}"
|
||||
REGISTRY="${REGISTRY:-rg.fr-par.scw.cloud/weworkstudio}"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
K8S_DIR="${HERE}/../k8s"
|
||||
|
||||
log() { printf '\n>>> %s\n' "$*"; }
|
||||
fail() { printf '\nECHEC: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
rollback() {
|
||||
log "RETOUR ARRIERE"
|
||||
kubectl -n "$NAMESPACE" rollout undo deploy/xpeditis-backend || true
|
||||
kubectl -n "$NAMESPACE" rollout undo deploy/xpeditis-frontend || true
|
||||
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-backend --timeout=180s || true
|
||||
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-frontend --timeout=180s || true
|
||||
cat >&2 <<'MSG'
|
||||
|
||||
Les deploiements sont revenus a la version precedente.
|
||||
|
||||
ATTENTION : les MIGRATIONS DE BASE, elles, ne sont pas annulees. Si la version
|
||||
retiree contenait une migration destructrice (colonne supprimee, type change),
|
||||
l'ancienne version applicative peut ne plus fonctionner contre le schema
|
||||
courant. Voir docs/mise-en-prod/15-exploitation-incidents.md, "Retour arriere
|
||||
avec migration".
|
||||
MSG
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 0. Preconditions --------------------------------------------------------
|
||||
kubectl get ns "$NAMESPACE" >/dev/null || fail "namespace $NAMESPACE introuvable"
|
||||
kubectl -n "$NAMESPACE" get secret regcred >/dev/null \
|
||||
|| fail "secret 'regcred' absent : les images ne pourront pas etre telechargees"
|
||||
kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets >/dev/null \
|
||||
|| fail "secrets applicatifs absents : lancez scripts/secrets-apply.sh"
|
||||
|
||||
BACKEND_IMAGE="${REGISTRY}/xpeditis-backend:${TAG}"
|
||||
FRONTEND_IMAGE="${REGISTRY}/xpeditis-frontend:${TAG}"
|
||||
EXPORTER_IMAGE="${REGISTRY}/xpeditis-log-exporter:${TAG}"
|
||||
|
||||
log "Version deployee : ${TAG}"
|
||||
kubectl -n "$NAMESPACE" get deploy -o wide
|
||||
|
||||
# --- 1. Configuration --------------------------------------------------------
|
||||
log "Application de la configuration (ConfigMap, Ingress, politiques)"
|
||||
kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
|
||||
kubectl apply -f "${K8S_DIR}/base/01-limits.yaml"
|
||||
kubectl apply -f "${K8S_DIR}/base/02-configmap-backend.yaml"
|
||||
kubectl apply -f "${K8S_DIR}/base/08-traefik-middlewares.yaml"
|
||||
kubectl apply -f "${K8S_DIR}/base/10-network-policies.yaml"
|
||||
kubectl apply -f "${K8S_DIR}/base/11-certificate.yaml"
|
||||
kubectl apply -f "${K8S_DIR}/base/09-ingress.yaml"
|
||||
|
||||
# Empreinte de la configuration : sans elle, un changement de ConfigMap ne
|
||||
# provoque aucun redemarrage et reste sans effet jusqu'au deploiement suivant.
|
||||
CONFIG_SUM="$(kubectl -n "$NAMESPACE" get cm xpeditis-backend-config -o yaml \
|
||||
| sha256sum | cut -c1-16)"
|
||||
|
||||
# --- 2. Migrations -----------------------------------------------------------
|
||||
JOB_NAME="xpeditis-migrate-${TAG//[^a-z0-9-]/-}"
|
||||
log "Migrations de base (Job ${JOB_NAME})"
|
||||
|
||||
kubectl -n "$NAMESPACE" delete job "$JOB_NAME" --ignore-not-found >/dev/null
|
||||
|
||||
sed -e "s|__IMAGE_TAG__|${TAG}|g" "${K8S_DIR}/base/07-migration-job.yaml" \
|
||||
| kubectl apply -f -
|
||||
|
||||
if ! kubectl -n "$NAMESPACE" wait --for=condition=complete "job/${JOB_NAME}" --timeout=900s; then
|
||||
echo "--- journaux du Job de migration ---" >&2
|
||||
kubectl -n "$NAMESPACE" logs "job/${JOB_NAME}" --tail=200 >&2 || true
|
||||
fail "les migrations ont echoue : AUCUNE image n'a ete deployee, la production tourne toujours sur la version precedente"
|
||||
fi
|
||||
kubectl -n "$NAMESPACE" logs "job/${JOB_NAME}" --tail=50
|
||||
|
||||
# --- 3. Deploiement ----------------------------------------------------------
|
||||
log "Mise a jour du backend"
|
||||
kubectl -n "$NAMESPACE" patch deploy xpeditis-backend --type=strategic -p \
|
||||
"{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"xpeditis.com/config-checksum\":\"${CONFIG_SUM}\"}}}}}"
|
||||
kubectl -n "$NAMESPACE" set image deploy/xpeditis-backend "backend=${BACKEND_IMAGE}"
|
||||
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-backend --timeout=300s || rollback
|
||||
|
||||
log "Mise a jour du frontend"
|
||||
kubectl -n "$NAMESPACE" set image deploy/xpeditis-frontend "frontend=${FRONTEND_IMAGE}"
|
||||
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-frontend --timeout=300s || rollback
|
||||
|
||||
# Le collecteur de logs n'est pas critique : son echec ne doit pas declencher
|
||||
# un retour arriere de l'application.
|
||||
log "Mise a jour du log-exporter"
|
||||
kubectl -n "$NAMESPACE" set image deploy/xpeditis-log-exporter "log-exporter=${EXPORTER_IMAGE}" || true
|
||||
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-log-exporter --timeout=180s \
|
||||
|| log "AVERTISSEMENT: le log-exporter n'a pas demarre (non bloquant)"
|
||||
|
||||
# --- 4. Tests de fumee -------------------------------------------------------
|
||||
log "Tests de fumee"
|
||||
if ! bash "${HERE}/smoke-test.sh"; then
|
||||
rollback
|
||||
fi
|
||||
|
||||
# --- 5. Compte rendu ---------------------------------------------------------
|
||||
log "Deploiement termine"
|
||||
kubectl -n "$NAMESPACE" get pods -o wide
|
||||
echo
|
||||
echo "Backend : ${BACKEND_IMAGE}"
|
||||
echo "Frontend : ${FRONTEND_IMAGE}"
|
||||
echo
|
||||
echo "Retour arriere manuel si necessaire :"
|
||||
echo " kubectl -n ${NAMESPACE} rollout undo deploy/xpeditis-backend"
|
||||
echo " kubectl -n ${NAMESPACE} rollout undo deploy/xpeditis-frontend"
|
||||
151
infra/prod/scripts/harden-seed-data.sh
Executable file
151
infra/prod/scripts/harden-seed-data.sh
Executable file
@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Neutralisation des comptes de demonstration -- OUTIL DE SECOURS ET D'AUDIT
|
||||
# =============================================================================
|
||||
#
|
||||
# LE CAS NOMINAL N'A PLUS BESOIN DE CE SCRIPT.
|
||||
#
|
||||
# Le traitement est desormais fait par les migrations, donc automatiquement et
|
||||
# sans risque d'oubli :
|
||||
#
|
||||
# 1730000000007-SeedTestUsers ne s'execute plus si
|
||||
# NODE_ENV=production
|
||||
# 1756000000000-NeutralizeSeedAccountsInProduction filet de securite
|
||||
# 1756000000001-BootstrapAdminFromEnv cree VOTRE administrateur
|
||||
#
|
||||
# Ce script reste utile dans trois situations :
|
||||
#
|
||||
# - une base de production migree AVANT l'ajout de la garde NODE_ENV ;
|
||||
# - un deploiement ou NODE_ENV n'etait pas correctement positionne (le journal
|
||||
# du Job de migration affiche alors "Seeded test users successfully") ;
|
||||
# - une verification manuelle : il affiche l'etat des comptes sans rien
|
||||
# changer s'il n'y a rien a changer.
|
||||
#
|
||||
# ssh deploy@<db-01>
|
||||
# sudo bash /opt/xpeditis/infra-prod/scripts/harden-seed-data.sh
|
||||
#
|
||||
# RAPPEL DU PROBLEME
|
||||
#
|
||||
# La migration 1730000000007-SeedTestUsers cree trois comptes dont le mot de
|
||||
# passe est ecrit en clair dans le depot :
|
||||
#
|
||||
# admin@xpeditis.com role ADMIN Password123!
|
||||
# manager@xpeditis.com role MANAGER Password123!
|
||||
# user@xpeditis.com role USER Password123!
|
||||
#
|
||||
# Sur une base de production, cela donne un acces complet a la plateforme a
|
||||
# quiconque a lu le depot.
|
||||
#
|
||||
# Ce script ne SUPPRIME pas les lignes (des cles etrangeres peuvent y pointer,
|
||||
# et une suppression en cascade dans audit_logs serait pire). Il :
|
||||
# 1. renomme les adresses vers un domaine invalide -- ce qui libere au passage
|
||||
# admin@xpeditis.com pour votre vrai compte ;
|
||||
# 2. remplace le mot de passe par une valeur aleatoire inutilisable ;
|
||||
# 3. desactive les comptes (is_active = false).
|
||||
#
|
||||
# Idempotent : peut etre relance sans risque.
|
||||
set -euo pipefail
|
||||
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
|
||||
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
|
||||
|
||||
[[ -f "$ENV_FILE" ]] || { echo "Fichier d'environnement introuvable : $ENV_FILE" >&2; exit 1; }
|
||||
# shellcheck disable=SC1090
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
|
||||
psql_run() {
|
||||
docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" \
|
||||
exec -T -u postgres postgres psql -v ON_ERROR_STOP=1 -d "$POSTGRES_DB" "$@"
|
||||
}
|
||||
|
||||
echo ">>> Etat avant intervention"
|
||||
psql_run -c "
|
||||
SELECT email, role, is_active
|
||||
FROM users
|
||||
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
|
||||
"
|
||||
|
||||
echo
|
||||
echo ">>> Neutralisation"
|
||||
psql_run <<'SQL'
|
||||
BEGIN;
|
||||
|
||||
-- Mot de passe remplace par une valeur aleatoire : le format reste un hash
|
||||
-- Argon2 valide en apparence, mais aucun mot de passe ne peut y correspondre.
|
||||
UPDATE users
|
||||
SET
|
||||
email = 'seed-desactive-' || substr(id::text, 1, 8) || '@invalid.local',
|
||||
-- md5(random()) plutot que gen_random_bytes : pas besoin de l'extension
|
||||
-- pgcrypto, qui n'est pas installee sur cette base.
|
||||
password_hash = '$argon2id$v=19$m=65536,t=3,p=4$' || md5(random()::text)
|
||||
|| '$' || md5(random()::text) || md5(clock_timestamp()::text),
|
||||
is_active = false,
|
||||
updated_at = NOW()
|
||||
WHERE email IN ('admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com');
|
||||
|
||||
COMMIT;
|
||||
SQL
|
||||
|
||||
echo
|
||||
echo ">>> Etat apres intervention"
|
||||
psql_run -c "
|
||||
SELECT email, role, is_active
|
||||
FROM users
|
||||
WHERE email LIKE 'seed-desactive-%@invalid.local';
|
||||
"
|
||||
|
||||
echo
|
||||
echo ">>> Controle : aucun compte de demonstration ne doit subsister"
|
||||
RESTE=$(psql_run -tAc "
|
||||
SELECT count(*) FROM users
|
||||
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
|
||||
")
|
||||
if [[ "$RESTE" != "0" ]]; then
|
||||
echo "ECHEC : ${RESTE} compte(s) de demonstration encore actifs." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "OK : aucun compte de demonstration actif."
|
||||
|
||||
echo
|
||||
echo ">>> Organisations de demonstration presentes (a examiner, non modifiees)"
|
||||
# Non supprimees automatiquement : les comptes desactives y sont rattaches, et
|
||||
# une suppression en cascade toucherait aussi audit_logs.
|
||||
psql_run -c "
|
||||
SELECT id, name, created_at
|
||||
FROM organizations
|
||||
WHERE name IN ('Test Freight Forwarder Inc.','Demo Shipping Company','Sample Shipper Ltd.');
|
||||
"
|
||||
|
||||
cat <<'NEXT'
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
Etape suivante : disposer d'un administrateur.
|
||||
|
||||
La migration 1756000000001-BootstrapAdminFromEnv s'en charge normalement, a
|
||||
partir de BOOTSTRAP_ADMIN_EMAIL (ConfigMap). Si elle a ete ignoree parce qu'un
|
||||
ADMIN actif existait alors -- typiquement le compte de demonstration que ce
|
||||
script vient de neutraliser -- relancez simplement le Job de migration :
|
||||
|
||||
kubectl -n xpeditis-prod delete job -l app.kubernetes.io/name=xpeditis-migrate
|
||||
# puis redeployez, ou rejouez le Job pour le tag courant
|
||||
|
||||
Elle ne rejouera pas les migrations deja appliquees ; pour forcer uniquement
|
||||
l'amorcage, creez votre compte a la main :
|
||||
|
||||
1. Inscrivez-vous sur https://app.xpeditis.com/fr/register
|
||||
2. Promouvez le compte :
|
||||
UPDATE users SET role = 'ADMIN' WHERE email = '<votre adresse>';
|
||||
|
||||
Dans les deux cas, verifiez qu'il n'existe qu'un seul ADMIN actif :
|
||||
|
||||
SELECT email, role, is_active FROM users WHERE role = 'ADMIN';
|
||||
|
||||
Puis controlez depuis l'exterieur que l'ancien compte est bien mort :
|
||||
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
|
||||
https://api.xpeditis.com/api/v1/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"admin@xpeditis.com","password":"Password123!"}'
|
||||
# Attendu : 401 (et surtout pas 200 ni 201)
|
||||
-----------------------------------------------------------------------------
|
||||
NEXT
|
||||
187
infra/prod/scripts/preflight-check.sh
Executable file
187
infra/prod/scripts/preflight-check.sh
Executable file
@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Controle go / no-go avant ouverture au public
|
||||
# =============================================================================
|
||||
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||
# bash scripts/preflight-check.sh
|
||||
#
|
||||
# Chaque point BLOQUANT en echec doit etre corrige avant d'ouvrir le service.
|
||||
# Les points d'AVERTISSEMENT peuvent etre acceptes consciemment et notes dans
|
||||
# le registre des risques.
|
||||
set -uo pipefail
|
||||
|
||||
NAMESPACE=xpeditis-prod
|
||||
API="${PROD_API_URL:-https://api.xpeditis.com}"
|
||||
DB_HOST="${DB_PRIVATE_IP:-10.10.1.20}"
|
||||
DB_PUBLIC_IP="${DB_PUBLIC_IP:-}"
|
||||
|
||||
BLOCK=0
|
||||
WARN=0
|
||||
|
||||
ok() { printf ' \033[32m[OK]\033[0m %s\n' "$1"; }
|
||||
block() { printf ' \033[31m[BLOQUANT]\033[0m %s\n' "$1"; BLOCK=$((BLOCK+1)); }
|
||||
warn() { printf ' \033[33m[ATTENTION]\033[0m %s\n' "$1"; WARN=$((WARN+1)); }
|
||||
|
||||
section() { printf '\n\033[1m%s\033[0m\n' "$1"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "1. Cluster"
|
||||
|
||||
if kubectl get nodes >/dev/null 2>&1; then
|
||||
ok "Cluster joignable"
|
||||
if kubectl get nodes --no-headers | grep -qv ' Ready'; then
|
||||
block "Un noeud n'est pas Ready"
|
||||
else
|
||||
ok "Tous les noeuds sont Ready"
|
||||
fi
|
||||
else
|
||||
block "Cluster injoignable : rien d'autre ne peut etre verifie"
|
||||
fi
|
||||
|
||||
if kubectl -n "$NAMESPACE" get pods --no-headers 2>/dev/null | grep -qE 'CrashLoop|ImagePull|Error|Pending'; then
|
||||
block "Des pods ne sont pas sains dans $NAMESPACE"
|
||||
else
|
||||
ok "Tous les pods de $NAMESPACE sont sains"
|
||||
fi
|
||||
|
||||
for d in xpeditis-backend xpeditis-frontend; do
|
||||
READY=$(kubectl -n "$NAMESPACE" get deploy "$d" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo 0)
|
||||
[[ "${READY:-0}" -ge 2 ]] && ok "$d : ${READY} replicas prets" \
|
||||
|| warn "$d : ${READY:-0} replica(s) pret(s), 2 attendus (pas de haute disponibilite)"
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "2. Secrets"
|
||||
|
||||
if kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets >/dev/null 2>&1; then
|
||||
ok "Secrets applicatifs presents"
|
||||
JWT=$(kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o jsonpath='{.data.JWT_SECRET}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
|
||||
[[ ${#JWT} -ge 32 ]] && ok "JWT_SECRET fait ${#JWT} caracteres" || block "JWT_SECRET trop court (${#JWT} caracteres, 32 minimum)"
|
||||
|
||||
# Les secrets publies dans infra/preprod/docker-stack.preprod.yml sont dans
|
||||
# l'historique Git : les retrouver en production serait une compromission.
|
||||
for compromis in "4C4tQC8qym" "9Lc3M9qoPBeHLKHDXGUf1" "hXiy5GMPswMtxMZujjS2O" "RBJfD0QVXC5JDfAHCwdUW"; do
|
||||
if kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o json 2>/dev/null \
|
||||
| grep -q "$(printf '%s' "$compromis" | base64 | cut -c1-12)"; then
|
||||
block "Un secret de preprod (present dans Git, donc compromis) est reutilise en production"
|
||||
fi
|
||||
done
|
||||
ok "Aucun secret de preprod detecte"
|
||||
|
||||
STRIPE=$(kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o jsonpath='{.data.STRIPE_SECRET_KEY}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
|
||||
case "$STRIPE" in
|
||||
sk_live_*) ok "Cle Stripe en mode LIVE" ;;
|
||||
sk_test_*) block "Cle Stripe en mode TEST : aucun paiement reel ne sera encaisse" ;;
|
||||
*) warn "Cle Stripe absente ou non reconnue" ;;
|
||||
esac
|
||||
else
|
||||
block "Secrets applicatifs absents"
|
||||
fi
|
||||
|
||||
if kubectl -n "$NAMESPACE" get secret regcred >/dev/null 2>&1; then
|
||||
ok "Acces au registre configure"
|
||||
else
|
||||
block "Secret 'regcred' absent : aucune image ne pourra etre telechargee"
|
||||
fi
|
||||
|
||||
if sudo k3s secrets-encrypt status 2>/dev/null | grep -qi 'Encryption Status: Enabled'; then
|
||||
ok "Chiffrement des Secrets au repos actif"
|
||||
else
|
||||
warn "Chiffrement des Secrets au repos non verifiable depuis ce poste (a controler sur app-01)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "3. TLS et exposition"
|
||||
|
||||
CERT=$(kubectl -n "$NAMESPACE" get certificate xpeditis-wildcard -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "")
|
||||
[[ "$CERT" == "True" ]] && ok "Certificat wildcard emis" || block "Certificat wildcard non pret"
|
||||
|
||||
ISSUER=$(kubectl -n "$NAMESPACE" get certificate xpeditis-wildcard -o jsonpath='{.spec.issuerRef.name}' 2>/dev/null || echo "")
|
||||
[[ "$ISSUER" == "letsencrypt-prod" ]] && ok "Emetteur : letsencrypt-prod" \
|
||||
|| block "Emetteur '$ISSUER' : un certificat de staging n'est pas reconnu par les navigateurs"
|
||||
|
||||
if curl -sSI --max-time 15 "${API}/api/v1/health" 2>/dev/null | grep -qi '^strict-transport-security:'; then
|
||||
ok "HSTS actif"
|
||||
else
|
||||
block "En-tete HSTS absent"
|
||||
fi
|
||||
|
||||
if [[ -n "$DB_PUBLIC_IP" ]]; then
|
||||
if command -v nc >/dev/null; then
|
||||
if nc -z -w3 "$DB_PUBLIC_IP" 5432 2>/dev/null; then
|
||||
block "PostgreSQL repond sur l'IP PUBLIQUE de db-01"
|
||||
else
|
||||
ok "PostgreSQL injoignable publiquement"
|
||||
fi
|
||||
if nc -z -w3 "$DB_PUBLIC_IP" 6379 2>/dev/null; then
|
||||
block "Redis repond sur l'IP PUBLIQUE de db-01"
|
||||
else
|
||||
ok "Redis injoignable publiquement"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
warn "DB_PUBLIC_IP non fournie : exposition de la base non verifiee"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "4. Comptes de demonstration"
|
||||
|
||||
# La migration 1730000000007-SeedTestUsers creait admin@xpeditis.com avec le mot
|
||||
# de passe "Password123!", ecrit en clair dans le depot. Elle ne s'execute plus
|
||||
# quand NODE_ENV=production, et 1756000000000 neutralise ces comptes s'ils
|
||||
# existent malgre tout.
|
||||
#
|
||||
# Ce controle verifie le RESULTAT en conditions reelles, pas l'intention : c'est
|
||||
# le seul moyen de detecter un NODE_ENV mal positionne ou une base restauree
|
||||
# depuis une sauvegarde anterieure. Le controle le plus important de la liste.
|
||||
for compte in admin manager user; do
|
||||
CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \
|
||||
-X POST "${API}/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"${compte}@xpeditis.com\",\"password\":\"Password123!\"}" 2>/dev/null || echo "000")
|
||||
case "$CODE" in
|
||||
200|201) block "Le compte de demonstration ${compte}@xpeditis.com accepte encore Password123! (HTTP ${CODE})" ;;
|
||||
000) warn "Impossible de tester ${compte}@xpeditis.com (API injoignable)" ;;
|
||||
*) ok "${compte}@xpeditis.com neutralise (HTTP ${CODE})" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo " Verifier aussi qu'il n'existe qu'un seul ADMIN actif, sur db-01 :"
|
||||
echo " SELECT email, role, is_active FROM users WHERE role = 'ADMIN';"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "5. Sauvegardes"
|
||||
|
||||
cat <<'MANUEL'
|
||||
Ces points se verifient sur db-01, ils ne peuvent pas l'etre d'ici :
|
||||
|
||||
ssh deploy@<db-01>
|
||||
systemctl list-timers 'xpeditis-*' # timers actifs
|
||||
sudo journalctl -u xpeditis-backup -n 30 # derniere execution
|
||||
sudo /opt/xpeditis/data-node/backup/pg-restore.sh verify
|
||||
|
||||
Une sauvegarde jamais restauree n'est pas une sauvegarde. Le test de
|
||||
restauration DOIT avoir ete passe au moins une fois avant l'ouverture.
|
||||
MANUEL
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
section "6. Fonctionnement"
|
||||
|
||||
if bash "$(dirname "${BASH_SOURCE[0]}")/smoke-test.sh" >/dev/null 2>&1; then
|
||||
ok "Tests de fumee au vert"
|
||||
else
|
||||
block "Tests de fumee en echec (relancer smoke-test.sh pour le detail)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
printf '\n=========================================\n'
|
||||
printf ' Bloquants : %d Avertissements : %d\n' "$BLOCK" "$WARN"
|
||||
printf '=========================================\n'
|
||||
|
||||
if [[ "$BLOCK" -gt 0 ]]; then
|
||||
printf '\n\033[31mNO-GO\033[0m : corrigez les points bloquants avant d ouvrir au public.\n'
|
||||
exit 1
|
||||
fi
|
||||
printf '\n\033[32mGO\033[0m : les controles bloquants sont passes.\n'
|
||||
[[ "$WARN" -gt 0 ]] && printf 'Consignez les %d avertissement(s) dans le registre des risques.\n' "$WARN"
|
||||
exit 0
|
||||
88
infra/prod/scripts/refresh-cloudflare-ips.sh
Executable file
88
infra/prod/scripts/refresh-cloudflare-ips.sh
Executable file
@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Rafraichissement des rangs d'IP Cloudflare
|
||||
# =============================================================================
|
||||
# Cloudflare fait evoluer ses rangs. Deux endroits en dependent :
|
||||
# - terraform/firewall.tf (qui peut atteindre 80/443 sur app-01)
|
||||
# - la configuration Traefik (quelles IP ont le droit d'ecrire X-Forwarded-For)
|
||||
#
|
||||
# Une liste perimee produit deux pannes distinctes et peu evidentes :
|
||||
# - du trafic legitime rejete par le firewall Hetzner ;
|
||||
# - une IP client mal identifiee, donc une limitation de debit qui frappe
|
||||
# tout le monde et des audit_logs faux.
|
||||
#
|
||||
# A relancer tous les trimestres, et systematiquement avant un `terraform apply`.
|
||||
#
|
||||
# bash scripts/refresh-cloudflare-ips.sh # compare et affiche
|
||||
# bash scripts/refresh-cloudflare-ips.sh --write # met a jour firewall.tf
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TF_FILE="${HERE}/../terraform/firewall.tf"
|
||||
WRITE=false
|
||||
[[ "${1:-}" == "--write" ]] && WRITE=true
|
||||
|
||||
echo ">>> Recuperation des rangs officiels Cloudflare"
|
||||
V4="$(curl -fsS --max-time 15 https://www.cloudflare.com/ips-v4)"
|
||||
V6="$(curl -fsS --max-time 15 https://www.cloudflare.com/ips-v6)"
|
||||
|
||||
[[ -n "$V4" && -n "$V6" ]] || { echo "Reponse vide de Cloudflare, abandon." >&2; exit 1; }
|
||||
|
||||
echo
|
||||
echo "IPv4 ($(echo "$V4" | wc -l | tr -d ' ') rangs) :"
|
||||
echo "$V4" | sed 's/^/ /'
|
||||
echo
|
||||
echo "IPv6 ($(echo "$V6" | wc -l | tr -d ' ') rangs) :"
|
||||
echo "$V6" | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo ">>> Rangs actuellement declares dans firewall.tf :"
|
||||
grep -oE '"[0-9a-f:.]+/[0-9]+"' "$TF_FILE" | tr -d '"' | sort -u | sed 's/^/ /'
|
||||
|
||||
DIFF=$(diff <(printf '%s\n%s\n' "$V4" "$V6" | sort -u) \
|
||||
<(grep -oE '"[0-9a-f:.]+/[0-9]+"' "$TF_FILE" | tr -d '"' | sort -u) || true)
|
||||
|
||||
if [[ -z "$DIFF" ]]; then
|
||||
echo
|
||||
echo ">>> Aucune difference : rien a faire."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo ">>> DIFFERENCES DETECTEES :"
|
||||
echo "$DIFF"
|
||||
|
||||
if ! $WRITE; then
|
||||
cat <<'MSG'
|
||||
|
||||
Relancez avec --write pour mettre a jour terraform/firewall.tf, puis :
|
||||
cd terraform && terraform plan && terraform apply
|
||||
|
||||
Pensez aussi a reporter la liste dans la configuration Traefik :
|
||||
/var/lib/rancher/k3s/server/manifests/traefik-config.yaml (sur app-01)
|
||||
puis : sudo systemctl restart k3s
|
||||
MSG
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BLOCK_V4=$(echo "$V4" | sed 's/^/ "/; s/$/",/')
|
||||
BLOCK_V6=$(echo "$V6" | sed 's/^/ "/; s/$/",/')
|
||||
|
||||
python3 - "$TF_FILE" <<PY
|
||||
import re, sys
|
||||
path = sys.argv[1]
|
||||
src = open(path).read()
|
||||
src = re.sub(r'(cloudflare_ipv4 = \[\n).*?(\n \])',
|
||||
lambda m: m.group(1) + """${BLOCK_V4}""".rstrip(',') + m.group(2),
|
||||
src, flags=re.S)
|
||||
src = re.sub(r'(cloudflare_ipv6 = \[\n).*?(\n \])',
|
||||
lambda m: m.group(1) + """${BLOCK_V6}""".rstrip(',') + m.group(2),
|
||||
src, flags=re.S)
|
||||
open(path, 'w').write(src)
|
||||
print("firewall.tf mis a jour")
|
||||
PY
|
||||
|
||||
echo
|
||||
echo ">>> Verifiez le diff avant d'appliquer :"
|
||||
echo " git diff infra/prod/terraform/firewall.tf"
|
||||
echo " cd infra/prod/terraform && terraform plan"
|
||||
50
infra/prod/scripts/secrets-apply.sh
Executable file
50
infra/prod/scripts/secrets-apply.sh
Executable file
@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Application des secrets chiffres SOPS sur le cluster
|
||||
# =============================================================================
|
||||
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||
# bash scripts/secrets-apply.sh
|
||||
#
|
||||
# Le contenu dechiffre ne touche JAMAIS le disque : sops ecrit sur la sortie
|
||||
# standard, kubectl lit sur l'entree standard. Rien a nettoyer, rien a oublier.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SECRETS_FILE="${HERE}/../k8s/base/03-secrets.sops.yaml"
|
||||
|
||||
command -v sops >/dev/null || { echo "sops n'est pas installe." >&2; exit 1; }
|
||||
[[ -f "$SECRETS_FILE" ]] || {
|
||||
cat >&2 <<MSG
|
||||
Fichier introuvable : $SECRETS_FILE
|
||||
|
||||
Creez-le a partir du gabarit :
|
||||
cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml
|
||||
\$EDITOR /tmp/secrets.yaml
|
||||
sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml
|
||||
shred -u /tmp/secrets.yaml
|
||||
MSG
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Refuse d'appliquer un fichier qui ne serait pas reellement chiffre : c'est le
|
||||
# genre d'erreur qui ne se voit qu'une fois le secret pousse sur GitHub.
|
||||
grep -q 'sops:' "$SECRETS_FILE" || {
|
||||
echo "ERREUR: $SECRETS_FILE ne semble pas chiffre par SOPS. Abandon." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo ">>> Application des secrets sur $(kubectl config current-context)"
|
||||
sops -d "$SECRETS_FILE" | kubectl apply -f -
|
||||
|
||||
echo
|
||||
echo ">>> Secrets presents :"
|
||||
kubectl -n xpeditis-prod get secrets
|
||||
kubectl -n monitoring get secrets 2>/dev/null || true
|
||||
kubectl -n cert-manager get secret cloudflare-api-token 2>/dev/null || true
|
||||
|
||||
cat <<'NEXT'
|
||||
|
||||
Rappel : modifier un Secret ne redemarre PAS les pods qui l'utilisent.
|
||||
Pour que la nouvelle valeur soit prise en compte :
|
||||
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
|
||||
NEXT
|
||||
81
infra/prod/scripts/smoke-test.sh
Executable file
81
infra/prod/scripts/smoke-test.sh
Executable file
@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Tests de fumee post-deploiement
|
||||
# =============================================================================
|
||||
# Verifie ce qu'un utilisateur constate reellement, depuis l'exterieur, a
|
||||
# travers Cloudflare et Traefik -- pas l'etat interne du cluster.
|
||||
#
|
||||
# bash scripts/smoke-test.sh
|
||||
#
|
||||
# Code de retour 0 = production saine. Utilise par deploy.sh et cd-main.yml
|
||||
# pour declencher un retour arriere automatique.
|
||||
set -uo pipefail
|
||||
|
||||
API="${PROD_API_URL:-https://api.xpeditis.com}"
|
||||
APP="${PROD_APP_URL:-https://app.xpeditis.com}"
|
||||
SITE="${PROD_SITE_URL:-https://xpeditis.com}"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
check() {
|
||||
local label="$1"; shift
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
printf ' [OK] %s\n' "$label"; PASS=$((PASS + 1))
|
||||
else
|
||||
printf ' [ECHEC] %s\n' "$label"; FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
http_code() { curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "$1"; }
|
||||
|
||||
expect_code() {
|
||||
local url="$1" expected="$2"
|
||||
[[ "$(http_code "$url")" == "$expected" ]]
|
||||
}
|
||||
|
||||
expect_header() {
|
||||
local url="$1" header="$2"
|
||||
curl -sSI --max-time 15 "$url" | grep -qi "^${header}:"
|
||||
}
|
||||
|
||||
echo "Tests de fumee - $(date -Is)"
|
||||
echo
|
||||
|
||||
echo "Disponibilite"
|
||||
check "API en ligne (200 sur /api/v1/health)" expect_code "${API}/api/v1/health" 200
|
||||
check "Frontend en ligne (app)" expect_code "${APP}/" 200
|
||||
check "Vitrine en ligne (apex)" expect_code "${SITE}/" 200
|
||||
|
||||
echo
|
||||
echo "TLS et redirections"
|
||||
check "HTTP redirige vers HTTPS" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 'http://api.xpeditis.com/api/v1/health') =~ ^30 ]]"
|
||||
check "Certificat valide (pas d'option -k)" curl -sS --max-time 15 -o /dev/null "${API}/api/v1/health"
|
||||
check "En-tete HSTS present" expect_header "${API}/api/v1/health" "strict-transport-security"
|
||||
|
||||
echo
|
||||
echo "Durcissement"
|
||||
check "X-Frame-Options present" expect_header "${APP}/" "x-frame-options"
|
||||
check "X-Content-Type-Options present" expect_header "${APP}/" "x-content-type-options"
|
||||
# main.ts desactive Swagger en production sauf si SWAGGER_USERNAME/PASSWORD
|
||||
# sont definis. 404 = desactive, 401 = protege : les deux sont acceptables,
|
||||
# 200 signifie que la documentation de l'API est publique.
|
||||
check "Swagger non accessible librement" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 '${API}/api/docs') != '200' ]]"
|
||||
check "Route protegee refuse l'anonyme (401/403)" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 '${API}/api/v1/bookings') =~ ^(401|403)$ ]]"
|
||||
check "CORS refuse une origine inconnue" bash -c "! curl -sSI --max-time 15 -H 'Origin: https://evil.example' '${API}/api/v1/health' | grep -qi 'access-control-allow-origin: https://evil.example'"
|
||||
|
||||
echo
|
||||
echo "Etat du cluster"
|
||||
if command -v kubectl >/dev/null && kubectl get ns xpeditis-prod >/dev/null 2>&1; then
|
||||
check "Aucun pod en erreur" bash -c "! kubectl -n xpeditis-prod get pods --no-headers | grep -qE 'CrashLoopBackOff|ImagePullBackOff|Error'"
|
||||
check "Certificat cert-manager pret" bash -c "kubectl -n xpeditis-prod get certificate xpeditis-wildcard -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}' | grep -q True"
|
||||
else
|
||||
echo " [saute] kubectl indisponible : verifications cluster ignorees"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-----------------------------------------"
|
||||
printf ' Reussis : %d Echecs : %d\n' "$PASS" "$FAIL"
|
||||
echo "-----------------------------------------"
|
||||
|
||||
[[ "$FAIL" -eq 0 ]]
|
||||
56
infra/prod/scripts/ssh-deploy-wrapper.sh
Executable file
56
infra/prod/scripts/ssh-deploy-wrapper.sh
Executable file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Enveloppe de la cle SSH de deploiement
|
||||
# =============================================================================
|
||||
# Reference depuis ~deploy/.ssh/authorized_keys sur app-01 :
|
||||
#
|
||||
# restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
|
||||
#
|
||||
# Le `command=` d'OpenSSH ignore ce que le client demande et execute ce script,
|
||||
# en placant la commande d'origine dans SSH_ORIGINAL_COMMAND. Une cle volee ne
|
||||
# donne donc pas un shell : elle ne peut lancer que ce qui est autorise ici.
|
||||
set -euo pipefail
|
||||
|
||||
CMD="${SSH_ORIGINAL_COMMAND:-}"
|
||||
LOG_TAG=xpeditis-ssh-deploy
|
||||
|
||||
deny() {
|
||||
logger -t "$LOG_TAG" -- "REFUS: ${CMD}"
|
||||
echo "Commande non autorisee pour cette cle." >&2
|
||||
exit 126
|
||||
}
|
||||
|
||||
logger -t "$LOG_TAG" -- "DEMANDE: ${CMD}"
|
||||
|
||||
case "$CMD" in
|
||||
# rsync doit pouvoir se lancer en mode serveur pour recevoir infra/prod.
|
||||
# --server et --sender uniquement, chemin de destination contraint.
|
||||
"rsync --server "*"/opt/xpeditis/infra-prod/"*)
|
||||
exec $CMD
|
||||
;;
|
||||
|
||||
# Deploiement d'une version. Le tag est valide par une expression stricte :
|
||||
# sans cela, `deploy.sh "; rm -rf /"` serait accepte.
|
||||
"deploy "*)
|
||||
TAG="${CMD#deploy }"
|
||||
[[ "$TAG" =~ ^prod-[a-f0-9]{7,40}$ ]] || deny
|
||||
exec /opt/xpeditis/infra-prod/scripts/deploy.sh "$TAG"
|
||||
;;
|
||||
|
||||
# Retour arriere d'urgence.
|
||||
"rollback")
|
||||
kubectl -n xpeditis-prod rollout undo deploy/xpeditis-backend
|
||||
kubectl -n xpeditis-prod rollout undo deploy/xpeditis-frontend
|
||||
exec kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend --timeout=180s
|
||||
;;
|
||||
|
||||
# Diagnostic en lecture seule.
|
||||
"status")
|
||||
kubectl -n xpeditis-prod get pods,deploy -o wide
|
||||
exec kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp | tail -20
|
||||
;;
|
||||
|
||||
*)
|
||||
deny
|
||||
;;
|
||||
esac
|
||||
76
infra/prod/terraform/cloud-init.yaml.tftpl
Normal file
76
infra/prod/terraform/cloud-init.yaml.tftpl
Normal file
@ -0,0 +1,76 @@
|
||||
#cloud-config
|
||||
# =============================================================================
|
||||
# Amorcage minimal des serveurs Xpeditis prod
|
||||
# =============================================================================
|
||||
# Ce fichier ne fait que le strict necessaire pour obtenir un serveur joignable
|
||||
# en SSH par un compte non-root. Tout le durcissement reel est fait par
|
||||
# scripts/00-bootstrap-common.sh, versionne et relisible.
|
||||
|
||||
hostname: ${hostname}
|
||||
fqdn: ${hostname}
|
||||
preserve_hostname: false
|
||||
|
||||
users:
|
||||
- name: ${deploy_user}
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||
lock_passwd: true
|
||||
ssh_authorized_keys:
|
||||
- ${ssh_public_key}
|
||||
|
||||
# Le compte root n'a ni mot de passe ni acces SSH par mot de passe.
|
||||
disable_root: true
|
||||
ssh_pwauth: false
|
||||
|
||||
package_update: true
|
||||
package_upgrade: true
|
||||
|
||||
packages:
|
||||
- curl
|
||||
- ca-certificates
|
||||
- gnupg
|
||||
- ufw
|
||||
- fail2ban
|
||||
- unattended-upgrades
|
||||
- chrony
|
||||
- jq
|
||||
- git
|
||||
- htop
|
||||
- rsync
|
||||
|
||||
write_files:
|
||||
# Pare-feu local minimal des le premier boot : le serveur n'est jamais
|
||||
# accessible "nu", meme entre cloud-init et l'execution du script de
|
||||
# durcissement.
|
||||
- path: /etc/ssh/sshd_config.d/99-xpeditis-hardening.conf
|
||||
permissions: "0644"
|
||||
content: |
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
ChallengeResponseAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
AllowTcpForwarding yes
|
||||
MaxAuthTries 3
|
||||
MaxSessions 5
|
||||
LoginGraceTime 20
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
AllowUsers ${deploy_user}
|
||||
|
||||
runcmd:
|
||||
- systemctl restart ssh || systemctl restart sshd
|
||||
- systemctl enable --now fail2ban
|
||||
- systemctl enable --now chrony
|
||||
- |
|
||||
ufw --force reset
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow 22/tcp
|
||||
ufw --force enable
|
||||
- touch /var/log/cloud-init-xpeditis-done
|
||||
|
||||
final_message: "Xpeditis prod node ${hostname} pret. Lancer scripts/00-bootstrap-common.sh."
|
||||
157
infra/prod/terraform/firewall.tf
Normal file
157
infra/prod/terraform/firewall.tf
Normal file
@ -0,0 +1,157 @@
|
||||
# =============================================================================
|
||||
# Firewalls Hetzner Cloud (interfaces publiques uniquement)
|
||||
# =============================================================================
|
||||
# Politique : tout est ferme par defaut. On n'ouvre que le strict necessaire,
|
||||
# et jamais vers 0.0.0.0/0 sauf pour le trafic web derriere Cloudflare.
|
||||
|
||||
locals {
|
||||
# Rangs d'IP Cloudflare. A rafraichir avec :
|
||||
# curl -s https://www.cloudflare.com/ips-v4
|
||||
# curl -s https://www.cloudflare.com/ips-v6
|
||||
# Verifiez cette liste a chaque `terraform apply` (cf. scripts/refresh-cloudflare-ips.sh).
|
||||
cloudflare_ipv4 = [
|
||||
"173.245.48.0/20",
|
||||
"103.21.244.0/22",
|
||||
"103.22.200.0/22",
|
||||
"103.31.4.0/22",
|
||||
"141.101.64.0/18",
|
||||
"108.162.192.0/18",
|
||||
"190.93.240.0/20",
|
||||
"188.114.96.0/20",
|
||||
"197.234.240.0/22",
|
||||
"198.41.128.0/17",
|
||||
"162.158.0.0/15",
|
||||
"104.16.0.0/13",
|
||||
"104.24.0.0/14",
|
||||
"172.64.0.0/13",
|
||||
"131.0.72.0/22",
|
||||
]
|
||||
|
||||
cloudflare_ipv6 = [
|
||||
"2400:cb00::/32",
|
||||
"2606:4700::/32",
|
||||
"2803:f800::/32",
|
||||
"2405:b500::/32",
|
||||
"2405:8100::/32",
|
||||
"2a06:98c0::/29",
|
||||
"2c0f:f248::/32",
|
||||
]
|
||||
|
||||
http_sources_v4 = var.restrict_http_to_cloudflare ? local.cloudflare_ipv4 : ["0.0.0.0/0"]
|
||||
http_sources_v6 = var.restrict_http_to_cloudflare ? local.cloudflare_ipv6 : ["::/0"]
|
||||
http_sources = concat(local.http_sources_v4, local.http_sources_v6)
|
||||
}
|
||||
|
||||
# --- Noeud applicatif (k3s server) ------------------------------------------
|
||||
|
||||
resource "hcloud_firewall" "app" {
|
||||
name = "${var.project_name}-fw-app"
|
||||
|
||||
# SSH : uniquement depuis les IPs d'administration.
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "tcp"
|
||||
port = "22"
|
||||
source_ips = var.admin_ip_allowlist
|
||||
description = "SSH administrateur"
|
||||
}
|
||||
|
||||
# API Kubernetes : uniquement depuis les IPs d'administration.
|
||||
# Les runners GitHub Actions n'ont pas d'IP fixe : le deploiement passe donc
|
||||
# par SSH + kubectl local sur le serveur, pas par 6443 expose.
|
||||
# Cf. .github/workflows/cd-main.yml.
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "tcp"
|
||||
port = "6443"
|
||||
source_ips = var.admin_ip_allowlist
|
||||
description = "API k3s (kubectl administrateur)"
|
||||
}
|
||||
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "tcp"
|
||||
port = "80"
|
||||
source_ips = local.http_sources
|
||||
description = "HTTP (redirection 301 + ACME HTTP-01 de secours)"
|
||||
}
|
||||
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "tcp"
|
||||
port = "443"
|
||||
source_ips = local.http_sources
|
||||
description = "HTTPS via Cloudflare"
|
||||
}
|
||||
|
||||
# ICMP : utile pour le diagnostic reseau, sans risque notable.
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "icmp"
|
||||
source_ips = ["0.0.0.0/0", "::/0"]
|
||||
description = "ICMP (ping / MTU discovery)"
|
||||
}
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
managed = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Ouverture temporaire pour la CI/CD -------------------------------------
|
||||
# Les runners GitHub Actions n'ont pas d'IP fixe : impossible de les inscrire
|
||||
# une fois pour toutes dans une liste blanche, et ouvrir SSH au monde entier
|
||||
# n'est pas une option.
|
||||
#
|
||||
# Ce firewall est attache a app-01 et reste VIDE en regime nominal. Le workflow
|
||||
# cd-main.yml y injecte l'IP du runner juste avant le deploiement, puis le vide
|
||||
# systematiquement (etape `if: always()`). La fenetre d'exposition dure le temps
|
||||
# du deploiement, pour une seule IP, sur le seul port 22.
|
||||
#
|
||||
# `ignore_changes = [rule]` est indispensable : sans lui, le prochain
|
||||
# `terraform apply` supprimerait une regle posee par la CI en cours d'execution.
|
||||
|
||||
resource "hcloud_firewall" "cicd" {
|
||||
name = "${var.project_name}-fw-cicd"
|
||||
|
||||
# Aucune regle : l'etat au repos est "ferme".
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
managed = "terraform"
|
||||
purpose = "cicd-temporaire"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [rule]
|
||||
}
|
||||
}
|
||||
|
||||
# --- Noeud de donnees --------------------------------------------------------
|
||||
# Aucun port applicatif expose publiquement. PostgreSQL et Redis n'ecoutent que
|
||||
# sur l'IP privee (cf. conf/postgresql.conf et conf/redis.conf) et sont en plus
|
||||
# filtres par nftables. Ce firewall ne laisse passer que le SSH d'administration.
|
||||
|
||||
resource "hcloud_firewall" "db" {
|
||||
name = "${var.project_name}-fw-db"
|
||||
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "tcp"
|
||||
port = "22"
|
||||
source_ips = var.admin_ip_allowlist
|
||||
description = "SSH administrateur"
|
||||
}
|
||||
|
||||
rule {
|
||||
direction = "in"
|
||||
protocol = "icmp"
|
||||
source_ips = ["0.0.0.0/0", "::/0"]
|
||||
description = "ICMP (ping / MTU discovery)"
|
||||
}
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
managed = "terraform"
|
||||
}
|
||||
}
|
||||
52
infra/prod/terraform/network.tf
Normal file
52
infra/prod/terraform/network.tf
Normal file
@ -0,0 +1,52 @@
|
||||
# =============================================================================
|
||||
# Reseau prive Hetzner
|
||||
# =============================================================================
|
||||
# Le noeud de donnees n'est JAMAIS joignable depuis Internet sur 5432/6379.
|
||||
# Tout le trafic applicatif -> base passe par ce reseau prive, isole au niveau
|
||||
# du projet Hetzner.
|
||||
#
|
||||
# Attention : le trafic sur un reseau prive Hetzner n'est pas chiffre par
|
||||
# l'hyperviseur. On ajoute donc deux couches par-dessus :
|
||||
# 1. TLS PostgreSQL (ssl = on + DATABASE_SSL=true cote applicatif)
|
||||
# 2. Filtrage nftables sur db-01 (cf. scripts/01-setup-data-node.sh)
|
||||
# Les firewalls Hetzner Cloud ne filtrent que les interfaces PUBLIQUES.
|
||||
|
||||
resource "hcloud_network" "main" {
|
||||
name = "${var.project_name}-net"
|
||||
ip_range = var.network_cidr
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
managed = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
resource "hcloud_network_subnet" "main" {
|
||||
network_id = hcloud_network.main.id
|
||||
type = "cloud"
|
||||
network_zone = "eu-central"
|
||||
ip_range = var.subnet_cidr
|
||||
}
|
||||
|
||||
# Cle SSH partagee par les deux serveurs.
|
||||
resource "hcloud_ssh_key" "admin" {
|
||||
name = "${var.project_name}-admin"
|
||||
public_key = var.ssh_public_key
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
managed = "terraform"
|
||||
}
|
||||
}
|
||||
|
||||
# Repartit les deux VMs sur des hotes physiques differents : une panne materielle
|
||||
# ne peut pas emporter l'app ET la base en meme temps.
|
||||
resource "hcloud_placement_group" "spread" {
|
||||
name = "${var.project_name}-spread"
|
||||
type = "spread"
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
managed = "terraform"
|
||||
}
|
||||
}
|
||||
64
infra/prod/terraform/outputs.tf
Normal file
64
infra/prod/terraform/outputs.tf
Normal file
@ -0,0 +1,64 @@
|
||||
output "app_public_ipv4" {
|
||||
description = "IP publique du noeud applicatif. A pointer depuis Cloudflare (enregistrements A, proxifies)."
|
||||
value = hcloud_server.app.ipv4_address
|
||||
}
|
||||
|
||||
output "app_public_ipv6" {
|
||||
description = "IPv6 du noeud applicatif (enregistrements AAAA)."
|
||||
value = hcloud_server.app.ipv6_address
|
||||
}
|
||||
|
||||
output "app_private_ip" {
|
||||
description = "IP privee du noeud applicatif."
|
||||
value = var.app_private_ip
|
||||
}
|
||||
|
||||
output "db_public_ipv4" {
|
||||
description = "IP publique du noeud de donnees. NE JAMAIS publier en DNS : elle ne sert qu'au SSH d'administration."
|
||||
value = hcloud_server.db.ipv4_address
|
||||
}
|
||||
|
||||
output "db_private_ip" {
|
||||
description = "IP privee du noeud de donnees. C'est cette valeur qui alimente DATABASE_HOST et REDIS_HOST."
|
||||
value = var.db_private_ip
|
||||
}
|
||||
|
||||
output "pgdata_volume_device" {
|
||||
description = "Chemin du peripherique du volume PostgreSQL sur db-01 (a monter sur /var/lib/xpeditis/pgdata)."
|
||||
value = hcloud_volume.pgdata.linux_device
|
||||
}
|
||||
|
||||
output "cicd_firewall_name" {
|
||||
description = "Firewall temporaire pilote par cd-main.yml. Doit rester vide au repos."
|
||||
value = hcloud_firewall.cicd.name
|
||||
}
|
||||
|
||||
output "ssh_commands" {
|
||||
description = "Commandes de connexion."
|
||||
value = {
|
||||
app = "ssh ${var.deploy_user}@${hcloud_server.app.ipv4_address}"
|
||||
db = "ssh ${var.deploy_user}@${hcloud_server.db.ipv4_address}"
|
||||
}
|
||||
}
|
||||
|
||||
output "dns_records_to_create" {
|
||||
description = "Enregistrements DNS a creer dans Cloudflare (tous proxifies, nuage orange)."
|
||||
value = {
|
||||
"xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||
"www.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||
"app.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||
"api.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||
"grafana.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||
}
|
||||
}
|
||||
|
||||
output "monthly_cost_estimate_eur" {
|
||||
description = "Estimation indicative HT (tarifs Hetzner T2 2026, hors Storage Box et services tiers)."
|
||||
value = {
|
||||
app_server = "${var.app_server_type} : voir grille Hetzner"
|
||||
db_server = "${var.db_server_type} : voir grille Hetzner"
|
||||
volume = "${var.db_volume_size} Go x 0.048 EUR/Go = ${format("%.2f", var.db_volume_size * 0.048)} EUR"
|
||||
backups = var.enable_hetzner_backups ? "+20% du prix des deux serveurs" : "desactives"
|
||||
note = "Reference budgetaire : Xpeditis_Previsions_Couts.xlsx, feuille 'Hetzner (auto-heberge)'."
|
||||
}
|
||||
}
|
||||
116
infra/prod/terraform/servers.tf
Normal file
116
infra/prod/terraform/servers.tf
Normal file
@ -0,0 +1,116 @@
|
||||
# =============================================================================
|
||||
# Serveurs
|
||||
# =============================================================================
|
||||
# Topologie retenue (2 VMs, cf. docs/mise-en-prod/00 et Excel previsions couts) :
|
||||
#
|
||||
# app-01 CPX41 8 vCPU / 16 Go k3s server + tous les pods (stateless)
|
||||
# db-01 CPX31 4 vCPU / 8 Go PostgreSQL 15 + Redis 7 (stateful, Docker)
|
||||
#
|
||||
# Pourquoi la base HORS de Kubernetes : PostgreSQL en StatefulSet apporte de la
|
||||
# complexite (PV, ordre de demarrage, upgrades) sans aucun gain a cette echelle.
|
||||
# Le hors-cluster rend les sauvegardes, la PITR et les restaurations triviales,
|
||||
# et permet de reconstruire integralement le noeud app sans toucher aux donnees.
|
||||
|
||||
resource "hcloud_server" "app" {
|
||||
name = "${var.project_name}-app-01"
|
||||
server_type = var.app_server_type
|
||||
image = var.image
|
||||
location = var.location
|
||||
ssh_keys = [hcloud_ssh_key.admin.id]
|
||||
# Deux firewalls : le permanent, et celui que la CI ouvre puis referme.
|
||||
firewall_ids = [hcloud_firewall.app.id, hcloud_firewall.cicd.id]
|
||||
placement_group_id = hcloud_placement_group.spread.id
|
||||
backups = var.enable_hetzner_backups
|
||||
|
||||
public_net {
|
||||
ipv4_enabled = true
|
||||
ipv6_enabled = true
|
||||
}
|
||||
|
||||
network {
|
||||
network_id = hcloud_network.main.id
|
||||
ip = var.app_private_ip
|
||||
}
|
||||
|
||||
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
|
||||
hostname = "${var.project_name}-app-01"
|
||||
deploy_user = var.deploy_user
|
||||
ssh_public_key = var.ssh_public_key
|
||||
})
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
role = "app"
|
||||
managed = "terraform"
|
||||
}
|
||||
|
||||
depends_on = [hcloud_network_subnet.main]
|
||||
|
||||
lifecycle {
|
||||
# Un changement d'image ou de user_data recreerait le serveur : on veut une
|
||||
# decision explicite, pas une destruction silencieuse de la prod.
|
||||
ignore_changes = [image, user_data]
|
||||
}
|
||||
}
|
||||
|
||||
resource "hcloud_server" "db" {
|
||||
name = "${var.project_name}-db-01"
|
||||
server_type = var.db_server_type
|
||||
image = var.image
|
||||
location = var.location
|
||||
ssh_keys = [hcloud_ssh_key.admin.id]
|
||||
firewall_ids = [hcloud_firewall.db.id]
|
||||
placement_group_id = hcloud_placement_group.spread.id
|
||||
backups = var.enable_hetzner_backups
|
||||
|
||||
public_net {
|
||||
ipv4_enabled = true
|
||||
ipv6_enabled = true
|
||||
}
|
||||
|
||||
network {
|
||||
network_id = hcloud_network.main.id
|
||||
ip = var.db_private_ip
|
||||
}
|
||||
|
||||
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
|
||||
hostname = "${var.project_name}-db-01"
|
||||
deploy_user = var.deploy_user
|
||||
ssh_public_key = var.ssh_public_key
|
||||
})
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
role = "data"
|
||||
managed = "terraform"
|
||||
}
|
||||
|
||||
depends_on = [hcloud_network_subnet.main]
|
||||
|
||||
lifecycle {
|
||||
ignore_changes = [image, user_data]
|
||||
}
|
||||
}
|
||||
|
||||
# --- Volume de donnees PostgreSQL -------------------------------------------
|
||||
# Volume dedie plutot que le disque systeme : agrandissable a chaud, snapshotable
|
||||
# independamment, et survit a une reinstallation complete du serveur.
|
||||
|
||||
resource "hcloud_volume" "pgdata" {
|
||||
name = "${var.project_name}-pgdata"
|
||||
size = var.db_volume_size
|
||||
server_id = hcloud_server.db.id
|
||||
automount = false
|
||||
format = "ext4"
|
||||
|
||||
labels = {
|
||||
project = var.project_name
|
||||
role = "pgdata"
|
||||
managed = "terraform"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
# Garde-fou : un `terraform destroy` ne doit jamais emporter les donnees.
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
37
infra/prod/terraform/terraform.tfvars.example
Normal file
37
infra/prod/terraform/terraform.tfvars.example
Normal file
@ -0,0 +1,37 @@
|
||||
# =============================================================================
|
||||
# Copier en terraform.tfvars (gitignore) et completer.
|
||||
# cp terraform.tfvars.example terraform.tfvars
|
||||
# =============================================================================
|
||||
|
||||
# Token API Hetzner Cloud, projet "xpeditis-prod" UNIQUEMENT (Read & Write).
|
||||
# Console Hetzner > Security > API tokens.
|
||||
# Ne le mettez pas ici si vous preferez la variable d'environnement :
|
||||
# export TF_VAR_hcloud_token="..."
|
||||
hcloud_token = "REMPLACER"
|
||||
|
||||
project_name = "xpeditis-prod"
|
||||
location = "fsn1"
|
||||
|
||||
# Cle publique SSH de l'administrateur (ed25519 recommande).
|
||||
# ssh-keygen -t ed25519 -a 100 -C "xpeditis-prod-admin" -f ~/.ssh/xpeditis_prod
|
||||
# cat ~/.ssh/xpeditis_prod.pub
|
||||
ssh_public_key = "ssh-ed25519 AAAA... xpeditis-prod-admin"
|
||||
|
||||
# IP publique fixe depuis laquelle vous administrez (SSH + kubectl).
|
||||
# Trouver la votre : curl -s https://ifconfig.me
|
||||
# Si votre IP est dynamique, utilisez plutot un VPN a IP fixe, ou acceptez de
|
||||
# mettre a jour cette liste puis de relancer `terraform apply`.
|
||||
admin_ip_allowlist = [
|
||||
"203.0.113.7/32",
|
||||
]
|
||||
|
||||
# Dimensionnement (phase 1 = 0-100 utilisateurs)
|
||||
app_server_type = "cpx41" # 8 vCPU / 16 Go
|
||||
db_server_type = "cpx31" # 4 vCPU / 8 Go
|
||||
db_volume_size = 50 # Go
|
||||
|
||||
enable_hetzner_backups = true
|
||||
|
||||
# Laisser a true en regime nominal : personne ne doit pouvoir contourner
|
||||
# Cloudflare en tapant directement l'IP d'origine.
|
||||
restrict_http_to_cloudflare = true
|
||||
120
infra/prod/terraform/variables.tf
Normal file
120
infra/prod/terraform/variables.tf
Normal file
@ -0,0 +1,120 @@
|
||||
variable "hcloud_token" {
|
||||
description = "Token API Hetzner Cloud (Read & Write), projet xpeditis-prod uniquement."
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "project_name" {
|
||||
description = "Prefixe applique a toutes les ressources."
|
||||
type = string
|
||||
default = "xpeditis-prod"
|
||||
}
|
||||
|
||||
variable "location" {
|
||||
description = "Datacenter Hetzner. fsn1 = Falkenstein (DE), nbg1 = Nuremberg (DE), hel1 = Helsinki (FI). Rester dans l'UE pour le RGPD."
|
||||
type = string
|
||||
default = "fsn1"
|
||||
|
||||
validation {
|
||||
condition = contains(["fsn1", "nbg1", "hel1"], var.location)
|
||||
error_message = "Le RGPD impose de rester en UE : fsn1, nbg1 ou hel1."
|
||||
}
|
||||
}
|
||||
|
||||
# --- Dimensionnement (cf. Xpeditis_Previsions_Couts.xlsx, feuille Hetzner) ---
|
||||
|
||||
variable "app_server_type" {
|
||||
description = "Type du noeud applicatif (k3s server + pods). CPX41 = 8 vCPU / 16 Go / 240 Go."
|
||||
type = string
|
||||
default = "cpx41"
|
||||
}
|
||||
|
||||
variable "db_server_type" {
|
||||
description = "Type du noeud de donnees (PostgreSQL + Redis). CPX31 = 4 vCPU / 8 Go / 160 Go."
|
||||
type = string
|
||||
default = "cpx31"
|
||||
}
|
||||
|
||||
variable "image" {
|
||||
description = "Image systeme de base."
|
||||
type = string
|
||||
default = "ubuntu-24.04"
|
||||
}
|
||||
|
||||
variable "db_volume_size" {
|
||||
description = "Volume dedie aux donnees PostgreSQL, en Go. Detache du disque systeme : on peut agrandir, snapshotter et reattacher sans toucher au serveur."
|
||||
type = number
|
||||
default = 50
|
||||
}
|
||||
|
||||
variable "enable_hetzner_backups" {
|
||||
description = "Snapshots automatiques Hetzner (+20% du prix du serveur). Ce n'est PAS une strategie de sauvegarde suffisante : cf. 12-sauvegardes-restauration.md."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
# --- Reseau ------------------------------------------------------------------
|
||||
|
||||
variable "network_cidr" {
|
||||
description = "CIDR du reseau prive Hetzner."
|
||||
type = string
|
||||
default = "10.10.0.0/16"
|
||||
}
|
||||
|
||||
variable "subnet_cidr" {
|
||||
description = "CIDR du sous-reseau."
|
||||
type = string
|
||||
default = "10.10.1.0/24"
|
||||
}
|
||||
|
||||
variable "app_private_ip" {
|
||||
description = "IP privee fixe du noeud applicatif."
|
||||
type = string
|
||||
default = "10.10.1.10"
|
||||
}
|
||||
|
||||
variable "db_private_ip" {
|
||||
description = "IP privee fixe du noeud de donnees."
|
||||
type = string
|
||||
default = "10.10.1.20"
|
||||
}
|
||||
|
||||
# --- Acces administrateur ----------------------------------------------------
|
||||
|
||||
variable "ssh_public_key" {
|
||||
description = "Cle publique SSH (ed25519) de l'administrateur. C'est la seule methode d'authentification autorisee sur les serveurs."
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "admin_ip_allowlist" {
|
||||
description = <<-EOT
|
||||
IPs/CIDR autorises a atteindre SSH (22) et l'API Kubernetes (6443).
|
||||
Mettez votre IP fixe ou celle de votre VPN, JAMAIS 0.0.0.0/0.
|
||||
Format CIDR obligatoire, ex. ["203.0.113.7/32"].
|
||||
EOT
|
||||
type = list(string)
|
||||
|
||||
validation {
|
||||
condition = !contains(var.admin_ip_allowlist, "0.0.0.0/0")
|
||||
error_message = "Ouvrir SSH et l'API k3s au monde entier est interdit. Renseignez votre IP en /32."
|
||||
}
|
||||
}
|
||||
|
||||
variable "deploy_user" {
|
||||
description = "Compte non-root utilise pour l'administration et les deploiements."
|
||||
type = string
|
||||
default = "deploy"
|
||||
}
|
||||
|
||||
# --- Cloudflare --------------------------------------------------------------
|
||||
|
||||
variable "restrict_http_to_cloudflare" {
|
||||
description = <<-EOT
|
||||
Si true, seuls les rangs d'IP Cloudflare peuvent joindre 80/443 sur le noeud
|
||||
applicatif : impossible de contourner le WAF en tapant l'IP d'origine.
|
||||
Mettre a false UNIQUEMENT le temps d'emettre le premier certificat en HTTP-01
|
||||
ou si le proxy Cloudflare (nuage orange) est desactive.
|
||||
EOT
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
33
infra/prod/terraform/versions.tf
Normal file
33
infra/prod/terraform/versions.tf
Normal file
@ -0,0 +1,33 @@
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
hcloud = {
|
||||
source = "hetznercloud/hcloud"
|
||||
version = "~> 1.48"
|
||||
}
|
||||
}
|
||||
|
||||
# Backend distant recommande des que vous n'etes plus seul sur le projet.
|
||||
# Par defaut le state reste local : il contient des donnees sensibles
|
||||
# (IPs, ids), il est donc gitignore. Sauvegardez-le dans votre coffre-fort.
|
||||
#
|
||||
# Pour passer sur un backend S3 (Hetzner Object Storage compatible S3) :
|
||||
#
|
||||
# backend "s3" {
|
||||
# bucket = "xpeditis-prod-tfstate"
|
||||
# key = "prod/terraform.tfstate"
|
||||
# region = "fsn1"
|
||||
# endpoints = { s3 = "https://fsn1.your-objectstorage.com" }
|
||||
# skip_credentials_validation = true
|
||||
# skip_region_validation = true
|
||||
# skip_requesting_account_id = true
|
||||
# skip_s3_checksum = true
|
||||
# use_path_style = true
|
||||
# encrypt = true
|
||||
# }
|
||||
}
|
||||
|
||||
provider "hcloud" {
|
||||
token = var.hcloud_token
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user