diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cd48994..0bdc175 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,17 +1,61 @@ -name: Deploy +name: Deploy -on: +on: push: branches: - main - + workflow_dispatch: +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: + build-and-push: + name: "Build & Push Image" + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image_tag: ${{ steps.meta.outputs.tags }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix= + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ steps.meta.outputs.tags }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + deploy: - name: "Deploy to VPS" + name: "Deploy to k3s" + needs: build-and-push runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Configure SSH run: | mkdir -p ~/.ssh/ @@ -28,20 +72,80 @@ jobs: SSH_USER: ${{ secrets.SSH_USER }} SSH_IP: ${{ secrets.SSH_IP }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} - - - name: Create .env file on server + + - name: Copy k8s manifests to server run: | - ssh droplet "mkdir -p /root/portfolio && printf 'MYSQL_DATABASE=%s\nMYSQL_USER=%s\nMYSQL_PASSWORD=%s\nMYSQL_HOST=%s\nURL=%s\nCERTBOT_EMAIL=%s\nMARIADB_ROOT_PASSWORD=%s\nGRAFANA_PASSWORD=%s\n' '$MYSQL_DATABASE' '$MYSQL_USER' '$MYSQL_PASSWORD' '$MYSQL_HOST' '$URL' '$CERTBOT_EMAIL' '$MARIADB_ROOT_PASSWORD' '$GRAFANA_PASSWORD' > /root/portfolio/.env" + ssh droplet "mkdir -p /root/k8s" + scp -r k8s/* droplet:/root/k8s/ + + - name: Apply secrets (idempotent) + run: | + ssh droplet " + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + + # Backend secrets + kubectl -n portfolio create secret generic backend-secrets \ + --from-literal=MYSQL_DATABASE='$MYSQL_DATABASE' \ + --from-literal=MYSQL_USER='$MYSQL_USER' \ + --from-literal=MYSQL_PASSWORD='$MYSQL_PASSWORD' \ + --from-literal=MYSQL_HOST=mariadb \ + --from-literal=URL='$URL' \ + --dry-run=client -o yaml | kubectl apply -f - + + # MariaDB secrets + kubectl -n portfolio create secret generic mariadb-secrets \ + --from-literal=MYSQL_DATABASE='$MYSQL_DATABASE' \ + --from-literal=MYSQL_USER='$MYSQL_USER' \ + --from-literal=MYSQL_PASSWORD='$MYSQL_PASSWORD' \ + --from-literal=MARIADB_ROOT_PASSWORD='$MARIADB_ROOT_PASSWORD' \ + --dry-run=client -o yaml | kubectl apply -f - + + # Grafana secrets + kubectl -n monitoring create secret generic grafana-secrets \ + --from-literal=GRAFANA_PASSWORD='$GRAFANA_PASSWORD' \ + --dry-run=client -o yaml | kubectl apply -f - + " env: MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }} MYSQL_USER: ${{ secrets.MYSQL_USER }} MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }} - MYSQL_HOST: ${{ secrets.MYSQL_HOST }} - URL: ${{ secrets.URL }} - CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }} MARIADB_ROOT_PASSWORD: ${{ secrets.MARIADB_ROOT_PASSWORD }} + URL: ${{ secrets.URL }} GRAFANA_PASSWORD: ${{ secrets.GRAFANA_PASSWORD }} - - name: Deploy site + - name: Apply cert-manager issuer + run: | + ssh droplet " + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + + # Substitute CERTBOT_EMAIL in cert-issuer + sed -i 's/\${CERTBOT_EMAIL}/$CERTBOT_EMAIL/' /root/k8s/ingress/cert-issuer.yaml + + kubectl apply -f /root/k8s/ingress/cert-issuer.yaml + " + env: + CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }} + + - name: Apply k8s manifests + run: | + ssh droplet " + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + + # Apply namespaces first + kubectl apply -f /root/k8s/namespace.yaml + + # Apply all manifests + kubectl apply -R -f /root/k8s/mariadb/ + kubectl apply -R -f /root/k8s/backend/ + kubectl apply -R -f /root/k8s/ingress/ + kubectl apply -R -f /root/k8s/monitoring/ + " + + - name: Rolling update backend run: | - ssh droplet 'set -e && if [ ! -d /root/portfolio/.git ]; then git clone https://github.com/dddictionary/portfolio.git /root/portfolio; fi && cd /root/portfolio && git fetch && git reset origin/main --hard && docker compose -f docker-compose.prod.yml -f docker-compose.monitoring.yml down && docker compose -f docker-compose.prod.yml up -d --build && docker compose -f docker-compose.monitoring.yml up -d --build' \ No newline at end of file + IMAGE_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:$(echo ${{ github.sha }} | cut -c1-7)" + ssh droplet " + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + kubectl -n portfolio set image deployment/backend backend=${IMAGE_TAG} + kubectl -n portfolio rollout status deployment/backend --timeout=300s + " diff --git a/Dockerfile b/Dockerfile index d6181d3..ea3daec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN touch src/main.rs && cargo build --release --target x86_64-unknown-linux-mus # Stage 2: Runtime (minimal Alpine image) FROM alpine:3.21 +LABEL org.opencontainers.image.source=https://github.com/dddictionary/portfolio RUN apk add --no-cache ca-certificates WORKDIR /app diff --git a/k8s/backend/deployment.yaml b/k8s/backend/deployment.yaml new file mode 100644 index 0000000..e5282e0 --- /dev/null +++ b/k8s/backend/deployment.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: portfolio + labels: + app: backend +spec: + replicas: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + containers: + - name: backend + image: ghcr.io/dddictionary/portfolio:latest + ports: + - containerPort: 5000 + envFrom: + - secretRef: + name: backend-secrets + readinessProbe: + httpGet: + path: /healthz + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /healthz + port: 5000 + initialDelaySeconds: 15 + periodSeconds: 20 + failureThreshold: 3 + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" diff --git a/k8s/backend/service.yaml b/k8s/backend/service.yaml new file mode 100644 index 0000000..2043fb8 --- /dev/null +++ b/k8s/backend/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: portfolio + labels: + app: backend +spec: + type: ClusterIP + ports: + - port: 5000 + targetPort: 5000 + protocol: TCP + selector: + app: backend diff --git a/k8s/ingress/cert-issuer.yaml b/k8s/ingress/cert-issuer.yaml new file mode 100644 index 0000000..9a135aa --- /dev/null +++ b/k8s/ingress/cert-issuer.yaml @@ -0,0 +1,14 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-prod +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: ${CERTBOT_EMAIL} + privateKeySecretRef: + name: letsencrypt-prod-account-key + solvers: + - http01: + ingress: + class: traefik diff --git a/k8s/ingress/ingress.yaml b/k8s/ingress/ingress.yaml new file mode 100644 index 0000000..d8e5ead --- /dev/null +++ b/k8s/ingress/ingress.yaml @@ -0,0 +1,24 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: portfolio-ingress + namespace: portfolio + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.middlewares: portfolio-rate-limit@kubernetescrd +spec: + tls: + - hosts: + - 4brar.me + secretName: portfolio-tls + rules: + - host: 4brar.me + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: backend + port: + number: 5000 diff --git a/k8s/ingress/middleware.yaml b/k8s/ingress/middleware.yaml new file mode 100644 index 0000000..9039311 --- /dev/null +++ b/k8s/ingress/middleware.yaml @@ -0,0 +1,13 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: rate-limit + namespace: portfolio +spec: + rateLimit: + average: 4 + period: 1m + burst: 2 + sourceCriterion: + ipStrategy: + depth: 1 diff --git a/k8s/mariadb/service.yaml b/k8s/mariadb/service.yaml new file mode 100644 index 0000000..047379e --- /dev/null +++ b/k8s/mariadb/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: mariadb + namespace: portfolio + labels: + app: mariadb +spec: + clusterIP: None + ports: + - port: 3306 + targetPort: 3306 + protocol: TCP + selector: + app: mariadb diff --git a/k8s/mariadb/statefulset.yaml b/k8s/mariadb/statefulset.yaml new file mode 100644 index 0000000..f84f062 --- /dev/null +++ b/k8s/mariadb/statefulset.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mariadb + namespace: portfolio + labels: + app: mariadb +spec: + serviceName: mariadb + replicas: 1 + selector: + matchLabels: + app: mariadb + template: + metadata: + labels: + app: mariadb + spec: + containers: + - name: mariadb + image: mariadb:latest + ports: + - containerPort: 3306 + envFrom: + - secretRef: + name: mariadb-secrets + volumeMounts: + - name: mariadb-data + mountPath: /var/lib/mysql + readinessProbe: + exec: + command: + - healthcheck.sh + - --connect + - --innodb_initialized + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumeClaimTemplates: + - metadata: + name: mariadb-data + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path + resources: + requests: + storage: 5Gi diff --git a/k8s/monitoring/grafana/configmap-dashboard-files.yaml b/k8s/monitoring/grafana/configmap-dashboard-files.yaml new file mode 100644 index 0000000..e6e4bdd --- /dev/null +++ b/k8s/monitoring/grafana/configmap-dashboard-files.yaml @@ -0,0 +1,120 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-files + namespace: monitoring +data: + backend.json: | + { + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "uid": "backend-app", + "title": "Backend Application", + "tags": ["backend"], + "timezone": "browser", + "refresh": "10s", + "schemaVersion": 39, + "panels": [ + { + "title": "Request Rate (req/s)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { "unit": "reqps" }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(http_requests_total[1m])", + "legendFormat": "{{method}} {{status}} {{path}}" + } + ] + }, + { + "title": "Error Rate (5xx)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { "unit": "reqps", "color": { "mode": "fixed", "fixedColor": "red" } }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(http_requests_total{status=~\"5..\"}[1m])", + "legendFormat": "{{method}} {{status}} {{path}}" + } + ] + }, + { + "title": "Request Latency (p50 / p95 / p99)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { "unit": "s" }, + "overrides": [] + }, + "targets": [ + { + "expr": "histogram_quantile(0.50, rate(http_request_duration_seconds_bucket[1m]))", + "legendFormat": "p50" + }, + { + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[1m]))", + "legendFormat": "p95" + }, + { + "expr": "histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[1m]))", + "legendFormat": "p99" + } + ] + }, + { + "title": "Status Code Breakdown", + "type": "piechart", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "targets": [ + { + "expr": "sum by (status) (increase(http_requests_total[5m]))", + "legendFormat": "{{status}}" + } + ] + }, + { + "title": "Timeline Posts Created", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "fieldConfig": { + "defaults": { "unit": "short", "color": { "mode": "fixed", "fixedColor": "green" } }, + "overrides": [] + }, + "targets": [ + { + "expr": "rate(timeline_posts_created_total[5m])", + "legendFormat": "posts/sec" + } + ] + }, + { + "title": "Requests by Path", + "type": "bargauge", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "PBFA97CFB590B2093" }, + "targets": [ + { + "expr": "sum by (path) (increase(http_requests_total[5m]))", + "legendFormat": "{{path}}" + } + ] + } + ], + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" } + } diff --git a/k8s/monitoring/grafana/configmap-dashboards.yaml b/k8s/monitoring/grafana/configmap-dashboards.yaml new file mode 100644 index 0000000..aba1974 --- /dev/null +++ b/k8s/monitoring/grafana/configmap-dashboards.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboards + namespace: monitoring +data: + dashboards.yaml: | + apiVersion: 1 + providers: + - name: "default" + orgId: 1 + folder: "" + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/k8s/monitoring/grafana/configmap-datasource.yaml b/k8s/monitoring/grafana/configmap-datasource.yaml new file mode 100644 index 0000000..ecb593d --- /dev/null +++ b/k8s/monitoring/grafana/configmap-datasource.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-datasource + namespace: monitoring +data: + datasource.yaml: | + apiVersion: 1 + datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus.monitoring:9090 + uid: PBFA97CFB590B2093 + isDefault: true + editable: false diff --git a/k8s/monitoring/grafana/deployment.yaml b/k8s/monitoring/grafana/deployment.yaml new file mode 100644 index 0000000..0d67643 --- /dev/null +++ b/k8s/monitoring/grafana/deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: monitoring + labels: + app: grafana +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + labels: + app: grafana + spec: + containers: + - name: grafana + image: grafana/grafana:latest + ports: + - containerPort: 3000 + env: + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: grafana-secrets + key: GRAFANA_PASSWORD + - name: GF_SERVER_ROOT_URL + value: "https://observe.4brar.me/" + volumeMounts: + - name: data + mountPath: /var/lib/grafana + - name: datasource-config + mountPath: /etc/grafana/provisioning/datasources + - name: dashboard-config + mountPath: /etc/grafana/provisioning/dashboards + - name: dashboard-files + mountPath: /var/lib/grafana/dashboards + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + volumes: + - name: data + persistentVolumeClaim: + claimName: grafana-data + - name: datasource-config + configMap: + name: grafana-datasource + - name: dashboard-config + configMap: + name: grafana-dashboards + - name: dashboard-files + configMap: + name: grafana-dashboard-files diff --git a/k8s/monitoring/grafana/ingress.yaml b/k8s/monitoring/grafana/ingress.yaml new file mode 100644 index 0000000..6416fa0 --- /dev/null +++ b/k8s/monitoring/grafana/ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: grafana-ingress + namespace: monitoring + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - observe.4brar.me + secretName: grafana-tls + rules: + - host: observe.4brar.me + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: grafana + port: + number: 3000 diff --git a/k8s/monitoring/grafana/pvc.yaml b/k8s/monitoring/grafana/pvc.yaml new file mode 100644 index 0000000..84c6b08 --- /dev/null +++ b/k8s/monitoring/grafana/pvc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: grafana-data + namespace: monitoring +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 5Gi diff --git a/k8s/monitoring/grafana/service.yaml b/k8s/monitoring/grafana/service.yaml new file mode 100644 index 0000000..83f2934 --- /dev/null +++ b/k8s/monitoring/grafana/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: monitoring + labels: + app: grafana +spec: + type: ClusterIP + ports: + - port: 3000 + targetPort: 3000 + protocol: TCP + selector: + app: grafana diff --git a/k8s/monitoring/node-exporter/daemonset.yaml b/k8s/monitoring/node-exporter/daemonset.yaml new file mode 100644 index 0000000..0f5d0fe --- /dev/null +++ b/k8s/monitoring/node-exporter/daemonset.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: node-exporter + namespace: monitoring + labels: + app: node-exporter +spec: + selector: + matchLabels: + app: node-exporter + template: + metadata: + labels: + app: node-exporter + spec: + hostNetwork: true + hostPID: true + containers: + - name: node-exporter + image: prom/node-exporter:latest + args: + - "--path.procfs=/host/proc" + - "--path.sysfs=/host/sys" + - "--path.rootfs=/host/rootfs" + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)" + ports: + - containerPort: 9100 + hostPort: 9100 + volumeMounts: + - name: proc + mountPath: /host/proc + readOnly: true + - name: sys + mountPath: /host/sys + readOnly: true + - name: rootfs + mountPath: /host/rootfs + readOnly: true + resources: + requests: + memory: "32Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "200m" + volumes: + - name: proc + hostPath: + path: /proc + - name: sys + hostPath: + path: /sys + - name: rootfs + hostPath: + path: / diff --git a/k8s/monitoring/node-exporter/service.yaml b/k8s/monitoring/node-exporter/service.yaml new file mode 100644 index 0000000..2ba0103 --- /dev/null +++ b/k8s/monitoring/node-exporter/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: node-exporter + namespace: monitoring + labels: + app: node-exporter +spec: + type: ClusterIP + ports: + - port: 9100 + targetPort: 9100 + protocol: TCP + selector: + app: node-exporter diff --git a/k8s/monitoring/prometheus/configmap.yaml b/k8s/monitoring/prometheus/configmap.yaml new file mode 100644 index 0000000..1f5ea72 --- /dev/null +++ b/k8s/monitoring/prometheus/configmap.yaml @@ -0,0 +1,54 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: monitoring +data: + prometheus.yml: | + global: + scrape_interval: 15s + evaluation_interval: 15s + + scrape_configs: + - job_name: "portfolio" + kubernetes_sd_configs: + - role: endpoints + namespaces: + names: ["portfolio"] + relabel_configs: + - source_labels: [__meta_kubernetes_service_label_app] + action: keep + regex: backend + - source_labels: [__meta_kubernetes_endpoints_name] + action: keep + regex: backend + + - job_name: "node-exporter" + kubernetes_sd_configs: + - role: endpoints + namespaces: + names: ["monitoring"] + relabel_configs: + - source_labels: [__meta_kubernetes_service_label_app] + action: keep + regex: node-exporter + + - job_name: "prometheus" + static_configs: + - targets: ["localhost:9090"] + + - job_name: "kubelet" + 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_(.+) + metric_relabel_configs: + - source_labels: [__name__] + regex: "container_.*" + action: keep diff --git a/k8s/monitoring/prometheus/deployment.yaml b/k8s/monitoring/prometheus/deployment.yaml new file mode 100644 index 0000000..336d7bf --- /dev/null +++ b/k8s/monitoring/prometheus/deployment.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: monitoring + labels: + app: prometheus +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + serviceAccountName: prometheus + containers: + - name: prometheus + image: prom/prometheus:latest + args: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=30d" + - "--storage.tsdb.path=/prometheus" + ports: + - containerPort: 9090 + volumeMounts: + - name: config + mountPath: /etc/prometheus + - name: data + mountPath: /prometheus + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumes: + - name: config + configMap: + name: prometheus-config + - name: data + persistentVolumeClaim: + claimName: prometheus-data diff --git a/k8s/monitoring/prometheus/pvc.yaml b/k8s/monitoring/prometheus/pvc.yaml new file mode 100644 index 0000000..3b2584a --- /dev/null +++ b/k8s/monitoring/prometheus/pvc.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: prometheus-data + namespace: monitoring +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 10Gi diff --git a/k8s/monitoring/prometheus/rbac.yaml b/k8s/monitoring/prometheus/rbac.yaml new file mode 100644 index 0000000..1004a3b --- /dev/null +++ b/k8s/monitoring/prometheus/rbac.yaml @@ -0,0 +1,32 @@ +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/proxy", "services", "endpoints", "pods"] + verbs: ["get", "list", "watch"] + - apiGroups: ["extensions"] + resources: ["ingresses"] + verbs: ["get", "list", "watch"] + - nonResourceURLs: ["/metrics"] + 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 diff --git a/k8s/monitoring/prometheus/service.yaml b/k8s/monitoring/prometheus/service.yaml new file mode 100644 index 0000000..b865f41 --- /dev/null +++ b/k8s/monitoring/prometheus/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: prometheus + namespace: monitoring + labels: + app: prometheus +spec: + type: ClusterIP + ports: + - port: 9090 + targetPort: 9090 + protocol: TCP + selector: + app: prometheus diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..fc4fe2a --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: portfolio +--- +apiVersion: v1 +kind: Namespace +metadata: + name: monitoring diff --git a/src/main.rs b/src/main.rs index 7788dbd..83ea32d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,6 +108,7 @@ async fn main() { get(routes::api::get_timeline_posts).post(routes::api::post_timeline_post), ) .route("/api/test-ci", get(routes::api::test_ci)) + .route("/healthz", get(routes::api::healthz)) .route("/metrics", get(routes::api::metrics_handler)) .nest_service("/static", ServeDir::new("app/static")) .layer(middleware::from_fn(track_metrics)) diff --git a/src/routes/api.rs b/src/routes/api.rs index 46753da..50a7a2e 100644 --- a/src/routes/api.rs +++ b/src/routes/api.rs @@ -58,6 +58,17 @@ pub async fn test_ci() -> Json { Json(json!("ci should be working and this endpoint should be reachable")) } +pub async fn healthz( + State(state): State>, +) -> Result { + sqlx::query("SELECT 1") + .execute(&state.db) + .await + .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, e.to_string()))?; + + Ok(Json(json!({ "status": "ok" }))) +} + pub async fn metrics_handler( State(state): State>, ) -> String {