diff --git a/.github/workflows/release_chart.yaml b/.github/workflows/release_chart.yaml new file mode 100755 index 000000000..3222e1b39 --- /dev/null +++ b/.github/workflows/release_chart.yaml @@ -0,0 +1,24 @@ +name: Release Chart + +on: + # Push includes PR merge + push: + branches: + - develop + paths: + # Workflow is triggered only if chart dir changes + - chart/** + # Allow manual trigger + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + uses: hotosm/gh-workflows/.github/workflows/just.yml@3.3.2 + with: + environment: "test" + command: "chart publish" + secrets: inherit diff --git a/.gitignore b/.gitignore index 5df042fa9..7b88b02bd 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ fAIr-utilities data fair-app-data/* + +# helm charts +fair-*.tgz diff --git a/Justfile b/Justfile new file mode 100644 index 000000000..e4c11537a --- /dev/null +++ b/Justfile @@ -0,0 +1,34 @@ +set dotenv-load + +# List available commands +[private] +default: + just help + +# List available commands +help: + just --justfile {{justfile()}} --list + +# Chart module from https://github.com/hotosm/justfiles +chart *args: + @curl -sS https://raw.githubusercontent.com/hotosm/justfiles/main/chart.just \ + -o {{justfile_directory()}}/tasks/chart.just; + @just --justfile {{justfile_directory()}}/tasks/chart.just --set chart_name "fair" {{args}} + +# Echo to terminal with blue colour +[no-cd] +_echo-blue text: + #!/usr/bin/env sh + printf "\033[0;34m%s\033[0m\n" "{{ text }}" + +# Echo to terminal with yellow colour +[no-cd] +_echo-yellow text: + #!/usr/bin/env sh + printf "\033[0;33m%s\033[0m\n" "{{ text }}" + +# Echo to terminal with red colour +[no-cd] +_echo-red text: + #!/usr/bin/env sh + printf "\033[0;41m%s\033[0m\n" "{{ text }}" diff --git a/chart/.helmignore b/chart/.helmignore new file mode 100644 index 000000000..acf3c7144 --- /dev/null +++ b/chart/.helmignore @@ -0,0 +1,9 @@ +.DS_Store +.git +.gitignore +.idea +*.swp +*.bak +*.tmp +*.orig +*~ diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 000000000..5b3221e73 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: fair +description: AI Assisted Mapping Tool +type: application +version: 0.1.0 +appVersion: "2.2.19" diff --git a/chart/README.md b/chart/README.md new file mode 100644 index 000000000..d1c2d4033 --- /dev/null +++ b/chart/README.md @@ -0,0 +1,48 @@ +# fAIr Helm Chart + +Deploys the fAIr backend API and Django-Q async worker. + +The frontend is deployed separately (S3 + CloudFront via GitHub Actions). +PostgreSQL is expected to be provided externally (e.g. +[CloudNativePG](https://cloudnative-pg.io/) or a managed database service). + +## Quick start + +```bash +helm install fair oci://ghcr.io/hotosm/charts/fair +``` + +## Example values + +```yaml +externalDatabase: + host: my-pg-cluster-rw.db.svc + database: ai + username: fair + existingSecret: fair-db-credentials + existingSecretKey: password + +ingress: + enabled: true + className: nginx + hosts: + - host: fair.example.com + paths: + - path: /api + pathType: Prefix + +backend: + envFrom: + - secretRef: + name: fair-backend-secrets +``` + +## Key values + +| Parameter | Description | Default | +|---|---|---| +| `externalDatabase.host` | PostgreSQL host | `""` | +| `externalDatabase.existingSecret` | Secret containing DB password | `""` | +| `backend.djangoQ.enabled` | Run Django-Q sidecar for async tasks | `true` | +| `backend.migrate.enabled` | Run migrations on install/upgrade | `true` | +| `ingress.enabled` | Create Ingress resource | `false` | diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 000000000..bfc7cfcda --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,20 @@ +fAIr has been deployed! + +Components: + - Backend API: {{ include "fair.backend.fullname" . }} +{{- if .Values.backend.djangoQ.enabled }} + - Django-Q: running as sidecar in backend pod +{{- end }} + - PostgreSQL: external ({{ .Values.externalDatabase.host }}) + +{{- if .Values.ingress.enabled }} + +Access the application at: +{{- range .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }} +{{- end }} +{{- else }} + +To access the backend API, run: + kubectl port-forward svc/{{ include "fair.backend.fullname" . }} 8000:{{ .Values.backend.service.port }} +{{- end }} diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 000000000..da72ce2e0 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,90 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "fair.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "fair.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 "fair.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "fair.labels" -}} +helm.sh/chart: {{ include "fair.chart" . }} +{{ include "fair.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "fair.selectorLabels" -}} +app.kubernetes.io/name: {{ include "fair.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "fair.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "fair.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Backend fullname +*/}} +{{- define "fair.backend.fullname" -}} +{{- printf "%s-backend" (include "fair.fullname" .) | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Backend selector labels +*/}} +{{- define "fair.backend.selectorLabels" -}} +{{ include "fair.selectorLabels" . }} +app.kubernetes.io/component: backend +{{- end }} + +{{/* +Backend image +*/}} +{{- define "fair.backend.image" -}} +{{- $tag := default .Chart.AppVersion .Values.image.backend.tag -}} +{{- printf "%s:%s" .Values.image.backend.repository $tag }} +{{- end }} + +{{/* +DATABASE_URL for Django +*/}} +{{- define "fair.databaseUrl" -}} +postgis://$(DATABASE_USER):$(DATABASE_PASSWORD)@{{ .Values.externalDatabase.host }}:{{ .Values.externalDatabase.port | toString }}/{{ .Values.externalDatabase.database }} +{{- end }} diff --git a/chart/templates/backend/configmap.yaml b/chart/templates/backend/configmap.yaml new file mode 100644 index 000000000..e0bd88f45 --- /dev/null +++ b/chart/templates/backend/configmap.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "fair.backend.fullname" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +data: + DATABASE_HOST: {{ .Values.externalDatabase.host | quote }} + DATABASE_PORT: {{ .Values.externalDatabase.port | toString | quote }} + DATABASE_NAME: {{ .Values.externalDatabase.database | quote }} + DATABASE_USER: {{ .Values.externalDatabase.username | quote }} + {{- range $key, $value := .Values.backend.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} diff --git a/chart/templates/backend/deployment.yaml b/chart/templates/backend/deployment.yaml new file mode 100644 index 000000000..56ca9e03d --- /dev/null +++ b/chart/templates/backend/deployment.yaml @@ -0,0 +1,126 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "fair.backend.fullname" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backendReplicaCount }} + selector: + matchLabels: + {{- include "fair.backend.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/backend/configmap.yaml") . | sha256sum }} + {{- with .Values.backend.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "fair.backend.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "fair.serviceAccountName" . }} + {{- with .Values.backend.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: api + image: {{ include "fair.backend.image" . }} + imagePullPolicy: {{ .Values.image.backend.pullPolicy }} + {{- with .Values.backend.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.backend.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "fair.backend.fullname" . }} + {{- with .Values.backend.envFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + {{- if .Values.externalDatabase.existingSecret }} + name: {{ .Values.externalDatabase.existingSecret }} + key: {{ .Values.externalDatabase.existingSecretKey }} + {{- else }} + name: {{ include "fair.backend.fullname" . }}-db + key: password + {{- end }} + - name: DATABASE_URL + value: {{ include "fair.databaseUrl" . | quote }} + {{- with .Values.backend.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.backend.djangoQ.enabled }} + - name: django-q + image: {{ include "fair.backend.image" . }} + imagePullPolicy: {{ .Values.image.backend.pullPolicy }} + command: + - python + - manage.py + - qcluster + envFrom: + - configMapRef: + name: {{ include "fair.backend.fullname" . }} + {{- with .Values.backend.envFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + {{- if .Values.externalDatabase.existingSecret }} + name: {{ .Values.externalDatabase.existingSecret }} + key: {{ .Values.externalDatabase.existingSecretKey }} + {{- else }} + name: {{ include "fair.backend.fullname" . }}-db + key: password + {{- end }} + - name: DATABASE_URL + value: {{ include "fair.databaseUrl" . | quote }} + {{- with .Values.backend.djangoQ.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- end }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/chart/templates/backend/migrate-job.yaml b/chart/templates/backend/migrate-job.yaml new file mode 100644 index 000000000..0e9e44734 --- /dev/null +++ b/chart/templates/backend/migrate-job.yaml @@ -0,0 +1,53 @@ +{{- if .Values.backend.migrate.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "fair.backend.fullname" . }}-migrate + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded +spec: + template: + metadata: + labels: + {{- include "fair.backend.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "fair.serviceAccountName" . }} + restartPolicy: OnFailure + containers: + - name: migrate + image: {{ include "fair.backend.image" . }} + imagePullPolicy: {{ .Values.image.backend.pullPolicy }} + command: + - python + - manage.py + - migrate + - --noinput + envFrom: + - configMapRef: + name: {{ include "fair.backend.fullname" . }} + {{- with .Values.backend.envFrom }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: DATABASE_PASSWORD + valueFrom: + secretKeyRef: + {{- if .Values.externalDatabase.existingSecret }} + name: {{ .Values.externalDatabase.existingSecret }} + key: {{ .Values.externalDatabase.existingSecretKey }} + {{- else }} + name: {{ include "fair.backend.fullname" . }}-db + key: password + {{- end }} + - name: DATABASE_URL + value: {{ include "fair.databaseUrl" . | quote }} +{{- end }} diff --git a/chart/templates/backend/secret.yaml b/chart/templates/backend/secret.yaml new file mode 100644 index 000000000..a89635420 --- /dev/null +++ b/chart/templates/backend/secret.yaml @@ -0,0 +1,12 @@ +{{- if not .Values.externalDatabase.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "fair.backend.fullname" . }}-db + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +type: Opaque +data: + password: {{ .Values.externalDatabase.password | b64enc | quote }} +{{- end }} diff --git a/chart/templates/backend/service.yaml b/chart/templates/backend/service.yaml new file mode 100644 index 000000000..4bd6d696b --- /dev/null +++ b/chart/templates/backend/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "fair.backend.fullname" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + type: {{ .Values.backend.service.type }} + ports: + - port: {{ .Values.backend.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "fair.backend.selectorLabels" . | nindent 4 }} diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 000000000..3ddee9cbb --- /dev/null +++ b/chart/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "fair.fullname" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "fair.backend.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/templates/serviceaccount.yaml b/chart/templates/serviceaccount.yaml new file mode 100644 index 000000000..275a251e6 --- /dev/null +++ b/chart/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "fair.serviceAccountName" . }} + labels: + {{- include "fair.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 000000000..c5ec6a8a6 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,116 @@ +# -- Number of backend replicas +backendReplicaCount: 1 + +image: + backend: + repository: ghcr.io/hotosm/fair-api + tag: "" # defaults to appVersion + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +# -- Backend (Django API) configuration +backend: + command: + - gunicorn + - --bind=0.0.0.0:8000 + - --workers=3 + - --timeout=120 + - fairproject.wsgi:application + + # -- Run Django-Q cluster as a sidecar for lightweight async tasks + djangoQ: + enabled: true + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 250m + memory: 512Mi + + port: 8000 + + env: {} + # DJANGO_SECRET_KEY: "" + # DJANGO_ALLOWED_HOSTS: "*" + # OSM_CLIENT_ID: "" + # OSM_CLIENT_SECRET: "" + # OSM_SECRET_KEY: "" + + # -- Reference an existing Secret for sensitive env vars + envFrom: [] + # - secretRef: + # name: fair-backend-secrets + + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: 500m + memory: 512Mi + + livenessProbe: + httpGet: + path: /api/ + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + + readinessProbe: + httpGet: + path: /api/ + port: http + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + + nodeSelector: {} + tolerations: [] + affinity: {} + podAnnotations: {} + podSecurityContext: {} + securityContext: {} + + # -- Run Django migrations as a pre-install/upgrade hook + migrate: + enabled: true + + service: + type: ClusterIP + port: 8000 + +# -- Ingress configuration +ingress: + enabled: false + className: "" + annotations: {} + # nginx.ingress.kubernetes.io/proxy-body-size: "50m" + hosts: + - host: fair.example.com + paths: + - path: /api + pathType: Prefix + tls: [] + # - secretName: fair-tls + # hosts: + # - fair.example.com + +# -- External PostgreSQL connection +externalDatabase: + host: "" + port: 5432 + database: ai + username: postgres + password: "" + existingSecret: "" + existingSecretKey: password diff --git a/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md b/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md new file mode 100644 index 000000000..3ef5988fd --- /dev/null +++ b/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md @@ -0,0 +1,46 @@ +# Architecture Decision Record 1: Use react-markdown with remark-gfm for Rendering Markdown Content + +Date: 05/03/2026 + +# Context + +The base model detail page displays long-form content (overview, use cases, performance, limitations) that was previously rendered using manual paragraph splitting and hardcoded HTML structures. As content grows in complexity — with bold text, inline code, lists, headings, and links — maintaining this as plain strings with custom rendering logic becomes difficult and error-prone. + +We need a solution to render rich, structured text from markdown strings so that content authors can express formatting naturally, while the UI consistently applies the project's design system. + +## Decision Drivers + +- Content flexibility: authors should be able to use headings, bold, lists, code, and links without code changes. +- Consistency: rendered markdown must match the application's existing design system (colors, typography, spacing). +- Minimal bundle impact: the chosen library should be lightweight and avoid unnecessary overhead. +- Existing ecosystem: leverage libraries already present in the project wherever possible. +- Security: HTML should be sanitised by default to prevent XSS from user-supplied content. + +## Considered Options + +- **[react-markdown](https://github.com/remarkjs/react-markdown) + [remark-gfm](https://github.com/remarkjs/remark-gfm)** — A lightweight React component that converts markdown to React elements via the unified/remark ecosystem. `remark-gfm` adds GitHub Flavored Markdown support (tables, strikethrough, task lists, autolinks). Does **not** use `dangerouslySetInnerHTML`; it builds a React virtual DOM tree. Already installed as project dependencies. +- **[markdown-to-jsx](https://www.npmjs.com/package/markdown-to-jsx)** — A single-package alternative that compiles markdown to JSX. Slightly smaller bundle size, but lacks the plugin ecosystem of remark and does not support GFM features without extra work. + +- **Custom rendering logic** — Continue splitting strings on `\n\n` and mapping to `

`, `