Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions Justfile

This file was deleted.

170 changes: 170 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# OpsWarden Ops — runner unique du repo (infra). Convention projet : ops = Make,
# app/web = Just (un seul runner par repo, jamais deux empilés).
# Cycle de vie : provision -> deploy -> harden -> verify -> destroy.
# Inclut aussi la qualité (fmt / validate / lint), repliée ici depuis l'ex-Justfile.
#
# Démarrage rapide (depuis ce dossier) :
# cp .env.example .env && $EDITOR .env # poser DIGITALOCEAN_TOKEN
# nix develop # ou: export $(grep -v '^#' .env | xargs)
# make all # infra + deploy (couche prête)
# make hosts && make smoke # DNS local + smoke test
#
# Placeholder : les services applicatifs (server/client-web/investigation/worker)
# ne sont pas encore déployés. `deploy` n'applique que la couche prête
# (observability + postgres + redis + traefik) ; la couche app est commentée.

# Recettes POSIX sh (pas de dépendance dure à /bin/bash : NixOS / images minimales).
TF_DIR := terraform
# Kubeconfig par défaut : ~/.kube/config (ce que minikube, kubectl et k9s utilisent).
# DOKS est le cas particulier : Terraform écrit ./kubeconfig, utilisé par all/infra.
KUBECONFIG ?= $(HOME)/.kube/config
export KUBECONFIG
DOKS_KUBECONFIG := $(CURDIR)/kubeconfig

# minikube écrit son contexte dans ~/.kube/config (le défaut). 2 nœuds reflètent
# DOKS pour que l'anti-affinity required (2 replicas) marche tel quel.
MINIKUBE_NODES ?= 2

# Hôtes publics (placeholders : .example ne résout pas, mappé via /etc/hosts).
WEB_HOST ?= app.opswarden.example
API_HOST ?= api.opswarden.example

# Manifests groupés par phase de déploiement.
MONITORING := k8s/observability/cadvisor.daemonset.yaml
DATA := k8s/postgres/postgres.secret.yaml k8s/postgres/postgres.configmap.yaml \
k8s/postgres/postgres.volume.yaml k8s/postgres/postgres.deployment.yaml \
k8s/postgres/postgres.service.yaml \
k8s/redis/redis.configmap.yaml k8s/redis/redis.deployment.yaml \
k8s/redis/redis.service.yaml
LB := k8s/traefik/traefik.ingressclass.yaml k8s/traefik/traefik.rbac.yaml \
k8s/traefik/traefik.deployment.yaml k8s/traefik/traefik.service.yaml
# Couche app (placeholders) — décommenter au fil des images publiées :
# APP := k8s/server/ k8s/client-web/ k8s/investigation/ k8s/worker/

.DEFAULT_GOAL := help
.PHONY: help all infra kubeconfig deploy db-check hosts smoke status destroy \
fmt fmt-check validate tf-lint \
metrics hpa pdb load harden soft-affinity hard-affinity \
minikube minikube-up minikube-deploy minikube-hosts minikube-smoke minikube-down

help: ## Affiche cette aide
@echo "OpsWarden Ops — cibles make :"
@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | \
awk 'BEGIN{FS=":.*## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'

all: infra ## DOKS : provisionne le cluster puis déploie la couche prête
$(MAKE) deploy KUBECONFIG=$(DOKS_KUBECONFIG)

## --- Cœur ------------------------------------------------------------------

infra: ## Provisionne le cluster DOKS via Terraform (écrit ./kubeconfig)
cd $(TF_DIR) && terraform init -input=false && terraform apply -auto-approve
@echo ">> Attente des nœuds Ready..."
KUBECONFIG=$(DOKS_KUBECONFIG) kubectl wait --for=condition=Ready nodes --all --timeout=300s

kubeconfig: ## (Re)génère ./kubeconfig depuis l'état Terraform
cd $(TF_DIR) && terraform apply -auto-approve -target=local_file.kubeconfig

deploy: ## Applique la couche prête (observability + data + traefik), dans l'ordre
kubectl apply -f $(MONITORING)
kubectl apply $(addprefix -f ,$(DATA))
@echo ">> Attente de postgres & redis..."
kubectl rollout status deploy/postgres --timeout=180s
kubectl rollout status deploy/redis --timeout=120s
kubectl apply $(addprefix -f ,$(LB))
kubectl -n kube-public rollout status deploy/traefik --timeout=120s
@echo ">> Couche app (server/client-web/investigation/worker) : placeholders, non déployée."

db-check: ## Vérifie la connectivité Postgres (le schéma est géré par opswarden-server)
@POD=$$(kubectl get pods -l app=postgres -o jsonpath='{.items[0].metadata.name}'); \
echo ">> Test connexion sur le pod $$POD"; \
kubectl exec -i $$POD -c postgres -- sh -c 'psql -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -c "SELECT 1;"'

hosts: ## Mappe l'IP d'un nœud -> hôtes web/api dans /etc/hosts (sudo)
@NODES=$$(kubectl get nodes -o jsonpath='{ $$.items[*].status.addresses[?(@.type=="ExternalIP")].address }'); \
IP=$$(echo $$NODES | awk '{print $$1}'); \
echo ">> $$IP -> $(WEB_HOST) $(API_HOST)"; \
echo "$$IP $(WEB_HOST) $(API_HOST)" | sudo tee -a /etc/hosts

smoke: ## Smoke test bout-en-bout (Traefik + routes app best-effort)
WEB_HOST=$(WEB_HOST) API_HOST=$(API_HOST) ./scripts/smoke.sh

status: ## État du cluster (pods / services / ingress, namespaces utiles)
kubectl get pods -o wide
kubectl get svc,ingress
kubectl -n kube-public get pods,svc -o wide
kubectl -n kube-system get ds cadvisor

destroy: ## Détruit le cluster DOKS
cd $(TF_DIR) && terraform destroy -auto-approve

## --- Qualité (ex-Justfile, ce que vérifie la CI) ---------------------------

fmt: ## Formate yaml/md/json (prettier) + terraform fmt
npx --yes prettier --write "**/*.{yaml,yml,md,json}"
terraform -chdir=$(TF_DIR) fmt

fmt-check: ## Vérifie le formatage sans rien modifier (miroir CI)
npx --yes prettier --check "**/*.{yaml,yml,md,json}"
terraform -chdir=$(TF_DIR) fmt -check

validate: ## Valide les manifests k8s (kubeconform) + terraform
terraform -chdir=$(TF_DIR) validate || true
find k8s -name '*.yaml' -print0 | xargs -0 -I{} sh -c 'kubeconform -strict -summary "{}" || true'

tf-lint: ## Lint terraform (tflint)
cd $(TF_DIR) && tflint || true

## --- Durcissement production -----------------------------------------------

metrics: ## Assure la présence de metrics-server (les clusters managés l'ont souvent)
@kubectl top nodes >/dev/null 2>&1 && echo "metrics-server déjà présent" || \
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

pdb: ## Applique les PodDisruptionBudgets (traefik prêt ; server si déployé)
kubectl apply -f k8s/traefik/traefik.pdb.yaml
@kubectl get deploy server >/dev/null 2>&1 \
&& kubectl apply -f k8s/server/server.pdb.yaml \
|| echo ">> deploy/server absent — k8s/server/server.pdb.yaml prêt à appliquer plus tard."
kubectl get pdb -A

hpa: metrics ## Applique le HPA du server (sauté tant que le Deployment n'existe pas)
@kubectl get deploy server >/dev/null 2>&1 \
&& { kubectl apply -f k8s/server/server.hpa.yaml; kubectl get hpa; } \
|| echo ">> deploy/server absent — k8s/server/server.hpa.yaml prêt à appliquer plus tard."

load: ## Génère de la charge HTTP (test autoscaling) contre l'hôte web
WEB_URL=http://$(WEB_HOST):30021 ./scripts/load.sh

soft-affinity: ## Relâche l'anti-affinity required->preferred (clusters nodes < replicas)
./scripts/soft-affinity.sh on

hard-affinity: ## Restaure l'anti-affinity stricte (preferred->required)
./scripts/soft-affinity.sh off

harden: pdb hpa ## Applique PDB + HPA (ce qui est prêt)

## --- Cluster local (minikube, sans DigitalOcean) ---------------------------

minikube: minikube-up minikube-deploy ## One-shot : cluster local + couche prête
@echo ">> Fait. Ensuite : 'make minikube-smoke' pour vérifier."

minikube-up: ## Démarre minikube (driver docker) + metrics-server + storage
minikube start --nodes $(MINIKUBE_NODES) --driver=docker \
--addons=metrics-server,default-storageclass,storage-provisioner

minikube-deploy: ## Déploie la couche prête sur le minikube courant
$(MAKE) deploy

minikube-hosts: ## Mappe l'IP minikube -> hôtes web/api (/etc/hosts ; NixOS-aware)
@IP=$$(minikube ip); LINE="$$IP $(WEB_HOST) $(API_HOST)"; \
echo "$$LINE" | sudo tee -a /etc/hosts 2>/dev/null \
|| { echo ">> /etc/hosts en lecture seule (NixOS). Ajouter dans configuration.nix :"; \
echo " networking.extraHosts = \"$$LINE\";"; \
echo ">> Ou vérifier sans /etc/hosts : make minikube-smoke"; }

minikube-smoke: ## Smoke test minikube sans /etc/hosts (curl --resolve)
RESOLVE_IP=$$(minikube ip) WEB_HOST=$(WEB_HOST) API_HOST=$(API_HOST) ./scripts/smoke.sh

minikube-down: ## Supprime le cluster minikube local
minikube delete
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,19 @@ cloud showcase** — it must never be a prerequisite to run or grade the product
opswarden-ops/
├── k8s/
│ ├── server/ # OpsWarden server (Rust/Axum) — placeholder
│ ├── server/ # OpsWarden server (Rust/Axum) + HPA/PDB tmpl — placeholder
│ ├── client-web/ # Next.js client (or Vercel) — placeholder
│ ├── investigation/ # AI SRE agent (RAG/FastAPI) — placeholder
│ ├── worker/ # async Redis workers — placeholder
│ ├── postgres/ # PostgreSQL — ready
│ ├── redis/ # Redis — ready
│ ├── traefik/ # ingress controller & LB — ready
│ ├── traefik/ # ingress controller & LB (+ IngressClass, PDB) — ready
│ └── observability/ # cAdvisor (+ prom/grafana/loki) — partial
├── terraform/ # DOKS cluster provisioning (main/outputs/providers/variables.tf)
├── scripts/ # smoke.sh, load.sh, soft-affinity.sh (ops helpers)
├── docs/ # architecture + cluster screenshots
├── Makefile # single runner: provision → deploy → harden → verify → destroy + fmt/validate/lint
├── flake.nix / flake.lock # Nix dev shell (kubectl, terraform, k9s, helm…)
├── .env # API tokens (git-ignored)
├── LICENSE / NOTICE # Apache-2.0
Expand Down Expand Up @@ -136,6 +138,14 @@ screenshots will replace the application-level ones once it is deployed.

## Installation & Configuration

> **One-command path.** The whole lifecycle is automated by the
> [`Makefile`](Makefile): `make all` (provision + deploy the ready layer),
> `make hosts && make smoke` (local DNS + end-to-end check), `make harden`
> (PDB/HPA), `make destroy`. Run `make help` for every target. **No cloud
> account?** Run the same manifests for free on a local 2-node minikube:
> `make minikube`, then `make minikube-smoke`. The manual steps below spell out
> the same flow.

### Prerequisites

- [Nix](https://nixos.org/download.html) package manager
Expand Down Expand Up @@ -185,11 +195,38 @@ kubectl apply -f k8s/traefik/
### Teardown

```bash
cd terraform && terraform destroy
cd terraform && terraform destroy # or: make destroy
```

---

## Production hardening

Reusable patterns ported from the reference deployment, applied with `make harden`:

- **Disruption budgets** — [`k8s/traefik/traefik.pdb.yaml`](k8s/traefik/traefik.pdb.yaml)
keeps ≥1 Traefik replica during node drain; [`k8s/server/server.pdb.yaml`](k8s/server/server.pdb.yaml)
is the template for the API server.
- **Autoscaling** — [`k8s/server/server.hpa.yaml`](k8s/server/server.hpa.yaml)
(CPU-based HPA template), enabled by `requests.cpu` + metrics-server
(`make metrics` / `make load`).
- **Modern ingress** — [`k8s/traefik/traefik.ingressclass.yaml`](k8s/traefik/traefik.ingressclass.yaml)
replaces the deprecated `kubernetes.io/ingress.class` annotation; app Ingresses
use `spec.ingressClassName: traefik`.
- **Strict HA** — replicated services use _required_ pod anti-affinity (one
replica per node). On clusters where `nodes < replicas`,
[`scripts/soft-affinity.sh`](scripts/soft-affinity.sh) (`make soft-affinity`)
relaxes it to _preferred_ without editing the manifests.
- **Smoke & load** — [`scripts/smoke.sh`](scripts/smoke.sh) checks the public
path (Traefik + app routes, NixOS/minikube aware) and
[`scripts/load.sh`](scripts/load.sh) drives autoscaling.

> HPA/PDB for the app tier (`server`, `client-web`) are **templates** until those
> images are deployed — `make harden` applies what is ready (Traefik) and skips
> the rest with a notice.

---

## License

OpsWarden is distributed under the **Apache License 2.0**. See
Expand Down
9 changes: 7 additions & 2 deletions k8s/server/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
# Quand l'image existera (ghcr.io/romeocavazza/opswarden-server) et que l'app
# sera stable, remplir ce dossier avec :
# - deployment.yaml : Deployment (replicas, pod anti-affinity, env via
# ConfigMap/Secret, probes /health)
# ConfigMap/Secret, probes /health, resources.requests.cpu)
# - service.yaml : Service ClusterIP sur :8080
# - ingress.yaml : route Traefik (ex. api.opswarden.example)
# - ingress.yaml : route Traefik (ex. api.opswarden.example),
# spec.ingressClassName: traefik (cf. k8s/traefik/)
#
# Durcissement deja pret a appliquer (templates dans ce dossier) :
# - server.hpa.yaml : HorizontalPodAutoscaler CPU (besoin de requests.cpu)
# - server.pdb.yaml : PodDisruptionBudget (>=1 replica au drain)
#
# Exemple de service reel et complet : voir k8s/postgres/.
24 changes: 24 additions & 0 deletions k8s/server/server.hpa.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# HorizontalPodAutoscaler pour le server OpsWarden (basé CPU).
# TEMPLATE — requiert (1) le Deployment server avec resources.requests.cpu
# défini, et (2) metrics-server (DOKS le fournit ; en local `make metrics`).
# Scaler au-delà des replicas de base suppose une anti-affinity *preferred*
# (pas required) sur le Deployment server (cf. scripts/soft-affinity.sh).
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: server
namespace: default
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: server
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
14 changes: 14 additions & 0 deletions k8s/server/server.pdb.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# PodDisruptionBudget pour le server OpsWarden (API Rust/Axum).
# TEMPLATE — le Deployment référencé (k8s/server/deployment.yaml) est un
# placeholder ; appliquer ceci une fois l'image du server déployée. Un PDB sans
# pod correspondant est inerte : l'appliquer en avance est sans danger.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: server
namespace: default
spec:
minAvailable: 1
selector:
matchLabels:
app: server
11 changes: 11 additions & 0 deletions k8s/traefik/traefik.ingressclass.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Remplaçant moderne de l'annotation dépréciée `kubernetes.io/ingress.class`.
# Les Ingress applicatifs (server/client-web) référencent ceci via
# `spec.ingressClassName: traefik`. Ressource cluster-scoped ; la ClusterRole
# Traefik (k8s/traefik/traefik.rbac.yaml) accorde déjà la lecture des
# `ingressclasses`.
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: traefik
spec:
controller: traefik.io/ingress-controller
14 changes: 14 additions & 0 deletions k8s/traefik/traefik.pdb.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# PodDisruptionBudget — garde >=1 replica Traefik en service pendant les
# perturbations volontaires (drain de nœud, rolling update, éviction). Traefik
# tourne en 2 replicas avec anti-affinity required ; ce PDB protège l'entrée
# publique du cluster.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: traefik
namespace: kube-public
spec:
minAvailable: 1
selector:
matchLabels:
app: traefik
36 changes: 36 additions & 0 deletions scripts/load.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# OpsWarden Ops — générateur de charge pour déclencher l'autoscaling (HPA).
# Suivre l'effet dans un autre terminal :
# watch -n2 kubectl get hpa,deploy server
# kubectl get pods -l app=server -o wide -w
#
# Usage :
# ./scripts/load.sh # 60s, auto-détecte hey/ab, sinon boucle curl
# DURATION=120 CONCURRENCY=100 ./scripts/load.sh
# WEB_URL=http://api.opswarden.example:30021 ./scripts/load.sh
set -euo pipefail

WEB_URL="${WEB_URL:-http://app.opswarden.example:30021}"
DURATION="${DURATION:-60}"
CONCURRENCY="${CONCURRENCY:-50}"

echo ">> Cible : $WEB_URL"
echo ">> Durée : ${DURATION}s"
echo ">> Concurrence : $CONCURRENCY"

if command -v hey >/dev/null 2>&1; then
echo ">> Avec hey"
hey -z "${DURATION}s" -c "$CONCURRENCY" "$WEB_URL"
elif command -v ab >/dev/null 2>&1; then
echo ">> Avec ApacheBench (ab) — ~$((CONCURRENCY * DURATION * 10)) requêtes"
ab -t "$DURATION" -c "$CONCURRENCY" "$WEB_URL/"
else
echo ">> Pas de hey/ab — repli sur une boucle curl parallèle"
end=$(( $(date +%s) + DURATION ))
for _ in $(seq 1 "$CONCURRENCY"); do
( while [ "$(date +%s)" -lt "$end" ]; do curl -fsS "$WEB_URL" -o /dev/null || true; done ) &
done
wait
fi

echo ">> Charge terminée. Vérifier : kubectl get hpa"
Loading