Add isolated UAT and production environments

This commit is contained in:
hz
2026-07-20 20:32:22 -05:00
parent 4e0ca70157
commit 17747d96a0
30 changed files with 780 additions and 282 deletions
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
: "${DEPLOYMENT_ENVIRONMENT:?DEPLOYMENT_ENVIRONMENT is required}"
: "${KUBE_NAMESPACE:?KUBE_NAMESPACE is required}"
: "${PUBLIC_API_ORIGIN:?PUBLIC_API_ORIGIN is required}"
: "${MONGODB_CONNECTION_STRING:?MONGODB_CONNECTION_STRING is required}"
: "${AUTH_JWT_KEY:?AUTH_JWT_KEY is required}"
: "${INTERNAL_PURGE_SECRET:?INTERNAL_PURGE_SECRET is required}"
: "${STRIPE_SECRET_KEY:?STRIPE_SECRET_KEY is required}"
: "${STRIPE_WEBHOOK_SECRET:?STRIPE_WEBHOOK_SECRET is required}"
NODES="${NODES:-192.168.86.72 192.168.86.73 192.168.86.74}"
BUILD_TAG="$(git rev-parse HEAD)"
API_IMAGE="step3-questions:${BUILD_TAG}"
WEB_IMAGE="step3-web:${BUILD_TAG}"
API_TAR="/tmp/step3-questions-${DEPLOYMENT_ENVIRONMENT}.tar"
WEB_TAR="/tmp/step3-web-${DEPLOYMENT_ENVIRONMENT}.tar"
cleanup() {
rm -f "${API_TAR}" "${WEB_TAR}"
docker image rm "${API_IMAGE}" "${WEB_IMAGE}" 2>/dev/null || true
docker image prune -f >/dev/null || true
}
trap cleanup EXIT
rm -f "${API_TAR}" "${WEB_TAR}"
docker container prune -f
docker image prune -f
docker image prune -af --filter "until=24h"
docker builder prune -af || true
docker system df
df -h
dotnet test api/QuestionsApi.Tests/QuestionsApi.Tests.csproj -c Release
docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -t "${API_IMAGE}" api/QuestionsApi
docker build \
--build-arg BUILD_REVISION="${BUILD_TAG}" \
--build-arg DEPLOYMENT_ENVIRONMENT="${DEPLOYMENT_ENVIRONMENT}" \
--build-arg PUBLIC_API_ORIGIN="${PUBLIC_API_ORIGIN}" \
-f web/Dockerfile -t "${WEB_IMAGE}" .
docker save "${API_IMAGE}" -o "${API_TAR}"
docker save "${WEB_IMAGE}" -o "${WEB_TAR}"
for node in ${NODES}; do
scp -o StrictHostKeyChecking=no "${API_TAR}" hz@"${node}":/tmp/step3-questions.tar
scp -o StrictHostKeyChecking=no "${WEB_TAR}" hz@"${node}":/tmp/step3-web.tar
ssh -o StrictHostKeyChecking=no hz@"${node}" \
"sudo ctr -n k8s.io images import /tmp/step3-questions.tar && sudo ctr -n k8s.io images import /tmp/step3-web.tar && rm -f /tmp/step3-questions.tar /tmp/step3-web.tar"
done
kubectl apply -f "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}/namespace.yaml"
kubectl create secret generic step3-runtime-secrets \
--from-literal=mongodb-connection-string="${MONGODB_CONNECTION_STRING}" \
--from-literal=jwt-key="${AUTH_JWT_KEY}" \
--from-literal=purge-secret="${INTERNAL_PURGE_SECRET}" \
--from-literal=stripe-secret-key="${STRIPE_SECRET_KEY}" \
--from-literal=stripe-webhook-secret="${STRIPE_WEBHOOK_SECRET}" \
--dry-run=client -o yaml | kubectl apply -n "${KUBE_NAMESPACE}" -f -
kubectl kustomize "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}" \
| sed "s|step3-questions:latest|${API_IMAGE}|g; s|step3-web:latest|${WEB_IMAGE}|g" \
| kubectl apply -f -
kubectl rollout status deployment/step3-api -n "${KUBE_NAMESPACE}" --timeout=240s
kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-api:http/proxy/readyz" >/dev/null
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-web:http/proxy/healthz" >/dev/null
echo "Deployed ${DEPLOYMENT_ENVIRONMENT} revision ${BUILD_TAG}."
+41
View File
@@ -0,0 +1,41 @@
name: Deploy STEP3 PRD
on:
push:
branches:
- production
workflow_dispatch: {}
concurrency:
group: step3-prd
cancel-in-progress: false
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure kubeconfig
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
run: |
if [ -n "$KUBECONFIG_CONTENT" ]; then
mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
echo "KUBECONFIG=/tmp/kube/config" >> "$GITHUB_ENV"
fi
- name: Deploy PRD
env:
DEPLOYMENT_ENVIRONMENT: prd
KUBE_NAMESPACE: step3-prd
PUBLIC_API_ORIGIN: https://s3q.ranaze.com
MONGODB_CONNECTION_STRING: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
AUTH_JWT_KEY: ${{ secrets.PRD_AUTH_JWT_KEY }}
INTERNAL_PURGE_SECRET: ${{ secrets.PRD_INTERNAL_PURGE_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.PRD_STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_WEBHOOK_SECRET }}
run: bash .gitea/scripts/deploy-environment.sh
-111
View File
@@ -1,111 +0,0 @@
name: Deploy STEP3 Questions API
on:
push:
branches:
- main
workflow_dispatch: {}
jobs:
deploy:
runs-on: self-hosted
env:
IMAGE_NAME: step3-questions:latest
IMAGE_TAR: /tmp/step3-questions.tar
NODES: "192.168.86.72 192.168.86.73 192.168.86.74"
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Build Docker image
run: |
cd api/QuestionsApi
docker build -t "${IMAGE_NAME}" .
- name: Save Docker image to TAR
run: |
docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}"
- name: Copy TAR to nodes
run: |
for node in ${NODES}; do
echo "Copying image tar to $node ..."
scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/step3-questions.tar
done
- name: Import image on nodes
run: |
for node in ${NODES}; do
echo "Importing image on $node ..."
ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/step3-questions.tar"
done
- name: Clean TAR from nodes
run: |
for node in ${NODES}; do
echo "Removing image tar on $node ..."
ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/step3-questions.tar"
done
- name: Clean TAR on runner
run: |
rm -f "${IMAGE_TAR}"
- name: Configure kubeconfig
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
run: |
if [ -n "$KUBECONFIG_CONTENT" ]; then
mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
echo "KUBECONFIG=/tmp/kube/config" >> "$GITHUB_ENV"
echo "Using kubeconfig from KUBECONFIG secret."
else
echo "KUBECONFIG secret is not set for this repo; using runner default kubeconfig."
fi
- name: Create namespace if missing
run: |
kubectl create namespace step3-questions --dry-run=client -o yaml | kubectl apply -f -
- name: Configure internal API secret
env:
INTERNAL_PURGE_SECRET: ${{ secrets.INTERNAL_PURGE_SECRET }}
run: |
test -n "$INTERNAL_PURGE_SECRET"
kubectl create secret generic medmind-internal-api \
--from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \
--dry-run=client -o yaml | kubectl apply -n step3-questions -f -
- name: Configure Stripe secrets
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
run: |
test -n "$STRIPE_SECRET_KEY"
test -n "$STRIPE_WEBHOOK_SECRET"
kubectl create secret generic medmind-stripe \
--from-literal=secret-key="$STRIPE_SECRET_KEY" \
--from-literal=webhook-secret="$STRIPE_WEBHOOK_SECRET" \
--dry-run=client -o yaml | kubectl apply -n step3-questions -f -
- name: Configure authentication secrets
env:
AUTH_JWT_KEY: ${{ secrets.AUTH_JWT_KEY }}
run: |
test -n "$AUTH_JWT_KEY"
kubectl create secret generic ranaze-auth \
--from-literal=jwt-key="$AUTH_JWT_KEY" \
--dry-run=client -o yaml | kubectl apply -n step3-questions -f -
- name: Apply Questions deployment & service
run: |
kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions
kubectl apply -f api/QuestionsApi/k8s/service.yaml -n step3-questions
- name: Restart Questions deployment
run: |
kubectl rollout restart deployment/step3-questions -n step3-questions
kubectl rollout status deployment/step3-questions -n step3-questions
+41
View File
@@ -0,0 +1,41 @@
name: Deploy STEP3 UAT
on:
push:
branches:
- main
workflow_dispatch: {}
concurrency:
group: step3-uat
cancel-in-progress: false
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure kubeconfig
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
run: |
if [ -n "$KUBECONFIG_CONTENT" ]; then
mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
echo "KUBECONFIG=/tmp/kube/config" >> "$GITHUB_ENV"
fi
- name: Deploy UAT
env:
DEPLOYMENT_ENVIRONMENT: uat
KUBE_NAMESPACE: step3-uat
PUBLIC_API_ORIGIN: https://us3q.ranaze.com
MONGODB_CONNECTION_STRING: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
AUTH_JWT_KEY: ${{ secrets.UAT_AUTH_JWT_KEY }}
INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.UAT_STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_WEBHOOK_SECRET }}
run: bash .gitea/scripts/deploy-environment.sh
-85
View File
@@ -1,85 +0,0 @@
name: Deploy STEP3 Web Game
on:
push:
branches:
- main
workflow_dispatch: {}
jobs:
deploy:
runs-on: self-hosted
env:
IMAGE_TAR: /tmp/step3-web.tar
NODES: "192.168.86.72 192.168.86.73 192.168.86.74"
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Set build identity
run: |
echo "BUILD_TAG=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Reclaim failed build layers
run: |
rm -f "${IMAGE_TAR}"
docker container prune -f
docker image prune -f
docker image prune -af --filter "until=24h"
docker builder prune -af || true
docker system df
df -h
- name: Build Web game image
run: |
docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -f web/Dockerfile -t "step3-web:${BUILD_TAG}" .
- name: Save image
run: |
docker save "step3-web:${BUILD_TAG}" -o "${IMAGE_TAR}"
- name: Import image on Kubernetes nodes
run: |
for node in ${NODES}; do
scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/step3-web.tar
ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/step3-web.tar && rm -f /tmp/step3-web.tar"
done
- name: Clean runner build artifacts
if: always()
run: |
rm -f "${IMAGE_TAR}"
if [ -n "${BUILD_TAG}" ]; then
docker image rm "step3-web:${BUILD_TAG}" 2>/dev/null || true
fi
docker image prune -f
- name: Configure kubeconfig
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
run: |
if [ -n "$KUBECONFIG_CONTENT" ]; then
mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
echo "KUBECONFIG=/tmp/kube/config" >> "$GITHUB_ENV"
fi
- name: Deploy Web game
run: |
for attempt in $(seq 1 30); do
if kubectl get --raw "/api/v1/namespaces/step3-questions/services/step3-questions:http/proxy/api/Auth/legal" >/dev/null; then
echo "Merged authentication API is ready."
break
fi
if [ "$attempt" -eq 30 ]; then
echo "Merged authentication API did not become ready; preserving the current web deployment."
exit 1
fi
sleep 10
done
kubectl create namespace step3-web --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f web/k8s/service.yaml -n step3-web
sed "s|step3-web:latest|step3-web:${BUILD_TAG}|" web/k8s/deployment.yaml | kubectl apply -n step3-web -f -
kubectl rollout status deployment/step3-web -n step3-web --timeout=180s
+120
View File
@@ -0,0 +1,120 @@
name: Promote UAT content to PRD
on:
workflow_dispatch:
inputs:
mode:
description: "dry-run or apply"
required: true
default: dry-run
confirmation:
description: "Enter PROMOTE_TO_PRD when mode is apply"
required: false
default: ""
concurrency:
group: step3-content-promotion
cancel-in-progress: false
jobs:
promote:
runs-on: self-hosted
steps:
- name: Checkout production configuration
uses: actions/checkout@v4
with:
ref: production
- name: Configure kubeconfig
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
run: |
if [ -n "$KUBECONFIG_CONTENT" ]; then
mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
echo "KUBECONFIG=/tmp/kube/config" >> "$GITHUB_ENV"
fi
- name: Validate promotion request
env:
MODE: ${{ inputs.mode }}
CONFIRMATION: ${{ inputs.confirmation }}
run: |
test "$MODE" = "dry-run" || test "$MODE" = "apply"
if [ "$MODE" = "apply" ]; then
test "$CONFIRMATION" = "PROMOTE_TO_PRD"
fi
- name: Configure promotion credentials
env:
SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
run: |
test -n "$SOURCE_CONNECTION"
test -n "$TARGET_CONNECTION"
kubectl create secret generic step3-content-promotion \
--from-literal=source-connection="$SOURCE_CONNECTION" \
--from-literal=target-connection="$TARGET_CONNECTION" \
--dry-run=client -o yaml | kubectl apply -n step3-prd -f -
- name: Run promotion
env:
MODE: ${{ inputs.mode }}
CONFIRMATION: ${{ inputs.confirmation }}
run: |
IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')"
REVISION="${IMAGE##*:}"
POD="content-promotion-${GITHUB_RUN_NUMBER}"
APPLY_ARGS=""
if [ "$MODE" = "apply" ]; then
APPLY_ARGS="--apply --confirm $CONFIRMATION"
fi
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: ${POD}
namespace: step3-prd
labels:
app: step3-content-promotion
spec:
restartPolicy: Never
containers:
- name: promotion
image: ${IMAGE}
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- >-
dotnet QuestionsApi.dll promote-content
--source-database ranaze_step3_uat
--target-database ranaze_step3_prd
--operator "${GITHUB_ACTOR:-gitea-operator}"
--build-revision "${REVISION}"
${APPLY_ARGS}
env:
- name: SOURCE_MONGODB_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: step3-content-promotion
key: source-connection
- name: TARGET_MONGODB_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: step3-content-promotion
key: target-connection
EOF
for attempt in $(seq 1 120); do
PHASE="$(kubectl get pod "$POD" -n step3-prd -o jsonpath='{.status.phase}')"
if [ "$PHASE" = "Succeeded" ] || [ "$PHASE" = "Failed" ]; then break; fi
sleep 2
done
kubectl logs "$POD" -n step3-prd
test "$(kubectl get pod "$POD" -n step3-prd -o jsonpath='{.status.phase}')" = "Succeeded"
- name: Remove promotion credentials and pod
if: always()
run: |
kubectl delete pod "content-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
kubectl delete secret step3-content-promotion -n step3-prd --ignore-not-found
+91
View File
@@ -0,0 +1,91 @@
# UAT and production operations
## Environment map
| Environment | Branch | Web | API | Namespace | Mongo database | NodePorts |
| --- | --- | --- | --- | --- | --- | --- |
| UAT | `main` | `https://ustep3.ranaze.com` | `https://us3q.ranaze.com` | `step3-uat` | `ranaze_step3_uat` | API `30088`, web `30089` |
| PRD | `production` | `https://step3.ranaze.com` | `https://s3q.ranaze.com` | `step3-prd` | `ranaze_step3_prd` | API `30090`, web `30091` |
`doctor.ranaze.com` should issue an HTTP redirect to `https://ustep3.ranaze.com`. Traefik and Cloudflare configuration are external to this repository.
`main` is the UAT integration branch. Protect `production`, disable direct pushes, and promote releases through a reviewed pull request from `main`. Both deployment workflows build and deploy the exact branch commit with immutable SHA image tags.
## Required Gitea secrets
The existing `KUBECONFIG` secret is shared. Configure these environment-specific secrets before enabling the new workflows:
- `UAT_MONGODB_CONNECTION_STRING` and `PRD_MONGODB_CONNECTION_STRING`
- `UAT_AUTH_JWT_KEY` and `PRD_AUTH_JWT_KEY` (different values)
- `UAT_INTERNAL_PURGE_SECRET` and `PRD_INTERNAL_PURGE_SECRET`
- `UAT_STRIPE_SECRET_KEY` and `UAT_STRIPE_WEBHOOK_SECRET` (Stripe sandbox)
- `PRD_STRIPE_SECRET_KEY` and `PRD_STRIPE_WEBHOOK_SECRET` (Stripe live mode)
Use independent Stripe webhook endpoints for `https://us3q.ranaze.com/api/donations/stripe-webhook` and `https://s3q.ranaze.com/api/donations/stripe-webhook`. No Stripe publishable key is required for hosted Checkout.
## Initial UAT database migration
Schedule a short maintenance window so writes cannot occur between the dump and cutover.
1. Route the existing app to a maintenance response or scale the legacy API to zero.
2. Back up the current database:
```bash
mongodump --uri="$UAT_MONGODB_CONNECTION_STRING" --db=promiscuity --archive=promiscuity.archive --gzip
```
3. Restore it under the explicit UAT name:
```bash
mongorestore --uri="$UAT_MONGODB_CONNECTION_STRING" --archive=promiscuity.archive --gzip --nsFrom='promiscuity.*' --nsTo='ranaze_step3_uat.*'
```
4. Compare collection/document counts between `promiscuity` and `ranaze_step3_uat` and retain the archive until acceptance testing is complete.
5. The legacy services currently reserve NodePorts `30088` and `30089`. Immediately before the first UAT deployment, release them:
```bash
kubectl delete service step3-questions -n step3-questions
kubectl delete service step3-web -n step3-web
```
6. Deploy `main`, route `ustep3` and `us3q` to the UAT NodePorts, verify login and study activity, then redirect `doctor` to UAT.
7. After acceptance, remove the superseded `step3-questions` and `step3-web` namespaces. Do not remove them before retaining any logs needed for migration review.
The hostname change creates a new browser origin, so testers should expect to sign in again. Their accounts and activity remain in UAT.
## Initial PRD launch
1. Create and protect the `production` branch, then merge the tested `main` revision.
2. Route `step3.ranaze.com` and `s3q.ranaze.com` to PRD NodePorts.
3. Run **Promote UAT content to PRD** in `dry-run` mode and inspect the counts and hash.
4. Run it again in `apply` mode with confirmation `PROMOTE_TO_PRD`.
5. Verify `ranaze_step3_prd` contains published content and has no user/activity documents.
6. Register the initial PRD account normally, then promote it manually:
```javascript
use ranaze_step3_prd
db.Users.updateOne({Username: "SUPER"}, {$set: {Role: "SUPER"}})
```
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
## Content promotion behavior
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only published questions, referenced media, current published CCS revisions, and the CCS order catalog. It never copies users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Every applied promotion writes a `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
## Verification and rollback
Check both internal and public readiness after every rollout:
```bash
kubectl get pods -n step3-uat
kubectl get pods -n step3-prd
kubectl get --raw /api/v1/namespaces/step3-uat/services/step3-api:http/proxy/readyz
kubectl get --raw /api/v1/namespaces/step3-prd/services/step3-api:http/proxy/readyz
curl -fsS https://us3q.ranaze.com/api/runtime
curl -fsS https://s3q.ranaze.com/api/runtime
```
Rollback application code by setting each deployment back to a previously imported SHA-tagged image. Database promotion is non-destructive; restore the pre-cutover archive if the initial UAT migration itself must be reversed.
+2
View File
@@ -1,5 +1,7 @@
# RanaZe: Step 3 Trainer
Environment deployment, database migration, and UAT-to-PRD promotion procedures are documented in [ENVIRONMENTS.md](ENVIRONMENTS.md).
Godot-based USMLE Step 3 study trainer with a bundled ASP.NET Core Questions API.
## Local API
+3 -35
View File
@@ -1,37 +1,5 @@
# Browser Pre-Alpha Deployment
# Browser deployment
The browser build is served at `https://doctor.ranaze.com`. Gitea builds and
deploys the Web image to the `step3-web` Kubernetes namespace on every push to
`main`. The service is exposed on NodePort `30089`.
Browser, API, DNS, Traefik, database, secret, promotion, and rollback procedures are maintained in [ENVIRONMENTS.md](ENVIRONMENTS.md).
## Cloudflare
Create a proxied `doctor` DNS record that targets the same public endpoint as
the existing `s3q` and `pauth` records. Keep SSL/TLS mode consistent with those
working services and redirect HTTP to HTTPS.
## Traefik
Create one `websecure` router with a host rule for `doctor.ranaze.com` and route
it to the Kubernetes nodes on port `30089`, the Web game NodePort. Reuse the existing
certificate resolver, load-balancing node list, and HTTP-to-HTTPS middleware.
The Web container proxies `/api/Auth/*` to `pauth.ranaze.com` and all other
`/api/*` requests to `s3q.ranaze.com`, so Traefik does not need separate API
routers or prefix middleware for this hostname.
## Verification
After DNS and Traefik are configured:
```bash
curl -fsS https://doctor.ranaze.com/healthz
curl -i -X OPTIONS https://doctor.ranaze.com/api/Auth/login
curl -i -X OPTIONS https://doctor.ranaze.com/api/questions/search
```
The health endpoint must return `ok`. Open the site in a desktop browser and
verify registration or login, question loading, images, answer submission,
ratings, and question reporting. Browser requests must remain on
`doctor.ranaze.com`; requests to `pauth.ranaze.com` or `s3q.ranaze.com` indicate
that the Web origin resolver did not run.
The browser container proxies `/api/*` to the `step3-api` Kubernetes service in its own namespace. Traefik therefore routes the web and public API hostnames to their respective NodePorts without path rewriting.
@@ -0,0 +1,34 @@
using MongoDB.Bson;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class ContentPromotionTests
{
[Fact]
public void CollectMediaIdsDeduplicatesValidObjectIds()
{
var id = ObjectId.GenerateNewId();
var questions = new[]
{
new BsonDocument("Media", new BsonArray { id.ToString(), "not-an-object-id" }),
new BsonDocument("Media", new BsonArray { id.ToString() })
};
var result = ContentPromotionCommand.CollectMediaIds(questions);
Assert.Equal(new[] { id }, result);
}
[Fact]
public void ContentHashDoesNotDependOnInputOrder()
{
var first = new BsonDocument { ["ExternalId"] = "a", ["Stem"] = "one" };
var second = new BsonDocument { ["ExternalId"] = "b", ["Stem"] = "two" };
Assert.Equal(
ContentPromotionCommand.HashDocuments([first, second]),
ContentPromotionCommand.HashDocuments([second, first]));
}
}
+7 -2
View File
@@ -1,18 +1,23 @@
ARG BUILD_REVISION=development
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_REVISION
WORKDIR /src
COPY ["QuestionsApi.csproj", "./"]
RUN dotnet restore "QuestionsApi.csproj"
COPY . .
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
ARG BUILD_REVISION
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_ENVIRONMENT=Production \
Deployment__BuildRevision=${BUILD_REVISION}
EXPOSE 5008
+11
View File
@@ -10,6 +10,12 @@ using System.Security.Claims;
using System.Text;
using System.Threading.RateLimiting;
if (args.FirstOrDefault() == "promote-content")
{
Environment.ExitCode = await ContentPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -209,6 +215,11 @@ app.MapGet("/readyz", async (MongoDatabaseProvider mongo) =>
await mongo.Database.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
return Results.Ok("ready");
});
app.MapGet("/api/runtime", (IConfiguration configuration) => Results.Ok(new
{
environment = configuration["Deployment:Environment"] ?? "development",
buildRevision = configuration["Deployment:BuildRevision"] ?? "development"
}));
app.MapGet("/metrics", (ServiceMetrics metrics) => Results.Text(metrics.RenderPrometheus(), "text/plain; version=0.0.4"));
app.UseSwagger();
app.UseSwaggerUI(o =>
@@ -0,0 +1,198 @@
using MongoDB.Bson;
using MongoDB.Driver;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class ContentPromotionCommand
{
public const string Confirmation = "PROMOTE_TO_PRD";
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
try
{
var options = Parse(args);
if (options.Apply && options.Confirmation != Confirmation)
throw new ArgumentException($"--confirm {Confirmation} is required with --apply");
if (options.SourceDatabase.Equals(options.TargetDatabase, StringComparison.OrdinalIgnoreCase)
&& options.SourceConnection == options.TargetConnection)
throw new ArgumentException("source and target databases must be different");
var source = new MongoClient(options.SourceConnection).GetDatabase(options.SourceDatabase);
var target = new MongoClient(options.TargetConnection).GetDatabase(options.TargetDatabase);
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var questions = await source.GetCollection<BsonDocument>("Questions")
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
: await source.GetCollection<BsonDocument>("QuestionMediaAssets")
.Find(Builders<BsonDocument>.Filter.In("_id", mediaIds)).ToListAsync();
if (media.Count != mediaIds.Count)
throw new InvalidOperationException($"{mediaIds.Count - media.Count} referenced question media assets are missing");
var cases = await source.GetCollection<BsonDocument>("CcsCaseRevisions")
.Find(Builders<BsonDocument>.Filter.Eq("IsCurrent", true)
& Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var orders = await source.GetCollection<BsonDocument>("CcsOrderCatalog")
.Find(FilterDefinition<BsonDocument>.Empty).ToListAsync();
var summary = new PromotionSummary(
questions.Count,
media.Count,
cases.Count,
orders.Count,
HashDocuments(questions.Concat(media).Concat(cases).Concat(orders)));
await output.WriteLineAsync(JsonSerializer.Serialize(new
{
mode = options.Apply ? "apply" : "dry-run",
source = options.SourceDatabase,
target = options.TargetDatabase,
summary.Questions,
summary.MediaAssets,
summary.CcsCases,
summary.CcsOrders,
summary.ContentHash
}));
if (!options.Apply) return 0;
await UpsertMediaAsync(target, media);
await UpsertByIdAsync(target, "CcsOrderCatalog", orders);
await UpsertByFieldsAsync(target, "Questions", questions, ["ExternalId"]);
await UpsertCasesAsync(target, cases);
await target.GetCollection<BsonDocument>("ContentPromotions").InsertOneAsync(new BsonDocument
{
["PromotionId"] = Guid.NewGuid().ToString("N"),
["AppliedUtc"] = DateTime.UtcNow,
["Operator"] = options.Operator,
["BuildRevision"] = options.BuildRevision,
["SourceDatabase"] = options.SourceDatabase,
["TargetDatabase"] = options.TargetDatabase,
["Questions"] = summary.Questions,
["MediaAssets"] = summary.MediaAssets,
["CcsCases"] = summary.CcsCases,
["CcsOrders"] = summary.CcsOrders,
["ContentHash"] = summary.ContentHash
});
await output.WriteLineAsync("Content promotion completed.");
return 0;
}
catch (Exception exception)
{
await output.WriteLineAsync($"Content promotion failed: {exception.Message}");
return 1;
}
}
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
{
var ids = new HashSet<ObjectId>();
foreach (var question in questions)
{
if (!question.TryGetValue("Media", out var raw) || !raw.IsBsonArray) continue;
foreach (var value in raw.AsBsonArray)
if (value.IsString && ObjectId.TryParse(value.AsString, out var id)) ids.Add(id);
}
return ids;
}
internal static string HashDocuments(IEnumerable<BsonDocument> documents)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var document in documents.OrderBy(DocumentIdentity, StringComparer.Ordinal))
hash.AppendData(Encoding.UTF8.GetBytes(document.ToJson()));
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static string DocumentIdentity(BsonDocument document) =>
document.GetValue("ExternalId", document.GetValue("_id", BsonNull.Value)).ToString() ?? "";
private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable<BsonDocument> documents) =>
await UpsertByIdAsync(target, "QuestionMediaAssets", documents);
private static async Task UpsertByIdAsync(IMongoDatabase target, string collectionName, IEnumerable<BsonDocument> documents)
{
var collection = target.GetCollection<BsonDocument>(collectionName);
foreach (var document in documents)
await collection.ReplaceOneAsync(Builders<BsonDocument>.Filter.Eq("_id", document["_id"]), document,
new ReplaceOptions { IsUpsert = true });
}
private static async Task UpsertByFieldsAsync(
IMongoDatabase target,
string collectionName,
IEnumerable<BsonDocument> documents,
IReadOnlyList<string> keys)
{
var collection = target.GetCollection<BsonDocument>(collectionName);
foreach (var source in documents)
{
var filter = keys.Aggregate(
Builders<BsonDocument>.Filter.Empty,
(current, key) => current & Builders<BsonDocument>.Filter.Eq(key, source[key]));
var values = new BsonDocument(source.Where(element => element.Name != "_id"));
await collection.UpdateOneAsync(filter, new BsonDocument("$set", values), new UpdateOptions { IsUpsert = true });
}
}
private static async Task UpsertCasesAsync(IMongoDatabase target, IEnumerable<BsonDocument> cases)
{
var collection = target.GetCollection<BsonDocument>("CcsCaseRevisions");
foreach (var source in cases)
{
await collection.UpdateManyAsync(
Builders<BsonDocument>.Filter.Eq("ExternalId", source["ExternalId"]),
Builders<BsonDocument>.Update.Set("IsCurrent", false));
await UpsertByFieldsAsync(target, "CcsCaseRevisions", [source], ["ExternalId", "Version"]);
}
}
private static PromotionOptions Parse(IReadOnlyList<string> args)
{
string Required(string name, string environmentName)
{
var index = args.IndexOf($"--{name}");
var value = index >= 0 && index + 1 < args.Count ? args[index + 1] : Environment.GetEnvironmentVariable(environmentName);
return string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"--{name} is required") : value;
}
string Optional(string name, string fallback)
{
var index = args.IndexOf($"--{name}");
return index >= 0 && index + 1 < args.Count ? args[index + 1] : fallback;
}
return new PromotionOptions(
Required("source-connection", "SOURCE_MONGODB_CONNECTION_STRING"),
Required("source-database", "SOURCE_MONGODB_DATABASE"),
Required("target-connection", "TARGET_MONGODB_CONNECTION_STRING"),
Required("target-database", "TARGET_MONGODB_DATABASE"),
args.Contains("--apply"),
Optional("confirm", ""),
Optional("operator", "unknown"),
Optional("build-revision", "unknown"));
}
private sealed record PromotionOptions(
string SourceConnection,
string SourceDatabase,
string TargetConnection,
string TargetDatabase,
bool Apply,
string Confirmation,
string Operator,
string BuildRevision);
internal sealed record PromotionSummary(int Questions, int MediaAssets, int CcsCases, int CcsOrders, string ContentHash);
}
internal static class ArgumentListExtensions
{
public static int IndexOf(this IReadOnlyList<string> values, string value)
{
for (var index = 0; index < values.Count; index++)
if (values[index] == value) return index;
return -1;
}
}
@@ -1,9 +1,7 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: step3-questions
labels:
app: step3-questions
name: step3-api
spec:
replicas: 2
strategy:
@@ -13,63 +11,51 @@ spec:
maxSurge: 1
selector:
matchLabels:
app: step3-questions
app: step3-api
template:
metadata:
labels:
app: step3-questions
app: step3-api
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
prometheus.io/path: /metrics
prometheus.io/port: "5008"
spec:
containers:
- name: step3-questions
- name: api
image: step3-questions:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5008
envFrom:
- configMapRef:
name: step3-runtime-config
env:
- name: MongoDB__ConnectionString
valueFrom:
secretKeyRef:
name: step3-runtime-secrets
key: mongodb-connection-string
- name: Jwt__Key
valueFrom:
secretKeyRef:
name: ranaze-auth
name: step3-runtime-secrets
key: jwt-key
- name: Jwt__Issuer
value: promiscuity
- name: Jwt__Audience
value: promiscuity-auth-api
- name: PasswordReset__Enabled
value: "false"
- name: Steam__Realm
value: https://s3q.ranaze.com
- name: Steam__ReturnUrl
value: https://s3q.ranaze.com/api/Auth/steam/callback
- name: Legal__TermsVersion
value: "2026-07-20"
- name: Legal__PrivacyVersion
value: "2026-07-20"
- name: Legal__EffectiveUtc
value: "2026-07-20T00:00:00Z"
- name: Legal__TermsUrl
value: https://doctor.ranaze.com/terms.html
- name: Legal__PrivacyUrl
value: https://doctor.ranaze.com/privacy.html
- name: InternalPurge__Secret
valueFrom:
secretKeyRef:
name: medmind-internal-api
name: step3-runtime-secrets
key: purge-secret
- name: Stripe__SecretKey
valueFrom:
secretKeyRef:
name: medmind-stripe
key: secret-key
name: step3-runtime-secrets
key: stripe-secret-key
- name: Stripe__WebhookSecret
valueFrom:
secretKeyRef:
name: medmind-stripe
key: webhook-secret
name: step3-runtime-secrets
key: stripe-webhook-secret
resources:
requests:
cpu: 250m
@@ -93,3 +79,4 @@ spec:
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
@@ -1,12 +1,12 @@
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: step3-questions
name: step3-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: step3-questions
name: step3-api
minReplicas: 2
maxReplicas: 10
behavior:
@@ -33,3 +33,4 @@ spec:
target:
type: Utilization
averageUtilization: 60
@@ -1,9 +1,10 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: step3-questions
name: step3-api
spec:
minAvailable: 1
selector:
matchLabels:
app: step3-questions
app: step3-api
@@ -1,15 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: step3-questions
labels:
app: step3-questions
name: step3-api
spec:
selector:
app: step3-questions
app: step3-api
type: NodePort
ports:
- name: http
port: 80
targetPort: 5008
nodePort: 30088
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- api-deployment.yaml
- api-service.yaml
- api-hpa.yaml
- api-pdb.yaml
- web-deployment.yaml
- web-service.yaml
@@ -2,8 +2,6 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: step3-web
labels:
app: step3-web
spec:
replicas: 2
strategy:
@@ -46,3 +44,4 @@ spec:
initialDelaySeconds: 10
periodSeconds: 20
timeoutSeconds: 2
@@ -2,8 +2,6 @@ apiVersion: v1
kind: Service
metadata:
name: step3-web
labels:
app: step3-web
spec:
selector:
app: step3-web
@@ -13,3 +11,4 @@ spec:
port: 80
targetPort: 8080
nodePort: 30089
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: step3-api
spec:
ports:
- name: http
port: 80
targetPort: 5008
nodePort: 30090
+31
View File
@@ -0,0 +1,31 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: step3-prd
resources:
- ../../base
- namespace.yaml
patches:
- path: api-service-port.yaml
- path: web-service-port.yaml
commonLabels:
app.kubernetes.io/part-of: ranaze-step3
app.kubernetes.io/environment: production
configMapGenerator:
- name: step3-runtime-config
literals:
- Deployment__Environment=production
- MongoDB__DatabaseName=ranaze_step3_prd
- Jwt__Issuer=ranaze-step3-prd
- Jwt__Audience=ranaze-step3-prd
- PasswordReset__Enabled=false
- Steam__Realm=https://s3q.ranaze.com
- Steam__ReturnUrl=https://s3q.ranaze.com/api/Auth/steam/callback
- Legal__TermsVersion=2026-07-20
- Legal__PrivacyVersion=2026-07-20
- Legal__EffectiveUtc=2026-07-20T00:00:00Z
- Legal__TermsUrl=https://step3.ranaze.com/terms.html
- Legal__PrivacyUrl=https://step3.ranaze.com/privacy.html
- Stripe__SuccessUrl=https://step3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
- Stripe__CancelUrl=https://step3.ranaze.com/donation-canceled.html
generatorOptions:
disableNameSuffixHash: true
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v1
kind: Namespace
metadata:
name: step3-prd
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: step3-web
spec:
ports:
- name: http
port: 80
targetPort: 8080
nodePort: 30091
+29
View File
@@ -0,0 +1,29 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: step3-uat
resources:
- ../../base
- namespace.yaml
commonLabels:
app.kubernetes.io/part-of: ranaze-step3
app.kubernetes.io/environment: uat
configMapGenerator:
- name: step3-runtime-config
literals:
- Deployment__Environment=uat
- MongoDB__DatabaseName=ranaze_step3_uat
- Jwt__Issuer=ranaze-step3-uat
- Jwt__Audience=ranaze-step3-uat
- PasswordReset__Enabled=false
- Steam__Realm=https://us3q.ranaze.com
- Steam__ReturnUrl=https://us3q.ranaze.com/api/Auth/steam/callback
- Legal__TermsVersion=2026-07-20
- Legal__PrivacyVersion=2026-07-20
- Legal__EffectiveUtc=2026-07-20T00:00:00Z
- Legal__TermsUrl=https://ustep3.ranaze.com/terms.html
- Legal__PrivacyUrl=https://ustep3.ranaze.com/privacy.html
- Stripe__SuccessUrl=https://ustep3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
- Stripe__CancelUrl=https://ustep3.ranaze.com/donation-canceled.html
generatorOptions:
disableNameSuffixHash: true
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v1
kind: Namespace
metadata:
name: step3-uat
+1
View File
@@ -58,6 +58,7 @@ textures/vram_compression/import_etc2_astc=true
[step3]
deployment_environment="development"
questions_api_url="https://s3q.ranaze.com/api/questions/search"
question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
+22
View File
@@ -7,6 +7,7 @@ const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
var screens := {}
var header_panel: PanelContainer
var header_title: Label
var environment_badge: Label
var app_menu: PanelContainer
var menu_exit_button: Button
var sound_toggle: CheckButton
@@ -149,6 +150,16 @@ func _build_ui() -> void:
header_title.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode()))
header.add_child(header_title)
environment_badge = Label.new()
environment_badge.text = "UAT"
environment_badge.tooltip_text = "User acceptance testing environment"
environment_badge.visible = str(ProjectSettings.get_setting("step3/deployment_environment", "development")) == "uat"
environment_badge.add_theme_font_override("font", Brand.font)
environment_badge.add_theme_font_size_override("font_size", 13)
environment_badge.add_theme_color_override("font_color", Color("052e16"))
environment_badge.add_theme_stylebox_override("normal", _environment_badge_box())
header.add_child(environment_badge)
var header_spacer := Control.new()
header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(header_spacer)
@@ -609,6 +620,17 @@ func _top_bar_box() -> StyleBoxFlat:
return box
func _environment_badge_box() -> StyleBoxFlat:
var box := StyleBoxFlat.new()
box.bg_color = Color("86efac")
box.set_corner_radius_all(6)
box.content_margin_left = 9
box.content_margin_right = 9
box.content_margin_top = 4
box.content_margin_bottom = 4
return box
func _style_header_feedback_button(button: Button) -> void:
var dark := UIThemeService.is_dark_mode()
var normal := Style.button_box(Color(0, 0, 0, 0), Brand.ROYAL_BLUE)
+4
View File
@@ -4,6 +4,8 @@ ARG GODOT_VERSION=4.6.2
ARG GODOT_RELEASE=${GODOT_VERSION}-stable
ARG GODOT_TEMPLATE_DIR=${GODOT_VERSION}.stable
ARG BUILD_REVISION=development
ARG DEPLOYMENT_ENVIRONMENT=production
ARG PUBLIC_API_ORIGIN=https://s3q.ranaze.com
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -30,6 +32,8 @@ WORKDIR /src
COPY . .
RUN mkdir -p /build \
&& sed -i "s|build_revision=\"development\"|build_revision=\"${BUILD_REVISION}\"|" project.godot \
&& sed -i "s|deployment_environment=\"development\"|deployment_environment=\"${DEPLOYMENT_ENVIRONMENT}\"|" project.godot \
&& sed -i "s|https://s3q.ranaze.com|${PUBLIC_API_ORIGIN}|g" project.godot \
&& sed -i "s|custom_template/release=\"\"|custom_template/release=\"/root/.local/share/godot/export_templates/${GODOT_TEMPLATE_DIR}/web_nothreads_release.zip\"|" export_presets.cfg \
&& godot --headless --editor --path /src --quit \
&& godot --verbose --headless --path /src --export-release Web /build/index.html \
+2 -4
View File
@@ -14,10 +14,8 @@ server {
}
location ^~ /api/ {
proxy_pass https://s3q.ranaze.com;
proxy_ssl_server_name on;
proxy_ssl_name s3q.ranaze.com;
proxy_set_header Host s3q.ranaze.com;
proxy_pass http://step3-api;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 10s;