diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..4598c5a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +/helm/ @Mobyman +/.github/ @Mobyman \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..bf68d00 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,130 @@ +name: Deploy to Kubernetes + +on: + push: + branches: + - main + - canary + - staging + +jobs: + build: + runs-on: ubuntu-latest + env: + APP_ENV: ${{ github.ref == 'refs/heads/main' && 'production' || github.ref == 'refs/heads/canary' && 'canary' || github.ref == 'refs/heads/staging' && 'staging' || 'unknown' }} + APP_DOMAIN: ${{ github.ref == 'refs/heads/staging' && vars.APP_DOMAIN_STAGING || vars.APP_DOMAIN }} + + permissions: + packages: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set sha-short + run: echo "GITHUB_SHA_SHORT=$(echo $GITHUB_SHA | cut -c 1-7)" >> $GITHUB_ENV + + - id: lower-repo + name: Repository to lowercase + run: | + echo "repository=${GITHUB_REPOSITORY@L}" >> $GITHUB_OUTPUT + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.lower-repo.outputs.repository }} + github-token: ${{ secrets.GITHUB_TOKEN }} + tags: | + type=sha + type=sha,format=long + type=ref,event=branch + + - name: Build and push Docker image ${{ steps.lower-repo.outputs.repository }}:${{ env.APP_ENV }} + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/${{ steps.lower-repo.outputs.repository }}:${{ env.GITHUB_SHA_SHORT }},ghcr.io/${{ steps.lower-repo.outputs.repository }}:${{ env.APP_ENV }} + build-args: | + sha=${{ github.sha }} + sha_short=${{ env.GITHUB_SHA_SHORT }} + app_env=${{ vars.APP_ENV }} + + - name: Apply AWS k8s config + run: aws eks update-kubeconfig --name ${{ vars.AWS_CLUSTER }} --region ${{ vars.AWS_REGION }} + + - name: Create namespace + run: | + kubectl create ns ${{ vars.APP_NAME }}-${{ env.APP_ENV }} || echo "Namespace $EKS_NAMESPACE already exists" + + - name: Deploy ${{ vars.APP_NAME }} to Kubernetes + run: | + helm upgrade --install ${{ vars.APP_NAME }} ./helm/app \ + --namespace ${{ vars.APP_NAME }}-${{ env.APP_ENV }} \ + --values ./helm/app/values.yaml \ + --values ./helm/app/values-${{ env.APP_ENV }}.yaml \ + --set imageRepo="ghcr.io/${{ steps.lower-repo.outputs.repository }}" \ + --set imageTag="${{ env.GITHUB_SHA_SHORT }}" \ + --set host=${{ env.APP_DOMAIN }} \ + --set appName=${{ vars.APP_NAME }} \ + --set ghcrSecret=${{ secrets.GHCR_SECRET }} \ + --set secrets.publicProxyKey=${{ secrets.NEXT_PUBLIC_MIXPANEL_TOKEN }} \ + --set secrets.publicMixPanelToken=${{ secrets.NEXT_PUBLIC_ANALYTICS_ENABLED }} \ + --set secrets.publicProxyKey=${{ secrets.NEXT_PUBLIC_ANALYTICS_ENABLED }} + + - name: Verify deployment + run: | + kubectl -n ${{ vars.APP_NAME }}-${{ env.APP_ENV }} rollout status deployment/${{ vars.APP_NAME }}-${{ env.APP_ENV }} + + - name: Verify TLS Certificate + run: | + kubectl describe certificate ${{ env.APP_DOMAIN }} -n ${{ vars.APP_NAME }}-${{ env.APP_ENV }} + + - name: Telegram Notify + uses: appleboy/telegram-action@v1.0.0 + if: success() && contains('${{ vars.ENABLE_DEPLOY_BOT }}', 1) + with: + to: ${{ secrets.TELEGRAM_DEPLOY_CHAT_ID }} + token: ${{ secrets.TELEGRAM_DEPLOY_TOKEN }} + format: markdown + message: | + πŸš‚ The application from repository [${{ steps.lower-repo.outputs.repository }}](https://github.com/${{ steps.lower-repo.outputs.repository }}) has been successfully deployed by [${{ github.actor }}](https://github.com/users/${{ github.actor }}) on ${{ env.APP_ENV }}. + + πŸ—οΈ [GitHub Actions Build](https://github.com/${{ steps.lower-repo.outputs.repository }}/actions/runs/${{ github.run_id }}) + 🐳 [Image](https://ghcr.io/${{ steps.lower-repo.outputs.repository }}:${{ env.GITHUB_SHA_SHORT }} + πŸ”— [Link](https://${{ env.APP_DOMAIN }}) + + - name: Telegram Notify + uses: appleboy/telegram-action@v1.0.0 + if: failure() + with: + to: ${{ secrets.TELEGRAM_DEPLOY_CHAT_ID }} + token: ${{ secrets.TELEGRAM_DEPLOY_TOKEN }} + format: markdown + message: | + 🚨Deploy of the application from repository [${{ steps.lower-repo.outputs.repository }}](https://github.com/${{ steps.lower-repo.outputs.repository }}) on ${{ env.APP_ENV }} has been failed. + + πŸ—οΈ [GitHub Actions Build](https://github.com/${{ steps.lower-repo.outputs.repository }}/actions/runs/${{ github.run_id }}) + 🐳 [Image](https://ghcr.io/${{ steps.lower-repo.outputs.repository }}:${{ env.GITHUB_SHA_SHORT }} + πŸ”— [Link](https://${{ env.APP_DOMAIN }}) + + + + + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/web-ide.iml b/.idea/web-ide.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/web-ide.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cabec61 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:21-alpine AS base + +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/helm/app/Chart.yaml b/helm/app/Chart.yaml new file mode 100644 index 0000000..cf9ba02 --- /dev/null +++ b/helm/app/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: Node.js Chart +description: A Helm chart for deploying my Node.js application +version: 0.1.2 diff --git a/helm/app/templates/deployment.yaml b/helm/app/templates/deployment.yaml new file mode 100644 index 0000000..50fe9d2 --- /dev/null +++ b/helm/app/templates/deployment.yaml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.appName }}-{{ .Values.deployEnv}} + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Values.appName }}-{{ .Values.deployEnv }} + release: prometheus-stack +spec: + replicas: {{ .Values.defaultReplicaCount }} + strategy: + type: RollingUpdate + selector: + matchLabels: + app: {{ .Values.appName }}-{{ .Values.deployEnv}} + template: + metadata: + labels: + app: {{ .Values.appName }}-{{ .Values.deployEnv}} + release: prometheus-stack + spec: + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: {{ .Values.appName }}-{{ .Values.deployEnv }} + containers: + - name: {{ .Values.appName }}-{{ .Values.deployEnv}} + image: "{{ .Values.imageRepo }}:{{ .Values.imageTag }}" + env: + - name: APP_ENV + value: {{ .Values.deployEnv }} + - name: APP_VERSION + value: {{ .Values.appVersion | quote }} + - name: NEXT_PUBLIC_PROXY_KEY + value: {{ .Values.secrets.publicProxyKey }} + - name: NEXT_PUBLIC_MIXPANEL_TOKEN + value: {{ .Values.secrets.publicMixPanelToken }} + - name: NEXT_PUBLIC_ANALYTICS_ENABLED + value:{{ .Values.secrets.publicProxyKey }} + ports: + - containerPort: {{ .Values.containerPort }} + resources: + limits: + cpu: {{ .Values.cpuLimit }} + memory: {{ .Values.memoryLimit }} + requests: + cpu: {{ .Values.cpuRequest }} + memory: {{ .Values.memoryRequest }} + imagePullPolicy: Always + imagePullSecrets: + - name: dockerconfigjson-github-com diff --git a/helm/app/templates/ghcr-secret.yaml b/helm/app/templates/ghcr-secret.yaml new file mode 100644 index 0000000..d126766 --- /dev/null +++ b/helm/app/templates/ghcr-secret.yaml @@ -0,0 +1,10 @@ +kind: Secret +type: kubernetes.io/dockerconfigjson +apiVersion: v1 +metadata: + name: dockerconfigjson-github-com + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Values.appName }}-{{ .Values.deployEnv}} +data: + .dockerconfigjson: {{ .Values.ghcrSecret }} \ No newline at end of file diff --git a/helm/app/templates/hpa.yaml b/helm/app/templates/hpa.yaml new file mode 100644 index 0000000..c5c85d2 --- /dev/null +++ b/helm/app/templates/hpa.yaml @@ -0,0 +1,25 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ .Values.appName }}-{{ .Values.deployEnv}} + namespace: {{ .Release.Namespace }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ .Values.appName }}-{{ .Values.deployEnv}} + minReplicas: {{ .Values.minReplicas }} + maxReplicas: {{ .Values.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/helm/app/templates/ingress.yaml b/helm/app/templates/ingress.yaml new file mode 100644 index 0000000..d1759c8 --- /dev/null +++ b/helm/app/templates/ingress.yaml @@ -0,0 +1,44 @@ +{{ if .Values.publicService }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Values.appName }}-{{ .Values.deployEnv}} + namespace: {{ .Release.Namespace }} + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/ssl-redirect: "{{ .Values.sslRedirect }}" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "10s" + nginx.ingress.kubernetes.io/proxy-read-timeout: "15s" + nginx.ingress.kubernetes.io/proxy-send-timeout: "15s" + nginx.ingress.kubernetes.io/proxy-next-upstream: "error timeout http_502 http_503 http_504" + nginx.ingress.kubernetes.io/proxy-next-upstream-tries: "3" + cert-manager.io/cluster-issuer: {{ .Values.tlsIssuer }} + {{- if eq .Values.deployEnv "canary" }} + nginx.ingress.kubernetes.io/canary: "true" + nginx.ingress.kubernetes.io/canary-weight: {{ .Values.canaryWeight | quote }} + {{- end }} + nginx.ingress.kubernetes.io/server-snippet: | + location ~ ^/(metrics|ready|health)$ { + return 403; + } + + labels: + release: prometheus-stack + app: {{ .Values.appName }}-{{ .Values.deployEnv}} +spec: + tls: + - hosts: + - {{ .Values.host }} + secretName: {{ .Values.host }} + rules: + - host: {{ .Values.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ .Values.appName }}-{{ .Values.deployEnv }} + port: + name: http +{{- end }} \ No newline at end of file diff --git a/helm/app/templates/ns-resource-quota.yaml b/helm/app/templates/ns-resource-quota.yaml new file mode 100644 index 0000000..631e73d --- /dev/null +++ b/helm/app/templates/ns-resource-quota.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: ResourceQuota +metadata: + name: resource-quota + namespace: {{ .Release.Namespace }} +spec: + hard: + {{- if eq .Values.deployEnv "staging" }} + pods: "2" + services: "1" + requests.memory: "256Mi" + limits.cpu: "1" + limits.memory: "16Gi" + persistentvolumeclaims: "0" + {{- end }} + {{- if eq .Values.deployEnv "canary" }} + pods: "10" + limits.cpu: "4" + limits.memory: "8Gi" + persistentvolumeclaims: "0" + {{- end }} + {{- if eq .Values.deployEnv "production" }} + pods: "100" + limits.cpu: "8" + limits.memory: "16Gi" + persistentvolumeclaims: "0" + {{- end }} diff --git a/helm/app/templates/service-monitor.yaml b/helm/app/templates/service-monitor.yaml new file mode 100644 index 0000000..30b47f6 --- /dev/null +++ b/helm/app/templates/service-monitor.yaml @@ -0,0 +1,19 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ .Values.appName }}-{{ .Values.deployEnv}} + namespace: {{ .Release.Namespace }} + labels: + release: prometheus-stack +spec: + selector: + matchLabels: + app: {{ .Values.appName }}-{{ .Values.deployEnv}} + endpoints: + - port: http + interval: 30s + path: /metrics + - port: https + interval: 30s + path: /metrics + diff --git a/helm/app/templates/service.yaml b/helm/app/templates/service.yaml new file mode 100644 index 0000000..6a6d990 --- /dev/null +++ b/helm/app/templates/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.appName }}-{{ .Values.deployEnv}} + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Values.appName }}-{{ .Values.deployEnv }} + release: prometheus-stack +spec: + type: ClusterIP + selector: + app: {{ .Values.appName }}-{{ .Values.deployEnv}} + ports: + - name: http + protocol: TCP + port: 80 + targetPort: {{ .Values.containerPort }} \ No newline at end of file diff --git a/helm/app/templates/tls-cert.yaml b/helm/app/templates/tls-cert.yaml new file mode 100644 index 0000000..b0aff17 --- /dev/null +++ b/helm/app/templates/tls-cert.yaml @@ -0,0 +1,15 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: {{ .Values.appName }}-{{ .Values.deployEnv}} + namespace: {{ .Release.Namespace }} +spec: + secretName: {{ .Values.host }} + issuerRef: + kind: ClusterIssuer + name: {{ .Values.tlsIssuer }} + commonName: {{ .Values.host }} + duration: 2160h + renewBefore: 360h + dnsNames: + - {{ .Values.host }} diff --git a/helm/app/values-production.yaml b/helm/app/values-production.yaml new file mode 100644 index 0000000..dd66045 --- /dev/null +++ b/helm/app/values-production.yaml @@ -0,0 +1,9 @@ +deployEnv: production +defaultReplicaCount: 6 +imageTag: "production" + +minReplicas: 1 +maxReplicas: 1 + +memoryLimit: 120Mi +memoryRequest: 100Mi diff --git a/helm/app/values-staging.yaml b/helm/app/values-staging.yaml new file mode 100644 index 0000000..4c7c9ad --- /dev/null +++ b/helm/app/values-staging.yaml @@ -0,0 +1,9 @@ +deployEnv: staging +defaultReplicaCount: 6 +imageTag: "staging" + +minReplicas: 1 +maxReplicas: 1 + +memoryLimit: 120Mi +memoryRequest: 100Mi diff --git a/helm/app/values.yaml b/helm/app/values.yaml new file mode 100644 index 0000000..64cd594 --- /dev/null +++ b/helm/app/values.yaml @@ -0,0 +1,41 @@ +# app +appVersion: "0.1" + +# limits & requests +cpuLimit: "500m" +memoryLimit: "128Mi" +cpuRequest: "500m" +memoryRequest: "64Mi" + +# replicas +minReplicas: 2 +maxReplicas: 40 + +# docker +containerPort: 3000 +nodePort: 80 + +# from github deploy +imageRepo: "" +imageTag: "" +host: "" +appName: "" +ghcrSecret: "" + +tlsCert: "" +tlsKey: "" + + +# do not change +tlsIssuer: "letsencrypt" +certIssuingMode: false + +# http +publicService: true +sslRedirect: true + + +secrets: + publicProxyKey: "" + publicMixPanelToken: "" + secrets.publicProxyKey: ""