From 7a30dafae69a8f01506bb3e4d39d405f65fb9dba Mon Sep 17 00:00:00 2001 From: Jesse Schmidt Date: Wed, 7 Jun 2023 15:38:50 -0700 Subject: [PATCH] feat: REL-955 Add rest-gateway --- README.md | 3 +- aerospike-rest-gateway/Chart.yaml | 9 ++ aerospike-rest-gateway/README.md | 123 ++++++++++++++++++ aerospike-rest-gateway/charts/.gitkeep | 0 aerospike-rest-gateway/templates/NOTES.txt | 27 ++++ aerospike-rest-gateway/templates/_helpers.tpl | 63 +++++++++ .../templates/deployment.yaml | 79 +++++++++++ aerospike-rest-gateway/templates/ingress.yaml | 38 ++++++ aerospike-rest-gateway/templates/service.yaml | 16 +++ .../templates/serviceaccount.yaml | 13 ++ .../templates/tests/test-connection.yaml | 15 +++ aerospike-rest-gateway/values.yaml | 96 ++++++++++++++ docs/aerospike-rest-gateway-0.1.0.tgz | Bin 0 -> 5071 bytes docs/index.yaml | 18 ++- 14 files changed, 497 insertions(+), 3 deletions(-) create mode 100644 aerospike-rest-gateway/Chart.yaml create mode 100644 aerospike-rest-gateway/README.md create mode 100644 aerospike-rest-gateway/charts/.gitkeep create mode 100644 aerospike-rest-gateway/templates/NOTES.txt create mode 100644 aerospike-rest-gateway/templates/_helpers.tpl create mode 100644 aerospike-rest-gateway/templates/deployment.yaml create mode 100644 aerospike-rest-gateway/templates/ingress.yaml create mode 100644 aerospike-rest-gateway/templates/service.yaml create mode 100644 aerospike-rest-gateway/templates/serviceaccount.yaml create mode 100644 aerospike-rest-gateway/templates/tests/test-connection.yaml create mode 100644 aerospike-rest-gateway/values.yaml create mode 100644 docs/aerospike-rest-gateway-0.1.0.tgz diff --git a/README.md b/README.md index dc73f7e..054858a 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,5 @@ This is a public Helm repository for [Aerospike](https://aerospike.com/) product The following helm charts are provided - [Aerospike Kafka Outbound](aerospike-kafka-outbound) -- [Aerospike Point in Time Recovery](aerospike-point-in-time-recovery) \ No newline at end of file +- [Aerospike Point in Time Recovery](aerospike-point-in-time-recovery) +- [Aerospike REST Gateway](aerospike-rest-gateway) \ No newline at end of file diff --git a/aerospike-rest-gateway/Chart.yaml b/aerospike-rest-gateway/Chart.yaml new file mode 100644 index 0000000..b45fcde --- /dev/null +++ b/aerospike-rest-gateway/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: aerospike-rest-gateway +description: Helm Chart For Aerospike REST Gateway On Kubernetes +version: 0.1.0 +icon: https://avatars0.githubusercontent.com/u/2214313?s=200&v=4 +sources: +- https://github.com/aerospike/helm-charts +type: application +appVersion: 2.1.1 diff --git a/aerospike-rest-gateway/README.md b/aerospike-rest-gateway/README.md new file mode 100644 index 0000000..ac1f2b5 --- /dev/null +++ b/aerospike-rest-gateway/README.md @@ -0,0 +1,123 @@ +# Helm chart for Aerospike REST Gateway on Kubernetes + +Implements Aerospike REST Gateway deployment on Kubernetes + +## Usage + +### Add Aerospike repository + +```shell +helm repo add aerospike https://aerospike.github.io/helm-charts +``` + +### Install the chart + +```shell +helm install rest-gateway aerospike/aerospike-rest-gateway --set config.hostname= +``` + +## Configuration + + +### Connect to Aerospike cluster + +#### Specify aerospike cluster seed IP or hostname and port + +```shell +helm install rest-gateway aerospike/aerospike-rest-gateway \ + --set config.hostname=demo-aerospike-enterprise \ + --set config.port=3000 +``` + +#### Specify username and password (for security enabled clusters) + +```shell +helm install rest-gateway aerospike/aerospike-rest-gateway \ + --set config.hostname=demo-aerospike-enterprise \ + --set config.port=3000 \ + --set config.user=superman \ + --set config.password=krypton +``` + +#### Specify cluster name (if applicable) +```shell +--set config.clusterName=demo-aerospike-enterprise +``` + + +### Expose REST client deployment + +#### Using NodePort type service + +Aerospike REST client can be exposed for external access using NodePort type services. + +Set `service.type=NodePort` to expose REST client via a `NodePort`. + +Applications can access REST client endpoint at `:`. + +#### Using LoadBalancer type service + +Aerospike REST client can be exposed for external access using Load balancers. + +Set `service.type=LoadBalancer` to expose REST client via a network load balancer. + +Applications can access REST client endpoint at `:` + +#### Using Ingress + +Aerospike REST client can be exposed for external access using ingress. + +- Set `ingress.enabled=true`, +- Specify `ingress.annotations`, `ingress.rules` and `ingress.tls` configuration. + +For example (using `nginx` controller), +```yaml +# Ingress resource settings +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/ssl-redirect: "false" + rules: [] + - paths: + - '/' + # host: rest.aerospike.com + tls: [] + # - secretName: rest-gateway-tls + # hosts: + # - rest.aerospike.com +``` + +Applications can access REST client endpoint at the specified url (or `host`) in the `rules` or at the external IP of ingress controller (loadbalancer). + + +## Configuration parameters and default values + +| Parameter | Description| Default Value | +|:----------|:----------:|:-------------:| +| `replicaCount` | Number of replicas for the Aerospike REST client deployment | `1` | +| `image.repository` | Aerospike REST client docker image repository | `aerospike/aerospike-rest-gateway` | +| `image.tag` | Aerospike REST client docker image tag | `latest` | +| `config.hostname` | Aerospike cluster Seed IP address to connect to | `127.0.0.1` | +| `config.port` | Aerospike cluster Seed Port | `3000` | +| `config.user` | Username for access control to connect to the aerospike cluster | `""` | +| `config.password` | Password for access control to connect to the aerospike cluster | `""` | +| `config.clusterName` | Cluster name defined for the aerospike cluster | `""` | +| `config.requireAuthentication` | Require the Basic Authentication on each request | `false` | +| `config.poolSize` | Represents the max size of the clients LRU cache | `16` | +| `serviceAccount.create` | Create and use a serviceAccount for the deployment | `true` | +| `serviceAccount.annotations` | Annotations for the serviceAccount resource | `{} (nil)` | +| `serviceAccount.name` | ServiceAccount name whether to be created or already exists | `true` | +| `podSecurityContext` | Pod security context | `{} (nil)` | +| `securityContext` | Container security context | `{} (nil)` | +| `containerPort` | Aerospike REST client deployment container port | `8080` | +| `service.type` | Type of service to access REST client deployment | `ClusterIP` | +| `service.port` | Port for the service to access REST client deployment | `8080` | +| `ingress.enabled` | Enable Ingress resource | `false` | +| `ingress.annotations` | Annotations for Ingress resource | `{} (nil)` | +| `ingress.rules` | Rules for Ingress resource | `[] (nil)` | +| `ingress.tls` | TLS configuration for Ingress resource | `[] (nil)` | +| `resources` | Resources and Limits for REST client Deployment | `{} (nil)` | +| `nodeSelector` | Specify node selectors labels for Pod scheduling | `{} (nil)` | +| `tolerations` | Define tolerations and taints for Pod scheduling and execution | `[] (nil)` | +| `affinity` | Define pod and node affinity and antiAffinity | `{} (nil)` | diff --git a/aerospike-rest-gateway/charts/.gitkeep b/aerospike-rest-gateway/charts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/aerospike-rest-gateway/templates/NOTES.txt b/aerospike-rest-gateway/templates/NOTES.txt new file mode 100644 index 0000000..b6df156 --- /dev/null +++ b/aerospike-rest-gateway/templates/NOTES.txt @@ -0,0 +1,27 @@ + _ _ _ ____ _____ ____ _____ _ _ _ + / \ ___ _ __ ___ ___ _ __ (_) | _____ | _ \| ____/ ___|_ _| ___| (_) ___ _ __ | |_ + / _ \ / _ \ '__/ _ \/ __| '_ \| | |/ / _ \ | |_) | _| \___ \ | | / __| | |/ _ \ '_ \| __| + / ___ \ __/ | | (_) \__ \ |_) | | < __/ | _ <| |___ ___) || | | (__| | | __/ | | | |_ + /_/ \_\___|_| \___/|___/ .__/|_|_|\_\___| |_| \_\_____|____/ |_| \___|_|_|\___|_| |_|\__| + |_| +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 }}{{ . }} + {{- 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 "aerospike-rest-gateway.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 "aerospike-rest-gateway.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "aerospike-rest-gateway.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 "aerospike-rest-gateway.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080/swagger-ui.html to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:8080 +{{- end }} diff --git a/aerospike-rest-gateway/templates/_helpers.tpl b/aerospike-rest-gateway/templates/_helpers.tpl new file mode 100644 index 0000000..138e5f2 --- /dev/null +++ b/aerospike-rest-gateway/templates/_helpers.tpl @@ -0,0 +1,63 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "aerospike-rest-gateway.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 "aerospike-rest-gateway.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 "aerospike-rest-gateway.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "aerospike-rest-gateway.labels" -}} +helm.sh/chart: {{ include "aerospike-rest-gateway.chart" . }} +{{ include "aerospike-rest-gateway.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} + +{{/* +Selector labels +*/}} +{{- define "aerospike-rest-gateway.selectorLabels" -}} +app.kubernetes.io/name: {{ include "aerospike-rest-gateway.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* +Create the name of the service account to use +*/}} +{{- define "aerospike-rest-gateway.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} + {{ default (include "aerospike-rest-gateway.fullname" .) .Values.serviceAccount.name }} +{{- else -}} + {{ default "default" .Values.serviceAccount.name }} +{{- end -}} +{{- end -}} diff --git a/aerospike-rest-gateway/templates/deployment.yaml b/aerospike-rest-gateway/templates/deployment.yaml new file mode 100644 index 0000000..c2c6dd9 --- /dev/null +++ b/aerospike-rest-gateway/templates/deployment.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "aerospike-rest-gateway.fullname" . }} + labels: + {{- include "aerospike-rest-gateway.labels" . | nindent 4 }} + release: {{ .Release.Name }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "aerospike-rest-gateway.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "aerospike-rest-gateway.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "aerospike-rest-gateway.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: AEROSPIKE_RESTCLIENT_HOSTNAME + value: {{ quote .Values.config.hostname }} + - name: AEROSPIKE_RESTCLIENT_PORT + value: {{ quote .Values.config.port }} + - name: AEROSPIKE_RESTCLIENT_CLIENTPOLICY_USER + value: {{ quote .Values.config.user }} + - name: AEROSPIKE_RESTCLIENT_CLIENTPOLICY_PASSWORD + value: {{ quote .Values.config.password }} + - name: AEROSPIKE_RESTCLIENT_CLIENTPOLICY_CLUSTERNAME + value: {{ quote .Values.config.clusterName }} + - name: AEROSPIKE_RESTCLIENT_REQUIREAUTHENTICATION + value: {{ quote .Values.config.requireAuthentication }} + - name: AEROSPIKE_RESTCLIENT_POOL_SIZE + value: {{ quote .Values.config.poolSize }} + ports: + - name: http + containerPort: {{ .Values.containerPort | default 8080 }} + protocol: TCP + livenessProbe: + httpGet: + path: / + port: http + # Delay livenessProbe by 10s at startup + initialDelaySeconds: 10 + # Perform livenessProbe check every 10s (default) + periodSeconds: 10 + readinessProbe: + httpGet: + path: / + port: http + # Delay readiness check by 10s at startup + initialDelaySeconds: 10 + # Perform readiness check every 10s (default) + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/aerospike-rest-gateway/templates/ingress.yaml b/aerospike-rest-gateway/templates/ingress.yaml new file mode 100644 index 0000000..e67bfce --- /dev/null +++ b/aerospike-rest-gateway/templates/ingress.yaml @@ -0,0 +1,38 @@ +{{- if .Values.ingress.enabled -}} +{{- $dot := . }} +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ include "aerospike-rest-gateway.fullname" . }} + {{- with .Values.ingress.annotations }} + annotations: {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "aerospike-rest-gateway.labels" . | nindent 4 }} + release: {{ .Release.Name }} +spec: + {{- if .Values.ingress.tls }} + tls: + {{- range $tls := .Values.ingress.tls }} + - hosts: + {{- range $host := $tls.hosts }} + - {{ $host | quote }} + {{- end }} + secretName: {{ $tls.secretName }} + {{- end }} + {{- end }} + rules: + {{- range $rule := .Values.ingress.rules }} + - http: + paths: + {{- range $path := $rule.paths }} + - path: {{ $path | quote }} + backend: + serviceName: {{ include "aerospike-rest-gateway.fullname" $dot }} + servicePort: {{ $.Values.service.port }} + {{- end }} + {{- if $rule.host }} + host: $rule.host + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/aerospike-rest-gateway/templates/service.yaml b/aerospike-rest-gateway/templates/service.yaml new file mode 100644 index 0000000..5ae8312 --- /dev/null +++ b/aerospike-rest-gateway/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "aerospike-rest-gateway.fullname" . }} + labels: + {{- include "aerospike-rest-gateway.labels" . | nindent 4 }} + release: {{ .Release.Name }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "aerospike-rest-gateway.selectorLabels" . | nindent 4 }} diff --git a/aerospike-rest-gateway/templates/serviceaccount.yaml b/aerospike-rest-gateway/templates/serviceaccount.yaml new file mode 100644 index 0000000..514484e --- /dev/null +++ b/aerospike-rest-gateway/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "aerospike-rest-gateway.serviceAccountName" . }} + labels: + {{- include "aerospike-rest-gateway.labels" . | nindent 4 }} + release: {{ .Release.Name }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end -}} diff --git a/aerospike-rest-gateway/templates/tests/test-connection.yaml b/aerospike-rest-gateway/templates/tests/test-connection.yaml new file mode 100644 index 0000000..5653714 --- /dev/null +++ b/aerospike-rest-gateway/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "aerospike-rest-gateway.fullname" . }}-test-connection" + labels: + {{- include "aerospike-rest-gateway.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "aerospike-rest-gateway.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/aerospike-rest-gateway/values.yaml b/aerospike-rest-gateway/values.yaml new file mode 100644 index 0000000..1af662d --- /dev/null +++ b/aerospike-rest-gateway/values.yaml @@ -0,0 +1,96 @@ +# Default values for aerospike-rest-gateway. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# Number of replicas +replicaCount: 1 + +# Aerospike REST client docker image +image: + repository: aerospike/aerospike-rest-gateway + tag: latest + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# Configuration to connect to an Aerospike cluster +config: + hostname: "127.0.0.1" + port: "3000" + user: "" + password: "" + clusterName: "" + requireAuthentication: "false" + poolSize: "16" + +# Service Account to be used with REST client 'Deployment' +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: + +# Security context (pod level) +podSecurityContext: {} + # fsGroup: 2000 + +# Security context (container level) +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# Container port where REST client listens on +containerPort: 8080 + +# Service to access REST client 'Deployment' +# service.type can be 'ClusterIP', 'NodePort' or 'LoadBalancer' +# set at default 'ClusterIP' for when using ingress +service: + type: ClusterIP + port: 8080 + +# Ingress resource settings +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # nginx.ingress.kubernetes.io/ssl-redirect: "false" + rules: [] + # - paths: + # - '/' + # host: rest.aerospike.com + tls: [] + # - secretName: rest-client-tls + # hosts: + # - rest.aerospike.com + +# Resources - limits and requests for the REST client 'Deployment' +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# Node Selectors +nodeSelector: {} + +# Tolerations and Taints +tolerations: [] + +# Affinity rules +affinity: {} diff --git a/docs/aerospike-rest-gateway-0.1.0.tgz b/docs/aerospike-rest-gateway-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..af2681b97b405db79e0241d5de493865233be790 GIT binary patch literal 5071 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