Files
step3/api/QuestionsApi/Services/ContentPromotionCommand.cs
T

199 lines
8.9 KiB
C#

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;
}
}