Add isolated UAT and production environments
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user