Stop mislabeling the Compose box as production in read-only audits

This commit is contained in:
2026-08-18 09:29:25 +07:00
parent 332cb657ee
commit e5afedfa2f
18 changed files with 414 additions and 494 deletions
+40 -48
View File
@@ -23,45 +23,37 @@ jobs:
version: v3.17.3
- name: Lint chart
run: helm lint infra/helm/medical-chatbot
- name: Render default and production manifests
- name: Render defaults and check the immutable-tag guard
run: |
helm template default infra/helm/medical-chatbot > /tmp/default.yaml
# values-prod.yaml leaves the image tags empty on purpose: production
# must run an immutable commit SHA, supplied per deploy. Rendering it
# without one has to fail rather than fall back to a development tag,
# so assert that failure here — otherwise the guard could rot into a
# silent default and nobody would notice until a cutover.
if helm template production infra/helm/medical-chatbot \
--values infra/helm/medical-chatbot/values-prod.yaml \
> /tmp/untagged.yaml 2>/tmp/untagged.err; then
echo "::error::production render succeeded with no image tag; the immutable-tag guard is gone"
# The live releases carry no image tag in Git -- it is supplied per
# deploy as a commit SHA through the ArgoCD Application. Rendering
# with an empty tag must FAIL rather than fall back to the chart's
# `local` development tag, so assert the failure directly; otherwise
# the guard could rot into a silent default unnoticed.
if helm template production infra/helm/medical-chatbot --values infra/helm/medical-chatbot/values-production.yaml --set aiService.image.tag="" --set web.image.tag="" > /tmp/untagged.yaml 2>/tmp/untagged.err; then
echo "::error::render succeeded with no image tag; the immutable-tag guard is gone"
exit 1
fi
grep -q 'image.tag must be set to an immutable tag' /tmp/untagged.err
helm template production infra/helm/medical-chatbot \
--values infra/helm/medical-chatbot/values-prod.yaml \
--set aiService.image.tag="$GITHUB_SHA" \
--set web.image.tag="$GITHUB_SHA" \
> /tmp/production.yaml
grep -q 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"' /tmp/production.yaml
grep -q 'RERANK_ENABLED: "true"' /tmp/production.yaml
grep -q 'checksum/runtime-config:' /tmp/production.yaml
grep -q "image: \"ghcr.io/baovu2k4/vsf-duocthu-ai-service:$GITHUB_SHA\"" /tmp/production.yaml
grep -q "image: \"ghcr.io/baovu2k4/vsf-duocthu-web:$GITHUB_SHA\"" /tmp/production.yaml
# ...and with a tag it must resolve the GHCR package, not the local
# development image name.
helm template production infra/helm/medical-chatbot --values infra/helm/medical-chatbot/values-production.yaml --set aiService.image.repository=ghcr.io/baovu2k4/vsf-duocthu-ai-service --set web.image.repository=ghcr.io/baovu2k4/vsf-duocthu-web --set aiService.image.tag="$GITHUB_SHA" --set web.image.tag="$GITHUB_SHA" > /tmp/tagged.yaml
grep -q "image: \"ghcr.io/baovu2k4/vsf-duocthu-ai-service:$GITHUB_SHA\"" /tmp/tagged.yaml
grep -q "image: \"ghcr.io/baovu2k4/vsf-duocthu-web:$GITHUB_SHA\"" /tmp/tagged.yaml
# The practice cluster is only evidence for the production migration
# while it renders the same behavioural contract as production, so both
# of its releases are asserted here rather than trusted by review.
- name: Render practice manifests
# These two releases are what realvuxbaro.me actually serves, so their
# contract is asserted here rather than trusted by review.
- name: Render the live production manifests
run: |
helm template medical-chatbot-app infra/helm/medical-chatbot \
--values infra/helm/medical-chatbot/values-practice.yaml \
> /tmp/practice-app.yaml
--values infra/helm/medical-chatbot/values-production.yaml \
> /tmp/prod-app.yaml
helm template medical-chatbot-data infra/helm/medical-chatbot \
--values infra/helm/medical-chatbot/values-practice-data.yaml \
> /tmp/practice-data.yaml
--values infra/helm/medical-chatbot/values-production-data.yaml \
> /tmp/prod-data.yaml
# A bare `grep -q` fails the step with no indication of which
# assertion broke, and `set -e` ignores a status inverted with `!`,
@@ -83,35 +75,35 @@ jobs:
}
# Behavioural parity with the audited production runtime contract.
expect /tmp/practice-app.yaml 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"'
expect /tmp/practice-app.yaml 'ANSWER_PROVIDER: "bedrock-converse"'
expect /tmp/practice-app.yaml 'EMBEDDING_PROVIDER: "cohere-v4"'
expect /tmp/practice-app.yaml 'EMBEDDING_DIMENSIONS: "1024"'
expect /tmp/practice-app.yaml 'EVIDENCE_MINIMUM_SCORE: "0.12"'
expect /tmp/practice-app.yaml 'RERANK_ENABLED: "true"'
expect /tmp/practice-app.yaml 'AWS_REGION: "us-east-1"'
expect /tmp/practice-app.yaml 'checksum/runtime-config:'
expect /tmp/practice-app.yaml '- host: "readytochat.realvuxbaro.me"'
expect /tmp/prod-app.yaml 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"'
expect /tmp/prod-app.yaml 'ANSWER_PROVIDER: "bedrock-converse"'
expect /tmp/prod-app.yaml 'EMBEDDING_PROVIDER: "cohere-v4"'
expect /tmp/prod-app.yaml 'EMBEDDING_DIMENSIONS: "1024"'
expect /tmp/prod-app.yaml 'EVIDENCE_MINIMUM_SCORE: "0.12"'
expect /tmp/prod-app.yaml 'RERANK_ENABLED: "true"'
expect /tmp/prod-app.yaml 'AWS_REGION: "us-east-1"'
expect /tmp/prod-app.yaml 'checksum/runtime-config:'
expect /tmp/prod-app.yaml '- host: "readytochat.realvuxbaro.me"'
# The production hostname now lives on this cluster, routed and with
# its own certificate secret -- kept separate from the rehearsal
# hostname's so one renewal failure cannot take both names offline.
expect /tmp/practice-app.yaml '- host: "realvuxbaro.me"'
expect /tmp/practice-app.yaml 'secretName: realvuxbaro-tls'
expect /tmp/practice-app.yaml 'secretName: readytochat-tls'
expect /tmp/prod-app.yaml '- host: "realvuxbaro.me"'
expect /tmp/prod-app.yaml 'secretName: realvuxbaro-tls'
expect /tmp/prod-app.yaml 'secretName: readytochat-tls'
# Grafana answers on that same public hostname. Anonymous access may
# be open, but never as Admin, never with the login form disabled,
# and its root URL must be the name users actually arrive on.
expect /tmp/practice-app.yaml 'value: "https://realvuxbaro.me/grafana/"'
refute /tmp/practice-app.yaml 'value: "Admin"'
expect /tmp/prod-app.yaml 'value: "https://realvuxbaro.me/grafana/"'
refute /tmp/prod-app.yaml 'value: "Admin"'
# grep is line-oriented, so read the value on the line after each
# flag rather than trying to match the pair as one pattern.
for check in "GF_AUTH_ANONYMOUS_ORG_ROLE:Viewer" "GF_AUTH_DISABLE_LOGIN_FORM:false"; do
flag=${check%%:*}
want=${check#*:}
got=$(grep -A1 -- "$flag" /tmp/practice-app.yaml | grep -- 'value:' | tr -d ' "' | cut -d: -f2)
got=$(grep -A1 -- "$flag" /tmp/prod-app.yaml | grep -- 'value:' | tr -d ' "' | cut -d: -f2)
if [ "$got" != "$want" ]; then
echo "::error::$flag rendered as '$got', expected '$want'"
exit 1
@@ -123,10 +115,10 @@ jobs:
# prune can never delete the corpus or the query history. Only those
# two use volumeClaimTemplates — the observability PVCs are the app
# release's own and are expected here.
refute /tmp/practice-app.yaml 'volumeClaimTemplates'
expect /tmp/practice-app.yaml 'medical-chatbot-data-medical-chatbot-qdrant'
refute /tmp/prod-app.yaml 'volumeClaimTemplates'
expect /tmp/prod-app.yaml 'medical-chatbot-data-medical-chatbot-qdrant'
# ...and the data release must own nothing else.
refute /tmp/practice-data.yaml 'medical-chatbot-data-medical-chatbot-ai-service'
refute /tmp/practice-data.yaml 'kind: Ingress'
expect /tmp/practice-data.yaml 'volumeClaimTemplates'
refute /tmp/prod-data.yaml 'medical-chatbot-data-medical-chatbot-ai-service'
refute /tmp/prod-data.yaml 'kind: Ingress'
expect /tmp/prod-data.yaml 'volumeClaimTemplates'