diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..42f3b3c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Contexte Docker = racine du dépôt (voir server/Dockerfile) +node_modules +dist-portal +dist-* +artifacts +.git +.github +e2e +playwright-report +test-results +blob-report +playwright/.cache +.cursor +*.log diff --git a/.gitea/workflows/build-and-push.yaml b/.gitea/workflows/build-and-push.yaml new file mode 100644 index 0000000..1c8e2ab --- /dev/null +++ b/.gitea/workflows/build-and-push.yaml @@ -0,0 +1,128 @@ +name: Talks slides — image & chart + +# Build image + chart quand le site statique (portail, decks, toolchain) ou l’infra change. +on: + push: + branches: [main, master, develop, cicd] + tags: ['v*'] + paths: + - 'server/**' + - 'talks-slides-dist/**' + - '.gitea/workflows/**' + - 'portal/**' + - 'slides/**' + - 'tools/**' + - 'package.json' + - 'package-lock.json' + - '.dockerignore' + pull_request: + branches: [main, master, develop] + paths: + - 'server/**' + - 'talks-slides-dist/**' + - '.gitea/workflows/**' + - 'portal/**' + - 'slides/**' + - 'tools/**' + - 'package.json' + - 'package-lock.json' + - '.dockerignore' + +env: + # GIT_DEFAULT_HASH: sha256 + DOCKER_HOST: 'unix:///var/run/docker.sock' + DOCKER_CERT_PATH: '/certs/client' + IMAGE_REGISTRY: git.specificat.io + IMAGE_NAME: specificat.io/talks-slides + # Même registre OCI Helm que les autres charts (ex. knowledge-mcp). + HELM_OCI_REPOSITORY: oci://git.specificat.io/specificat.io + HELM_DIR: ./talks-slides-dist + +jobs: + vars: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.out.outputs.version }} + steps: + - id: out + env: + RUN_NUMBER: ${{ github.run_number }} + REF_NAME: ${{ github.ref_name }} + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "version=${REF_NAME#v}" >> $GITHUB_OUTPUT + else + echo "version=0.0.${RUN_NUMBER}" >> $GITHUB_OUTPUT + fi + + build-image: + runs-on: ubuntu-latest + name: Build container image + needs: vars + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + if: github.event_name == 'push' + with: + registry: ${{ env.IMAGE_REGISTRY }} + username: ${{ secrets.CI_USER }} + password: ${{ secrets.CI_TOKEN }} + - name: Build and push image + if: github.event_name == 'push' + env: + VERSION: ${{ needs.vars.outputs.version }} + run: | + set -euo pipefail + IMAGE="${IMAGE_REGISTRY}/${IMAGE_NAME}:${VERSION}" + docker build -f server/Dockerfile -t "${IMAGE}" . + docker push "${IMAGE}" + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + docker tag "${IMAGE}" "${IMAGE_REGISTRY}/${IMAGE_NAME}:latest" + docker push "${IMAGE_REGISTRY}/${IMAGE_NAME}:latest" + fi + - name: Build image (PR, no push) + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + docker build -f server/Dockerfile -t talks-slides:ci . + + helm: + runs-on: ubuntu-latest + name: Helm chart + needs: vars + steps: + - uses: actions/checkout@v4 + - name: Install Helm + run: | + set -euo pipefail + curl -fsSL -o helm.tgz --user "${{ secrets.CI_USER }}:${{ secrets.CI_TOKEN }}" \ + "https://git.specificat.io/api/packages/Specificat.io/generic/helm/4.1.1/helm-v4.1.1-linux-amd64.tar.gz" + tar -xzf helm.tgz + sudo mv linux-amd64/helm /usr/local/bin/helm + helm version + # Chart publié avec le même tag que l’image (évite latest / oubli de --set au déploiement). + - name: Aligner slides.image.tag sur la version build + env: + VERSION: ${{ needs.vars.outputs.version }} + run: | + set -euo pipefail + f="${HELM_DIR}/values.yaml" + sed -i 's|^ tag: ".*"| tag: "'"${VERSION}"'"|' "$f" + grep -E '^ tag:' "$f" || true + - name: Helm lint & template + run: | + set -euo pipefail + helm lint "${HELM_DIR}" + helm template talks-slides "${HELM_DIR}" + - name: Package and push chart (OCI) + if: github.event_name == 'push' + env: + VERSION: ${{ needs.vars.outputs.version }} + run: | + set -euo pipefail + helm package "${HELM_DIR}" --version "${VERSION}" --app-version "${VERSION}" + helm registry login "${IMAGE_REGISTRY}" -u "${{ secrets.CI_USER }}" -p "${{ secrets.CI_TOKEN }}" + helm push "talks-slides-chart-${VERSION}.tgz" "${HELM_OCI_REPOSITORY}" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index ee922d4..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Vérifie que le build production (VitePress + Slidev) réussit sans lancer les E2E -name: Build - -on: - push: - branches: [main, master, develop] - pull_request: - workflow_dispatch: - -concurrency: - group: build-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - production-build: - name: Build statique - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - - run: npm ci - - - name: Build (sync + VitePress + decks Slidev) - run: npm run build - - - name: Tests layout dist/ - run: node --test tests/dist-layout.test.mjs - - - name: Archive dist/ (aperçu) - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ - retention-days: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 8f0dace..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Tests unitaires + E2E Playwright (build statique complet) -name: CI - -on: - push: - branches: [main, master, develop] - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - node: - name: Tests Node (contrats + cohérence + sync tmp) - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - - name: Installer les dépendances - run: npm ci - - - name: Tests Node (voir scripts/presentation-contracts.mjs) - run: npm run test:node - - e2e: - name: E2E Playwright - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '22' - cache: npm - - - name: Installer les dépendances - run: npm ci - - - name: Navigateur Chromium + dépendances système - run: npx playwright install chromium --with-deps - - - name: Parcours E2E (build + serve dist) - run: npm run test:e2e - env: - CI: true - - - name: Rapport Playwright (échec) - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: playwright-report/ - retention-days: 7 - - - name: Traces Playwright (échec) - if: failure() - uses: actions/upload-artifact@v4 - with: - name: test-results - path: test-results/ - retention-days: 7 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..be7b377 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# Image statique : build npm (VitePress + Slidev) puis nginx non-root. +FROM node:22-bookworm-slim AS builder +WORKDIR /app + +COPY package.json package-lock.json ./ +# npm 10 (image officielle) et npm 11 (lock généré localement) divergent sur la validation du lock ; `install` reste déterministe via package-lock. +RUN npm install + +COPY . . +RUN npm run build + +# Même arborescence que tools/static-preview.mjs : portail à la racine, decks sous slides// +RUN set -eux \ + && mkdir -p /opt/site \ + && cp -a dist-portal/. /opt/site/ \ + && for d in dist-*; do \ + [ -d "$d" ] || continue; \ + [ "$d" = "dist-portal" ] && continue; \ + slug="${d#dist-}"; \ + mkdir -p "/opt/site/slides/${slug}"; \ + cp -a "${d}/." "/opt/site/slides/${slug}/"; \ + done + +FROM nginxinc/nginx-unprivileged:stable-alpine + +ARG UID=101 +ARG GID=101 + +USER root +COPY --from=builder /opt/site /usr/share/nginx/html +COPY server/nginx/default.conf /etc/nginx/conf.d/default.conf +RUN chown -R "${UID}:${GID}" /usr/share/nginx/html + +USER ${UID} +EXPOSE 8080 +CMD ["nginx", "-g", "daemon off;"] diff --git a/server/nginx/default.conf b/server/nginx/default.conf new file mode 100644 index 0000000..d2bd2d5 --- /dev/null +++ b/server/nginx/default.conf @@ -0,0 +1,22 @@ +server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Portail VitePress (fichiers statiques + clean URLs) + location / { + try_files $uri $uri/ $uri.html =404; + } + + # Slidev : /slides/ ou /slides// → première planche (comme static-preview.mjs) + # $http_host conserve le port client (reverse proxy, docker -p host:8080). + location ~ ^/slides/([^/]+)/?$ { + return 302 $scheme://$http_host/slides/$1/1$is_args$args; + } + + # Decks Slidev : assets et routes SPA (/1, /2, …) + location ~ ^/slides/(?[^/]+)(?/.*)$ { + try_files $uri $uri/ /slides/$slug/index.html; + } +} diff --git a/talks-slides-dist/.helmignore b/talks-slides-dist/.helmignore new file mode 100644 index 0000000..29becec --- /dev/null +++ b/talks-slides-dist/.helmignore @@ -0,0 +1,4 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +*.tgz diff --git a/talks-slides-dist/Chart.yaml b/talks-slides-dist/Chart.yaml new file mode 100644 index 0000000..08df2c9 --- /dev/null +++ b/talks-slides-dist/Chart.yaml @@ -0,0 +1,11 @@ +apiVersion: v2 +name: talks-slides-chart +description: Helm chart for Talks slides (Nginx + contenu git) +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - slides + - nginx + - reveal +maintainers: [] diff --git a/talks-slides-dist/templates/NOTES.txt b/talks-slides-dist/templates/NOTES.txt new file mode 100644 index 0000000..daf230c --- /dev/null +++ b/talks-slides-dist/templates/NOTES.txt @@ -0,0 +1,2 @@ +Déploiement Talks slides (Nginx). +Vérifiez l’Ingress et le DNS pour l’URL publique. diff --git a/talks-slides-dist/templates/_helpers.tpl b/talks-slides-dist/templates/_helpers.tpl new file mode 100644 index 0000000..04a7314 --- /dev/null +++ b/talks-slides-dist/templates/_helpers.tpl @@ -0,0 +1,41 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "talks-slides.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "talks-slides.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "talks-slides.labels" -}} +helm.sh/chart: {{ include "talks-slides.name" . }}-{{ .Chart.Version | replace "+" "_" }} +app.kubernetes.io/name: {{ include "talks-slides.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "talks-slides.selectorLabels" -}} +app.kubernetes.io/name: {{ include "talks-slides.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/talks-slides-dist/templates/deployment.yaml b/talks-slides-dist/templates/deployment.yaml new file mode 100644 index 0000000..3c88bf2 --- /dev/null +++ b/talks-slides-dist/templates/deployment.yaml @@ -0,0 +1,67 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "talks-slides.fullname" . }} + labels: + {{- include "talks-slides.labels" . | nindent 4 }} +spec: + {{- if not .Values.slides.autoscaling.enabled }} + replicas: {{ .Values.slides.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "talks-slides.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "talks-slides.selectorLabels" . | nindent 8 }} + {{- with .Values.slides.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.slides.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.slides.podSecurityContext | nindent 8 }} + containers: + - name: nginx + image: "{{ .Values.slides.image.repository }}:{{ .Values.slides.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.slides.image.pullPolicy }} + securityContext: + allowPrivilegeEscalation: false + # Image : USER nginx + master non-root → pas de SETUID/SETGID ; port 8080 → pas de NET_BIND_SERVICE. + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: false + ports: + - name: http + containerPort: {{ .Values.slides.containerPort }} + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.slides.resources | nindent 12 }} + {{- with .Values.slides.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.slides.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.slides.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/talks-slides-dist/templates/ingress.yaml b/talks-slides-dist/templates/ingress.yaml new file mode 100644 index 0000000..4d94237 --- /dev/null +++ b/talks-slides-dist/templates/ingress.yaml @@ -0,0 +1,45 @@ +{{- if .Values.slides.ingress.enabled -}} +{{- $tlsOn := or .Values.slides.ingress.tls.enabled .Values.slides.ingress.certManager.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "talks-slides.fullname" . }} + labels: + {{- include "talks-slides.labels" . | nindent 4 }} + {{- if or .Values.slides.ingress.certManager.enabled .Values.slides.ingress.annotations }} + annotations: + {{- if .Values.slides.ingress.certManager.enabled }} + cert-manager.io/cluster-issuer: {{ .Values.slides.ingress.certManager.clusterIssuer | quote }} + {{- end }} + {{- with .Values.slides.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + {{- if .Values.slides.ingress.className }} + ingressClassName: {{ .Values.slides.ingress.className }} + {{- end }} + {{- if $tlsOn }} + tls: + - hosts: + {{- range .Values.slides.ingress.hosts }} + - {{ .host | quote }} + {{- end }} + secretName: {{ .Values.slides.ingress.tls.secretName | default (printf "%s-tls" (include "talks-slides.fullname" .)) }} + {{- end }} + rules: + {{- range .Values.slides.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "talks-slides.fullname" $ }} + port: + number: {{ $.Values.slides.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/talks-slides-dist/templates/service.yaml b/talks-slides-dist/templates/service.yaml new file mode 100644 index 0000000..2d9afa7 --- /dev/null +++ b/talks-slides-dist/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "talks-slides.fullname" . }} + labels: + {{- include "talks-slides.labels" . | nindent 4 }} +spec: + type: {{ .Values.slides.service.type }} + ports: + - port: {{ .Values.slides.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "talks-slides.selectorLabels" . | nindent 4 }} diff --git a/talks-slides-dist/values.yaml b/talks-slides-dist/values.yaml new file mode 100644 index 0000000..7ad0223 --- /dev/null +++ b/talks-slides-dist/values.yaml @@ -0,0 +1,67 @@ +# Image : git.specificat.io/specificat.io/talks-slides +# Avec release "talks-slides" et nameOverride "talks-slides", le fullname reste cohérent. +nameOverride: "talks-slides" +fullnameOverride: "" + +slides: + image: + repository: git.specificat.io/specificat.io/talks-slides + tag: "latest" + pullPolicy: IfNotPresent + + imagePullSecrets: + - name: prd-gitea-registry-secret + + replicaCount: 1 + + # Port d’écoute dans le conteneur (doit correspondre à server/nginx/default.conf, ex. 8080). + containerPort: 8080 + + # Aligné sur l’utilisateur nginx de l’image (UID/GID 101). L’image utilise USER nginx. + podSecurityContext: + runAsUser: 101 + runAsGroup: 101 + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + + service: + type: ClusterIP + # Port du Service (Ingress pointe ici) ; le trafic est envoyé vers containerPort sur les pods. + port: 80 + + resources: + limits: + memory: 128Mi + requests: + cpu: 10m + memory: 32Mi + + autoscaling: + enabled: false + + nodeSelector: {} + affinity: {} + tolerations: [] + podLabels: {} + + ingress: + enabled: true + className: "" + annotations: {} + hosts: + - host: slides.specificat.io + paths: + - path: / + pathType: Prefix + # TLS : soit manuel (tls.enabled + secretName), soit via cert-manager (certManager.enabled). + # Le nom d’hôte vient de hosts[].host (réutilisé pour le bloc tls.hosts). + certManager: + enabled: false + # Ex. letsencrypt-prod — requis si certManager.enabled est true + clusterIssuer: "" + tls: + enabled: false + # Secret TLS dans le namespace (créé par cert-manager ou importé à la main). + # Vide : suffixe -tls sur le nom complet de la release (ex. talks-slides-tls). + secretName: ""