diff --git a/.github/workflows/deploy_kube.yaml b/.github/workflows/deploy_kube.yaml new file mode 100644 index 0000000000..169321d630 --- /dev/null +++ b/.github/workflows/deploy_kube.yaml @@ -0,0 +1,178 @@ +name: Deploy production on Kube + +on: + workflow_dispatch: ~ + schedule: + - cron: '30 7 * * 1,2,3,4,5' # At 08:30 (UTC, => 08:30 / 09:30 in Europe/Paris depending on the DST), each day of the week. + push: + branches: + - kube + +jobs: + + build-production: + name: '🚧 Build 🚀' + runs-on: ubuntu-latest + timeout-minutes: 10 + + environment: + name: production + url: https://www.elao.io #${{ vars.WEBSITE_URL }} + + permissions: + contents: read + packages: write + + steps: + - name: 'Checkout' + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 'Configure deployer SSH key' + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.SSH_DEPLOY_KEY_PRODUCTION }} + + # https://github.com/actions/setup-node + - name: 'Setup node' + uses: actions/setup-node@v3 + with: + node-version: '20' + cache: 'npm' + + - name: 'Setup PHP' + uses: shivammathur/setup-php@v2 + with: + coverage: "none" + ini-values: "memory_limit=-1" + php-version: "8.3" + + - name: 'Cache resized images' + uses: actions/cache@v3 + with: + path: public/resized + key: resized-images-${{ github.workflow }}-${{ secrets.CACHE_VERSION }} + + - name: 'Determine composer cache directory' + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: 'Cache composer dependencies' + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: 'Install dependencies' + run: | + echo "::group::composer install" + composer install --no-progress --ansi + echo "::endgroup::" + + echo "::group::npm install" + npm install --color=always --no-progress --no-audit --no-fund + echo "::endgroup::" + + - name: 'Warmup' + run: | + echo "::group::warmup production env" + npx encore production --color + bin/console cache:clear --ansi + bin/console cache:warmup --ansi + echo "::endgroup::" + env: + APP_ENV: prod + + - name: 'Build static site' + run: | + bin/console stenope:build --no-interaction -vv --ansi --ignore-content-not-found + bin/console app:generate-redirections --target=site --no-interaction -vv --ansi > build/redirections-site.conf + bin/console app:generate-redirections --target=blog --no-interaction -vv --ansi > build/redirections-blog.conf + env: + APP_ENV: prod + ROUTER_DEFAULT_URI: https://www.elao.io #${{ vars.WEBSITE_URL }} + INCLUDE_SAMPLES: 0 + SHOW_UNPUBLISHED_ARTICLES: 0 + MATOMO_ID: ${{ vars.MATOMO_ID }} + + - name: 'Login to GitHub Container Registry' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ElaoBot + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 'Replace values for production' + run: | + sed -i 's/__REDIRECTIONIO_ELAO_PROJECT_KEY__/${{ secrets.REDIRECTIONIO_ELAO_PROJECT_KEY }}/g' kubernetes/app.conf + sed -i 's/__REDIRECTIONIO_BLOG_PROJECT_KEY__/${{ secrets.REDIRECTIONIO_BLOG_PROJECT_KEY }}/g' kubernetes/app.conf + + - name: 'Build and push the Docker image for prod' + uses: docker/build-push-action@v5 + with: + context: . + file: kubernetes/Dockerfile + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: | + ghcr.io/elao/website:latest + + deploy-helm: + runs-on: ubuntu-22.04 + timeout-minutes: 5 + needs: [build-production] + + steps: + - name: 'Set the Kubernetes context' + uses: azure/k8s-set-context@v4 + with: + method: kubeconfig + kubeconfig: $${{ secrets.KUBERNETES_KUBECONFIG }} + context: kubernetes-admin@elao_argon-website-prod + + - name: Checkout source code + uses: actions/checkout@v4 + + - name: 'Render charts website' + uses: azure/k8s-bake@v2.4 + id: bake_website + with: + renderEngine: helm + helmChart: kubernetes/charts/website + helm-version: latest + silent: false + + - name: 'Deploy to the Kubernetes cluster' + uses: azure/k8s-deploy@v5 + with: + action: deploy + force: true + namespace: website-prod + manifests: ${{ steps.bake_website.outputs.manifestsBundle }} + pull-images: false + + deploy-image: + runs-on: ubuntu-22.04 + timeout-minutes: 5 + needs: [deploy-helm] + + steps: + - name: 'Set the Kubernetes context' + uses: azure/k8s-set-context@v4 + with: + method: kubeconfig + kubeconfig: $${{ secrets.KUBERNETES_KUBECONFIG }} + context: kubernetes-admin@elao_argon-website-prod + + - uses: azure/setup-kubectl@v4 + name: Setup kubectl + with: + version: v1.28.2 + + - name: 'Rollout deployment website' + run: | + kubectl rollout restart deployment/website --namespace website-prod diff --git a/kubernetes/Dockerfile b/kubernetes/Dockerfile new file mode 100644 index 0000000000..fa3395ecbc --- /dev/null +++ b/kubernetes/Dockerfile @@ -0,0 +1,63 @@ +ARG DEBIAN=bookworm + +FROM debian:${DEBIAN}-slim + +ARG DEBIAN +ARG NGINX_VERSION=1.22 +ARG USER_ID=1000 +ARG GROUP_ID=1000 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + apt-transport-https \ + ca-certificates \ + curl \ + gnupg2 \ + sudo + +# User +RUN addgroup --gid ${GROUP_ID} app \ + && adduser --home /home/app --shell /bin/bash --uid ${USER_ID} --gecos app --ingroup app --disabled-password app \ + && echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app \ + # App dir + && mkdir --parents /srv && chown app:app /srv + +######### +# Nginx # +######### +RUN \ + echo "deb http://nginx.org/packages/debian/ ${DEBIAN} nginx" > /etc/apt/sources.list.d/nginx.list \ + && curl -sSL http://nginx.org/keys/nginx_signing.key \ + | apt-key add - \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + nginx=${NGINX_VERSION}.* \ + && chown -R $USER_ID:0 /etc/nginx \ + && chmod -R g+w /etc/nginx + +################## +# Redirection.io # +################## +RUN curl -sSL https://packages.redirection.io/gpg.key | apt-key add - \ + && echo "deb https://packages.redirection.io/deb/stable/2 ${DEBIAN} main" > /etc/apt/sources.list.d/packages_redirection_io_deb.list \ + && apt-get update \ + && apt-get install -y \ + libnginx-mod-redirectionio + +################# +# Configuration # +################# +COPY --chown=app:app kubernetes/app.conf /etc/nginx/app.conf +COPY --chown=app:app build/ /srv/app/website/ + +######### +# Clean # +######### +RUN \ + rm -rf \ + /var/lib/apt/lists/* \ + /var/cache/debconf/*-old \ + /var/lib/dpkg/*-old \ + && truncate -s 0 /var/log/*.log + +CMD ["nginx", "-c", "/etc/nginx/app.conf"] diff --git a/kubernetes/app.conf b/kubernetes/app.conf new file mode 100644 index 0000000000..ed10b8c441 --- /dev/null +++ b/kubernetes/app.conf @@ -0,0 +1,85 @@ +include /etc/nginx/modules-enabled/*.conf; +worker_processes 1; + +error_log stderr "warn"; +pid /etc/nginx/nginx.pid; + +events { + worker_connections 1024; + multi_accept on; + use epoll; +} + +daemon off; + +http { + include /etc/nginx/mime.types; + + proxy_temp_path /tmp/proxy_temp; + client_body_temp_path /tmp/client_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + log_format json escape=json '{' + '"remote_addr": "$remote_addr",' + '"remote_user": "$remote_user",' + '"time_local": "$time_local",' + '"request": "$request",' + '"request_length": $request_length,' + '"status": $status,' + '"body_bytes_sent": $body_bytes_sent,' + '"http_host": "$http_host",' + '"http_referer": "$http_referer",' + '"http_user_agent": "$http_user_agent",' + '"http_x_forwarded_for": "$http_x_forwarded_for",' + '"request_time": $request_time,' + '"upstream_connect_time": "$upstream_connect_time",' + '"upstream_header_time": "$upstream_header_time",' + '"upstream_response_time": "$upstream_response_time"' + '}'; + + access_log /dev/stdout json; + + server { + listen 8080; + server_name www.elao.io; + root /srv/app/website; + absolute_redirect off; + redirectionio_project_key __REDIRECTIONIO_ELAO_PROJECT_KEY__; + gzip on; + gzip_disable msie6; + gzip_vary on; + gzip_proxied expired no-cache no-store private auth; + gzip_comp_level 6; + gzip_min_length 1000; + gzip_types text/css text/javascript text/xml text/plain application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; + # https://scotthelme.co.uk/hardening-your-http-response-headers/ + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy strict-origin-when-cross-origin; + add_header Feature-Policy 'geolocation \'self\';fullscreen \'self\';microphone \'none\';camera \'none\';autoplay \'none\';payment \'none\';speaker \'none\''; + location ~* ^.+\.(?:css|cur|js|jpe?g|gif|htc|ico|png|xml|otf|ttf|eot|woff|woff2|svg|webp)$ { + expires 60d; + add_header Cache-Control public; + } + location / { + try_files $uri $uri/index.html =404; + } + location ~ \.html { + internal; + } + error_page 404 /404.html; + } + + server { + listen 8080; + server_name blog.elao.io; + redirectionio_project_key __REDIRECTIONIO_BLOG_PROJECT_KEY__; + } +} diff --git a/kubernetes/charts/website/Chart.yaml b/kubernetes/charts/website/Chart.yaml new file mode 100644 index 0000000000..8717f8aefc --- /dev/null +++ b/kubernetes/charts/website/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: website +version: 0.0.1 diff --git a/kubernetes/charts/website/templates/_helpers.tpl b/kubernetes/charts/website/templates/_helpers.tpl new file mode 100644 index 0000000000..db7a8008d7 --- /dev/null +++ b/kubernetes/charts/website/templates/_helpers.tpl @@ -0,0 +1,47 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "fullname" -}} +{{- $name := .Chart.Name -}} +{{- if contains $name .Chart.Name -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Chart.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "labels" -}} +helm.sh/chart: {{ include "chart" . }} +{{ include "selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "selectorLabels" -}} +app.kubernetes.io/name: {{ include "name" . }} +app.kubernetes.io/instance: {{ .Chart.Name }} +{{- end }} diff --git a/kubernetes/charts/website/templates/deployment.yaml b/kubernetes/charts/website/templates/deployment.yaml new file mode 100644 index 0000000000..0d09a569d7 --- /dev/null +++ b/kubernetes/charts/website/templates/deployment.yaml @@ -0,0 +1,38 @@ +--- + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "fullname" . }} + labels: + {{- include "labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + {{- include "selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "labels" . | nindent 8 }} + annotations: {} + spec: + imagePullSecrets: + - name: dockerconfig + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: website + image: ghcr.io/elao/website:latest + resources: + requests: + cpu: 100m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP diff --git a/kubernetes/charts/website/templates/ingress.yaml b/kubernetes/charts/website/templates/ingress.yaml new file mode 100644 index 0000000000..9da86a17c7 --- /dev/null +++ b/kubernetes/charts/website/templates/ingress.yaml @@ -0,0 +1,40 @@ +--- + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "fullname" . }}-ingress + labels: + {{- include "labels" . | nindent 4 }} + annotations: + cert-manager.io/cluster-issuer: letsencrypt +spec: + ingressClassName: nginx + tls: + - hosts: + - www.elao.io + secretName: www.elao.io.tls + - hosts: + - blog.elao.io + secretName: blog.elao.io.tls + rules: + - host: www.elao.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "fullname" . }}-service + port: + number: 8080 + - host: blog.elao.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "fullname" . }}-service + port: + number: 8080 diff --git a/kubernetes/charts/website/templates/service.yaml b/kubernetes/charts/website/templates/service.yaml new file mode 100644 index 0000000000..8a6b038fea --- /dev/null +++ b/kubernetes/charts/website/templates/service.yaml @@ -0,0 +1,15 @@ +--- + +kind: Service +apiVersion: v1 +metadata: + name: {{ include "fullname" . }}-service + labels: + {{- include "labels" . | nindent 4 }} +spec: + selector: + {{- include "selectorLabels" . | nindent 4 }} + type: ClusterIP + ports: + - name: web + port: 8080