diff --git a/.github/workflows/api_kube.yaml b/.github/workflows/api_kube.yaml new file mode 100644 index 00000000..ae941696 --- /dev/null +++ b/.github/workflows/api_kube.yaml @@ -0,0 +1,81 @@ +name: Lint and Test Charts + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + helm-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install chart-testing + uses: helm/chart-testing-action@v2.6.0 + + - run: helm repo add bitnami https://charts.bitnami.com/bitnami + + - name: Run chart-testing (lint) + run: ct lint --charts ./api/helm-chart --debug + + api-test: + runs-on: ubuntu-latest + environment: + name: test + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: azure/setup-helm@v4.2.0 + - uses: azure/setup-kubectl@v3 + + - name: Start minikube - if this breaks on local do a 'minikube delete' + id: minikube + uses: medyagh/setup-minikube@master + with: + cpus: 4 + memory: 4G # 'wait' below times out on Actions with 2G + start-args: '--force' + + - run: helm repo add bitnami https://charts.bitnami.com/bitnami + + - name: Get helm dependencies + run: helm dependency build ./api/helm-chart + + - name: Start everything + run: | + helm install bia-api-local ./api/helm-chart \ + --set "api_env.mongoRootUser=${{ vars.MONGO_INITDB_ROOT_USERNAME }}" \ + --set "api_env.mongoRootPassword=${{ secrets.MONGO_INITDB_ROOT_PASSWORD }}" \ + --set "api_env.jwtSecretKey=${{ secrets.JWT_SECRET_KEY }}" \ + --set "api_env.userCreateSecretToken=${{ secrets.USER_CREATE_SECRET_TOKEN }}" \ + --set "mongodb.enabled=true" \ + --set "mongodb.auth.rootUser=${{ vars.MONGO_INITDB_ROOT_USERNAME }}" \ + --set "mongodb.auth.rootPassword=${{ secrets.MONGO_INITDB_ROOT_PASSWORD }}" + + - name: Wait for api to start and health-check + run: kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=api --timeout=180s + + - name: Run tests + run: helm test bia-api-local --logs + + - name: List pods for CI minikube debug + run: | + kubectl get pods + kubectl describe pods -l app.kubernetes.io/name=api + kubectl logs -l app.kubernetes.io/name=api + if: ${{ failure() }} + + - name: Cleanup + run: | + minikube delete + if: ${{ always() }} diff --git a/api/.gitignore b/api/.gitignore index f9317ecc..7412c9b1 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -62,4 +62,6 @@ env # Project-specific #### .env -.venv \ No newline at end of file +.venv + +helm-chart/charts \ No newline at end of file diff --git a/api/helm-chart/.helmignore b/api/helm-chart/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/api/helm-chart/.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/api/helm-chart/Chart.lock b/api/helm-chart/Chart.lock new file mode 100644 index 00000000..ff7ca767 --- /dev/null +++ b/api/helm-chart/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: mongodb + repository: https://charts.bitnami.com/bitnami + version: 15.4.3 +digest: sha256:59add6e9bcbda9f7e6d374adf69b03c7d28f0f1ca6660d734a86b2bb3b5b7f41 +generated: "2024-05-29T16:21:49.346169347+01:00" diff --git a/api/helm-chart/Chart.yaml b/api/helm-chart/Chart.yaml new file mode 100644 index 00000000..2bcb85b0 --- /dev/null +++ b/api/helm-chart/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: api +description: A Helm chart for the BioImage Archive api +maintainers: + - name: LA # Liviu Anita + email: liviu@ebi.ac.uk + +type: application + +version: 0.1.0 + +appVersion: "0.1.0" + +dependencies: + - name: mongodb + version: 15.4.3 + repository: https://charts.bitnami.com/bitnami + condition: mongodb.enabled diff --git a/api/helm-chart/templates/NOTES.txt b/api/helm-chart/templates/NOTES.txt new file mode 100644 index 00000000..1bdec90b --- /dev/null +++ b/api/helm-chart/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 "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 "api.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "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 "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/api/helm-chart/templates/_helpers.tpl b/api/helm-chart/templates/_helpers.tpl new file mode 100644 index 00000000..5cb8632d --- /dev/null +++ b/api/helm-chart/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "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 "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 "api.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "api.labels" -}} +helm.sh/chart: {{ include "api.chart" . }} +{{ include "api.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "api.selectorLabels" -}} +app.kubernetes.io/name: {{ include "api.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "api.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "api.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/api/helm-chart/templates/deployment.yaml b/api/helm-chart/templates/deployment.yaml new file mode 100644 index 00000000..1c110047 --- /dev/null +++ b/api/helm-chart/templates/deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "api.fullname" . }} +spec: + selector: + matchLabels: + {{- include "api.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "api.labels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + image: "ghcr.io/bioimage-archive/bia-integrator-api:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + livenessProbe: + httpGet: + path: /v1/search/studies?limit=1 + port: http + initialDelaySeconds: 10 + failureThreshold: 10 + periodSeconds: 60 + readinessProbe: + httpGet: + path: /v1/openapi.json + port: http + initialDelaySeconds: 10 + failureThreshold: 10 + periodSeconds: 60 + env: + - name: DB_NAME + value: {{ .Values.api_env.dbName }} + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "api.fullname" . }} + key: jwtSecretKey + - name: USER_CREATE_SECRET_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "api.fullname" . }} + key: userCreateSecretToken + - name: MONGO_CONNSTRING + valueFrom: + secretKeyRef: + name: {{ include "api.fullname" . }} + key: mongoConnstring diff --git a/api/helm-chart/templates/ingress.yaml b/api/helm-chart/templates/ingress.yaml new file mode 100644 index 00000000..d7a5ccd5 --- /dev/null +++ b/api/helm-chart/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "api.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "api.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + 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 }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/api/helm-chart/templates/secrets.yaml b/api/helm-chart/templates/secrets.yaml new file mode 100644 index 00000000..58fb5f42 --- /dev/null +++ b/api/helm-chart/templates/secrets.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "api.fullname" . }} +type: Opaque +stringData: + mongoConnstring: "mongodb://{{ .Values.api_env.mongoRootUser }}:{{ .Values.api_env.mongoRootPassword }}@{{ .Values.api_env.mongoHost }}:27017" + jwtSecretKey: {{ .Values.api_env.jwtSecretKey }} + userCreateSecretToken: {{ .Values.api_env.userCreateSecretToken }} \ No newline at end of file diff --git a/api/helm-chart/templates/service.yaml b/api/helm-chart/templates/service.yaml new file mode 100644 index 00000000..72a8f3b3 --- /dev/null +++ b/api/helm-chart/templates/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "api.fullname" . }} + labels: +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + selector: + {{- include "api.selectorLabels" . | nindent 4 }} diff --git a/api/helm-chart/templates/tests/api_tests.yaml b/api/helm-chart/templates/tests/api_tests.yaml new file mode 100644 index 00000000..a88fb4bb --- /dev/null +++ b/api/helm-chart/templates/tests/api_tests.yaml @@ -0,0 +1,42 @@ +{{- if .Values.mongodb.enabled }} +# API tests should *never* run in a non-throwaway environment because they persist test data in the db + +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "api.fullname" . }}-api-tests" + labels: + {{- include "api.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-weight": "-10" + # https://github.com/helm/helm/issues/9098 + #"helm.sh/hook-delete-policy": hook-succeeded +spec: + containers: + - name: run-api-tests + image: ghcr.io/bioimage-archive/bia-integrator-api:{{ .Values.image.tag }} + command: ["poetry", "run", "pytest"] + workingDir: /integrator-api + env: + - name: DB_NAME + value: {{ .Values.api_env.dbName }} + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "api.fullname" . }} + key: jwtSecretKey + - name: USER_CREATE_SECRET_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "api.fullname" . }} + key: userCreateSecretToken + # INTENTIONALLY USING MONGO VALUES (instead of api values) + # so in case the api points to a remote mongo but mongo is enabled (for some reason/by accident), + # only the throwaway mongo created by the chart would be used for tests + - name: MONGO_CONNSTRING + value: "mongodb://{{ .Values.mongodb.auth.rootUser }}:{{ .Values.mongodb.auth.rootPassword }}@{{ .Values.mongodb.service.nameOverride }}:{{ .Values.mongodb.service.nodePorts.mongodb }}/" + + restartPolicy: Never + +{{- end }} diff --git a/api/helm-chart/templates/tests/test-connection.yaml b/api/helm-chart/templates/tests/test-connection.yaml new file mode 100644 index 00000000..c3430657 --- /dev/null +++ b/api/helm-chart/templates/tests/test-connection.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "api.fullname" . }}-test-connection" + labels: + {{- include "api.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test + "helm.sh/hook-weight": "0" + # https://github.com/helm/helm/issues/9098 + #"helm.sh/hook-delete-policy": hook-succeeded +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "api.fullname" . }}:{{ .Values.service.port }}/v1/openapi.json', '--quiet'] + restartPolicy: Never diff --git a/api/helm-chart/values.yaml b/api/helm-chart/values.yaml new file mode 100644 index 00000000..94b64d2b --- /dev/null +++ b/api/helm-chart/values.yaml @@ -0,0 +1,52 @@ +replicaCount: 1 + +image: + tag: "0.1.0" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 8080 + +ingress: + # TODO + enabled: false + className: "" + annotations: {} + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + +resources: + limits: + memory: "1G" + cpu: "1" + +api_env: + mongoRootUser: "" + mongoRootPassword: "" + mongoHost: "api-mongodb" + dbName: "bia-integrator" + jwtSecretKey: "" + userCreateSecretToken: "" + +mongodb: + enabled: false + architecture: standalone + auth: + rootUser: "" + rootPassword: "" + service: + type: ClusterIP + nodePorts: + mongodb: "27017" + nameOverride: "api-mongodb" + resources: + limits: + cpu: 2 + memory: 512M + requests: + cpu: 2 + memory: 512M diff --git a/api/local-minikube/README.md b/api/local-minikube/README.md new file mode 100644 index 00000000..a592b0dd --- /dev/null +++ b/api/local-minikube/README.md @@ -0,0 +1,20 @@ +## Setup + +```sh +minikube start --memory=10000 --cpus=4 --extra-config=apiserver.service-node-port-range=1-65535 +helm install bia-api-local ./helm-chart -f ./local/api_values.yml +helm test bia-api-local --logs + +# cleanup/delete +helm uninstall bia-api-local +minikube delete +``` + +## Accessing apps + +```bash +kubectl port-forward svc/api-mongodb 27017:27017 +kubectl port-forward svc/api 8080:8080 +``` + +If running the api (or tests) locally for development, the mongo port needs to be forwarded and MONGO_CONNSTRING in .env should point to localhost with the mongo values in `api_values.yml` \ No newline at end of file diff --git a/api/local-minikube/api_values.yml b/api/local-minikube/api_values.yml new file mode 100644 index 00000000..5ccc8248 --- /dev/null +++ b/api/local-minikube/api_values.yml @@ -0,0 +1,15 @@ +# Default throwaway api instance. Most commonly used + +api_env: + mongoRootUser: "root" + mongoRootPassword: "example" + mongoHost: "api-mongodb" + dbName: "bia-integrator" + jwtSecretKey: "5acacd0471b95e32ecb75693f785225472b9f3e57b6ad480619c8449fb2fc885" + userCreateSecretToken: "00123456789==" + +mongodb: + enabled: true + auth: + rootUser: "root" + rootPassword: "example" diff --git a/api/local-minikube/api_values_non_throwaway.yml b/api/local-minikube/api_values_non_throwaway.yml new file mode 100644 index 00000000..625c3859 --- /dev/null +++ b/api/local-minikube/api_values_non_throwaway.yml @@ -0,0 +1,10 @@ +api_env: + mongoRootUser: "independent-install-root" + mongoRootPassword: "independent-install-example" + mongoHost: "independent-install-mongodb" + dbName: "bia-integrator" + jwtSecretKey: "5acacd0471b95e32ecb75693f785225472b9f3e57b6ad480619c8449fb2fc885" + userCreateSecretToken: "00123456789==" + +mongodb: + enabled: false diff --git a/api/local-minikube/mongo_values.yml b/api/local-minikube/mongo_values.yml new file mode 100644 index 00000000..8ba31477 --- /dev/null +++ b/api/local-minikube/mongo_values.yml @@ -0,0 +1,25 @@ +# This is left in because sometimes it's useful to test assuming Mongo was installed external of the api chart +# Unless a very unusual context, use `api_values.yml` and the api chart + +architecture: standalone +auth: + rootUser: "independent-install-root" + rootPassword: "independent-install-example" +service: + type: ClusterIP + nodePorts: + mongodb: "27017" + nameOverride: "independent-install-mongodb" + +livenessProbe: + enabled: false +readinessProbe: + enabled: false + +resources: + limits: + cpu: 1 + memory: 256M + requests: + cpu: 1 + memory: 256M diff --git a/clients/openapi.json b/clients/openapi.json index 606dfa66..580e2705 100644 --- a/clients/openapi.json +++ b/clients/openapi.json @@ -2220,7 +2220,7 @@ }, "type": "array", "title": "Image Acquisitions Uuid", - "description": "Context in which the image was acquired. This list often has one item, but it can occasionally have more (e.g. for multimodal imaging)", + "description": "Context in which the image was acquired.", "default": [] } },