Skip to content
36 changes: 36 additions & 0 deletions applications/samples/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,42 @@ paths:
operationId: ping
summary: test the application is up
x-openapi-router-controller: samples.controllers.test_controller

/write-file:
post:
tags:
- test
requestBody:
description: Optional content of the file to write.
required: false
content:
application/json:
schema:
type: object
properties:
content:
type: string
responses:
"200":
content:
application/json:
schema:
type: object
properties:
filename:
type: string
path:
type: string
hostname:
type: string
description: The file was written on the application volume
operationId: write_file
summary: writes a file on the application volume
description: >-
Writes a timestamped file on the application volume and returns the name of the pod
that handled the request. On a statefulset deployment, route this endpoint to the
leader service to have all writes land on pod 0.
x-openapi-router-controller: samples.controllers.test_controller
/valid:
get:
tags:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,43 @@ def ping(): # noqa: E501
return time.time()


def write_file(body=None): # noqa: E501
"""writes a file on the application volume

# noqa: E501

:param body: Optional content of the file to write.
:type body: dict | bytes

:rtype: dict
"""
import os
import socket
import time

from flask import has_request_context, request

if body is None and has_request_context() and request.is_json:
body = request.get_json()

try:
from cloudharness.applications import get_current_configuration
mountpath = get_current_configuration().harness.deployment.volume.mountpath
except Exception:
# not running inside a cloudharness deployment (e.g. unit tests)
mountpath = "/tmp/myvolume"
Comment on lines +51 to +56

hostname = socket.gethostname()
filename = f"{time.strftime('%Y%m%d-%H%M%S')}-{hostname}.txt"
path = os.path.join(mountpath, filename)
content = (body or {}).get("content") or f"written by {hostname}"

os.makedirs(mountpath, exist_ok=True)
with open(path, "w") as f:
f.write(content)

return {"filename": filename, "path": path, "hostname": hostname}


def serialization():
return User(last_name="Last", first_name="First")
18 changes: 18 additions & 0 deletions applications/samples/backend/samples/test/test_sample.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,20 @@
def test_sample():
assert True


def test_write_file():
import socket

from samples.controllers import test_controller

# outside a deployment the controller falls back to writing under /tmp/myvolume
result = test_controller.write_file({"content": "hello"})

assert result["hostname"] == socket.gethostname()
assert result["hostname"] in result["filename"]
with open(result["path"]) as f:
assert f.read() == "hello"

result = test_controller.write_file()
with open(result["path"]) as f:
assert socket.gethostname() in f.read()
4 changes: 4 additions & 0 deletions applications/samples/deploy/values-statefulset.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
harness:
deployment:
statefulset: true
replicas: 2
5 changes: 5 additions & 0 deletions applications/samples/deploy/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ harness:
use_services:
- name: common
deployment:
replicas: 2
statefulset: true
volume:
name: my-shared-volume
mountpath: /tmp/myvolume
Expand Down Expand Up @@ -48,6 +50,9 @@ harness:
white-listed: true
- uri: /api/ping
white-listed: true
- uri: /api/write-file
methods: [POST]
white-listed: true
- uri: /api/openapi.json
white-listed: true
- uri: /icon.png
Expand Down
60 changes: 57 additions & 3 deletions deployment-configuration/helm/templates/auto-database.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,55 @@ spec:
{{- if and (eq .app.harness.database.type "postgres") (dig "postgres" "operator" false .app.harness.database) }}
{{- include "deploy_utils.database.postgres.operator" . }}
{{- else }}
{{- $isStatefulSet := .app.harness.database.statefulset | default false }}
{{- /* A statefulset database owns its volume through volumeClaimTemplates. A PVC named after the
database is the leftover of a previous Deployment: it is kept (never deleted by helm) and
its data is copied into the statefulset volume by a migration job (see
auto-volume-migration.yaml). Delete the legacy PVC once migrated. */}}
{{- $legacyClaim := false }}
{{- if $isStatefulSet }}
{{- if lookup "v1" "PersistentVolumeClaim" .root.Values.namespace .app.harness.database.name }}
{{- $legacyClaim = true }}
{{- end }}
{{- end }}
{{- if or (not $isStatefulSet) $legacyClaim }}
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ .app.harness.database.name | quote }}
namespace: {{ .root.Values.namespace }}
{{- if $legacyClaim }}
annotations:
helm.sh/resource-policy: keep
{{- end }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .app.harness.database.size }}
{{- end }}
---
apiVersion: apps/v1
kind: Deployment
kind: {{ ternary "StatefulSet" "Deployment" $isStatefulSet }}
metadata:
name: {{ .app.harness.database.name | quote }}
namespace: {{ .root.Values.namespace }}
labels:
usesvolume: {{ .app.harness.database.name }}-{{ .root.Values.namespace }}
spec:
{{- if $isStatefulSet }}
# StatefulSet updates terminate the old pod before creating its replacement, so no
# Recreate strategy or node pinning is needed.
serviceName: {{ .app.harness.database.name | quote }}
{{- else }}
# The database's ReadWriteOnce volume attaches to a single node and the pod is
# pinned to it via podAffinity. Recreate terminates the old pod before starting
# the new one, avoiding an unschedulable pod when the node can't fit both.
strategy:
type: Recreate
{{- end }}
replicas: 1
selector:
matchLabels:
Expand All @@ -54,6 +77,7 @@ spec:
service: db
usesvolume: {{ .app.harness.database.name }}-{{ .root.Values.namespace }}
spec:
{{- if not $isStatefulSet }}
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
Expand All @@ -65,6 +89,18 @@ spec:
- {{ .app.harness.database.name }}-{{ .root.Values.namespace }}
- {{ .app.harness.database.name }} # Obsolete, kept for backwards compatability, to be removed at a later date
topologyKey: "kubernetes.io/hostname"
{{- end }}
{{- if $legacyClaim }}
initContainers:
# Holds the pod until the volume-migration job has copied the legacy volume data into
# the statefulset volume. Delete the legacy PVC after verifying the migration.
- name: volume-migration
image: busybox:1.36
command: ['sh', '-c', 'until [ -f /migration/target/.cloudharness-volume-migrated ]; do echo "waiting for the volume migration job"; sleep 5; done']
volumeMounts:
- name: {{ .app.harness.database.name | quote }}
mountPath: /migration/target
{{- end }}
containers:
- name: {{ .app.harness.database.name | quote }}
imagePullPolicy: IfNotPresent
Expand All @@ -82,15 +118,33 @@ spec:
- mountPath: /dev/shm
name: dshm
{{- end }}
{{- if or (not $isStatefulSet) (eq .app.harness.database.type "postgres") }}
volumes:
{{- if not $isStatefulSet }}
- name: {{ .app.harness.database.name | quote }}
persistentVolumeClaim:
claimName: {{ .app.harness.database.name | quote }}
{{- end }}
{{- if eq .app.harness.database.type "postgres" }}
- emptyDir:
medium: Memory
name: dshm
{{- end }}
name: dshm
{{- end }}
{{- end }}
{{- if $isStatefulSet }}
volumeClaimTemplates:
- metadata:
name: {{ .app.harness.database.name | quote }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .app.harness.database.size }}
{{- end }}
{{- if $legacyClaim }}
{{- include "deploy_utils.volumeMigration" (dict "root" .root "name" .app.harness.database.name "pvc" .app.harness.database.name) }}
{{- end }}
Comment on lines +144 to +147
---
{{- if and .root.Values.backup.active (ne .app.harness.database.type "neo4j") }}
{{- include (print "deploy_utils.database." .app.harness.database.type ".backup") . }}
Expand Down
75 changes: 62 additions & 13 deletions deployment-configuration/helm/templates/auto-deployments.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
{{- define "deploy_utils.deployment" }}
{{- $isStatefulSet := .app.harness.deployment.statefulset | default false }}
{{- $volume := .app.harness.deployment.volume }}
{{- /* $rwoVolume: a ReadWriteOnce (non-nfs) volume pins the pod to a single node — it drives both
the Recreate strategy and the podAffinity. $ownVolume additionally requires the PVC to be
managed here (auto), which a statefulset turns into a volumeClaimTemplate. The checks are
nested so hasKey/index never runs on a nil volume: helm < 3.10 (go < 1.18) does not
short-circuit 'and'/'or', so a flat expression would fail for volume-less apps. */}}
{{- $rwoVolume := false }}
{{- $ownVolume := false }}
{{- if $volume }}
{{- if or (not (hasKey $volume "usenfs")) (not $volume.usenfs) }}
{{- $rwoVolume = true }}
{{- if or (not (hasKey $volume "auto")) $volume.auto }}
{{- $ownVolume = true }}
{{- end }}
{{- end }}
{{- end }}
{{- $useClaimTemplate := and $isStatefulSet $ownVolume }}
{{- /* A PVC named after the volume is the leftover of a previous Deployment: a migration job
(see auto-volume-migration.yaml) copies its data into each statefulset volume, and an init
container holds each pod until its volume has been migrated. Delete the legacy PVC once
migrated to end the migration. */}}
{{- $legacyClaim := false }}
{{- if $useClaimTemplate }}
{{- if lookup "v1" "PersistentVolumeClaim" .root.Values.namespace $volume.name }}
{{- $legacyClaim = true }}
{{- end }}
{{- end }}
apiVersion: apps/v1
kind: Deployment
kind: {{ ternary "StatefulSet" "Deployment" $isStatefulSet }}
metadata:
name: {{ .app.harness.deployment.name| quote }}
namespace: {{ .root.Values.namespace }}
Expand All @@ -11,18 +39,18 @@ metadata:
{{- end }}
{{- include "deploy_utils.labels" .root | indent 4 }}
spec:
{{- if .app.harness.deployment.volume }}
{{- if or (not (hasKey .app.harness.deployment.volume "usenfs")) (not .app.harness.deployment.volume.usenfs) }}
{{- if $isStatefulSet }}
# StatefulSet updates terminate the old pod before creating its replacement, so no
# Recreate strategy or node pinning is needed even for ReadWriteOnce volumes.
serviceName: {{ .app.harness.service.name | quote }}
{{- else if $rwoVolume }}
# A ReadWriteOnce volume attaches to a single node and the pod is pinned to the
# volume's node via podAffinity (see below). Recreate terminates the old pod
# before starting the new one, avoiding an unschedulable pod when the node can't
# fit both. NFS (ReadWriteMany) volumes have no pinning and can roll normally.
# Nested ifs (not a flat 'and'): helm < 3.10 (go < 1.18) does not short-circuit,
# so hasKey must not run on a nil volume.
strategy:
type: Recreate
{{- end }}
{{- end }}
replicas: {{ .app.harness.deployment.replicas | default 1 }}
selector:
matchLabels:
Expand All @@ -47,8 +75,7 @@ spec:
- name: {{ .root.Values.registry.secret.name }}
{{- end }}
{{- end }}
{{- if and .app.harness.deployment.volume }}
{{- if or (not (hasKey .app.harness.deployment.volume "usenfs")) (not .app.harness.deployment.volume.usenfs) }}
{{- if and $rwoVolume (not $isStatefulSet) }}
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
Expand All @@ -61,23 +88,30 @@ spec:
- {{ .app.harness.deployment.volume.name }} # Obsolete, kept for backwards compatability, to be removed at a later date
topologyKey: "kubernetes.io/hostname"
{{- end }}
{{- end }}
{{- if .app.harness.deployment.extraContainers }}
{{- $hasInit := false }}
{{- $hasInit := $legacyClaim }}
{{- range $name, $container := .app.harness.deployment.extraContainers }}
{{- if and $container.auto $container.initContainer }}
{{- $hasInit = true }}
{{- end }}
{{- end }}
{{- if $hasInit }}
initContainers:
{{- if $legacyClaim }}
# Holds the pod until the volume-migration job has copied the legacy volume data into
# this pod's volume. Delete the legacy PVC after verifying the migration to drop this gate.
- name: volume-migration
image: busybox:1.36
command: ['sh', '-c', 'until [ -f /migration/target/.cloudharness-volume-migrated ]; do echo "waiting for the volume migration job"; sleep 5; done']
volumeMounts:
- name: {{ $volume.name }}
mountPath: /migration/target
{{- end }}
{{- range $name, $container := .app.harness.deployment.extraContainers }}
{{- if and $container.auto $container.initContainer }}
{{- include "deploy_utils.extraContainerSpec" (dict "name" $name "container" $container "app" $.app "root" $.root) | nindent 6 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
containers:
- name: {{ .app.harness.deployment.name| default "cloudharness-docs" | quote }}
image: {{ .app.harness.deployment.image }}
Expand Down Expand Up @@ -150,7 +184,7 @@ spec:
{{- include "deploy_utils.secretsVolume" (dict "root" $root "app" $depApp "name" (printf "cloudharness-%s" $dep) "secretName" $dep) | nindent 8 }}
{{- end }}
{{- end }}
{{- if .app.harness.deployment.volume }}
{{- if and .app.harness.deployment.volume (not $useClaimTemplate) }}
- name: {{ .app.harness.deployment.volume.name }}
persistentVolumeClaim:
claimName: {{ .app.harness.deployment.volume.name }}
Expand All @@ -171,6 +205,21 @@ spec:
secretName: {{ printf "%s-db" .app.harness.deployment.name }}
{{- end }}
{{- end }}
{{- if $useClaimTemplate }}
volumeClaimTemplates:
- metadata:
name: {{ $volume.name }}
spec:
accessModes:
- ReadWriteOnce
storageClassName: standard
resources:
requests:
storage: {{ $volume.size }}
{{- end }}
{{- if $legacyClaim }}
{{- include "deploy_utils.volumeMigration" (dict "root" .root "name" .app.harness.deployment.name "pvc" $volume.name) }}
{{- end }}
Comment on lines +219 to +222
---
{{- end }}

Expand Down
Loading
Loading