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