Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e03ad9cf80 |
@@ -1,4 +1,4 @@
|
||||
name: Promote UAT content to PRD
|
||||
name: Promote ready UAT questions to PRD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: step3-content-promotion
|
||||
group: step3-question-promotion
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -45,26 +45,26 @@ jobs:
|
||||
test "$CONFIRMATION" = "PROMOTE_TO_PRD"
|
||||
fi
|
||||
|
||||
- name: Configure promotion credentials
|
||||
- name: Configure question promotion credentials
|
||||
env:
|
||||
SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
|
||||
TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
|
||||
run: |
|
||||
test -n "$SOURCE_CONNECTION"
|
||||
test -n "$TARGET_CONNECTION"
|
||||
kubectl create secret generic step3-content-promotion \
|
||||
kubectl create secret generic step3-question-promotion \
|
||||
--from-literal=source-connection="$SOURCE_CONNECTION" \
|
||||
--from-literal=target-connection="$TARGET_CONNECTION" \
|
||||
--dry-run=client -o yaml | kubectl apply -n step3-prd -f -
|
||||
|
||||
- name: Run promotion
|
||||
- name: Run ready-question promotion
|
||||
env:
|
||||
MODE: ${{ inputs.mode }}
|
||||
CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')"
|
||||
REVISION="${IMAGE##*:}"
|
||||
POD="content-promotion-${GITHUB_RUN_NUMBER}"
|
||||
POD="question-promotion-${GITHUB_RUN_NUMBER}"
|
||||
APPLY_ARGS=""
|
||||
if [ "$MODE" = "apply" ]; then
|
||||
APPLY_ARGS="--apply --confirm $CONFIRMATION"
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
name: ${POD}
|
||||
namespace: step3-prd
|
||||
labels:
|
||||
app: step3-content-promotion
|
||||
app: step3-question-promotion
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- >-
|
||||
dotnet QuestionsApi.dll promote-content
|
||||
dotnet QuestionsApi.dll promote-questions
|
||||
--source-database ranaze_step3_uat
|
||||
--target-database ranaze_step3_prd
|
||||
--operator "${GITHUB_ACTOR:-gitea-operator}"
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
- name: SOURCE_MONGODB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-content-promotion
|
||||
name: step3-question-promotion
|
||||
key: source-connection
|
||||
- name: TARGET_MONGODB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-content-promotion
|
||||
name: step3-question-promotion
|
||||
key: target-connection
|
||||
EOF
|
||||
for attempt in $(seq 1 120); do
|
||||
@@ -115,6 +115,5 @@ jobs:
|
||||
- name: Remove promotion credentials and pod
|
||||
if: always()
|
||||
run: |
|
||||
kubectl delete pod "content-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
|
||||
kubectl delete secret step3-content-promotion -n step3-prd --ignore-not-found
|
||||
|
||||
kubectl delete pod "question-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
|
||||
kubectl delete secret step3-question-promotion -n step3-prd --ignore-not-found
|
||||
|
||||
+5
-5
@@ -57,9 +57,9 @@ The hostname change creates a new browser origin, so testers should expect to si
|
||||
|
||||
1. Create and protect the `production` branch, then merge the tested `main` revision.
|
||||
2. Route `step3.ranaze.com` and `s3q.ranaze.com` to PRD NodePorts.
|
||||
3. Run **Promote UAT content to PRD** in `dry-run` mode and inspect the counts and hash.
|
||||
3. Run **Promote ready UAT questions to PRD** in `dry-run` mode and inspect the insert, update, unchanged, extra, media, and hash values.
|
||||
4. Run it again in `apply` mode with confirmation `PROMOTE_TO_PRD`.
|
||||
5. Verify `ranaze_step3_prd` contains published content and has no user/activity documents.
|
||||
5. Verify `ranaze_step3_prd` contains the published, unflagged UAT questions and has no user/activity documents.
|
||||
6. Register the initial PRD account normally, then promote it manually:
|
||||
|
||||
```javascript
|
||||
@@ -69,11 +69,11 @@ The hostname change creates a new browser origin, so testers should expect to si
|
||||
|
||||
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
|
||||
|
||||
## Content promotion behavior
|
||||
## Question promotion behavior
|
||||
|
||||
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only published questions, referenced media, current published CCS revisions, and the CCS order catalog. It never copies users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
|
||||
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
|
||||
|
||||
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Every applied promotion writes a `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
|
||||
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
|
||||
@@ -31,4 +31,129 @@ public sealed class ContentPromotionTests
|
||||
ContentPromotionCommand.HashDocuments([first, second]),
|
||||
ContentPromotionCommand.HashDocuments([second, first]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadyQuestionEligibilityRequiresPublishedAndUnflagged()
|
||||
{
|
||||
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument
|
||||
{
|
||||
["IsPublished"] = true,
|
||||
["FlaggedForReviewUtc"] = BsonNull.Value
|
||||
}));
|
||||
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", true)));
|
||||
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument
|
||||
{
|
||||
["IsPublished"] = true,
|
||||
["FlaggedForReviewUtc"] = DateTime.UtcNow
|
||||
}));
|
||||
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
|
||||
{
|
||||
var first = ObjectId.GenerateNewId();
|
||||
var second = ObjectId.GenerateNewId();
|
||||
var questions = new[]
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "one",
|
||||
["Media"] = new BsonArray { $"/api/questions/media/{first}", second.ToString() }
|
||||
},
|
||||
new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "two",
|
||||
["Media"] = new BsonArray { $"https://example.test/api/questions/media/{first}" }
|
||||
}
|
||||
};
|
||||
|
||||
var result = QuestionPromotionCommand.CollectMediaIds(questions);
|
||||
|
||||
Assert.Equal(new HashSet<ObjectId> { first, second }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidLocalQuestionMediaReferenceFailsValidation()
|
||||
{
|
||||
var question = new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "bad-media",
|
||||
["Media"] = new BsonArray { "/api/questions/media/not-an-id" }
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => QuestionPromotionCommand.CollectMediaIds([question]));
|
||||
|
||||
Assert.Contains("bad-media", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionComparisonClassifiesInsertUpdateAndUnchanged()
|
||||
{
|
||||
var source = new[]
|
||||
{
|
||||
Question("insert", "new"),
|
||||
Question("update", "new"),
|
||||
Question("same", "same")
|
||||
};
|
||||
var target = new[]
|
||||
{
|
||||
Question("update", "old"),
|
||||
new BsonDocument
|
||||
{
|
||||
["Stem"] = "same",
|
||||
["ExternalId"] = "same",
|
||||
["_id"] = ObjectId.GenerateNewId()
|
||||
}
|
||||
};
|
||||
|
||||
var result = QuestionPromotionCommand.ClassifyQuestions(source, target);
|
||||
|
||||
Assert.Equal(1, result.Inserted);
|
||||
Assert.Equal(1, result.Updated);
|
||||
Assert.Equal(1, result.Unchanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionHashIgnoresMongoIdsFieldOrderAndInputOrder()
|
||||
{
|
||||
var first = Question("a", "one");
|
||||
first["_id"] = ObjectId.GenerateNewId();
|
||||
var second = Question("b", "two");
|
||||
var equivalentFirst = new BsonDocument
|
||||
{
|
||||
["Stem"] = "one",
|
||||
["_id"] = ObjectId.GenerateNewId(),
|
||||
["ExternalId"] = "a"
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
QuestionPromotionCommand.HashQuestions([first, second]),
|
||||
QuestionPromotionCommand.HashQuestions([second, equivalentFirst]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyQuestionApplyRequiresExplicitProductionConfirmation()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
|
||||
var result = await QuestionPromotionCommand.RunAsync(
|
||||
[
|
||||
"--source-connection", "mongodb://source.invalid",
|
||||
"--source-database", "uat",
|
||||
"--target-connection", "mongodb://target.invalid",
|
||||
"--target-database", "prd",
|
||||
"--apply"
|
||||
], output);
|
||||
|
||||
Assert.Equal(1, result);
|
||||
Assert.Contains("--confirm PROMOTE_TO_PRD is required", output.ToString());
|
||||
}
|
||||
|
||||
private static BsonDocument Question(string externalId, string stem) => new()
|
||||
{
|
||||
["ExternalId"] = externalId,
|
||||
["Stem"] = stem
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ if (args.FirstOrDefault() == "promote-content")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-questions")
|
||||
{
|
||||
Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class QuestionPromotionCommand
|
||||
{
|
||||
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 questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
|
||||
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
|
||||
var questions = await source.GetCollection<BsonDocument>("Questions")
|
||||
.Find(questionFilter).ToListAsync();
|
||||
var duplicateIds = questions
|
||||
.GroupBy(ExternalId, StringComparer.Ordinal)
|
||||
.Where(group => group.Count() > 1)
|
||||
.Select(group => group.Key)
|
||||
.ToList();
|
||||
if (duplicateIds.Count > 0)
|
||||
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
|
||||
|
||||
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 sourceIds = questions.Select(ExternalId).ToHashSet(StringComparer.Ordinal);
|
||||
var targetCollection = target.GetCollection<BsonDocument>("Questions");
|
||||
var targetQuestions = sourceIds.Count == 0
|
||||
? []
|
||||
: await targetCollection.Find(
|
||||
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
|
||||
var targetTotal = await targetCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
var comparison = ClassifyQuestions(questions, targetQuestions);
|
||||
var summary = new PromotionSummary(
|
||||
questions.Count,
|
||||
media.Count,
|
||||
comparison.Inserted,
|
||||
comparison.Updated,
|
||||
comparison.Unchanged,
|
||||
checked((int)Math.Max(0, targetTotal - targetQuestions.Count)),
|
||||
HashQuestions(questions));
|
||||
|
||||
await WriteSummaryAsync(output, options.Apply ? "apply" : "dry-run", options, summary);
|
||||
if (!options.Apply)
|
||||
return 0;
|
||||
|
||||
await UpsertMediaAsync(target, media);
|
||||
await UpsertQuestionsAsync(target, questions);
|
||||
|
||||
var verifiedQuestions = sourceIds.Count == 0
|
||||
? []
|
||||
: await targetCollection.Find(
|
||||
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
|
||||
var verification = ClassifyQuestions(questions, verifiedQuestions);
|
||||
if (verification.Inserted != 0 || verification.Updated != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"target verification failed: {verification.Inserted} missing and {verification.Updated} mismatched questions");
|
||||
var verifiedMedia = mediaIds.Count == 0
|
||||
? 0
|
||||
: checked((int)await target.GetCollection<BsonDocument>("QuestionMediaAssets")
|
||||
.CountDocumentsAsync(Builders<BsonDocument>.Filter.In("_id", mediaIds)));
|
||||
if (verifiedMedia != mediaIds.Count)
|
||||
throw new InvalidOperationException(
|
||||
$"target verification failed: {mediaIds.Count - verifiedMedia} media assets are missing");
|
||||
|
||||
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,
|
||||
["Scope"] = "questions",
|
||||
["Eligibility"] = "published_unflagged",
|
||||
["Questions"] = summary.EligibleQuestions,
|
||||
["Inserted"] = summary.Inserted,
|
||||
["Updated"] = summary.Updated,
|
||||
["Unchanged"] = summary.Unchanged,
|
||||
["MediaAssets"] = summary.MediaAssets,
|
||||
["TargetExtras"] = summary.TargetExtras,
|
||||
["ContentHash"] = summary.ContentHash
|
||||
});
|
||||
await output.WriteLineAsync("Question promotion completed and verified.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await output.WriteLineAsync($"Question promotion failed: {exception.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsEligible(BsonDocument question) =>
|
||||
question.GetValue("IsPublished", false).ToBoolean()
|
||||
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
|
||||
|
||||
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)
|
||||
continue;
|
||||
var reference = value.AsString.Trim();
|
||||
var candidate = reference.TrimEnd('/').Split('/').LastOrDefault() ?? "";
|
||||
if (ObjectId.TryParse(reference, out var direct))
|
||||
ids.Add(direct);
|
||||
else if (ObjectId.TryParse(candidate, out var fromPath))
|
||||
ids.Add(fromPath);
|
||||
else if (reference.Contains("/api/questions/media/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"question {ExternalId(question)} has an invalid local media reference: {reference}");
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
internal static QuestionComparison ClassifyQuestions(
|
||||
IEnumerable<BsonDocument> sourceQuestions,
|
||||
IEnumerable<BsonDocument> targetQuestions)
|
||||
{
|
||||
var target = targetQuestions.ToDictionary(ExternalId, StringComparer.Ordinal);
|
||||
var inserted = 0;
|
||||
var updated = 0;
|
||||
var unchanged = 0;
|
||||
foreach (var source in sourceQuestions)
|
||||
{
|
||||
var externalId = ExternalId(source);
|
||||
if (!target.TryGetValue(externalId, out var existing))
|
||||
inserted++;
|
||||
else if (EquivalentQuestion(source, existing))
|
||||
unchanged++;
|
||||
else
|
||||
updated++;
|
||||
}
|
||||
return new QuestionComparison(inserted, updated, unchanged);
|
||||
}
|
||||
|
||||
internal static string HashQuestions(IEnumerable<BsonDocument> questions)
|
||||
{
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
foreach (var document in questions.OrderBy(ExternalId, StringComparer.Ordinal))
|
||||
hash.AppendData(Encoding.UTF8.GetBytes(CanonicalQuestion(document).ToJson()));
|
||||
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool EquivalentQuestion(BsonDocument source, BsonDocument target) =>
|
||||
CanonicalQuestion(source).Equals(CanonicalQuestion(target));
|
||||
|
||||
private static BsonDocument CanonicalQuestion(BsonDocument source) =>
|
||||
Canonicalize(new BsonDocument(source.Where(element => element.Name != "_id"))).AsBsonDocument;
|
||||
|
||||
private static BsonValue Canonicalize(BsonValue value)
|
||||
{
|
||||
if (value.IsBsonDocument)
|
||||
return new BsonDocument(value.AsBsonDocument
|
||||
.OrderBy(element => element.Name, StringComparer.Ordinal)
|
||||
.Select(element => new BsonElement(element.Name, Canonicalize(element.Value))));
|
||||
if (value.IsBsonArray)
|
||||
return new BsonArray(value.AsBsonArray.Select(Canonicalize));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string ExternalId(BsonDocument document) =>
|
||||
document.GetValue("ExternalId", "").AsString;
|
||||
|
||||
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
|
||||
{
|
||||
var collection = target.GetCollection<BsonDocument>("Questions");
|
||||
foreach (var source in questions)
|
||||
{
|
||||
var replacement = new BsonDocument(source.Where(element => element.Name != "_id"));
|
||||
await collection.ReplaceOneAsync(
|
||||
Builders<BsonDocument>.Filter.Eq("ExternalId", source["ExternalId"]),
|
||||
replacement,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable<BsonDocument> media)
|
||||
{
|
||||
var collection = target.GetCollection<BsonDocument>("QuestionMediaAssets");
|
||||
foreach (var source in media)
|
||||
await collection.ReplaceOneAsync(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", source["_id"]),
|
||||
source,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
|
||||
private static Task WriteSummaryAsync(
|
||||
TextWriter output,
|
||||
string mode,
|
||||
PromotionOptions options,
|
||||
PromotionSummary summary) =>
|
||||
output.WriteLineAsync(JsonSerializer.Serialize(new
|
||||
{
|
||||
mode,
|
||||
scope = "questions",
|
||||
eligibility = "published_unflagged",
|
||||
source = options.SourceDatabase,
|
||||
target = options.TargetDatabase,
|
||||
eligibleQuestions = summary.EligibleQuestions,
|
||||
mediaAssets = summary.MediaAssets,
|
||||
inserted = summary.Inserted,
|
||||
updated = summary.Updated,
|
||||
unchanged = summary.Unchanged,
|
||||
targetExtras = summary.TargetExtras,
|
||||
contentHash = summary.ContentHash
|
||||
}));
|
||||
|
||||
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 QuestionComparison(int Inserted, int Updated, int Unchanged);
|
||||
internal sealed record PromotionSummary(
|
||||
int EligibleQuestions,
|
||||
int MediaAssets,
|
||||
int Inserted,
|
||||
int Updated,
|
||||
int Unchanged,
|
||||
int TargetExtras,
|
||||
string ContentHash);
|
||||
}
|
||||
Reference in New Issue
Block a user