diff --git a/.github/workflows/deploy-apis-to-staging.yml b/.github/workflows/deploy-apis-to-staging.yml index 8cec968c72..408648b994 100644 --- a/.github/workflows/deploy-apis-to-staging.yml +++ b/.github/workflows/deploy-apis-to-staging.yml @@ -56,6 +56,7 @@ jobs: run_incentives: ${{ steps.check_files.outputs.run_incentives }} # incentives run_insights: ${{ steps.check_files.outputs.run_insights }} # incentives run_spatial: ${{ steps.check_files.outputs.run_spatial }} # spatial + run_website: ${{ steps.check_files.outputs.run_website }} # website run_kafka_connectors: ${{ steps.check_files.outputs.run_kafka_connectors }} # kafka connectors run_nginx: ${{ steps.check_files.outputs.run_nginx }} # nginx ingress @@ -98,6 +99,7 @@ jobs: echo "run_incentives=false" >>$GITHUB_OUTPUT echo "run_insights=false" >>$GITHUB_OUTPUT echo "run_spatial=false" >>$GITHUB_OUTPUT + echo "run_website=false" >>$GITHUB_OUTPUT echo "run_kafka_connectors=false" >>$GITHUB_OUTPUT echo "run_nginx=false" >>$GITHUB_OUTPUT @@ -271,6 +273,14 @@ jobs: if [[ $file == k8s/spatial/* ]]; then echo "run_spatial=true" >>$GITHUB_OUTPUT + fi + + if [[ $file == src/website/* ]]; then + echo "run_website=true" >>$GITHUB_OUTPUT + fi + + if [[ $file == k8s/website/* ]]; then + echo "run_website=true" >>$GITHUB_OUTPUT fi if [[ $file == k8s/nginx/staging/* ]]; then @@ -296,6 +306,7 @@ jobs: echo "run_incentives=true" >>$GITHUB_OUTPUT echo "run_insights=true" >>$GITHUB_OUTPUT echo "run_spatial=true" >>$GITHUB_OUTPUT + echo "run_website=true" >>$GITHUB_OUTPUT echo "run_view=true" >>$GITHUB_OUTPUT echo "run_kafka_connectors=true" >>$GITHUB_OUTPUT echo "run_nginx=true" >>$GITHUB_OUTPUT @@ -1723,6 +1734,66 @@ jobs: gcloud secrets versions access latest --secret="sta-key-analytics-service-account" > google_application_credentials.json kubectl create configmap --dry-run=client -o yaml --from-file=google_application_credentials.json stage-spatial-api-config-files | kubectl replace -f - -n staging + + ### website ### + website: + name: build-push-deploy-website + needs: [check, image-tag] + if: needs.check.outputs.run_website == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3.5.3 + + - name: Login to GCR + uses: docker/login-action@v2.2.0 + with: + registry: ${{ env.REGISTRY_URL }} + username: _json_key + password: ${{ secrets.GCR_CONFIG }} + + - name: Login to K8S + uses: azure/k8s-set-context@v3.0 + with: + method: kubeconfig + kubeconfig: ${{ secrets.K8S_CONFIG_STAGE }} + + - name: Build and Push Docker Image + run: | + cd src/website/ + docker build --target=staging --tag ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:${{ needs.image-tag.outputs.build_id }} . + docker tag ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:${{ needs.image-tag.outputs.build_id }} ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:latest + docker push ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:${{ needs.image-tag.outputs.build_id }} + docker push ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/airqo-stage-website-api:latest + + - name: Update corresponding helm values file(with retry) + uses: Wandalen/wretry.action@v1.0.36 # Retries action on fail + with: + action: fjogeleit/yaml-update-action@main # Action to retry + with: | + valueFile: "k8s/website/values-stage.yaml" + propertyPath: "image.tag" + value: ${{ needs.image-tag.outputs.build_id }} + branch: ${{ env.DEPLOY_BRANCH }} + token: ${{ secrets.YAML_UPDATER_TOKEN }} + message: "Update website staging image tag to ${{ needs.image-tag.outputs.build_id }}" + + - name: Login to GCP + uses: google-github-actions/auth@v1.1.1 + with: + credentials_json: ${{ secrets.GCP_SA_CREDENTIALS }} + + - name: Setup Cloud SDK + uses: google-github-actions/setup-gcloud@v1.1.1 + + - name: Update the corresponding k8s configmap(s) + run: | + cd src/website/ + gcloud secrets versions access latest --secret="sta-env-website-backend" > .env + kubectl create configmap --dry-run=client -o yaml --from-env-file=.env stage-website-api-config | kubectl replace -f - -n staging + gcloud secrets versions access latest --secret="sta-key-analytics-service-account" > google_application_credentials.json + kubectl create configmap --dry-run=client -o yaml --from-file=google_application_credentials.json stage-website-api-config-files | kubectl replace -f - -n staging + ### apply nginx ### nginx: name: apply-nginx diff --git a/.github/workflows/deploy-previews.yml b/.github/workflows/deploy-previews.yml index 7ed9f5bc9d..7d4870047d 100644 --- a/.github/workflows/deploy-previews.yml +++ b/.github/workflows/deploy-previews.yml @@ -37,6 +37,7 @@ jobs: run_calibrate: ${{ steps.check_files.outputs.run_calibrate }} # calibrate run_incentives: ${{ steps.check_files.outputs.run_incentives }} # incentives run_spatial: ${{ steps.check_files.outputs.run_spatial }} # spatial + run_website: ${{ steps.check_files.outputs.run_website }} # website runs-on: ubuntu-latest steps: - name: checkout code @@ -67,6 +68,7 @@ jobs: echo "run_calibrate=false" >>$GITHUB_OUTPUT echo "run_incentives=false" >>$GITHUB_OUTPUT echo "run_spatial=false" >>$GITHUB_OUTPUT + echo "run_website=false" >>$GITHUB_OUTPUT while IFS= read -r file do @@ -123,6 +125,10 @@ jobs: echo "run_spatial=true" >>$GITHUB_OUTPUT fi + if [[ $file == src/website/* ]]; then + echo "run_website=true" >>$GITHUB_OUTPUT + fi + done < files.txt @@ -1139,3 +1145,81 @@ jobs: repo: context.repo.repo, body: 'Spatial changes in this PR available for preview [here](${{ needs.spatial.outputs.url }})' }) + + ### website ### + website: + name: build-push-deploy-website + needs: [check, branch-name] + if: needs.check.outputs.run_website == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + outputs: + url: ${{ steps.preview-url.outputs.url }} + steps: + - name: Checkout + uses: actions/checkout@v3.5.3 + + - name: Google Auth + id: auth + uses: google-github-actions/auth@v1.1.1 + with: + credentials_json: ${{ secrets.GCP_SA_CREDENTIALS }} + + - name: Setup Cloud SDK + uses: google-github-actions/setup-gcloud@v1.1.1 + + - name: Docker Auth + id: docker-auth + uses: docker/login-action@v2.2.0 + with: + registry: ${{ env.REGISTRY_URL }} + username: _json_key + password: ${{ secrets.GCP_SA_CREDENTIALS }} + + - name: Build and Push Container + run: | + cd src/website/ + docker build --target=staging --tag ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/pr-previews/website-pr-previews:${{ github.sha }} ./ + docker push ${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/pr-previews/website-pr-previews:${{ github.sha }} + + - name: Deploy to Cloud Run + run: |- + gcloud run deploy ${{ needs.branch-name.outputs.lowercase }}-website-preview \ + --region=${{ secrets.REGION }} \ + --max-instances=10 \ + --timeout=60 \ + --concurrency=10 \ + --image=${{ env.REGISTRY_URL }}/${{ env.PROJECT_ID }}/pr-previews/website-pr-previews:${{ github.sha }} \ + --port=5000 \ + --cpu=1000m \ + --memory=1024Mi \ + --update-secrets=/etc/env/.env=sta-env-website-backend:latest,/etc/config/google_application_credentials.json=sta-key-analytics-service-account:latest \ + --command="/bin/sh","-c","cat /etc/env/.env >> /app/.env; /app/entrypoint.sh" \ + --allow-unauthenticated + + - name: Get preview service url + id: preview-url + run: | + read service_url < <(gcloud run services describe ${{ needs.branch-name.outputs.lowercase }}-website-preview \ + --format='value(status.url)' \ + --platform managed \ + --region ${{ secrets.REGION }}) + echo "url=${service_url}" >>$GITHUB_OUTPUT + + website-pr-comment: + name: website-preview-link-comment + if: needs.check.outputs.run_website == 'true' + needs: [website] + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v6 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'website changes in this PR available for preview [here](${{ needs.website.outputs.url }})' + }) \ No newline at end of file diff --git a/.github/workflows/remove-deploy-previews.yml b/.github/workflows/remove-deploy-previews.yml index 77a5f07a22..361e10b2f1 100644 --- a/.github/workflows/remove-deploy-previews.yml +++ b/.github/workflows/remove-deploy-previews.yml @@ -35,6 +35,7 @@ jobs: remove_preview__calibrate: ${{ steps.check_files.outputs.remove_preview__calibrate }} # calibrate remove_preview__incentives: ${{ steps.check_files.outputs.remove_preview__incentives }} #incentives remove_preview__spatial: ${{ steps.check_files.outputs.remove_preview__spatial }} #spatial + remove_preview__website: ${{ steps.check_files.outputs.remove_preview__website }} #website runs-on: ubuntu-latest steps: @@ -66,6 +67,7 @@ jobs: echo "remove_preview__calibrate=false" >>$GITHUB_OUTPUT echo "remove_preview__incentives=false" >>$GITHUB_OUTPUT echo "remove_preview__spatial=false" >>$GITHUB_OUTPUT + echo "remove_preview__website=false" >>$GITHUB_OUTPUT while IFS= read -r file do @@ -120,7 +122,11 @@ jobs: if [[ $file == src/spatial/* ]]; then echo "remove_preview__spatial=true" >>$GITHUB_OUTPUT - fi + fi + + if [[ $file == src/website/* ]]; then + echo "remove_preview__website=true" >>$GITHUB_OUTPUT + fi done < files.txt @@ -409,3 +415,25 @@ jobs: gcloud run services delete ${{ needs.branch-name.outputs.lowercase }}-spatial-preview \ --region=${{ secrets.REGION }} \ --quiet + + ### website ### + website: + name: build-push-website + needs: [check, branch-name] + if: needs.check.outputs.remove_preview__website == 'true' + runs-on: ubuntu-latest + steps: + - name: Google Auth + id: auth + uses: google-github-actions/auth@v1.1.1 + with: + credentials_json: "${{ secrets.GCP_SA_CREDENTIALS }}" + + - name: Setup Cloud SDK + uses: "google-github-actions/setup-gcloud@v1.1.1" + + - name: Delete PR deploy preview + run: |- + gcloud run services delete ${{ needs.branch-name.outputs.lowercase }}-website-preview \ + --region=${{ secrets.REGION }} \ + --quiet diff --git a/k8s/website/.helmignore b/k8s/website/.helmignore new file mode 100644 index 0000000000..0e8a0eb36f --- /dev/null +++ b/k8s/website/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/k8s/website/Chart.yaml b/k8s/website/Chart.yaml new file mode 100644 index 0000000000..35698bf2d8 --- /dev/null +++ b/k8s/website/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: airqo-website-api +description: AirQo Website Backend Helm Chart +version: 0.1.0 +appVersion: "1.16.0" +home: https://airqo.net +maintainers: + - name: AirQo + email: support@airqo.net + url: https://airqo.net diff --git a/k8s/website/templates/NOTES.txt b/k8s/website/templates/NOTES.txt new file mode 100644 index 0000000000..002709a4f7 --- /dev/null +++ b/k8s/website/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "airqo-website-api.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "airqo-website-api.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "airqo-website-api.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "airqo-website-api.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/k8s/website/templates/_helpers.tpl b/k8s/website/templates/_helpers.tpl new file mode 100644 index 0000000000..48f256e660 --- /dev/null +++ b/k8s/website/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "airqo-website-api.name" -}} +{{- default .Chart.Name .Values.nameOverride | 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 "airqo-website-api.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 }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "airqo-website-api.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "airqo-website-api.labels" -}} +helm.sh/chart: {{ include "airqo-website-api.chart" . }} +{{ include "airqo-website-api.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "airqo-website-api.selectorLabels" -}} +app.kubernetes.io/name: {{ include "airqo-website-api.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "airqo-website-api.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "airqo-website-api.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/k8s/website/templates/deployment.yaml b/k8s/website/templates/deployment.yaml new file mode 100644 index 0000000000..f94462da87 --- /dev/null +++ b/k8s/website/templates/deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.app.name }} + namespace: {{ .Values.app.namespace }} + labels: + {{- include "airqo-website-api.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + app: {{ .Values.app.label }} + replicas: {{ .Values.replicaCount }} + revisionHistoryLimit: 2 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + minReadySeconds: 5 + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app: {{ .Values.app.label }} + spec: + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 10 + preference: + matchExpressions: + - key: node-type + operator: In + values: + - high-memory + - weight: 1 + preference: + matchExpressions: + - key: node-type + operator: In + values: + - general-purpose + containers: + - name: {{ .Values.app.label }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: IfNotPresent + ports: + - containerPort: {{ .Values.service.targetPort }} + name: {{ .Values.app.label }} + envFrom: + - configMapRef: + name: {{ .Values.app.configmap }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/k8s/website/templates/hpa.yaml b/k8s/website/templates/hpa.yaml new file mode 100644 index 0000000000..fa77e05da5 --- /dev/null +++ b/k8s/website/templates/hpa.yaml @@ -0,0 +1,31 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: '{{ .Values.app.name }}-hpa' + namespace: {{ .Values.app.namespace }} + labels: + {{- include "airqo-website-api.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ .Values.app.name }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} diff --git a/k8s/website/templates/service.yaml b/k8s/website/templates/service.yaml new file mode 100644 index 0000000000..7419db2819 --- /dev/null +++ b/k8s/website/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: '{{ .Values.app.name }}-svc' + labels: {{- include "airqo-website-api.labels" . | nindent 4 }} + namespace: {{ .Values.app.namespace }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: {{ .Values.service.protocol }} + nodePort: {{ .Values.service.nodePort }} + selector: + app: {{ .Values.app.label }} diff --git a/k8s/website/templates/tests/test-connection.yaml b/k8s/website/templates/tests/test-connection.yaml new file mode 100644 index 0000000000..77f330a3ca --- /dev/null +++ b/k8s/website/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "airqo-website-api.fullname" . }}-test-connection" + labels: +{{ include "airqo-website-api.labels" . | indent 4 }} + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "airqo-website-api.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/k8s/website/values-prod.yaml b/k8s/website/values-prod.yaml new file mode 100644 index 0000000000..18bb722274 --- /dev/null +++ b/k8s/website/values-prod.yaml @@ -0,0 +1,38 @@ +app: + name: airqo-website-api + label: website-api + namespace: production + configmap: prod-website-api-config +replicaCount: 3 +image: + repository: eu.gcr.io/airqo-250220/airqo-website-api + tag: latest +nameOverride: '' +fullnameOverride: '' +podAnnotations: {} +resources: + limits: + cpu: 100m + memory: 350Mi + requests: + cpu: 10m + memory: 250Mi +volumeMounts: + - name: config-volume + mountPath: /etc/config +volumes: + - name: config-volume + configMap: + name: prod-website-api-config-files +ingress: + enabled: false +service: + type: NodePort + port: 8000 + protocol: TCP + targetPort: 8000 + nodePort: 30020 +autoscaling: + minReplicas: 1 + maxReplicas: 3 + targetMemoryUtilizationPercentage: 70 \ No newline at end of file diff --git a/k8s/website/values-stage.yaml b/k8s/website/values-stage.yaml new file mode 100644 index 0000000000..18a2be9032 --- /dev/null +++ b/k8s/website/values-stage.yaml @@ -0,0 +1,38 @@ +app: + name: airqo-stage-website-api + label: sta-website-api + namespace: staging + configmap: stage-website-api-config +replicaCount: 2 +image: + repository: eu.gcr.io/airqo-250220/airqo-stage-website-api + tag: latest +nameOverride: '' +fullnameOverride: '' +podAnnotations: {} +resources: + limits: + cpu: 100m + memory: 400Mi + requests: + cpu: 10m + memory: 180Mi +volumeMounts: + - name: config-volume + mountPath: /etc/config +volumes: + - name: config-volume + configMap: + name: stage-website-api-config-files +ingress: + enabled: false +service: + type: NodePort + port: 8000 + protocol: TCP + targetPort: 8000 + nodePort: 31020 +autoscaling: + minReplicas: 1 + maxReplicas: 2 + targetMemoryUtilizationPercentage: 80