diff --git a/.github/workflows/deploy-frontend-azurepreview.yml b/.github/workflows/deploy-frontend-azurepreview.yml index 77737ae7cd..94c5f3b56e 100644 --- a/.github/workflows/deploy-frontend-azurepreview.yml +++ b/.github/workflows/deploy-frontend-azurepreview.yml @@ -60,6 +60,8 @@ jobs: run_vertex: ${{ steps.check_files.outputs.run_vertex }} run_beacon: ${{ steps.check_files.outputs.run_beacon }} run_docs: ${{ steps.check_files.outputs.run_docs }} + run_nexus: ${{ steps.check_files.outputs.run_nexus }} + steps: - name: check modified frontends @@ -77,6 +79,7 @@ jobs: echo "run_vertex=false" >>$GITHUB_OUTPUT echo "run_beacon=false" >>$GITHUB_OUTPUT echo "run_docs=false" >>$GITHUB_OUTPUT + echo "run_nexus=false" >>$GITHUB_OUTPUT while IFS= read -r file do @@ -87,6 +90,9 @@ jobs: if [[ $file == src/platform/* ]]; then echo "run_analytics_platform=true" >>$GITHUB_OUTPUT fi + if [[ $file == src/nexus/* ]]; then + echo "run_nexus=true" >>$GITHUB_OUTPUT + fi if [[ $file == src/website/* ]]; then echo "run_website=true" >>$GITHUB_OUTPUT fi @@ -477,6 +483,134 @@ jobs: body: 'New azure analytics_platform changes available for preview [here](${{ needs.analytics_platform.outputs.url }})' }) + ######################################### + # NEXUS PREVIEW + ######################################### + + nexus: + name: build-push-deploy-nexus-preview + needs: [check, branch-name] + if: needs.check.outputs.run_nexus == 'true' + runs-on: ubuntu-latest + outputs: + url: ${{ steps.preview.outputs.url }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + + - uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - run: az acr login --name airqoacr + + - name: Pull secrets from Key Vault + run: | + BRANCH="${{ needs.branch-name.outputs.sanitized }}" + APP="${BRANCH}-nexus-preview" + + DEFAULT_DOMAIN=$(az containerapp env show \ + --name ${{ env.PR_PREVIEW_ENV }} \ + --resource-group ${{ env.RG }} \ + --query properties.defaultDomain -o tsv) + + NEXTAUTH_URL="https://$APP.$DEFAULT_DOMAIN" + + az keyvault secret show \ + --vault-name ${{ env.KEYVAULT }} \ + --name sta-env-nexus \ + --query value -o tsv > secrets.json + + jq -r 'to_entries | .[] | select(.key != "NEXTAUTH_URL" and .key != "NEXTAUTH_URL_INTERNAL" and .key != "NEXTAUTH_COOKIE_DOMAIN" and .key != "AUTH_URL" and .key != "AUTH_TRUST_HOST") | "\(.key)=\(.value|tojson)"' secrets.json > src/nexus/.env + { + echo "NEXTAUTH_URL=$NEXTAUTH_URL" + echo "NEXTAUTH_URL_INTERNAL=$NEXTAUTH_URL" + echo "AUTH_TRUST_HOST=true" + } >> src/nexus/.env + + - name: Build image + run: | + docker build -t ${{ env.REGISTRY_URL }}/nexus-pr-preview:${{ github.event.pull_request.head.sha }} src/nexus + docker push ${{ env.REGISTRY_URL }}/nexus-pr-preview:${{ github.event.pull_request.head.sha }} + + - name: Deploy Container App + run: | + BRANCH="${{ needs.branch-name.outputs.sanitized }}" + APP="${BRANCH}-nexus-preview" + + DEFAULT_DOMAIN=$(az containerapp env show \ + --name ${{ env.PR_PREVIEW_ENV }} \ + --resource-group ${{ env.RG }} \ + --query properties.defaultDomain -o tsv) + + NEXTAUTH_URL="https://$APP.$DEFAULT_DOMAIN" + + mapfile -t ENV_VARS < <(jq -r 'to_entries[] | select(.key != "NEXTAUTH_URL" and .key != "NEXTAUTH_URL_INTERNAL" and .key != "NEXTAUTH_COOKIE_DOMAIN" and .key != "AUTH_URL" and .key != "AUTH_TRUST_HOST") | "\(.key)=\(.value|tostring)"' secrets.json) + ENV_VARS+=("NEXTAUTH_URL=$NEXTAUTH_URL") + ENV_VARS+=("NEXTAUTH_URL_INTERNAL=$NEXTAUTH_URL") + ENV_VARS+=("AUTH_TRUST_HOST=true") + + jq -e '((.NEXTAUTH_SECRET // .AUTH_SECRET) | length > 0)' secrets.json >/dev/null + + az containerapp up \ + --name $APP \ + --resource-group ${{ env.RG }} \ + --environment ${{ env.PR_PREVIEW_ENV }} \ + --image ${{ env.REGISTRY_URL }}/nexus-pr-preview:${{ github.event.pull_request.head.sha }} \ + --target-port 3000 \ + --ingress external \ + --registry-server ${{ env.REGISTRY_URL }} \ + --registry-username airqoacr \ + --registry-password ${{ secrets.ACR_PASSWORD }} \ + --env-vars "${ENV_VARS[@]}" + + az containerapp update \ + --name $APP \ + --resource-group ${{ env.RG }} \ + --replace-env-vars "${ENV_VARS[@]}" + + AUTH_STATUS=$(curl -sS -o /tmp/nextauth-providers.json -w "%{http_code}" "$NEXTAUTH_URL/api/auth/providers") + if [ "$AUTH_STATUS" != "200" ]; then + echo "NextAuth providers endpoint returned HTTP $AUTH_STATUS" + cat /tmp/nextauth-providers.json || true + az containerapp logs show \ + --name $APP \ + --resource-group ${{ env.RG }} \ + --tail 80 || true + exit 1 + fi + + - id: preview + run: | + BRANCH="${{ needs.branch-name.outputs.sanitized }}" + APP="${BRANCH}-nexus-preview" + + URL=$(az containerapp show \ + --name $APP \ + --resource-group ${{ env.RG }} \ + --query properties.configuration.ingress.fqdn -o tsv) + + echo "url=https://$URL" >> $GITHUB_OUTPUT + + nexus-pr-comment: + needs: [check, nexus] + if: needs.check.outputs.run_nexus == 'true' + 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: 'New azure nexus changes available for preview [here](${{ needs.nexus.outputs.url }})' + }) + ######################################### # VERTEX PREVIEW ######################################### diff --git a/.github/workflows/deploy-frontend-to-azureprod.yml b/.github/workflows/deploy-frontend-to-azureprod.yml index f701bf087a..78ffe08c28 100644 --- a/.github/workflows/deploy-frontend-to-azureprod.yml +++ b/.github/workflows/deploy-frontend-to-azureprod.yml @@ -15,6 +15,10 @@ on: description: "Deploy Analytics Platform" required: false type: boolean + nexus: + description: "Deploy Nexus" + required: false + type: boolean vertex: description: "Deploy vertex" required: false @@ -195,6 +199,52 @@ jobs: token: ${{ secrets.YAML_UPDATER_TOKEN }} message: "Update analytics production image tag" + nexus: + name: build-push-nexus + needs: [image-tag] + if: inputs.nexus == true + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Azure Login + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: ACR Login + run: az acr login --name ${{ env.ACR_NAME }} + + - name: Pull secrets from Key Vault + run: | + az keyvault secret show \ + --vault-name ${{ env.KEYVAULT }} \ + --name prod-env-nexus \ + --query value -o tsv > secrets.json + + jq -r 'to_entries | .[] | "\(.key)=\(.value|tojson)"' secrets.json > src/nexus/.env + + - name: Build and Push Docker Image + run: | + cd src/nexus/ + docker build -t ${{ env.REGISTRY_URL }}/airqo-nexus:${{ needs.image-tag.outputs.build_id }} . + docker push ${{ env.REGISTRY_URL }}/airqo-nexus:${{ needs.image-tag.outputs.build_id }} + docker tag ${{ env.REGISTRY_URL }}/airqo-nexus:${{ needs.image-tag.outputs.build_id }} ${{ env.REGISTRY_URL }}/airqo-nexus:latest + docker push ${{ env.REGISTRY_URL }}/airqo-nexus:latest + + - name: Update Helm values + uses: Wandalen/wretry.action@v1.2.0 + with: + action: fjogeleit/yaml-update-action@main + with: | + valueFile: "k8s/nexus/k8s-aks/airqo-nexus/values-prod.yaml" + propertyPath: "image.tag" + value: ${{ needs.image-tag.outputs.build_id }} + branch: ${{ env.DEPLOY_BRANCH }} + token: ${{ secrets.YAML_UPDATER_TOKEN }} + message: "Update nexus production image tag" + vertex: name: build-push-vertex needs: [image-tag] diff --git a/.github/workflows/deploy-frontend-to-azurestaging.yml b/.github/workflows/deploy-frontend-to-azurestaging.yml index bc93aa069b..36cb61ae01 100644 --- a/.github/workflows/deploy-frontend-to-azurestaging.yml +++ b/.github/workflows/deploy-frontend-to-azurestaging.yml @@ -44,6 +44,7 @@ jobs: run_vertex: ${{ steps.check_files.outputs.run_vertex }} run_beacon: ${{ steps.check_files.outputs.run_beacon }} run_docs: ${{ steps.check_files.outputs.run_docs }} + run_nexus: ${{ steps.check_files.outputs.run_nexus }} runs-on: ubuntu-latest permissions: @@ -65,7 +66,7 @@ jobs: echo "run_vertex=false" >>$GITHUB_OUTPUT echo "run_beacon=false" >>$GITHUB_OUTPUT echo "run_docs=false" >>$GITHUB_OUTPUT - + echo "run_nexus=false" >>$GITHUB_OUTPUT while IFS= read -r file do echo $file @@ -87,6 +88,9 @@ jobs: if [[ $file == src/beacon/* ]]; then echo "run_beacon=true" >>$GITHUB_OUTPUT fi + if [[ $file == src/nexus/* ]]; then + echo "run_nexus=true" >>$GITHUB_OUTPUT + fi if [[ $file == src/docs-website/* ]]; then echo "run_docs=true" >>$GITHUB_OUTPUT fi @@ -98,6 +102,7 @@ jobs: echo "run_vertex=true" >>$GITHUB_OUTPUT echo "run_beacon=true" >>$GITHUB_OUTPUT echo "run_docs=true" >>$GITHUB_OUTPUT + echo "run_nexus=true" >>$GITHUB_OUTPUT fi done < files.txt @@ -249,6 +254,52 @@ jobs: token: ${{ secrets.YAML_UPDATER_TOKEN }} message: "Update next platform staging image tag to ${{ needs.image-tag.outputs.build_id }}" + nexus: + name: build-push-nexus-image + needs: [check, image-tag] + if: needs.check.outputs.run_nexus == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: ACR Login + run: az acr login --name ${{ env.ACR_NAME }} + + - name: Pull secrets from Key Vault + run: | + az keyvault secret show \ + --vault-name ${{ env.KEYVAULT }} \ + --name sta-env-nexus \ + --query value -o tsv > secrets.json + + jq -r 'to_entries | .[] | "\(.key)=\(.value|tojson)"' secrets.json > src/nexus/.env + + - name: Build and Push Docker Image + run: | + cd src/nexus/ + docker build --tag ${{ env.REGISTRY_URL }}/airqo-stage-nexus:${{ needs.image-tag.outputs.build_id }} . + docker push ${{ env.REGISTRY_URL }}/airqo-stage-nexus:${{ needs.image-tag.outputs.build_id }} + docker tag ${{ env.REGISTRY_URL }}/airqo-stage-nexus:${{ needs.image-tag.outputs.build_id }} ${{ env.REGISTRY_URL }}/airqo-stage-nexus:latest + docker push ${{ env.REGISTRY_URL }}/airqo-stage-nexus:latest + + - name: Update corresponding helm values file(with retry) + uses: Wandalen/wretry.action@v1.2.0 + with: + action: fjogeleit/yaml-update-action@main + with: | + valueFile: "k8s/nexus/k8s-aks/airqo-nexus/values-stage.yaml" + propertyPath: "image.tag" + value: ${{ needs.image-tag.outputs.build_id }} + branch: ${{ env.DEPLOY_BRANCH }} + token: ${{ secrets.YAML_UPDATER_TOKEN }} + message: "Update nexus staging image tag to ${{ needs.image-tag.outputs.build_id }}" + vertex: name: build-push-vertex-image needs: [check, image-tag] diff --git a/.github/workflows/remove-deploy-azurepreview.yml b/.github/workflows/remove-deploy-azurepreview.yml index d954e78741..e456ecdc37 100644 --- a/.github/workflows/remove-deploy-azurepreview.yml +++ b/.github/workflows/remove-deploy-azurepreview.yml @@ -53,7 +53,7 @@ jobs: run: | BRANCH="${{ needs.branch-name.outputs.sanitized }}" - for SERVICE in website beacon calibrate-app analytics-p vertex docs; do + for SERVICE in website beacon calibrate-app analytics-p nexus vertex docs; do APP="${BRANCH}-${SERVICE}-preview" echo "Attempting to delete $APP..." az containerapp delete \ diff --git a/k8s/nexus/k8s-aks/airqo-nexus/.helmignore b/k8s/nexus/k8s-aks/airqo-nexus/.helmignore new file mode 100644 index 0000000000..85e54c01b5 --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/.helmignore @@ -0,0 +1,10 @@ +# Helm ignore patterns +# This file is used to tell Helm which files or directories to exclude from the chart. +*.swp +*.swo +*~ +.DS_Store +.git +.gitignore +.helmignore + diff --git a/k8s/nexus/k8s-aks/airqo-nexus/Chart.yaml b/k8s/nexus/k8s-aks/airqo-nexus/Chart.yaml new file mode 100644 index 0000000000..5ccb295d08 --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: airqo-nexus +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/k8s/nexus/k8s-aks/airqo-nexus/templates/NOTES.txt b/k8s/nexus/k8s-aks/airqo-nexus/templates/NOTES.txt new file mode 100644 index 0000000000..d4fefc63a9 --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/templates/NOTES.txt @@ -0,0 +1,23 @@ +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-nexus.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 its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "airqo-nexus.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "airqo-nexus.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-nexus.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/nexus/k8s-aks/airqo-nexus/templates/_helpers.tpl b/k8s/nexus/k8s-aks/airqo-nexus/templates/_helpers.tpl new file mode 100644 index 0000000000..5f11d853ec --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/templates/_helpers.tpl @@ -0,0 +1,51 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "airqo-nexus.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-nexus.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-nexus.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "airqo-nexus.labels" -}} +helm.sh/chart: {{ include "airqo-nexus.chart" . }} +{{ include "airqo-nexus.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "airqo-nexus.selectorLabels" -}} +app.kubernetes.io/name: {{ include "airqo-nexus.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/k8s/nexus/k8s-aks/airqo-nexus/templates/deployment.yaml b/k8s/nexus/k8s-aks/airqo-nexus/templates/deployment.yaml new file mode 100644 index 0000000000..4926fe0b0a --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/templates/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.app.name }} + labels: + {{- include "airqo-nexus.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: + containers: + - name: {{ .Values.app.label }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ .Values.service.targetPort }} + name: {{ .Values.app.label }} + envFrom: + - secretRef: + name: {{ .Values.app.secret }} # ExternalSecret created per microservice + {{- 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/nexus/k8s-aks/airqo-nexus/templates/externalsecret.yaml b/k8s/nexus/k8s-aks/airqo-nexus/templates/externalsecret.yaml new file mode 100644 index 0000000000..debe454ab1 --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/templates/externalsecret.yaml @@ -0,0 +1,21 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ .Values.app.secret }} + labels: + {{- include "airqo-nexus.labels" . | nindent 4 }} +spec: + refreshInterval: 1h + secretStoreRef: + name: azure-kv-store # switched to Azure Key Vault store + kind: ClusterSecretStore + target: + name: {{ .Values.app.secret }} + creationPolicy: Owner + deletionPolicy: Retain + dataFrom: + - extract: + key: {{ .Values.app.secret }} # azure vault key + conversionStrategy: Default + decodingStrategy: None + metadataPolicy: None \ No newline at end of file diff --git a/k8s/nexus/k8s-aks/airqo-nexus/templates/hpa.yaml b/k8s/nexus/k8s-aks/airqo-nexus/templates/hpa.yaml new file mode 100644 index 0000000000..68a806c3d3 --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/templates/hpa.yaml @@ -0,0 +1,34 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: '{{ .Values.app.name }}-hpa' + labels: + {{- include "airqo-nexus.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ .Values.app.name }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + {{- with .Values.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} + 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/nexus/k8s-aks/airqo-nexus/templates/service.yaml b/k8s/nexus/k8s-aks/airqo-nexus/templates/service.yaml new file mode 100644 index 0000000000..170ed2f603 --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/templates/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: '{{ .Values.app.name }}-svc' + labels: {{- include "airqo-nexus.labels" . | nindent 4 }} +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/nexus/k8s-aks/airqo-nexus/templates/tests/test-connection.yaml b/k8s/nexus/k8s-aks/airqo-nexus/templates/tests/test-connection.yaml new file mode 100644 index 0000000000..88e3da021d --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "airqo-nexus.fullname" . }}-test-connection" + labels: + {{- include "airqo-nexus.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "airqo-nexus.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/k8s/nexus/k8s-aks/airqo-nexus/values-prod.yaml b/k8s/nexus/k8s-aks/airqo-nexus/values-prod.yaml new file mode 100644 index 0000000000..d38383cc66 --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/values-prod.yaml @@ -0,0 +1,41 @@ +replicaCount: 1 +image: + repository: airqoacr.azurecr.io/airqo-nexus + tag: latest + pullPolicy: IfNotPresent +imagePullSecrets: [] +nameOverride: '' +fullnameOverride: '' +service: + type: NodePort + protocol: TCP + nodePort: 30117 + targetPort: 3000 + port: 3000 +ingress: + enabled: false +resources: + requests: + cpu: 100m + memory: 300Mi + limits: + cpu: 500m + memory: 700Mi +autoscaling: + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 80 + behavior: + scaleDown: + stabilizationWindowSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 30 + policies: + - type: Pods + value: 2 + periodSeconds: 60 +app: + name: airqo-nexus + label: airqo-nexus + namespace: production + secret: prod-env-nexus \ No newline at end of file diff --git a/k8s/nexus/k8s-aks/airqo-nexus/values-stage.yaml b/k8s/nexus/k8s-aks/airqo-nexus/values-stage.yaml new file mode 100644 index 0000000000..6f4dc0171a --- /dev/null +++ b/k8s/nexus/k8s-aks/airqo-nexus/values-stage.yaml @@ -0,0 +1,32 @@ +replicaCount: 1 +image: + repository: airqoacr.azurecr.io/airqo-stage-nexus + tag: latest + pullPolicy: IfNotPresent +imagePullSecrets: [] +nameOverride: '' +fullnameOverride: '' +service: + type: NodePort + protocol: TCP + nodePort: 31117 + targetPort: 3000 + port: 3000 +ingress: + enabled: false +resources: + requests: + cpu: 20m + memory: 150Mi + limits: + cpu: 200m + memory: 350Mi +autoscaling: + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 80 +app: + name: airqo-stage-nexus + label: sta-nexus + namespace: staging + secret: sta-env-nexus diff --git a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml index 37e6f6a353..97099f7fd5 100644 --- a/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml +++ b/k8s/vertex/k8s-aks/airqo-vertex/values-prod.yaml @@ -1,7 +1,7 @@ replicaCount: 2 image: repository: airqoacr.azurecr.io/airqo-vertex - tag: prod-acb4e9b3-1783677967 + tag: prod-0aa2adc2-1783760733 pullPolicy: IfNotPresent imagePullSecrets: [] nameOverride: '' diff --git a/src/beacon/app/api/auth/[...nextauth]/route.ts b/src/beacon/app/api/auth/[...nextauth]/route.ts index 0a4c217bc5..1b06e23c44 100644 --- a/src/beacon/app/api/auth/[...nextauth]/route.ts +++ b/src/beacon/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,8 @@ import NextAuth from 'next-auth'; import { authOptions } from '@/lib/auth'; -const handler = NextAuth(authOptions); +const handler = (req: any, res: any) => { + return NextAuth(req, res, authOptions); +}; export { handler as GET, handler as POST }; diff --git a/src/beacon/components/device-model-3d-inner.tsx b/src/beacon/components/device-model-3d-inner.tsx index 29c1b0016e..fd30f2ba52 100644 --- a/src/beacon/components/device-model-3d-inner.tsx +++ b/src/beacon/components/device-model-3d-inner.tsx @@ -149,7 +149,7 @@ export default function DeviceModel3DInner({ onModelLoaded, onModelFailed }: Rea - + diff --git a/src/beacon/components/device-model-3d.tsx b/src/beacon/components/device-model-3d.tsx index e07d26322d..ee9f9fc43a 100644 --- a/src/beacon/components/device-model-3d.tsx +++ b/src/beacon/components/device-model-3d.tsx @@ -168,7 +168,7 @@ export default function DeviceModel3D({ onModelLoaded, onModelFailed }: Readonly {/* 3D Model */} - + {/* Interactive controls */} diff --git a/src/beacon/lib/auth.ts b/src/beacon/lib/auth.ts index 978254b02e..e9cce31d75 100644 --- a/src/beacon/lib/auth.ts +++ b/src/beacon/lib/auth.ts @@ -5,7 +5,34 @@ import { normalizeOAuthAccessToken, fetchEnhancedUserProfile, } from './oauth-session'; -import { buildPlatformApiUrl } from './config'; +import config, { buildPlatformApiUrl } from './config'; + +// Fallback defaults and environment-specific self-correction for NEXTAUTH_URL +const normalizeNextAuthUrl = () => { + const currentEnv = config.environment; + const rawUrl = process.env.NEXTAUTH_URL; + + // Check if NEXTAUTH_URL is missing or incorrectly set to localhost in staging/production + const isLocalhostUrl = !rawUrl || rawUrl.includes('localhost') || rawUrl.includes('127.0.0.1'); + + if (currentEnv === 'staging') { + if (isLocalhostUrl) { + process.env.NEXTAUTH_URL = 'https://staging-beacon.airqo.net'; + } + } else if (currentEnv === 'production') { + if (isLocalhostUrl) { + process.env.NEXTAUTH_URL = 'https://beacon.airqo.net'; + } + } else { + // Development / Localhost + if (!rawUrl) { + process.env.NEXTAUTH_URL = 'http://localhost:3000'; + } + } +}; + +normalizeNextAuthUrl(); + const isProduction = process.env.NODE_ENV === 'production'; @@ -53,7 +80,7 @@ const cookieOptions = { }; export const authOptions: NextAuthOptions = { - secret: process.env.NEXTAUTH_SECRET, + secret: process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET, useSecureCookies: isProduction, providers: [ CredentialsProvider({ diff --git a/src/beacon/lib/config.js b/src/beacon/lib/config.js index 8e17f2cd7a..58ae3217f1 100755 --- a/src/beacon/lib/config.js +++ b/src/beacon/lib/config.js @@ -14,6 +14,48 @@ const isLocalhost = !isServer && const isDevelopment = process.env.NODE_ENV === 'development'; const isProduction = process.env.NODE_ENV === 'production'; +/** + * Get the current environment context dynamically + */ +const getEnvironment = () => { + if (!isServer) { + const hostname = globalThis.location.hostname.toLowerCase(); + if ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + hostname.startsWith('172.') + ) { + return 'development'; + } + if (hostname.includes('stage') || hostname.includes('staging') || hostname.includes('beacon-stage')) { + return 'staging'; + } + return 'production'; + } + + // Server-side detection + const nextAuthUrl = process.env.NEXTAUTH_URL || ''; + if (nextAuthUrl.includes('stage') || nextAuthUrl.includes('staging') || nextAuthUrl.includes('beacon-stage')) { + return 'staging'; + } + if (nextAuthUrl.includes('localhost') || nextAuthUrl.includes('127.0.0.1')) { + const serviceName = (process.env.SERVICE_NAME || '').toLowerCase(); + const podHostName = (process.env.HOSTNAME || '').toLowerCase(); + if (serviceName.includes('stage') || podHostName.includes('stage')) { + return 'staging'; + } + return 'development'; + } + + if (process.env.NODE_ENV === 'development') { + return 'development'; + } + + return 'production'; +}; + /** * Strip trailing slashes from URLs */ @@ -63,91 +105,82 @@ export const enforceHttpsForRemote = (url) => { /** * Get the appropriate API URL based on environment - * Priority: - * 1. NEXT_PUBLIC_LOCAL_API_URL (for local development with local backend) - * 2. NEXT_PUBLIC_AIRQO_STAGING_API_BASE_URL (for staging) - * 3. NEXT_PUBLIC_AIRQO_API_BASE_URL (for production) - * 4. AIRQO_STAGING_API_BASE_URL (server-side staging) - * 5. AIRQO_API_BASE_URL (server-side production) */ const getBeaconApiUrl = () => { - // For local development, use local API - if (isLocalhost || isDevelopment) { + const env = getEnvironment(); + + if (env === 'development') { const localUrl = process.env.NEXT_PUBLIC_LOCAL_API_URL || process.env.BEACON_API_URL; if (localUrl) return enforceHttpsForRemote(localUrl); + return 'http://localhost:8000'; } - - // For client-side, prefer NEXT_PUBLIC_ variables - if (!isServer) { - const publicBeaconUrl = process.env.NEXT_PUBLIC_BEACON_API_URL; - const stagingUrl = process.env.NEXT_PUBLIC_AIRQO_STAGING_API_BASE_URL; - const prodUrl = process.env.NEXT_PUBLIC_AIRQO_API_BASE_URL; - if (publicBeaconUrl) return enforceHttpsForRemote(publicBeaconUrl); + + if (env === 'staging') { + const stagingUrl = process.env.NEXT_PUBLIC_AIRQO_STAGING_API_BASE_URL || + process.env.AIRQO_STAGING_API_BASE_URL; if (stagingUrl) return enforceHttpsForRemote(stagingUrl); - if (prodUrl) return enforceHttpsForRemote(prodUrl); + return 'https://staging-platform.airqo.net'; } - - // For server-side, can use non-public variables - const beaconUrl = process.env.BEACON_API_URL; - const serverStagingUrl = process.env.AIRQO_STAGING_API_BASE_URL; - const serverProdUrl = process.env.AIRQO_API_BASE_URL; - if (beaconUrl) return enforceHttpsForRemote(beaconUrl); - if (serverStagingUrl) return enforceHttpsForRemote(serverStagingUrl); - if (serverProdUrl) return enforceHttpsForRemote(serverProdUrl); - - // Default fallback (should be avoided in production) - console.warn('No API URL configured, using default localhost:8000'); - return 'http://localhost:8000'; + + // Production + const prodUrl = process.env.NEXT_PUBLIC_AIRQO_API_BASE_URL || + process.env.AIRQO_API_BASE_URL; + if (prodUrl) return enforceHttpsForRemote(prodUrl); + + return 'https://platform.airqo.net'; }; /** * Get the AirQo Platform API URL (for auth, users, etc.) */ const getAirQoPlatformApiUrl = () => { - // Prefer staging for development - if (isDevelopment) { + const env = getEnvironment(); + + if (env === 'development' || env === 'staging') { const stagingUrl = process.env.NEXT_PUBLIC_AIRQO_STAGING_API_BASE_URL || process.env.AIRQO_STAGING_API_BASE_URL; - if (stagingUrl) return stripTrailingSlash(stagingUrl); + if (stagingUrl) return enforceHttpsForRemote(stagingUrl); + return 'https://staging-platform.airqo.net'; } // Production const prodUrl = process.env.NEXT_PUBLIC_AIRQO_API_BASE_URL || process.env.AIRQO_API_BASE_URL; - if (prodUrl) return stripTrailingSlash(prodUrl); - - // Fallback to staging - const stagingUrl = process.env.NEXT_PUBLIC_AIRQO_STAGING_API_BASE_URL || - process.env.AIRQO_STAGING_API_BASE_URL; - if (stagingUrl) return stripTrailingSlash(stagingUrl); + if (prodUrl) return enforceHttpsForRemote(prodUrl); - return 'https://staging-platform.airqo.net'; + return 'https://platform.airqo.net'; }; /** - * API Configuration + * API Configuration with dynamic properties */ export const config = { // Environment flags - isServer, - isLocalhost, - isDevelopment, - isProduction, + get isServer() { return typeof window === 'undefined'; }, + get isLocalhost() { + return typeof window !== 'undefined' && + (window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1' || + window.location.hostname.startsWith('192.168.')); + }, + get isDevelopment() { return process.env.NODE_ENV === 'development'; }, + get isProduction() { return process.env.NODE_ENV === 'production'; }, + get environment() { return getEnvironment(); }, // API URLs - apiUrl: getBeaconApiUrl(), - beaconApiUrl: getBeaconApiUrl(), - airqoPlatformUrl: getAirQoPlatformApiUrl(), + get apiUrl() { return getBeaconApiUrl(); }, + get beaconApiUrl() { return getBeaconApiUrl(); }, + get airqoPlatformUrl() { return getAirQoPlatformApiUrl(); }, // API version apiVersion: process.env.AIRQO_API_VERSION || 'v2', // API prefixes apiPrefix: '/api/v1', - beaconApiPrefix: isLocalhost ? '/api/v1' : '/api/v1/beacon', + get beaconApiPrefix() { return this.isLocalhost ? '/api/v1' : '/api/v1/beacon'; }, // Auth settings - requiresAuth: !isLocalhost && isProduction, + get requiresAuth() { return !this.isLocalhost && this.isProduction; }, // Timeouts defaultTimeout: 30000, diff --git a/src/beacon/public/hdri/potsdamer_platz_1k.hdr b/src/beacon/public/hdri/potsdamer_platz_1k.hdr new file mode 100644 index 0000000000..e3121c15c2 Binary files /dev/null and b/src/beacon/public/hdri/potsdamer_platz_1k.hdr differ