Fingerprint the Qdrant corpus by content, not by count
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
name: Audit production Qdrant corpus (read-only)
|
||||
|
||||
# Answers "is the practice corpus the same corpus production serves?" with
|
||||
# content hashes rather than a point count, which two different corpora can
|
||||
# share. The identical script runs against the k3s cluster over SSH, so the
|
||||
# two fingerprints are directly comparable.
|
||||
#
|
||||
# Read-only: it scrolls points and reads collection info. It changes nothing on
|
||||
# production, and its path is not in deploy.yml's filters, so merging it cannot
|
||||
# restart the Compose stack.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: audit-qdrant-corpus
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Ship the script rather than inlining it, so production and practice
|
||||
# provably run the same bytes instead of two copies that can drift.
|
||||
- name: Encode fingerprint script
|
||||
run: echo "SCRIPT_B64=$(base64 -w0 scripts/qdrant_fingerprint.py)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Fingerprint production corpus over SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
env:
|
||||
SCRIPT_B64: ${{ env.SCRIPT_B64 }}
|
||||
with:
|
||||
host: ${{ secrets.EC2_HOST }}
|
||||
username: ubuntu
|
||||
key: ${{ secrets.EC2_SSH_KEY }}
|
||||
command_timeout: 30m
|
||||
envs: SCRIPT_B64
|
||||
script: |
|
||||
set -eu
|
||||
cd ~/app/infra/docker
|
||||
ai_id=$(sudo docker compose -f docker-compose.prod.yml ps -q ai-service)
|
||||
test -n "$ai_id"
|
||||
|
||||
printf '%s\n' '=== qdrant_version ==='
|
||||
sudo docker compose -f docker-compose.prod.yml images qdrant
|
||||
|
||||
printf '%s\n' '=== corpus_fingerprint ==='
|
||||
printf '%s' "$SCRIPT_B64" | base64 -d | sudo docker exec -i "$ai_id" python -
|
||||
@@ -26,12 +26,30 @@ jobs:
|
||||
- name: Render default and production manifests
|
||||
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"
|
||||
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
|
||||
|
||||
# The practice cluster is only evidence for the production migration
|
||||
# while it renders the same behavioural contract as production, so both
|
||||
@@ -45,36 +63,45 @@ jobs:
|
||||
--values infra/helm/medical-chatbot/values-practice-data.yaml \
|
||||
> /tmp/practice-data.yaml
|
||||
|
||||
# A bare `grep -q` fails the step with no indication of which
|
||||
# assertion broke, and `set -e` ignores a status inverted with `!`,
|
||||
# so a `! grep -q` assertion can never fail at all. Both directions
|
||||
# go through helpers that name the pattern and exit explicitly.
|
||||
# `--` matters: a YAML list item pattern starts with `-`, which grep
|
||||
# would otherwise parse as an option bundle.
|
||||
expect() {
|
||||
if ! grep -q -- "$2" "$1"; then
|
||||
echo "::error::$1 is missing: $2"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
refute() {
|
||||
if grep -q -- "$2" "$1"; then
|
||||
echo "::error::$1 must not contain: $2"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Behavioural parity with the audited production runtime contract.
|
||||
grep -q 'ANSWER_MODEL_ID: "qwen.qwen3-next-80b-a3b"' /tmp/practice-app.yaml
|
||||
grep -q 'ANSWER_PROVIDER: "bedrock-converse"' /tmp/practice-app.yaml
|
||||
grep -q 'EMBEDDING_PROVIDER: "cohere-v4"' /tmp/practice-app.yaml
|
||||
grep -q 'EMBEDDING_DIMENSIONS: "1024"' /tmp/practice-app.yaml
|
||||
grep -q 'EVIDENCE_MINIMUM_SCORE: "0.12"' /tmp/practice-app.yaml
|
||||
grep -q 'RERANK_ENABLED: "true"' /tmp/practice-app.yaml
|
||||
grep -q 'AWS_REGION: "us-east-1"' /tmp/practice-app.yaml
|
||||
grep -q 'checksum/runtime-config:' /tmp/practice-app.yaml
|
||||
grep -q 'host: readytochat.realvuxbaro.me' /tmp/practice-app.yaml
|
||||
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"'
|
||||
|
||||
# The app release must own neither data StatefulSet: PostgreSQL and
|
||||
# Qdrant belong to the data release, so an app-side sync failure or
|
||||
# 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.
|
||||
#
|
||||
# `set -e` ignores a command whose status is inverted with `!`, so
|
||||
# every must-NOT-contain assertion is written as an explicit exit.
|
||||
refute() {
|
||||
if grep -q "$2" "$1"; then
|
||||
echo "::error::$1 must not contain: $2"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
refute /tmp/practice-app.yaml 'volumeClaimTemplates'
|
||||
grep -q 'medical-chatbot-data-medical-chatbot-qdrant' /tmp/practice-app.yaml
|
||||
expect /tmp/practice-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'
|
||||
grep -q 'volumeClaimTemplates' /tmp/practice-data.yaml
|
||||
expect /tmp/practice-data.yaml 'volumeClaimTemplates'
|
||||
|
||||
Reference in New Issue
Block a user