diff --git a/.gitea/scripts/deploy-environment.sh b/.gitea/scripts/deploy-environment.sh new file mode 100644 index 0000000..5983ae5 --- /dev/null +++ b/.gitea/scripts/deploy-environment.sh @@ -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}." + diff --git a/.gitea/workflows/deploy-prd.yml b/.gitea/workflows/deploy-prd.yml new file mode 100644 index 0000000..b3e744e --- /dev/null +++ b/.gitea/workflows/deploy-prd.yml @@ -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 + diff --git a/.gitea/workflows/deploy-questions.yml b/.gitea/workflows/deploy-questions.yml deleted file mode 100644 index 833fdc7..0000000 --- a/.gitea/workflows/deploy-questions.yml +++ /dev/null @@ -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 diff --git a/.gitea/workflows/deploy-uat.yml b/.gitea/workflows/deploy-uat.yml new file mode 100644 index 0000000..47e4548 --- /dev/null +++ b/.gitea/workflows/deploy-uat.yml @@ -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 + diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml deleted file mode 100644 index f7b46cd..0000000 --- a/.gitea/workflows/deploy-web.yml +++ /dev/null @@ -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 diff --git a/.gitea/workflows/promote-content.yml b/.gitea/workflows/promote-content.yml new file mode 100644 index 0000000..5880b6a --- /dev/null +++ b/.gitea/workflows/promote-content.yml @@ -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 <- + 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 + diff --git a/ENVIRONMENTS.md b/ENVIRONMENTS.md new file mode 100644 index 0000000..f87eb28 --- /dev/null +++ b/ENVIRONMENTS.md @@ -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. diff --git a/README.md b/README.md index 96afcb0..e1c1673 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/WEB_DEPLOYMENT.md b/WEB_DEPLOYMENT.md index e842f9c..6480fbd 100644 --- a/WEB_DEPLOYMENT.md +++ b/WEB_DEPLOYMENT.md @@ -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. diff --git a/api/QuestionsApi.Tests/ContentPromotionTests.cs b/api/QuestionsApi.Tests/ContentPromotionTests.cs new file mode 100644 index 0000000..476991a --- /dev/null +++ b/api/QuestionsApi.Tests/ContentPromotionTests.cs @@ -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])); + } +} diff --git a/api/QuestionsApi/Dockerfile b/api/QuestionsApi/Dockerfile index 7e4a86e..91831aa 100644 --- a/api/QuestionsApi/Dockerfile +++ b/api/QuestionsApi/Dockerfile @@ -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 diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 29bc6b2..a584fbb 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -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(options => @@ -209,6 +215,11 @@ app.MapGet("/readyz", async (MongoDatabaseProvider mongo) => await mongo.Database.RunCommandAsync(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 => diff --git a/api/QuestionsApi/Services/ContentPromotionCommand.cs b/api/QuestionsApi/Services/ContentPromotionCommand.cs new file mode 100644 index 0000000..a1f49e6 --- /dev/null +++ b/api/QuestionsApi/Services/ContentPromotionCommand.cs @@ -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 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(new BsonDocument("ping", 1)); + await target.RunCommandAsync(new BsonDocument("ping", 1)); + + var questions = await source.GetCollection("Questions") + .Find(Builders.Filter.Eq("IsPublished", true)).ToListAsync(); + var mediaIds = CollectMediaIds(questions); + var media = mediaIds.Count == 0 + ? [] + : await source.GetCollection("QuestionMediaAssets") + .Find(Builders.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("CcsCaseRevisions") + .Find(Builders.Filter.Eq("IsCurrent", true) + & Builders.Filter.Eq("IsPublished", true)).ToListAsync(); + var orders = await source.GetCollection("CcsOrderCatalog") + .Find(FilterDefinition.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("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 CollectMediaIds(IEnumerable questions) + { + var ids = new HashSet(); + 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 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 documents) => + await UpsertByIdAsync(target, "QuestionMediaAssets", documents); + + private static async Task UpsertByIdAsync(IMongoDatabase target, string collectionName, IEnumerable documents) + { + var collection = target.GetCollection(collectionName); + foreach (var document in documents) + await collection.ReplaceOneAsync(Builders.Filter.Eq("_id", document["_id"]), document, + new ReplaceOptions { IsUpsert = true }); + } + + private static async Task UpsertByFieldsAsync( + IMongoDatabase target, + string collectionName, + IEnumerable documents, + IReadOnlyList keys) + { + var collection = target.GetCollection(collectionName); + foreach (var source in documents) + { + var filter = keys.Aggregate( + Builders.Filter.Empty, + (current, key) => current & Builders.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 cases) + { + var collection = target.GetCollection("CcsCaseRevisions"); + foreach (var source in cases) + { + await collection.UpdateManyAsync( + Builders.Filter.Eq("ExternalId", source["ExternalId"]), + Builders.Update.Set("IsCurrent", false)); + await UpsertByFieldsAsync(target, "CcsCaseRevisions", [source], ["ExternalId", "Version"]); + } + } + + private static PromotionOptions Parse(IReadOnlyList 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 values, string value) + { + for (var index = 0; index < values.Count; index++) + if (values[index] == value) return index; + return -1; + } +} diff --git a/api/QuestionsApi/k8s/deployment.yaml b/k8s/base/api-deployment.yaml similarity index 55% rename from api/QuestionsApi/k8s/deployment.yaml rename to k8s/base/api-deployment.yaml index d03a434..da6d278 100644 --- a/api/QuestionsApi/k8s/deployment.yaml +++ b/k8s/base/api-deployment.yaml @@ -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 + diff --git a/api/QuestionsApi/k8s/hpa.yaml b/k8s/base/api-hpa.yaml similarity index 93% rename from api/QuestionsApi/k8s/hpa.yaml rename to k8s/base/api-hpa.yaml index 01fb6c8..97518a2 100644 --- a/api/QuestionsApi/k8s/hpa.yaml +++ b/k8s/base/api-hpa.yaml @@ -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 + diff --git a/api/QuestionsApi/k8s/pdb.yaml b/k8s/base/api-pdb.yaml similarity index 68% rename from api/QuestionsApi/k8s/pdb.yaml rename to k8s/base/api-pdb.yaml index 98aa095..c7386e4 100644 --- a/api/QuestionsApi/k8s/pdb.yaml +++ b/k8s/base/api-pdb.yaml @@ -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 + diff --git a/api/QuestionsApi/k8s/service.yaml b/k8s/base/api-service.yaml similarity index 65% rename from api/QuestionsApi/k8s/service.yaml rename to k8s/base/api-service.yaml index 6e0683b..32430d0 100644 --- a/api/QuestionsApi/k8s/service.yaml +++ b/k8s/base/api-service.yaml @@ -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 + diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..d84f87e --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -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 diff --git a/web/k8s/deployment.yaml b/k8s/base/web-deployment.yaml similarity index 97% rename from web/k8s/deployment.yaml rename to k8s/base/web-deployment.yaml index 1808efe..267e681 100644 --- a/web/k8s/deployment.yaml +++ b/k8s/base/web-deployment.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 + diff --git a/web/k8s/service.yaml b/k8s/base/web-service.yaml similarity index 87% rename from web/k8s/service.yaml rename to k8s/base/web-service.yaml index 5089d0a..dd8c79a 100644 --- a/web/k8s/service.yaml +++ b/k8s/base/web-service.yaml @@ -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 + diff --git a/k8s/overlays/prd/api-service-port.yaml b/k8s/overlays/prd/api-service-port.yaml new file mode 100644 index 0000000..f138a33 --- /dev/null +++ b/k8s/overlays/prd/api-service-port.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: step3-api +spec: + ports: + - name: http + port: 80 + targetPort: 5008 + nodePort: 30090 + diff --git a/k8s/overlays/prd/kustomization.yaml b/k8s/overlays/prd/kustomization.yaml new file mode 100644 index 0000000..ee7fe30 --- /dev/null +++ b/k8s/overlays/prd/kustomization.yaml @@ -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 diff --git a/k8s/overlays/prd/namespace.yaml b/k8s/overlays/prd/namespace.yaml new file mode 100644 index 0000000..4390e0c --- /dev/null +++ b/k8s/overlays/prd/namespace.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: step3-prd + diff --git a/k8s/overlays/prd/web-service-port.yaml b/k8s/overlays/prd/web-service-port.yaml new file mode 100644 index 0000000..4a0bf3a --- /dev/null +++ b/k8s/overlays/prd/web-service-port.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: step3-web +spec: + ports: + - name: http + port: 80 + targetPort: 8080 + nodePort: 30091 + diff --git a/k8s/overlays/uat/kustomization.yaml b/k8s/overlays/uat/kustomization.yaml new file mode 100644 index 0000000..1161c56 --- /dev/null +++ b/k8s/overlays/uat/kustomization.yaml @@ -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 + diff --git a/k8s/overlays/uat/namespace.yaml b/k8s/overlays/uat/namespace.yaml new file mode 100644 index 0000000..1def825 --- /dev/null +++ b/k8s/overlays/uat/namespace.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: step3-uat + diff --git a/project.godot b/project.godot index 86c15c5..49d1903 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index c87b4d6..71533f4 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -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) diff --git a/web/Dockerfile b/web/Dockerfile index 214d5b5..9138a0d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -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 \ diff --git a/web/nginx.conf b/web/nginx.conf index bd2a295..6690ce1 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -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;