Add isolated UAT and production environments
This commit is contained in:
@@ -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}."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: step3-api
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 5008
|
||||
nodePort: 30090
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: step3-prd
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: step3-web
|
||||
spec:
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
nodePort: 30091
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: step3-uat
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user