202 lines
9.1 KiB
C#
202 lines
9.1 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 sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
|
|
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
|
|
var questions = sourceQuestions
|
|
.Select(QuestionPromotionCommand.PrepareForProduction)
|
|
.ToList();
|
|
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;
|
|
}
|
|
}
|