Add isolated UAT and production environments

This commit is contained in:
hz
2026-07-20 20:32:22 -05:00
parent 4e0ca70157
commit 17747d96a0
30 changed files with 780 additions and 282 deletions
@@ -0,0 +1,34 @@
using MongoDB.Bson;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class ContentPromotionTests
{
[Fact]
public void CollectMediaIdsDeduplicatesValidObjectIds()
{
var id = ObjectId.GenerateNewId();
var questions = new[]
{
new BsonDocument("Media", new BsonArray { id.ToString(), "not-an-object-id" }),
new BsonDocument("Media", new BsonArray { id.ToString() })
};
var result = ContentPromotionCommand.CollectMediaIds(questions);
Assert.Equal(new[] { id }, result);
}
[Fact]
public void ContentHashDoesNotDependOnInputOrder()
{
var first = new BsonDocument { ["ExternalId"] = "a", ["Stem"] = "one" };
var second = new BsonDocument { ["ExternalId"] = "b", ["Stem"] = "two" };
Assert.Equal(
ContentPromotionCommand.HashDocuments([first, second]),
ContentPromotionCommand.HashDocuments([second, first]));
}
}
+7 -2
View File
@@ -1,18 +1,23 @@
ARG BUILD_REVISION=development
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_REVISION
WORKDIR /src
COPY ["QuestionsApi.csproj", "./"]
RUN dotnet restore "QuestionsApi.csproj"
COPY . .
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
ARG BUILD_REVISION
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_ENVIRONMENT=Production \
Deployment__BuildRevision=${BUILD_REVISION}
EXPOSE 5008
+11
View File
@@ -10,6 +10,12 @@ using System.Security.Claims;
using System.Text;
using System.Threading.RateLimiting;
if (args.FirstOrDefault() == "promote-content")
{
Environment.ExitCode = await ContentPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -209,6 +215,11 @@ app.MapGet("/readyz", async (MongoDatabaseProvider mongo) =>
await mongo.Database.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
return Results.Ok("ready");
});
app.MapGet("/api/runtime", (IConfiguration configuration) => Results.Ok(new
{
environment = configuration["Deployment:Environment"] ?? "development",
buildRevision = configuration["Deployment:BuildRevision"] ?? "development"
}));
app.MapGet("/metrics", (ServiceMetrics metrics) => Results.Text(metrics.RenderPrometheus(), "text/plain; version=0.0.4"));
app.UseSwagger();
app.UseSwaggerUI(o =>
@@ -0,0 +1,198 @@
using MongoDB.Bson;
using MongoDB.Driver;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class ContentPromotionCommand
{
public const string Confirmation = "PROMOTE_TO_PRD";
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
try
{
var options = Parse(args);
if (options.Apply && options.Confirmation != Confirmation)
throw new ArgumentException($"--confirm {Confirmation} is required with --apply");
if (options.SourceDatabase.Equals(options.TargetDatabase, StringComparison.OrdinalIgnoreCase)
&& options.SourceConnection == options.TargetConnection)
throw new ArgumentException("source and target databases must be different");
var source = new MongoClient(options.SourceConnection).GetDatabase(options.SourceDatabase);
var target = new MongoClient(options.TargetConnection).GetDatabase(options.TargetDatabase);
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var questions = await source.GetCollection<BsonDocument>("Questions")
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
: await source.GetCollection<BsonDocument>("QuestionMediaAssets")
.Find(Builders<BsonDocument>.Filter.In("_id", mediaIds)).ToListAsync();
if (media.Count != mediaIds.Count)
throw new InvalidOperationException($"{mediaIds.Count - media.Count} referenced question media assets are missing");
var cases = await source.GetCollection<BsonDocument>("CcsCaseRevisions")
.Find(Builders<BsonDocument>.Filter.Eq("IsCurrent", true)
& Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var orders = await source.GetCollection<BsonDocument>("CcsOrderCatalog")
.Find(FilterDefinition<BsonDocument>.Empty).ToListAsync();
var summary = new PromotionSummary(
questions.Count,
media.Count,
cases.Count,
orders.Count,
HashDocuments(questions.Concat(media).Concat(cases).Concat(orders)));
await output.WriteLineAsync(JsonSerializer.Serialize(new
{
mode = options.Apply ? "apply" : "dry-run",
source = options.SourceDatabase,
target = options.TargetDatabase,
summary.Questions,
summary.MediaAssets,
summary.CcsCases,
summary.CcsOrders,
summary.ContentHash
}));
if (!options.Apply) return 0;
await UpsertMediaAsync(target, media);
await UpsertByIdAsync(target, "CcsOrderCatalog", orders);
await UpsertByFieldsAsync(target, "Questions", questions, ["ExternalId"]);
await UpsertCasesAsync(target, cases);
await target.GetCollection<BsonDocument>("ContentPromotions").InsertOneAsync(new BsonDocument
{
["PromotionId"] = Guid.NewGuid().ToString("N"),
["AppliedUtc"] = DateTime.UtcNow,
["Operator"] = options.Operator,
["BuildRevision"] = options.BuildRevision,
["SourceDatabase"] = options.SourceDatabase,
["TargetDatabase"] = options.TargetDatabase,
["Questions"] = summary.Questions,
["MediaAssets"] = summary.MediaAssets,
["CcsCases"] = summary.CcsCases,
["CcsOrders"] = summary.CcsOrders,
["ContentHash"] = summary.ContentHash
});
await output.WriteLineAsync("Content promotion completed.");
return 0;
}
catch (Exception exception)
{
await output.WriteLineAsync($"Content promotion failed: {exception.Message}");
return 1;
}
}
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
{
var ids = new HashSet<ObjectId>();
foreach (var question in questions)
{
if (!question.TryGetValue("Media", out var raw) || !raw.IsBsonArray) continue;
foreach (var value in raw.AsBsonArray)
if (value.IsString && ObjectId.TryParse(value.AsString, out var id)) ids.Add(id);
}
return ids;
}
internal static string HashDocuments(IEnumerable<BsonDocument> documents)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var document in documents.OrderBy(DocumentIdentity, StringComparer.Ordinal))
hash.AppendData(Encoding.UTF8.GetBytes(document.ToJson()));
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static string DocumentIdentity(BsonDocument document) =>
document.GetValue("ExternalId", document.GetValue("_id", BsonNull.Value)).ToString() ?? "";
private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable<BsonDocument> documents) =>
await UpsertByIdAsync(target, "QuestionMediaAssets", documents);
private static async Task UpsertByIdAsync(IMongoDatabase target, string collectionName, IEnumerable<BsonDocument> documents)
{
var collection = target.GetCollection<BsonDocument>(collectionName);
foreach (var document in documents)
await collection.ReplaceOneAsync(Builders<BsonDocument>.Filter.Eq("_id", document["_id"]), document,
new ReplaceOptions { IsUpsert = true });
}
private static async Task UpsertByFieldsAsync(
IMongoDatabase target,
string collectionName,
IEnumerable<BsonDocument> documents,
IReadOnlyList<string> keys)
{
var collection = target.GetCollection<BsonDocument>(collectionName);
foreach (var source in documents)
{
var filter = keys.Aggregate(
Builders<BsonDocument>.Filter.Empty,
(current, key) => current & Builders<BsonDocument>.Filter.Eq(key, source[key]));
var values = new BsonDocument(source.Where(element => element.Name != "_id"));
await collection.UpdateOneAsync(filter, new BsonDocument("$set", values), new UpdateOptions { IsUpsert = true });
}
}
private static async Task UpsertCasesAsync(IMongoDatabase target, IEnumerable<BsonDocument> cases)
{
var collection = target.GetCollection<BsonDocument>("CcsCaseRevisions");
foreach (var source in cases)
{
await collection.UpdateManyAsync(
Builders<BsonDocument>.Filter.Eq("ExternalId", source["ExternalId"]),
Builders<BsonDocument>.Update.Set("IsCurrent", false));
await UpsertByFieldsAsync(target, "CcsCaseRevisions", [source], ["ExternalId", "Version"]);
}
}
private static PromotionOptions Parse(IReadOnlyList<string> args)
{
string Required(string name, string environmentName)
{
var index = args.IndexOf($"--{name}");
var value = index >= 0 && index + 1 < args.Count ? args[index + 1] : Environment.GetEnvironmentVariable(environmentName);
return string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"--{name} is required") : value;
}
string Optional(string name, string fallback)
{
var index = args.IndexOf($"--{name}");
return index >= 0 && index + 1 < args.Count ? args[index + 1] : fallback;
}
return new PromotionOptions(
Required("source-connection", "SOURCE_MONGODB_CONNECTION_STRING"),
Required("source-database", "SOURCE_MONGODB_DATABASE"),
Required("target-connection", "TARGET_MONGODB_CONNECTION_STRING"),
Required("target-database", "TARGET_MONGODB_DATABASE"),
args.Contains("--apply"),
Optional("confirm", ""),
Optional("operator", "unknown"),
Optional("build-revision", "unknown"));
}
private sealed record PromotionOptions(
string SourceConnection,
string SourceDatabase,
string TargetConnection,
string TargetDatabase,
bool Apply,
string Confirmation,
string Operator,
string BuildRevision);
internal sealed record PromotionSummary(int Questions, int MediaAssets, int CcsCases, int CcsOrders, string ContentHash);
}
internal static class ArgumentListExtensions
{
public static int IndexOf(this IReadOnlyList<string> values, string value)
{
for (var index = 0; index < values.Count; index++)
if (values[index] == value) return index;
return -1;
}
}
-95
View File
@@ -1,95 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: step3-questions
labels:
app: step3-questions
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: step3-questions
template:
metadata:
labels:
app: step3-questions
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
prometheus.io/port: "5008"
spec:
containers:
- name: step3-questions
image: step3-questions:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5008
env:
- name: Jwt__Key
valueFrom:
secretKeyRef:
name: ranaze-auth
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
key: purge-secret
- name: Stripe__SecretKey
valueFrom:
secretKeyRef:
name: medmind-stripe
key: secret-key
- name: Stripe__WebhookSecret
valueFrom:
secretKeyRef:
name: medmind-stripe
key: webhook-secret
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
readinessProbe:
httpGet:
path: /readyz
port: 5008
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: 5008
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
-35
View File
@@ -1,35 +0,0 @@
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: step3-questions
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: step3-questions
minReplicas: 2
maxReplicas: 10
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 60
- type: Pods
value: 2
periodSeconds: 60
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
-9
View File
@@ -1,9 +0,0 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: step3-questions
spec:
minAvailable: 1
selector:
matchLabels:
app: step3-questions
-15
View File
@@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: step3-questions
labels:
app: step3-questions
spec:
selector:
app: step3-questions
type: NodePort
ports:
- name: http
port: 80
targetPort: 5008
nodePort: 30088