feat: restore legacy named playlist workflows
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MMoneyCoderSharp.Data;
|
||||
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
if (args.Length == 1 &&
|
||||
args[0].Equals("--manual-import-audit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return RunManualImportAudit();
|
||||
}
|
||||
|
||||
if (args.Length == 1 &&
|
||||
args[0].Equals("--manual-import-fixture", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return await RunManualImportFixtureAsync();
|
||||
}
|
||||
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(10));
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
@@ -38,7 +51,16 @@ try
|
||||
await VerifyExpertSelectorAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyTradingHaltSelectorAsync(runtime.Executor, cancellation.Token);
|
||||
await VerifyOperatorCatalogSchemaAsync(runtime.Executor, cancellation.Token);
|
||||
await NxtThemeRestoreDbAudit.RunAsync(runtime.Executor, cancellation.Token);
|
||||
await NamedPlaylistReadOnlyDbAudit.RunAsync(runtime.Executor, cancellation.Token);
|
||||
await NamedManualRestoreReadOnlyAudit.RunAsync(
|
||||
runtime.Executor,
|
||||
NamedManualRestoreReadOnlyAudit.DefaultTrustedDirectory(),
|
||||
cancellation.Token);
|
||||
await NxtThemeRestoreDbAudit.RunAsync(
|
||||
runtime.Executor,
|
||||
runtime.Options.MariaDb,
|
||||
cancellation.Token);
|
||||
await VerifyLegacyComparisonImportAsync(runtime.Executor, cancellation.Token);
|
||||
|
||||
var service = new LegacyMarketDataService(runtime.Executor);
|
||||
await VerifySnapshotAsync(service, MarketDataView.Kospi, cancellation.Token);
|
||||
@@ -123,7 +145,362 @@ static string? GetConfigurationPath(string[] arguments)
|
||||
}
|
||||
|
||||
throw new DatabaseConfigurationException(
|
||||
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>]");
|
||||
"Usage: MBN_STOCK_WEBVIEW.DbSmoke [--config <path>|--manual-import-audit|--manual-import-fixture]");
|
||||
}
|
||||
|
||||
static async Task VerifyLegacyComparisonImportAsync(
|
||||
IDataQueryExecutor executor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string expectedSourceSha256 =
|
||||
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2";
|
||||
var result = await new LegacyComparisonPairImportService(
|
||||
executor,
|
||||
TrustedLegacyComparisonFileSource.CreateDefault())
|
||||
.ImportAsync(cancellationToken);
|
||||
if (!string.Equals(
|
||||
result.SourceSha256,
|
||||
expectedSourceSha256,
|
||||
StringComparison.Ordinal) ||
|
||||
result.SourceRowCount != 8 ||
|
||||
result.Pairs.Count != 8 ||
|
||||
result.Pairs.Where((pair, index) => pair.RowNumber != index + 1).Any())
|
||||
{
|
||||
throw new DatabaseConfigurationException(
|
||||
"The fixed legacy comparison import did not reproduce its audited 8-row identity set.");
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"LegacyComparisonImport: rows={result.SourceRowCount.ToString(CultureInfo.InvariantCulture)}, " +
|
||||
$"sha256={result.SourceSha256}, identities=fresh-read-only");
|
||||
}
|
||||
|
||||
static async Task<int> RunManualImportFixtureAsync()
|
||||
{
|
||||
const string expectedSourceSha256 =
|
||||
"fd39a3639e574a19f174225e8e65234d6bdd4c58700bb5d10230a779a03a2114";
|
||||
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
|
||||
var profile = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.UserProfile,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
var localAppData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: FAIL - required profile roots are unavailable.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sourceDirectory = Path.Combine(
|
||||
profile,
|
||||
"source",
|
||||
"repos",
|
||||
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
|
||||
var operatingDirectory = Path.Combine(
|
||||
localAppData,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"OperatorData");
|
||||
var temporaryDirectory = Path.GetFullPath(Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"MBN_STOCK_WEBVIEW-manual-import-fixture-" + Guid.NewGuid().ToString("N")));
|
||||
var expectedDataFiles = new[]
|
||||
{
|
||||
"개인.dat",
|
||||
"외국인.dat",
|
||||
"기관.dat",
|
||||
"VI발동.dat"
|
||||
};
|
||||
var trackedOperatingFiles = expectedDataFiles.Concat(new[]
|
||||
{
|
||||
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||
LegacyManualOperatorDataImporter.IntentFileName
|
||||
}).ToArray();
|
||||
|
||||
var sourceBefore = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||
var operatingBefore = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
|
||||
try
|
||||
{
|
||||
var destination = TrustedManualDirectory.PreparePrivateWritableDirectory(
|
||||
temporaryDirectory);
|
||||
LegacyManualOperatorImportResult result;
|
||||
LegacyManualOperatorImportException? secondFailure = null;
|
||||
using (var importer = new LegacyManualOperatorDataImporter(
|
||||
destination,
|
||||
new FixedLegacyManualOperatorDataSource()))
|
||||
{
|
||||
result = await importer.ImportAsync(cancellation.Token);
|
||||
try
|
||||
{
|
||||
_ = await importer.ImportAsync(cancellation.Token);
|
||||
}
|
||||
catch (LegacyManualOperatorImportException exception)
|
||||
{
|
||||
secondFailure = exception;
|
||||
}
|
||||
}
|
||||
|
||||
var sourceAfter = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||
var operatingAfter = SnapshotFiles(operatingDirectory, trackedOperatingFiles);
|
||||
var fixtureFiles = SnapshotFiles(destination, trackedOperatingFiles);
|
||||
var dataHashesMatch = expectedDataFiles.All(name =>
|
||||
sourceBefore.TryGetValue(name, out var sourceValue) &&
|
||||
fixtureFiles.TryGetValue(name, out var fixtureValue) &&
|
||||
string.Equals(sourceValue, fixtureValue, StringComparison.Ordinal));
|
||||
var markerPresent = fixtureFiles.TryGetValue(
|
||||
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||
out var markerValue) && markerValue != "missing";
|
||||
var intentAbsent = fixtureFiles.TryGetValue(
|
||||
LegacyManualOperatorDataImporter.IntentFileName,
|
||||
out var intentValue) && intentValue == "missing";
|
||||
var runtimeState = LegacyManualOperatorDataRuntimeGuard.Inspect(destination).State;
|
||||
|
||||
if (!SnapshotsEqual(sourceBefore, sourceAfter) ||
|
||||
!SnapshotsEqual(operatingBefore, operatingAfter) ||
|
||||
!dataHashesMatch || !markerPresent || !intentAbsent ||
|
||||
result.MarkerVersion != LegacyManualOperatorDataImporter.MarkerVersion ||
|
||||
result.NetSellRowCount != 15 ||
|
||||
result.ViItemCount != 9 ||
|
||||
!string.Equals(
|
||||
result.SourceSha256,
|
||||
expectedSourceSha256,
|
||||
StringComparison.Ordinal) ||
|
||||
runtimeState != LegacyManualOperatorDataRuntimeState.ReadyImportedData ||
|
||||
secondFailure is null ||
|
||||
secondFailure.Failure != LegacyManualOperatorImportFailure.AlreadyImported ||
|
||||
secondFailure.OutcomeUnknown)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"MANUAL_IMPORT_FIXTURE: FAIL - an isolated import invariant was not reproduced.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine(
|
||||
$"MANUAL_IMPORT_FIXTURE: PASS rows={result.NetSellRowCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"vi={result.ViItemCount.ToString(CultureInfo.InvariantCulture)} " +
|
||||
$"sourceSha256={result.SourceSha256} state={runtimeState} " +
|
||||
"secondAttempt=AlreadyImported outcomeUnknown=false " +
|
||||
"sourceUnchanged=true operatingImportSetUnchanged=true");
|
||||
return 0;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
||||
{
|
||||
Console.Error.WriteLine("MANUAL_IMPORT_FIXTURE: CANCELED_OR_TIMED_OUT");
|
||||
return 3;
|
||||
}
|
||||
catch (LegacyManualOperatorImportException exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"MANUAL_IMPORT_FIXTURE: FAIL - failure={exception.Failure} " +
|
||||
$"outcomeUnknown={exception.OutcomeUnknown.ToString().ToLowerInvariant()}");
|
||||
return exception.OutcomeUnknown ? 5 : 1;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"MANUAL_IMPORT_FIXTURE: FAIL - {exception.GetType().Name}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
RemoveOwnedFixtureDirectory(temporaryDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
static int RunManualImportAudit()
|
||||
{
|
||||
try
|
||||
{
|
||||
var profile = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.UserProfile,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
var localAppData = Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolderOption.DoNotVerify);
|
||||
if (string.IsNullOrWhiteSpace(profile) || string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
Console.Error.WriteLine("MANUAL_IMPORT_AUDIT: FAIL - required profile roots are unavailable.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sourceDirectory = Path.Combine(
|
||||
profile,
|
||||
"source",
|
||||
"repos",
|
||||
FixedLegacyManualOperatorDataSource.FixedRelativeDirectory);
|
||||
var operatingDirectory = Path.Combine(
|
||||
localAppData,
|
||||
"MBN_STOCK_WEBVIEW",
|
||||
"OperatorData");
|
||||
var expectedDataFiles = new[]
|
||||
{
|
||||
"개인.dat",
|
||||
"외국인.dat",
|
||||
"기관.dat",
|
||||
"VI발동.dat"
|
||||
};
|
||||
var source = SnapshotFiles(sourceDirectory, expectedDataFiles);
|
||||
var operating = SnapshotFiles(
|
||||
operatingDirectory,
|
||||
expectedDataFiles.Concat(new[]
|
||||
{
|
||||
LegacyManualOperatorDataImporter.MarkerFileName,
|
||||
LegacyManualOperatorDataImporter.IntentFileName
|
||||
}));
|
||||
var markerPresent = operating[LegacyManualOperatorDataImporter.MarkerFileName] != "missing";
|
||||
var intentPresent = operating[LegacyManualOperatorDataImporter.IntentFileName] != "missing";
|
||||
var dataPresent = expectedDataFiles.Count(name => operating[name] != "missing");
|
||||
var sourcePresent = expectedDataFiles.Count(name => source[name] != "missing");
|
||||
var byteIdentical = expectedDataFiles.All(name =>
|
||||
source[name] != "missing" &&
|
||||
string.Equals(source[name], operating[name], StringComparison.Ordinal));
|
||||
var stagedPresent = Directory.Exists(operatingDirectory) &&
|
||||
Directory.EnumerateFileSystemEntries(
|
||||
operatingDirectory,
|
||||
".legacy-manual-import-v1-*",
|
||||
SearchOption.TopDirectoryOnly).Any();
|
||||
var runtimeState = LegacyManualOperatorDataRuntimeGuard
|
||||
.Inspect(operatingDirectory)
|
||||
.State;
|
||||
|
||||
var (decision, outcomeUnknown) = runtimeState switch
|
||||
{
|
||||
LegacyManualOperatorDataRuntimeState.InterruptedImport =>
|
||||
("InterruptedImport", true),
|
||||
LegacyManualOperatorDataRuntimeState.InvalidImportedState =>
|
||||
("InvalidImportedState", false),
|
||||
LegacyManualOperatorDataRuntimeState.ReadyImportedData =>
|
||||
("AlreadyImported", false),
|
||||
_ when dataPresent > 0 || stagedPresent =>
|
||||
("DestinationNotEmpty", false),
|
||||
_ when sourcePresent == expectedDataFiles.Length =>
|
||||
("Eligible", false),
|
||||
_ => ("SourceUnavailable", false)
|
||||
};
|
||||
Console.WriteLine(
|
||||
$"MANUAL_IMPORT_AUDIT: PASS sourceFiles={sourcePresent.ToString(CultureInfo.InvariantCulture)}/4 " +
|
||||
$"operatingFiles={dataPresent.ToString(CultureInfo.InvariantCulture)}/4 " +
|
||||
$"sourceByteIdentical={byteIdentical.ToString().ToLowerInvariant()} " +
|
||||
$"marker={markerPresent.ToString().ToLowerInvariant()} " +
|
||||
$"intent={intentPresent.ToString().ToLowerInvariant()} " +
|
||||
$"staged={stagedPresent.ToString().ToLowerInvariant()} " +
|
||||
$"runtime={runtimeState} " +
|
||||
$"decision={decision} outcomeUnknown={outcomeUnknown.ToString().ToLowerInvariant()} " +
|
||||
"writesAttempted=false");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"MANUAL_IMPORT_AUDIT: FAIL - {exception.GetType().Name}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static IReadOnlyDictionary<string, string> SnapshotFiles(
|
||||
string directory,
|
||||
IEnumerable<string> fileNames)
|
||||
{
|
||||
const long maximumAuditFileBytes = 32 * 1024;
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var fileName in fileNames)
|
||||
{
|
||||
var path = Path.Combine(directory, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
result.Add(fileName, "missing");
|
||||
continue;
|
||||
}
|
||||
|
||||
using var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
if (stream.Length is < 0 or > maximumAuditFileBytes)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"A manual-import audit file exceeded its closed size boundary.");
|
||||
}
|
||||
|
||||
var bytes = new byte[checked((int)stream.Length)];
|
||||
stream.ReadExactly(bytes);
|
||||
if (stream.ReadByte() != -1)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"A manual-import audit file changed beyond its closed size boundary.");
|
||||
}
|
||||
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
||||
result.Add(
|
||||
fileName,
|
||||
bytes.Length.ToString(CultureInfo.InvariantCulture) + ":" + hash);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool SnapshotsEqual(
|
||||
IReadOnlyDictionary<string, string> first,
|
||||
IReadOnlyDictionary<string, string> second) =>
|
||||
first.Count == second.Count && first.All(pair =>
|
||||
second.TryGetValue(pair.Key, out var value) &&
|
||||
string.Equals(pair.Value, value, StringComparison.Ordinal));
|
||||
|
||||
static void RemoveOwnedFixtureDirectory(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var temporaryRoot = Path.TrimEndingDirectorySeparator(
|
||||
Path.GetFullPath(Path.GetTempPath()));
|
||||
var resolved = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||
var prefix = temporaryRoot + Path.DirectorySeparatorChar;
|
||||
const string ownedNamePrefix = "MBN_STOCK_WEBVIEW-manual-import-fixture-";
|
||||
var ownedName = Path.GetFileName(resolved);
|
||||
var ownedSuffix = ownedName.StartsWith(ownedNamePrefix, StringComparison.Ordinal)
|
||||
? ownedName[ownedNamePrefix.Length..]
|
||||
: string.Empty;
|
||||
if (!resolved.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
ownedSuffix.Length != 32 ||
|
||||
ownedSuffix.Any(static value => !char.IsAsciiHexDigit(value)) ||
|
||||
(File.GetAttributes(resolved) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Refusing to remove a directory outside the owned fixture boundary.");
|
||||
}
|
||||
|
||||
var pendingDirectories = new Stack<string>();
|
||||
pendingDirectories.Push(resolved);
|
||||
while (pendingDirectories.TryPop(out var current))
|
||||
{
|
||||
foreach (var entry in Directory.EnumerateFileSystemEntries(
|
||||
current,
|
||||
"*",
|
||||
new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = false,
|
||||
ReturnSpecialDirectories = false,
|
||||
AttributesToSkip = 0
|
||||
}))
|
||||
{
|
||||
var attributes = File.GetAttributes(entry);
|
||||
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Refusing to recursively remove a fixture containing a reparse point.");
|
||||
}
|
||||
|
||||
if ((attributes & FileAttributes.Directory) != 0)
|
||||
{
|
||||
pendingDirectories.Push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Directory.Delete(resolved, recursive: true);
|
||||
}
|
||||
|
||||
static async Task VerifyOracleServerVersionAsync(
|
||||
|
||||
Reference in New Issue
Block a user