Add isolated UAT and production environments
This commit is contained in:
@@ -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,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
|
||||
@@ -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
|
||||
@@ -1,9 +0,0 @@
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: step3-questions
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: step3-questions
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user