Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/helm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ jobs:
- name: Lint chart
run: helm lint ./helm

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'

- name: Verify App rollout and storage examples
run: |
python -m pip install PyYAML
python -m unittest discover -s helm/tests -v

- name: Verify application image tags
shell: bash
run: |
Expand Down
56 changes: 54 additions & 2 deletions helm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,19 @@ helm install weknora ./helm \

### Production Installation

For production, use a values file:
For production with the default local file storage, use a single App replica and
an update strategy that releases the old Pod's volume before starting its
replacement:

```yaml
# values-production.yaml
global:
storageClass: "fast-ssd"

app:
replicaCount: 3
replicaCount: 1
strategy:
type: Recreate
resources:
requests:
cpu: 500m
Expand Down Expand Up @@ -140,6 +144,51 @@ helm install weknora ./helm \
-f values-production.yaml
```

The chart-created data-files PVC uses `ReadWriteOnce` (RWO). RWO allows several
Pods to mount the volume on the same node; it does not provide shared writable
access across nodes. With a single-node-attach volume, the default rolling update
(`maxSurge: 1`, `maxUnavailable: 0`) can leave a replacement Pod waiting for the
old Pod's attachment to be released. `Recreate` terminates the old Pods before
starting replacements during an upgrade, so **App upgrades have downtime**;
volume detach/attach can still take time. This strategy does not make RWO storage
suitable for multiple App replicas across nodes. See Kubernetes documentation on
[access modes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes)
and [Deployment strategies](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy).

For multiple App replicas using local storage, first provision and verify a
shared filesystem PVC that supports `ReadWriteMany` (RWX) on the intended nodes.
Apply the following override together with `values-production.yaml`:

```yaml
# values-shared-storage.yaml
app:
replicaCount: 3
strategy:
type: RollingUpdate

dataFiles:
persistence:
enabled: true
existingClaim: weknora-shared-files # Pre-provisioned, verified RWX PVC
```

```bash
helm install weknora ./helm \
--namespace weknora \
--create-namespace \
-f values-production.yaml \
-f values-shared-storage.yaml
```

The existing claim must be in the release namespace and allow the App containers
to read and write `/data/files`. The chart references it without creating or
validating its access mode. All App replicas must see the same files; disabling
persistence or assigning independent local volumes does not provide shared
storage. This override addresses App file storage only, not high availability
for the chart's database or other components. Object storage is another option
only after configuring the provider and checking that all paths needed by your
deployment are accessible to every replica.

## Configuration

### Service names
Expand Down Expand Up @@ -203,6 +252,9 @@ pre-existing Service name does not automatically transfer its ownership to Helm.
|-----------|-------------|---------|
| `app.enabled` | Enable backend | `true` |
| `app.replicaCount` | Number of replicas | `1` |
| `app.strategy.type` | App update strategy: `RollingUpdate` or `Recreate` | `RollingUpdate` |
| `app.strategy.rollingUpdate.maxSurge` | Extra Pods during rolling updates (integer or percentage); ignored with `Recreate` | `1` |
| `app.strategy.rollingUpdate.maxUnavailable` | Unavailable Pods during rolling updates (integer or percentage); ignored with `Recreate` | `0` |
| `app.image.repository` | Image repository | `wechatopenai/weknora-app` |
| `app.image.tag` | Image tag | `""` (uses appVersion) |
| `app.resources` | Resource limits | See values.yaml |
Expand Down
12 changes: 9 additions & 3 deletions helm/templates/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ spec:
matchLabels:
{{- include "weknora.componentSelectorLabels" (dict "component" "app" "context" .) | nindent 6 }}
strategy:
type: RollingUpdate
{{- $strategy := .Values.app.strategy | default dict }}
{{- $strategyType := $strategy.type | default "RollingUpdate" }}
{{- if not (has $strategyType (list "RollingUpdate" "Recreate")) }}
{{- fail "app.strategy.type must be RollingUpdate or Recreate" }}
{{- end }}
type: {{ $strategyType }}
{{- if eq $strategyType "RollingUpdate" }}
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
{{- toYaml ($strategy.rollingUpdate | default (dict "maxSurge" 1 "maxUnavailable" 0)) | nindent 6 }}
{{- end }}
template:
metadata:
labels:
Expand Down
118 changes: 118 additions & 0 deletions helm/tests/test_app_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Render-only checks for App rollout strategies and documented storage topologies.

Run with Helm on PATH and PyYAML installed:
python3 -m unittest discover -s helm/tests -v
"""

import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest

import yaml


CHART = Path(os.environ.get("HELM_CHART_DIR", Path(__file__).resolve().parents[1]))


def render(values=None):
with tempfile.TemporaryDirectory() as directory:
arguments = []
for index, layer in enumerate(values if isinstance(values, list) else [values or {}]):
path = Path(directory) / f"values-{index}.yaml"
path.write_text(yaml.safe_dump(layer))
arguments.extend(["-f", str(path)])
return subprocess.run(
[
"helm", "template", "audit", str(CHART),
"--namespace", "audit",
"--set", "secrets.existingSecret=fixture-secret",
*arguments,
],
capture_output=True, text=True,
)


class AppStorageTests(unittest.TestCase):
def documents(self, values=None):
result = render(values)
self.assertEqual(result.returncode, 0, result.stderr)
return [doc for doc in yaml.safe_load_all(result.stdout) if doc]

def app(self, documents):
return next(doc for doc in documents
if doc["kind"] == "Deployment"
and doc["metadata"]["name"] == "audit-weknora-app")

def assert_data_claim(self, app, name):
volumes = app["spec"]["template"]["spec"]["volumes"]
volume = next(v for v in volumes if v["name"] == "data-files")
self.assertEqual(volume["persistentVolumeClaim"]["claimName"], name)

def readme_values(self, filename):
blocks = re.findall(r"```yaml\n(.*?)\n```", (CHART / "README.md").read_text(), re.S)
return yaml.safe_load(next(block for block in blocks
if block.startswith(f"# {filename}\n")))

def test_default_strategy_is_unchanged(self):
app = self.app(self.documents())
self.assertEqual(app["spec"]["strategy"], {
"type": "RollingUpdate",
"rollingUpdate": {"maxSurge": 1, "maxUnavailable": 0},
})

def test_recreate_omits_rolling_update(self):
app = self.app(self.documents({"app": {"strategy": {"type": "Recreate"}}}))
self.assertEqual(app["spec"]["strategy"], {"type": "Recreate"})

def test_custom_rolling_update_preserves_zero_and_percentage(self):
app = self.app(self.documents({"app": {"strategy": {
"type": "RollingUpdate",
"rollingUpdate": {"maxSurge": 0, "maxUnavailable": "25%"},
}}}))
self.assertEqual(app["spec"]["strategy"]["rollingUpdate"], {
"maxSurge": 0, "maxUnavailable": "25%",
})

def test_null_strategy_falls_back_to_defaults(self):
app = self.app(self.documents({"app": {"strategy": None}}))
self.assertEqual(app["spec"]["strategy"], {
"type": "RollingUpdate",
"rollingUpdate": {"maxSurge": 1, "maxUnavailable": 0},
})

def test_invalid_strategy_is_rejected(self):
result = render({"app": {"strategy": {"type": "Invalid"}}})
self.assertNotEqual(result.returncode, 0)
self.assertIn("app.strategy.type must be RollingUpdate or Recreate", result.stderr)

def test_production_example_uses_single_replica_rwo_and_recreate(self):
documents = self.documents(self.readme_values("values-production.yaml"))
app = self.app(documents)
self.assertEqual(app["spec"]["replicas"], 1)
self.assertEqual(app["spec"]["strategy"], {"type": "Recreate"})
self.assert_data_claim(app, "audit-weknora-data-files")
pvc = next(doc for doc in documents
if doc["kind"] == "PersistentVolumeClaim"
and doc["metadata"]["name"] == "audit-weknora-data-files")
self.assertEqual(pvc["spec"]["accessModes"], ["ReadWriteOnce"])
env = app["spec"]["template"]["spec"]["containers"][0]["env"]
self.assertIn({"name": "STORAGE_TYPE", "value": "local"}, env)

def test_shared_storage_example_references_existing_claim(self):
values = self.readme_values("values-production.yaml")
override = self.readme_values("values-shared-storage.yaml")
documents = self.documents([values, override])
app = self.app(documents)
self.assertEqual(app["spec"]["replicas"], 3)
self.assertEqual(app["spec"]["strategy"]["type"], "RollingUpdate")
self.assert_data_claim(app, "weknora-shared-files")
self.assertFalse(any(doc["kind"] == "PersistentVolumeClaim"
and doc["metadata"]["name"] == "audit-weknora-data-files"
for doc in documents))


if __name__ == "__main__":
unittest.main()
8 changes: 8 additions & 0 deletions helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ app:
# -- Number of replicas
replicaCount: 1

# -- App update strategy. Use Recreate for single-replica, single-node-attach storage.
strategy:
type: RollingUpdate
# -- Only used with RollingUpdate; integers and percentages are supported.
rollingUpdate:
maxSurge: 1
maxUnavailable: 0

image:
# -- Image repository
repository: wechatopenai/weknora-app
Expand Down
Loading