mirror of
https://github.com/logos-storage/logos-storage-nim-cs-dist-tests.git
synced 2026-01-08 16:33:07 +00:00
Merge branch 'feature/extended-marketplace-testing'
This commit is contained in:
commit
e70b15209f
42
.github/workflows/trace-contract.yaml
vendored
Normal file
42
.github/workflows/trace-contract.yaml
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
name: Trace Contract
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
purchaseid:
|
||||
description: "Testnet Purchase ID"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
SOURCE: ${{ format('{0}/{1}', github.server_url, github.repository) }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
OUTPUT_FOLDER: "/tmp"
|
||||
ES_USERNAME: ${{ secrets.ES_USERNAME }}
|
||||
ES_PASSWORD: ${{ secrets.ES_PASSWORD }}
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
name: Run Release Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ inputs.workflow_source }}
|
||||
|
||||
- name: Variables
|
||||
run: |
|
||||
echo "PURCHASE_ID=${{ inputs.purchaseid }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Trace
|
||||
run: |
|
||||
dotnet run --project Tools/TraceContract
|
||||
|
||||
- name: Upload output
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: contract-trace
|
||||
path: /tmp/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@ -20,9 +20,10 @@ namespace BlockchainUtils
|
||||
|
||||
public BlockTimeEntry Get(ulong blockNumber)
|
||||
{
|
||||
bounds.Initialize();
|
||||
var b = cache.Get(blockNumber);
|
||||
if (b != null) return b;
|
||||
|
||||
bounds.Initialize();
|
||||
return GetBlock(blockNumber);
|
||||
}
|
||||
|
||||
@ -101,10 +102,18 @@ namespace BlockchainUtils
|
||||
previous.Utc < target;
|
||||
}
|
||||
|
||||
private BlockTimeEntry GetBlock(ulong number)
|
||||
private BlockTimeEntry GetBlock(ulong number, bool retry = false)
|
||||
{
|
||||
if (number < bounds.Genesis.BlockNumber) throw new Exception("Can't fetch block before genesis.");
|
||||
if (number > bounds.Current.BlockNumber) throw new Exception("Can't fetch block after current.");
|
||||
if (number > bounds.Current.BlockNumber)
|
||||
{
|
||||
if (retry) throw new Exception("Can't fetch block after current.");
|
||||
|
||||
// todo test and verify this:
|
||||
Thread.Sleep(1000);
|
||||
bounds.Initialize();
|
||||
return GetBlock(number, retry: true);
|
||||
}
|
||||
|
||||
var dateTime = web3.GetTimestampForBlock(number);
|
||||
if (dateTime == null) throw new Exception("Failed to get dateTime for block that should exist.");
|
||||
|
||||
@ -87,6 +87,8 @@
|
||||
private void AddCurrentBlock()
|
||||
{
|
||||
var currentBlockNumber = web3.GetCurrentBlockNumber();
|
||||
if (Current != null && Current.BlockNumber == currentBlockNumber) return;
|
||||
|
||||
var blockTime = web3.GetTimestampForBlock(currentBlockNumber);
|
||||
if (blockTime == null) throw new Exception("Unable to get dateTime for current block.");
|
||||
AddCurrentBlock(currentBlockNumber, blockTime.Value);
|
||||
|
||||
@ -33,14 +33,17 @@ namespace KubernetesWorkflow
|
||||
sourceLog.Log(msg);
|
||||
|
||||
LogFile.Write(msg);
|
||||
LogFile.WriteRaw(description);
|
||||
LogFile.Write(description);
|
||||
}
|
||||
|
||||
public LogFile LogFile { get; }
|
||||
|
||||
protected override void ProcessLine(string line)
|
||||
{
|
||||
LogFile.WriteRaw(line);
|
||||
if (line.Contains("Received JSON-RPC response")) return;
|
||||
if (line.Contains("object field not marked with serialize, skipping")) return;
|
||||
|
||||
LogFile.Write(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +65,7 @@ namespace Logging
|
||||
|
||||
public void Raw(string message)
|
||||
{
|
||||
LogFile.WriteRaw(message);
|
||||
LogFile.Write(message);
|
||||
}
|
||||
|
||||
public virtual void AddStringReplace(string from, string to)
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
using Utils;
|
||||
|
||||
namespace Logging
|
||||
namespace Logging
|
||||
{
|
||||
public class LogFile
|
||||
{
|
||||
@ -16,11 +14,6 @@ namespace Logging
|
||||
public string Filename { get; private set; }
|
||||
|
||||
public void Write(string message)
|
||||
{
|
||||
WriteRaw($"{GetTimestamp()} {message}");
|
||||
}
|
||||
|
||||
public void WriteRaw(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -50,11 +43,6 @@ namespace Logging
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetTimestamp()
|
||||
{
|
||||
return $"[{Time.FormatTimestamp(DateTime.UtcNow)}]";
|
||||
}
|
||||
|
||||
private void EnsurePathExists(string filename)
|
||||
{
|
||||
var path = new FileInfo(filename).Directory!.FullName;
|
||||
|
||||
@ -24,17 +24,17 @@
|
||||
|
||||
public void Debug(string message = "", int skipFrames = 0)
|
||||
{
|
||||
backingLog.Debug(Prefix + message, skipFrames);
|
||||
backingLog.Debug(GetPrefix() + message, skipFrames);
|
||||
}
|
||||
|
||||
public void Error(string message)
|
||||
{
|
||||
backingLog.Error(Prefix + message);
|
||||
backingLog.Error(GetPrefix() + message);
|
||||
}
|
||||
|
||||
public void Log(string message)
|
||||
{
|
||||
backingLog.Log(Prefix + message);
|
||||
backingLog.Log(GetPrefix() + message);
|
||||
}
|
||||
|
||||
public void AddStringReplace(string from, string to)
|
||||
@ -51,5 +51,10 @@
|
||||
{
|
||||
return backingLog.GetFullName();
|
||||
}
|
||||
|
||||
protected virtual string GetPrefix()
|
||||
{
|
||||
return Prefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
Framework/Logging/TimestampPrefixer.cs
Normal file
16
Framework/Logging/TimestampPrefixer.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using Utils;
|
||||
|
||||
namespace Logging
|
||||
{
|
||||
public class TimestampPrefixer : LogPrefixer
|
||||
{
|
||||
public TimestampPrefixer(ILog backingLog) : base(backingLog)
|
||||
{
|
||||
}
|
||||
|
||||
protected override string GetPrefix()
|
||||
{
|
||||
return $"[{Time.FormatTimestamp(DateTime.UtcNow)}]";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -143,5 +143,10 @@ namespace NethereumWorkflow
|
||||
var blockTimeFinder = new BlockTimeFinder(blockCache, wrapper, log);
|
||||
return blockTimeFinder.Get(number);
|
||||
}
|
||||
|
||||
public BlockWithTransactions GetBlockWithTransactions(ulong number)
|
||||
{
|
||||
return Time.Wait(web3.Eth.Blocks.GetBlockWithTransactionsByNumber.SendRequestAsync(new BlockParameter(number)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,23 +19,40 @@ namespace NethereumWorkflow
|
||||
|
||||
public ulong GetCurrentBlockNumber()
|
||||
{
|
||||
var number = Time.Wait(web3.Eth.Blocks.GetBlockNumber.SendRequestAsync());
|
||||
return Convert.ToUInt64(number.ToDecimal());
|
||||
return Retry(() =>
|
||||
{
|
||||
var number = Time.Wait(web3.Eth.Blocks.GetBlockNumber.SendRequestAsync());
|
||||
return Convert.ToUInt64(number.ToDecimal());
|
||||
});
|
||||
}
|
||||
|
||||
public DateTime? GetTimestampForBlock(ulong blockNumber)
|
||||
{
|
||||
try
|
||||
return Retry<DateTime?>(() =>
|
||||
{
|
||||
var block = Time.Wait(web3.Eth.Blocks.GetBlockWithTransactionsByNumber.SendRequestAsync(new BlockParameter(blockNumber)));
|
||||
if (block == null) return null;
|
||||
return DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(block.Timestamp.ToDecimal())).UtcDateTime;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("Exception while getting timestamp for block: " + ex);
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
var block = Time.Wait(web3.Eth.Blocks.GetBlockWithTransactionsByNumber.SendRequestAsync(new BlockParameter(blockNumber)));
|
||||
if (block == null) return null;
|
||||
return DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(block.Timestamp.ToDecimal())).UtcDateTime;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("Exception while getting timestamp for block: " + ex);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private T Retry<T>(Func<T> action)
|
||||
{
|
||||
var retry = new Retry(nameof(Web3Wrapper),
|
||||
maxTimeout: TimeSpan.FromSeconds(30),
|
||||
sleepAfterFail: TimeSpan.FromSeconds(3),
|
||||
onFail: f => { },
|
||||
failFast: false);
|
||||
|
||||
return retry.Run(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ namespace CodexContractsPlugin.ChainMonitor
|
||||
handler = changeHandler;
|
||||
this.doProofPeriodMonitoring = doProofPeriodMonitoring;
|
||||
TotalSpan = new TimeRange(startUtc, startUtc);
|
||||
PeriodMonitor = new PeriodMonitor(this.log, contracts);
|
||||
PeriodMonitor = new PeriodMonitor(contracts);
|
||||
}
|
||||
|
||||
public TimeRange TotalSpan { get; private set; }
|
||||
@ -78,7 +78,7 @@ namespace CodexContractsPlugin.ChainMonitor
|
||||
throw new Exception(msg);
|
||||
}
|
||||
|
||||
log.Log($"ChainState updating: {events.BlockInterval} = {events.All.Length} events.");
|
||||
log.Debug($"ChainState updating: {events.BlockInterval} = {events.All.Length} events.");
|
||||
|
||||
// Run through each block and apply the events to the state in order.
|
||||
var span = events.BlockInterval.TimeRange.Duration;
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
using Logging;
|
||||
using Utils;
|
||||
using Utils;
|
||||
|
||||
namespace CodexContractsPlugin.ChainMonitor
|
||||
{
|
||||
public class PeriodMonitor
|
||||
{
|
||||
private readonly ILog log;
|
||||
private readonly ICodexContracts contracts;
|
||||
private readonly List<PeriodReport> reports = new List<PeriodReport>();
|
||||
private ulong? currentPeriod = null;
|
||||
|
||||
public PeriodMonitor(ILog log, ICodexContracts contracts)
|
||||
public PeriodMonitor(ICodexContracts contracts)
|
||||
{
|
||||
this.log = log;
|
||||
this.contracts = contracts;
|
||||
}
|
||||
|
||||
@ -39,8 +36,6 @@ namespace CodexContractsPlugin.ChainMonitor
|
||||
|
||||
private void CreateReportForPeriod(ulong lastBlockInPeriod, ulong periodNumber, IChainStateRequest[] requests)
|
||||
{
|
||||
log.Log("Creating report for period " + periodNumber);
|
||||
|
||||
ulong total = 0;
|
||||
ulong required = 0;
|
||||
var missed = new List<PeriodProofMissed>();
|
||||
@ -87,6 +82,8 @@ namespace CodexContractsPlugin.ChainMonitor
|
||||
private void CalcStats()
|
||||
{
|
||||
IsEmpty = Reports.All(r => r.TotalProofsRequired == 0);
|
||||
if (Reports.Length == 0) return;
|
||||
|
||||
PeriodLow = Reports.Min(r => r.PeriodNumber);
|
||||
PeriodHigh = Reports.Max(r => r.PeriodNumber);
|
||||
AverageNumSlots = Reports.Average(r => Convert.ToSingle(r.TotalNumSlots));
|
||||
|
||||
@ -19,6 +19,7 @@ namespace CodexContractsPlugin
|
||||
SlotFreedEventDTO[] GetSlotFreedEvents();
|
||||
SlotReservationsFullEventDTO[] GetSlotReservationsFullEvents();
|
||||
ProofSubmittedEventDTO[] GetProofSubmittedEvents();
|
||||
ReserveSlotFunction[] GetReserveSlotCalls();
|
||||
}
|
||||
|
||||
public class CodexContractsEvents : ICodexContractsEvents
|
||||
@ -99,6 +100,17 @@ namespace CodexContractsPlugin
|
||||
return events.Select(SetBlockOnEvent).ToArray();
|
||||
}
|
||||
|
||||
public ReserveSlotFunction[] GetReserveSlotCalls()
|
||||
{
|
||||
var result = new List<ReserveSlotFunction>();
|
||||
gethNode.IterateFunctionCalls<ReserveSlotFunction>(BlockInterval, (b, fn) =>
|
||||
{
|
||||
fn.Block = b;
|
||||
result.Add(fn);
|
||||
});
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private T SetBlockOnEvent<T>(EventLog<T> e) where T : IHasBlock
|
||||
{
|
||||
var result = e.Event;
|
||||
|
||||
@ -15,6 +15,11 @@ namespace CodexContractsPlugin.Marketplace
|
||||
byte[] RequestId { get; set; }
|
||||
}
|
||||
|
||||
public interface IHasSlotIndex
|
||||
{
|
||||
ulong SlotIndex { get; set; }
|
||||
}
|
||||
|
||||
public partial class Request : RequestBase, IHasBlock, IHasRequestId
|
||||
{
|
||||
[JsonIgnore]
|
||||
@ -51,20 +56,20 @@ namespace CodexContractsPlugin.Marketplace
|
||||
public BlockTimeEntry Block { get; set; }
|
||||
}
|
||||
|
||||
public partial class SlotFilledEventDTO : IHasBlock, IHasRequestId
|
||||
public partial class SlotFilledEventDTO : IHasBlock, IHasRequestId, IHasSlotIndex
|
||||
{
|
||||
[JsonIgnore]
|
||||
public BlockTimeEntry Block { get; set; }
|
||||
public EthAddress Host { get; set; }
|
||||
}
|
||||
|
||||
public partial class SlotFreedEventDTO : IHasBlock, IHasRequestId
|
||||
public partial class SlotFreedEventDTO : IHasBlock, IHasRequestId, IHasSlotIndex
|
||||
{
|
||||
[JsonIgnore]
|
||||
public BlockTimeEntry Block { get; set; }
|
||||
}
|
||||
|
||||
public partial class SlotReservationsFullEventDTO : IHasBlock, IHasRequestId
|
||||
public partial class SlotReservationsFullEventDTO : IHasBlock, IHasRequestId, IHasSlotIndex
|
||||
{
|
||||
[JsonIgnore]
|
||||
public BlockTimeEntry Block { get; set; }
|
||||
@ -75,5 +80,11 @@ namespace CodexContractsPlugin.Marketplace
|
||||
[JsonIgnore]
|
||||
public BlockTimeEntry Block { get; set; }
|
||||
}
|
||||
|
||||
public partial class ReserveSlotFunction : IHasBlock, IHasRequestId, IHasSlotIndex
|
||||
{
|
||||
[JsonIgnore]
|
||||
public BlockTimeEntry Block { get; set; }
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
{
|
||||
public class CodexDockerImage
|
||||
{
|
||||
private const string DefaultDockerImage = "codexstorage/nim-codex:latest-dist-tests";
|
||||
private const string DefaultDockerImage = "codexstorage/nim-codex:0.2.2-dist-tests";
|
||||
|
||||
public static string Override { get; set; } = string.Empty;
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ namespace GethPlugin
|
||||
List<EventLog<TEvent>> GetEvents<TEvent>(string address, TimeRange timeRange) where TEvent : IEventDTO, new();
|
||||
BlockInterval ConvertTimeRangeToBlockRange(TimeRange timeRange);
|
||||
BlockTimeEntry GetBlockForNumber(ulong number);
|
||||
void IterateFunctionCalls<TFunc>(BlockInterval blockInterval, Action<BlockTimeEntry, TFunc> onCall) where TFunc : FunctionMessage, new();
|
||||
}
|
||||
|
||||
public class DeploymentGethNode : BaseGethNode, IGethNode
|
||||
@ -183,6 +184,33 @@ namespace GethPlugin
|
||||
return StartInteraction().GetBlockForNumber(number);
|
||||
}
|
||||
|
||||
public BlockWithTransactions GetBlk(ulong number)
|
||||
{
|
||||
return StartInteraction().GetBlockWithTransactions(number);
|
||||
}
|
||||
|
||||
public void IterateFunctionCalls<TFunc>(BlockInterval blockRange, Action<BlockTimeEntry, TFunc> onCall) where TFunc : FunctionMessage, new()
|
||||
{
|
||||
var i = StartInteraction();
|
||||
for (var blkI = blockRange.From; blkI <= blockRange.To; blkI++)
|
||||
{
|
||||
var blk = i.GetBlockWithTransactions(blkI);
|
||||
|
||||
foreach (var t in blk.Transactions)
|
||||
{
|
||||
if (t.IsTransactionForFunctionMessage<TFunc>())
|
||||
{
|
||||
var func = t.DecodeTransactionToFunctionMessage<TFunc>();
|
||||
if (func != null)
|
||||
{
|
||||
var b = GetBlockForNumber(blkI);
|
||||
onCall(b, func);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract NethereumInteraction StartInteraction();
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,11 +28,11 @@ namespace MetricsPlugin
|
||||
var file = log.CreateSubfile("csv");
|
||||
log.Log($"Downloading metrics for {nodeName} to file {file.Filename}");
|
||||
|
||||
file.WriteRaw(string.Join(",", headers));
|
||||
file.Write(string.Join(",", headers));
|
||||
|
||||
foreach (var pair in map)
|
||||
{
|
||||
file.WriteRaw(string.Join(",", new[] { FormatTimestamp(pair.Key) }.Concat(pair.Value)));
|
||||
file.Write(string.Join(",", new[] { FormatTimestamp(pair.Key) }.Concat(pair.Value)));
|
||||
}
|
||||
|
||||
return file;
|
||||
|
||||
@ -199,7 +199,7 @@ namespace ContinuousTests
|
||||
|
||||
private void WriteEntryToFile(LogQueueEntry currentEntry)
|
||||
{
|
||||
targetFile.WriteRaw(currentEntry.Message);
|
||||
targetFile.Write(currentEntry.Message);
|
||||
}
|
||||
|
||||
private void DeleteOldEntries(ulong wantedNumber)
|
||||
|
||||
78
Tests/CodexReleaseTests/MarketTests/ChainMonitor.cs
Normal file
78
Tests/CodexReleaseTests/MarketTests/ChainMonitor.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using CodexContractsPlugin;
|
||||
using CodexContractsPlugin.ChainMonitor;
|
||||
using Logging;
|
||||
|
||||
namespace CodexReleaseTests.MarketTests
|
||||
{
|
||||
public class ChainMonitor
|
||||
{
|
||||
private readonly ILog log;
|
||||
private readonly ICodexContracts contracts;
|
||||
private readonly DateTime startUtc;
|
||||
private readonly TimeSpan updateInterval;
|
||||
private CancellationTokenSource cts = new CancellationTokenSource();
|
||||
private Task worker = Task.CompletedTask;
|
||||
|
||||
public ChainMonitor(ILog log, ICodexContracts contracts, DateTime startUtc)
|
||||
: this(log, contracts, startUtc, TimeSpan.FromSeconds(3.0))
|
||||
{
|
||||
}
|
||||
|
||||
public ChainMonitor(ILog log, ICodexContracts contracts, DateTime startUtc, TimeSpan updateInterval)
|
||||
{
|
||||
this.log = log;
|
||||
this.contracts = contracts;
|
||||
this.startUtc = startUtc;
|
||||
this.updateInterval = updateInterval;
|
||||
}
|
||||
|
||||
public void Start(Action onFailure)
|
||||
{
|
||||
cts = new CancellationTokenSource();
|
||||
worker = Task.Run(() => Worker(onFailure));
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
cts.Cancel();
|
||||
worker.Wait();
|
||||
if (worker.Exception != null) throw worker.Exception;
|
||||
}
|
||||
|
||||
private void Worker(Action onFailure)
|
||||
{
|
||||
var state = new ChainState(log, contracts, new DoNothingChainEventHandler(), startUtc, doProofPeriodMonitoring: true);
|
||||
Thread.Sleep(updateInterval);
|
||||
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
UpdateChainState(state);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("Exception in chain monitor: " + ex);
|
||||
onFailure();
|
||||
throw;
|
||||
}
|
||||
|
||||
cts.Token.WaitHandle.WaitOne(updateInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateChainState(ChainState state)
|
||||
{
|
||||
state.Update();
|
||||
|
||||
var reports = state.PeriodMonitor.GetAndClearReports();
|
||||
if (reports.IsEmpty) return;
|
||||
|
||||
var slots = reports.Reports.Sum(r => Convert.ToInt32(r.TotalNumSlots));
|
||||
var required = reports.Reports.Sum(r => Convert.ToInt32(r.TotalProofsRequired));
|
||||
var missed = reports.Reports.Sum(r => r.MissedProofs.Length);
|
||||
|
||||
log.Log($"Proof report: Slots={slots} Required={required} Missed={missed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,16 +4,30 @@ using Utils;
|
||||
|
||||
namespace CodexReleaseTests.MarketTests
|
||||
{
|
||||
[TestFixture]
|
||||
[TestFixture(6, 3, 1)]
|
||||
[TestFixture(6, 4, 2)]
|
||||
[TestFixture(8, 5, 1)]
|
||||
[TestFixture(8, 6, 1)]
|
||||
[TestFixture(8, 6, 3)]
|
||||
public class ContractSuccessfulTest : MarketplaceAutoBootstrapDistTest
|
||||
{
|
||||
private const int FilesizeMb = 10;
|
||||
public ContractSuccessfulTest(int hosts, int slots, int tolerance)
|
||||
{
|
||||
this.hosts = hosts;
|
||||
this.slots = slots;
|
||||
this.tolerance = tolerance;
|
||||
}
|
||||
|
||||
protected override int NumberOfHosts => 6;
|
||||
private const int FilesizeMb = 10;
|
||||
private readonly TestToken pricePerBytePerSecond = 10.TstWei();
|
||||
private readonly int hosts;
|
||||
private readonly int slots;
|
||||
private readonly int tolerance;
|
||||
|
||||
protected override int NumberOfHosts => hosts;
|
||||
protected override int NumberOfClients => 1;
|
||||
protected override ByteSize HostAvailabilitySize => (5 * FilesizeMb).MB();
|
||||
protected override TimeSpan HostAvailabilityMaxDuration => Get8TimesConfiguredPeriodDuration();
|
||||
private readonly TestToken pricePerBytePerSecond = 10.TstWei();
|
||||
protected override TimeSpan HostAvailabilityMaxDuration => Get8TimesConfiguredPeriodDuration() * 12;
|
||||
|
||||
[Test]
|
||||
public void ContractSuccessful()
|
||||
@ -26,9 +40,12 @@ namespace CodexReleaseTests.MarketTests
|
||||
request.WaitForStorageContractSubmitted();
|
||||
AssertContractIsOnChain(request);
|
||||
|
||||
request.WaitForStorageContractStarted();
|
||||
WaitForContractStarted(request);
|
||||
AssertContractSlotsAreFilledByHosts(request, hosts);
|
||||
|
||||
Thread.Sleep(TimeSpan.FromSeconds(12.0));
|
||||
return;
|
||||
|
||||
request.WaitForStorageContractFinished();
|
||||
|
||||
AssertClientHasPaidForContract(pricePerBytePerSecond, client, request, hosts);
|
||||
@ -44,11 +61,8 @@ namespace CodexReleaseTests.MarketTests
|
||||
{
|
||||
Duration = GetContractDuration(),
|
||||
Expiry = GetContractExpiry(),
|
||||
// TODO: this should work with NumberOfHosts, but
|
||||
// an ongoing issue makes hosts sometimes not pick up slots.
|
||||
// When it's resolved, we can reduce the number of hosts and slim down this test.
|
||||
MinRequiredNumberOfNodes = 3,
|
||||
NodeFailureTolerance = 1,
|
||||
MinRequiredNumberOfNodes = (uint)slots,
|
||||
NodeFailureTolerance = (uint)tolerance,
|
||||
PricePerBytePerSecond = pricePerBytePerSecond,
|
||||
ProofProbability = 20,
|
||||
CollateralPerByte = 100.TstWei()
|
||||
@ -62,7 +76,7 @@ namespace CodexReleaseTests.MarketTests
|
||||
|
||||
private TimeSpan GetContractDuration()
|
||||
{
|
||||
return Get8TimesConfiguredPeriodDuration() / 2;
|
||||
return Get8TimesConfiguredPeriodDuration() * 4;
|
||||
}
|
||||
|
||||
private TimeSpan Get8TimesConfiguredPeriodDuration()
|
||||
|
||||
@ -4,6 +4,7 @@ using CodexContractsPlugin.Marketplace;
|
||||
using CodexPlugin;
|
||||
using CodexTests;
|
||||
using GethPlugin;
|
||||
using Logging;
|
||||
using Nethereum.Hex.HexConvertors.Extensions;
|
||||
using NUnit.Framework;
|
||||
using Utils;
|
||||
@ -21,7 +22,14 @@ namespace CodexReleaseTests.MarketTests
|
||||
{
|
||||
var geth = StartGethNode(s => s.IsMiner());
|
||||
var contracts = Ci.StartCodexContracts(geth, BootstrapNode.Version);
|
||||
handle = new MarketplaceHandle(geth, contracts);
|
||||
var monitor = SetupChainMonitor(GetTestLog(), contracts, GetTestRunTimeRange().From);
|
||||
handle = new MarketplaceHandle(geth, contracts, monitor);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDownMarketplace()
|
||||
{
|
||||
if (handle.ChainMonitor != null) handle.ChainMonitor.Stop();
|
||||
}
|
||||
|
||||
protected IGethNode GetGeth()
|
||||
@ -44,6 +52,7 @@ namespace CodexReleaseTests.MarketTests
|
||||
protected abstract int NumberOfClients { get; }
|
||||
protected abstract ByteSize HostAvailabilitySize { get; }
|
||||
protected abstract TimeSpan HostAvailabilityMaxDuration { get; }
|
||||
protected virtual bool MonitorChainState { get; } = true;
|
||||
|
||||
public ICodexNodeGroup StartHosts()
|
||||
{
|
||||
@ -106,6 +115,19 @@ namespace CodexReleaseTests.MarketTests
|
||||
});
|
||||
}
|
||||
|
||||
private ChainMonitor? SetupChainMonitor(ILog log, ICodexContracts contracts, DateTime startUtc)
|
||||
{
|
||||
if (!MonitorChainState) return null;
|
||||
|
||||
var result = new ChainMonitor(log, contracts, startUtc);
|
||||
result.Start(() =>
|
||||
{
|
||||
log.Error("Failure in chain monitor. No chain updates after this point.");
|
||||
//Assert.Fail("Failure in chain monitor.");
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private Retry GetBalanceAssertRetry()
|
||||
{
|
||||
return new Retry("AssertBalance",
|
||||
@ -232,6 +254,28 @@ namespace CodexReleaseTests.MarketTests
|
||||
}
|
||||
}
|
||||
|
||||
protected void WaitForContractStarted(IStoragePurchaseContract r)
|
||||
{
|
||||
try
|
||||
{
|
||||
r.WaitForStorageContractStarted();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Contract failed to start. Retrieve and log every call to ReserveSlot to identify which hosts
|
||||
// should have filled the slot.
|
||||
|
||||
var requestId = r.PurchaseId.ToLowerInvariant();
|
||||
var calls = GetContracts().GetEvents(GetTestRunTimeRange()).GetReserveSlotCalls();
|
||||
|
||||
Log($"Request '{requestId}' failed to start. There were {calls.Length} hosts who called reserve-slot for it:");
|
||||
foreach (var c in calls)
|
||||
{
|
||||
Log($" - {c.Block.Utc} Host: {c.FromAddress} RequestId: {c.RequestId.ToHex()} SlotIndex: {c.SlotIndex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TestToken GetContractFinalCost(TestToken pricePerBytePerSecond, IStoragePurchaseContract contract, ICodexNodeGroup hosts)
|
||||
{
|
||||
var fills = GetOnChainSlotFills(hosts);
|
||||
@ -324,14 +368,16 @@ namespace CodexReleaseTests.MarketTests
|
||||
|
||||
private class MarketplaceHandle
|
||||
{
|
||||
public MarketplaceHandle(IGethNode geth, ICodexContracts contracts)
|
||||
public MarketplaceHandle(IGethNode geth, ICodexContracts contracts, ChainMonitor? chainMonitor)
|
||||
{
|
||||
Geth = geth;
|
||||
Contracts = contracts;
|
||||
ChainMonitor = chainMonitor;
|
||||
}
|
||||
|
||||
public IGethNode Geth { get; }
|
||||
public ICodexContracts Contracts { get; }
|
||||
public ChainMonitor? ChainMonitor { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,28 +1,55 @@
|
||||
using CodexClient;
|
||||
using CodexPlugin;
|
||||
using NUnit.Framework;
|
||||
using Utils;
|
||||
|
||||
namespace CodexReleaseTests.MarketTests
|
||||
{
|
||||
[TestFixture]
|
||||
//[TestFixture(8, 3, 1)]
|
||||
[TestFixture(8, 4, 1)]
|
||||
//[TestFixture(10, 5, 1)]
|
||||
//[TestFixture(10, 6, 1)]
|
||||
//[TestFixture(10, 6, 3)]
|
||||
public class MultipleContractsTest : MarketplaceAutoBootstrapDistTest
|
||||
{
|
||||
private const int FilesizeMb = 10;
|
||||
public MultipleContractsTest(int hosts, int slots, int tolerance)
|
||||
{
|
||||
this.hosts = hosts;
|
||||
this.slots = slots;
|
||||
this.tolerance = tolerance;
|
||||
}
|
||||
|
||||
protected override int NumberOfHosts => 8;
|
||||
protected override int NumberOfClients => 3;
|
||||
protected override ByteSize HostAvailabilitySize => (5 * FilesizeMb).MB();
|
||||
protected override TimeSpan HostAvailabilityMaxDuration => Get8TimesConfiguredPeriodDuration();
|
||||
private const int FilesizeMb = 10;
|
||||
private readonly int hosts;
|
||||
private readonly int slots;
|
||||
private readonly int tolerance;
|
||||
|
||||
protected override int NumberOfHosts => hosts;
|
||||
protected override int NumberOfClients => 8;
|
||||
protected override ByteSize HostAvailabilitySize => (1000 * FilesizeMb).MB();
|
||||
protected override TimeSpan HostAvailabilityMaxDuration => Get8TimesConfiguredPeriodDuration() * 12;
|
||||
private readonly TestToken pricePerBytePerSecond = 10.TstWei();
|
||||
|
||||
[Test]
|
||||
[Ignore("TODO - Test where multiple successful contracts are run simultaenously")]
|
||||
public void MultipleSuccessfulContracts()
|
||||
[Combinatorial]
|
||||
public void MultipleContractGenerations(
|
||||
[Values(50)] int numGenerations)
|
||||
{
|
||||
var hosts = StartHosts();
|
||||
var clients = StartClients();
|
||||
|
||||
var requests = clients.Select(c => CreateStorageRequest(c)).ToArray();
|
||||
for (var i = 0; i < numGenerations; i++)
|
||||
{
|
||||
Log("Generation: " + i);
|
||||
Generation(clients, hosts);
|
||||
}
|
||||
|
||||
Thread.Sleep(TimeSpan.FromSeconds(12.0));
|
||||
}
|
||||
|
||||
private void Generation(ICodexNodeGroup clients, ICodexNodeGroup hosts)
|
||||
{
|
||||
var requests = All(clients.ToArray(), CreateStorageRequest);
|
||||
|
||||
All(requests, r =>
|
||||
{
|
||||
@ -30,28 +57,32 @@ namespace CodexReleaseTests.MarketTests
|
||||
AssertContractIsOnChain(r);
|
||||
});
|
||||
|
||||
All(requests, r => r.WaitForStorageContractStarted());
|
||||
All(requests, r => AssertContractSlotsAreFilledByHosts(r, hosts));
|
||||
All(requests, WaitForContractStarted);
|
||||
|
||||
All(requests, r => r.WaitForStorageContractFinished());
|
||||
|
||||
// todo: removed from codexclient:
|
||||
//contracts.WaitUntilNextPeriod();
|
||||
//contracts.WaitUntilNextPeriod();
|
||||
|
||||
//var blocks = 3;
|
||||
//Log($"Waiting {blocks} blocks for nodes to process payouts...");
|
||||
//Thread.Sleep(GethContainerRecipe.BlockInterval * blocks);
|
||||
|
||||
// todo:
|
||||
//AssertClientHasPaidForContract(pricePerSlotPerSecond, client, request, hosts);
|
||||
//AssertHostsWerePaidForContract(pricePerSlotPerSecond, request, hosts);
|
||||
//AssertHostsCollateralsAreUnchanged(hosts);
|
||||
// for the time being, we're only interested in whether these contracts start.
|
||||
//All(requests, r => AssertContractSlotsAreFilledByHosts(r, hosts));
|
||||
//All(requests, r => r.WaitForStorageContractFinished());
|
||||
}
|
||||
|
||||
private void All(IStoragePurchaseContract[] requests, Action<IStoragePurchaseContract> action)
|
||||
private void All<T>(T[] items, Action<T> action)
|
||||
{
|
||||
foreach (var r in requests) action(r);
|
||||
var tasks = items.Select(r => Task.Run(() => action(r))).ToArray();
|
||||
Task.WaitAll(tasks);
|
||||
foreach(var t in tasks)
|
||||
{
|
||||
if (t.Exception != null) throw t.Exception;
|
||||
}
|
||||
}
|
||||
|
||||
private TResult[] All<T, TResult>(T[] items, Func<T, TResult> action)
|
||||
{
|
||||
var tasks = items.Select(r => Task.Run(() => action(r))).ToArray();
|
||||
Task.WaitAll(tasks);
|
||||
foreach (var t in tasks)
|
||||
{
|
||||
if (t.Exception != null) throw t.Exception;
|
||||
}
|
||||
return tasks.Select(t => t.Result).ToArray();
|
||||
}
|
||||
|
||||
private IStoragePurchaseContract CreateStorageRequest(ICodexNode client)
|
||||
@ -62,11 +93,11 @@ namespace CodexReleaseTests.MarketTests
|
||||
{
|
||||
Duration = GetContractDuration(),
|
||||
Expiry = GetContractExpiry(),
|
||||
MinRequiredNumberOfNodes = (uint)NumberOfHosts,
|
||||
NodeFailureTolerance = (uint)(NumberOfHosts / 2),
|
||||
MinRequiredNumberOfNodes = (uint)slots,
|
||||
NodeFailureTolerance = (uint)tolerance,
|
||||
PricePerBytePerSecond = pricePerBytePerSecond,
|
||||
ProofProbability = 20,
|
||||
CollateralPerByte = 1.Tst()
|
||||
ProofProbability = 1,
|
||||
CollateralPerByte = 1.TstWei()
|
||||
});
|
||||
}
|
||||
|
||||
@ -77,7 +108,7 @@ namespace CodexReleaseTests.MarketTests
|
||||
|
||||
private TimeSpan GetContractDuration()
|
||||
{
|
||||
return Get8TimesConfiguredPeriodDuration() / 2;
|
||||
return Get8TimesConfiguredPeriodDuration() * 4;
|
||||
}
|
||||
|
||||
private TimeSpan Get8TimesConfiguredPeriodDuration()
|
||||
|
||||
@ -137,7 +137,7 @@ namespace DistTestCore
|
||||
|
||||
protected TimeRange GetTestRunTimeRange()
|
||||
{
|
||||
return new TimeRange(lifecycle.TestStart, DateTime.UtcNow);
|
||||
return new TimeRange(lifecycle.TestStartUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
protected virtual void Initialize(FixtureLog fixtureLog)
|
||||
|
||||
@ -8,7 +8,7 @@ namespace DistTestCore.Logs
|
||||
|
||||
protected BaseTestLog(ILog backingLog, string deployId)
|
||||
{
|
||||
this.backingLog = backingLog;
|
||||
this.backingLog = new TimestampPrefixer(backingLog);
|
||||
|
||||
DeployId = deployId;
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ namespace DistTestCore
|
||||
WebCallTimeSet = webCallTimeSet;
|
||||
K8STimeSet = k8sTimeSet;
|
||||
TestNamespace = testNamespace;
|
||||
TestStart = DateTime.UtcNow;
|
||||
TestStartUtc = DateTime.UtcNow;
|
||||
|
||||
entryPoint = new EntryPoint(log, configuration.GetK8sConfiguration(k8sTimeSet, this, testNamespace), configuration.GetFileManagerFolder(), webCallTimeSet, k8sTimeSet);
|
||||
metadata = entryPoint.GetPluginMetadata();
|
||||
@ -36,7 +36,7 @@ namespace DistTestCore
|
||||
log.WriteLogTag();
|
||||
}
|
||||
|
||||
public DateTime TestStart { get; }
|
||||
public DateTime TestStartUtc { get; }
|
||||
public TestLog Log { get; }
|
||||
public Configuration Configuration { get; }
|
||||
public IWebCallTimeSet WebCallTimeSet { get; }
|
||||
@ -76,7 +76,7 @@ namespace DistTestCore
|
||||
|
||||
public TimeSpan GetTestDuration()
|
||||
{
|
||||
return DateTime.UtcNow - TestStart;
|
||||
return DateTime.UtcNow - TestStartUtc;
|
||||
}
|
||||
|
||||
public void OnContainersStarted(RunningPod rc)
|
||||
|
||||
103
Tools/TraceContract/ChainRequestTracker.cs
Normal file
103
Tools/TraceContract/ChainRequestTracker.cs
Normal file
@ -0,0 +1,103 @@
|
||||
using System.Numerics;
|
||||
using BlockchainUtils;
|
||||
using CodexContractsPlugin.ChainMonitor;
|
||||
using Utils;
|
||||
|
||||
namespace TraceContract
|
||||
{
|
||||
public class ChainRequestTracker : IChainStateChangeHandler
|
||||
{
|
||||
private readonly string requestId;
|
||||
private readonly Output output;
|
||||
|
||||
public ChainRequestTracker(Output output, string requestId)
|
||||
{
|
||||
this.requestId = requestId.ToLowerInvariant();
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
public bool IsFinished { get; private set; } = false;
|
||||
public DateTime FinishUtc { get; private set; } = DateTime.MinValue;
|
||||
|
||||
public void OnError(string msg)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnNewRequest(RequestEvent requestEvent)
|
||||
{
|
||||
if (IsMyRequest(requestEvent)) output.LogRequestCreated(requestEvent);
|
||||
}
|
||||
|
||||
public void OnProofSubmitted(BlockTimeEntry block, string id)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnRequestCancelled(RequestEvent requestEvent)
|
||||
{
|
||||
if (IsMyRequest(requestEvent))
|
||||
{
|
||||
IsFinished = true;
|
||||
FinishUtc = requestEvent.Block.Utc;
|
||||
output.LogRequestCancelled(requestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnRequestFailed(RequestEvent requestEvent)
|
||||
{
|
||||
if (IsMyRequest(requestEvent))
|
||||
{
|
||||
IsFinished = true;
|
||||
FinishUtc = requestEvent.Block.Utc;
|
||||
output.LogRequestFailed(requestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnRequestFinished(RequestEvent requestEvent)
|
||||
{
|
||||
if (IsMyRequest(requestEvent))
|
||||
{
|
||||
IsFinished = true;
|
||||
FinishUtc = requestEvent.Block.Utc;
|
||||
output.LogRequestFinished(requestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnRequestFulfilled(RequestEvent requestEvent)
|
||||
{
|
||||
if (IsMyRequest(requestEvent))
|
||||
{
|
||||
output.LogRequestStarted(requestEvent);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSlotFilled(RequestEvent requestEvent, EthAddress host, BigInteger slotIndex)
|
||||
{
|
||||
if (IsMyRequest(requestEvent))
|
||||
{
|
||||
output.LogSlotFilled(requestEvent, host, slotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSlotFreed(RequestEvent requestEvent, BigInteger slotIndex)
|
||||
{
|
||||
if (IsMyRequest(requestEvent))
|
||||
{
|
||||
output.LogSlotFreed(requestEvent, slotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSlotReservationsFull(RequestEvent requestEvent, BigInteger slotIndex)
|
||||
{
|
||||
if (IsMyRequest(requestEvent))
|
||||
{
|
||||
output.LogSlotReservationsFull(requestEvent, slotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsMyRequest(RequestEvent requestEvent)
|
||||
{
|
||||
return requestId == requestEvent.Request.Request.Id.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
119
Tools/TraceContract/ChainTracer.cs
Normal file
119
Tools/TraceContract/ChainTracer.cs
Normal file
@ -0,0 +1,119 @@
|
||||
using CodexContractsPlugin;
|
||||
using CodexContractsPlugin.ChainMonitor;
|
||||
using CodexContractsPlugin.Marketplace;
|
||||
using Logging;
|
||||
using Nethereum.Hex.HexConvertors.Extensions;
|
||||
using Utils;
|
||||
|
||||
namespace TraceContract
|
||||
{
|
||||
public class ChainTracer
|
||||
{
|
||||
private readonly ILog log;
|
||||
private readonly ICodexContracts contracts;
|
||||
private readonly Input input;
|
||||
private readonly Output output;
|
||||
|
||||
public ChainTracer(ILog log, ICodexContracts contracts, Input input, Output output)
|
||||
{
|
||||
this.log = log;
|
||||
this.contracts = contracts;
|
||||
this.input = input;
|
||||
this.output = output;
|
||||
}
|
||||
|
||||
public TimeRange TraceChainTimeline()
|
||||
{
|
||||
log.Log("Querying blockchain...");
|
||||
var request = GetRequest();
|
||||
if (request == null) throw new Exception("Failed to find the purchase in the last week of transactions.");
|
||||
|
||||
log.Log($"Request started at {request.Block.Utc}");
|
||||
var contractEnd = RunToContractEnd(request);
|
||||
|
||||
var requestTimeline = new TimeRange(request.Block.Utc.AddMinutes(-1.0), contractEnd.AddMinutes(1.0));
|
||||
log.Log($"Request timeline: {requestTimeline.From} -> {requestTimeline.To}");
|
||||
|
||||
// For this timeline, we log all the calls to reserve-slot.
|
||||
var events = contracts.GetEvents(requestTimeline);
|
||||
output.LogReserveSlotCalls(Filter(events.GetReserveSlotCalls()));
|
||||
|
||||
log.Log("Writing blockchain output...");
|
||||
output.WriteContractEvents();
|
||||
|
||||
return requestTimeline;
|
||||
}
|
||||
|
||||
private DateTime RunToContractEnd(Request request)
|
||||
{
|
||||
var utc = request.Block.Utc.AddMinutes(-1.0);
|
||||
var tracker = new ChainRequestTracker(output, input.PurchaseId);
|
||||
var ignoreLog = new NullLog();
|
||||
var chainState = new ChainState(ignoreLog, contracts, tracker, utc, false);
|
||||
|
||||
while (!tracker.IsFinished)
|
||||
{
|
||||
utc += TimeSpan.FromHours(1.0);
|
||||
if (utc > DateTime.UtcNow)
|
||||
{
|
||||
log.Log("Caught up to present moment without finding contract end.");
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
|
||||
log.Log($"Querying up to {utc}");
|
||||
chainState.Update(utc);
|
||||
}
|
||||
|
||||
return tracker.FinishUtc;
|
||||
}
|
||||
|
||||
private ReserveSlotFunction[] Filter(ReserveSlotFunction[] calls)
|
||||
{
|
||||
return calls.Where(c => IsThisRequest(c.RequestId)).ToArray();
|
||||
}
|
||||
|
||||
private Request? GetRequest()
|
||||
{
|
||||
var request = FindRequest(LastHour());
|
||||
if (request == null) request = FindRequest(LastDay());
|
||||
if (request == null) request = FindRequest(LastWeek());
|
||||
return request;
|
||||
}
|
||||
|
||||
private Request? FindRequest(TimeRange timeRange)
|
||||
{
|
||||
var events = contracts.GetEvents(timeRange);
|
||||
var requests = events.GetStorageRequests();
|
||||
|
||||
foreach (var r in requests)
|
||||
{
|
||||
if (IsThisRequest(r.RequestId))
|
||||
{
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool IsThisRequest(byte[] requestId)
|
||||
{
|
||||
return requestId.ToHex().ToLowerInvariant() == input.PurchaseId.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static TimeRange LastHour()
|
||||
{
|
||||
return new TimeRange(DateTime.UtcNow.AddHours(-1.0), DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static TimeRange LastDay()
|
||||
{
|
||||
return new TimeRange(DateTime.UtcNow.AddDays(-1.0), DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static TimeRange LastWeek()
|
||||
{
|
||||
return new TimeRange(DateTime.UtcNow.AddDays(-7.0), DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Tools/TraceContract/Config.cs
Normal file
76
Tools/TraceContract/Config.cs
Normal file
File diff suppressed because one or more lines are too long
253
Tools/TraceContract/ElasticSearchLogDownloader.cs
Normal file
253
Tools/TraceContract/ElasticSearchLogDownloader.cs
Normal file
@ -0,0 +1,253 @@
|
||||
using System.Text;
|
||||
using Core;
|
||||
using Logging;
|
||||
using Utils;
|
||||
using WebUtils;
|
||||
|
||||
namespace TraceContract
|
||||
{
|
||||
public class ElasticSearchLogDownloader
|
||||
{
|
||||
private readonly ILog log;
|
||||
private readonly IPluginTools tools;
|
||||
private readonly Config config;
|
||||
|
||||
public ElasticSearchLogDownloader(ILog log, IPluginTools tools, Config config)
|
||||
{
|
||||
this.log = log;
|
||||
this.tools = tools;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public void Download(LogFile targetFile, string podName, DateTime startUtc, DateTime endUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
DownloadLog(targetFile, podName, startUtc, endUtc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Error("Failed to download log: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void DownloadLog(LogFile targetFile, string podName, DateTime startUtc, DateTime endUtc)
|
||||
{
|
||||
log.Log($"Downloading log (from ElasticSearch) for pod '{podName}' within time range: " +
|
||||
$"{startUtc.ToString("o")} - {endUtc.ToString("o")}");
|
||||
|
||||
var endpoint = CreateElasticSearchEndpoint();
|
||||
var queryTemplate = CreateQueryTemplate(podName, startUtc, endUtc);
|
||||
|
||||
targetFile.Write($"Downloading '{podName}' to '{targetFile.Filename}'.");
|
||||
var reconstructor = new LogReconstructor(targetFile, endpoint, queryTemplate);
|
||||
reconstructor.DownloadFullLog();
|
||||
|
||||
log.Log("Log download finished.");
|
||||
}
|
||||
|
||||
private string CreateQueryTemplate(string podName, DateTime startUtc, DateTime endUtc)
|
||||
{
|
||||
var start = startUtc.ToString("o");
|
||||
var end = endUtc.ToString("o");
|
||||
|
||||
//container_name : codex3-5 - deploymentName as stored in pod
|
||||
// pod_namespace : codex - continuous - nolimits - tests - 1
|
||||
|
||||
//var source = "{ \"sort\": [ { \"@timestamp\": { \"order\": \"asc\" } } ], \"fields\": [ { \"field\": \"@timestamp\", \"format\": \"strict_date_optional_time\" }, { \"field\": \"pod_name\" }, { \"field\": \"message\" } ], \"size\": <SIZE>, <SEARCHAFTER> \"_source\": false, \"query\": { \"bool\": { \"must\": [], \"filter\": [ { \"range\": { \"@timestamp\": { \"format\": \"strict_date_optional_time\", \"gte\": \"<STARTTIME>\", \"lte\": \"<ENDTIME>\" } } }, { \"match_phrase\": { \"pod_name\": \"<PODNAME>\" } } ] } } }";
|
||||
var source = "{ \"sort\": [ { \"@timestamp\": { \"order\": \"asc\" } } ], \"fields\": [ { \"field\": \"@timestamp\", \"format\": \"strict_date_optional_time\" }, { \"field\": \"message\" } ], \"size\": <SIZE>, <SEARCHAFTER> \"_source\": false, \"query\": { \"bool\": { \"must\": [], \"filter\": [ { \"range\": { \"@timestamp\": { \"format\": \"strict_date_optional_time\", \"gte\": \"<STARTTIME>\", \"lte\": \"<ENDTIME>\" } } }, { \"match_phrase\": { \"pod_name\": \"<PODNAME>\" } } ] } } }";
|
||||
return source
|
||||
.Replace("<STARTTIME>", start)
|
||||
.Replace("<ENDTIME>", end)
|
||||
.Replace("<PODNAME>", podName);
|
||||
//.Replace("<NAMESPACENAME>", config.StorageNodesKubernetesNamespace);
|
||||
}
|
||||
|
||||
private IEndpoint CreateElasticSearchEndpoint()
|
||||
{
|
||||
//var serviceName = "elasticsearch";
|
||||
//var k8sNamespace = "monitoring";
|
||||
//var address = new Address("ElasticSearchEndpoint", $"http://{serviceName}.{k8sNamespace}.svc.cluster.local", 9200);
|
||||
|
||||
var address = new Address("TestnetElasticSearchEndpoint", config.ElasticSearchUrl, 443);
|
||||
var baseUrl = "";
|
||||
|
||||
var username = config.GetElasticSearchUsername();
|
||||
var password = config.GetElasticSearchPassword();
|
||||
|
||||
var base64Creds = Convert.ToBase64String(
|
||||
Encoding.ASCII.GetBytes($"{username}:{password}")
|
||||
);
|
||||
|
||||
var http = tools.CreateHttp(address.ToString(), client =>
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("kbn-xsrf", "reporting");
|
||||
client.DefaultRequestHeaders.Add("Authorization", "Basic " + base64Creds);
|
||||
});
|
||||
|
||||
return http.CreateEndpoint(address, baseUrl);
|
||||
}
|
||||
|
||||
public class LogReconstructor
|
||||
{
|
||||
private readonly List<LogQueueEntry> queue = new List<LogQueueEntry>();
|
||||
private readonly LogFile targetFile;
|
||||
private readonly IEndpoint endpoint;
|
||||
private readonly string queryTemplate;
|
||||
private const int sizeOfPage = 2000;
|
||||
private string searchAfter = "";
|
||||
private int lastHits = 1;
|
||||
private ulong? lastLogLine;
|
||||
|
||||
public LogReconstructor(LogFile targetFile, IEndpoint endpoint, string queryTemplate)
|
||||
{
|
||||
this.targetFile = targetFile;
|
||||
this.endpoint = endpoint;
|
||||
this.queryTemplate = queryTemplate;
|
||||
}
|
||||
|
||||
public void DownloadFullLog()
|
||||
{
|
||||
while (lastHits > 0)
|
||||
{
|
||||
QueryElasticSearch();
|
||||
ProcessQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private void QueryElasticSearch()
|
||||
{
|
||||
var query = queryTemplate
|
||||
.Replace("<SIZE>", sizeOfPage.ToString())
|
||||
.Replace("<SEARCHAFTER>", searchAfter);
|
||||
|
||||
var response = endpoint.HttpPostString<SearchResponse>("/_search", query);
|
||||
|
||||
lastHits = response.hits.hits.Length;
|
||||
if (lastHits > 0)
|
||||
{
|
||||
UpdateSearchAfter(response);
|
||||
foreach (var hit in response.hits.hits)
|
||||
{
|
||||
AddHitToQueue(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void AddHitToQueue(SearchHitEntry hit)
|
||||
{
|
||||
var message = hit.fields.message.Single();
|
||||
var number = ParseCountNumber(message);
|
||||
if (number != null)
|
||||
{
|
||||
queue.Add(new LogQueueEntry(message, number.Value));
|
||||
}
|
||||
}
|
||||
|
||||
private ulong? ParseCountNumber(string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return null;
|
||||
var tokens = message.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (!tokens.Any()) return null;
|
||||
var countToken = tokens.SingleOrDefault(t => t.StartsWith("count="));
|
||||
if (countToken == null) return null;
|
||||
var number = countToken.Substring(6);
|
||||
if (ulong.TryParse(number, out ulong value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void UpdateSearchAfter(SearchResponse response)
|
||||
{
|
||||
var uniqueSearchNumbers = response.hits.hits.Select(h => h.sort.Single()).Distinct().ToList();
|
||||
uniqueSearchNumbers.Reverse();
|
||||
|
||||
var searchNumber = GetSearchNumber(uniqueSearchNumbers);
|
||||
searchAfter = $"\"search_after\": [{searchNumber}],";
|
||||
}
|
||||
|
||||
private long GetSearchNumber(List<long> uniqueSearchNumbers)
|
||||
{
|
||||
if (uniqueSearchNumbers.Count == 1) return uniqueSearchNumbers.First();
|
||||
return uniqueSearchNumbers.Skip(1).First();
|
||||
}
|
||||
|
||||
private void ProcessQueue()
|
||||
{
|
||||
if (lastLogLine == null)
|
||||
{
|
||||
lastLogLine = queue.Min(q => q.Number) - 1;
|
||||
}
|
||||
|
||||
while (queue.Any())
|
||||
{
|
||||
ulong wantedNumber = lastLogLine.Value + 1;
|
||||
|
||||
DeleteOldEntries(wantedNumber);
|
||||
|
||||
var currentEntry = queue.FirstOrDefault(e => e.Number == wantedNumber);
|
||||
|
||||
if (currentEntry != null)
|
||||
{
|
||||
WriteEntryToFile(currentEntry);
|
||||
queue.Remove(currentEntry);
|
||||
lastLogLine = currentEntry.Number;
|
||||
}
|
||||
else
|
||||
{
|
||||
// The line number we want is not in the queue.
|
||||
// It will be returned by the elastic search query, some time in the future.
|
||||
// Stop processing the queue for now.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteEntryToFile(LogQueueEntry currentEntry)
|
||||
{
|
||||
targetFile.Write(currentEntry.Message);
|
||||
}
|
||||
|
||||
private void DeleteOldEntries(ulong wantedNumber)
|
||||
{
|
||||
queue.RemoveAll(e => e.Number < wantedNumber);
|
||||
}
|
||||
|
||||
public class LogQueueEntry
|
||||
{
|
||||
public LogQueueEntry(string message, ulong number)
|
||||
{
|
||||
Message = message;
|
||||
Number = number;
|
||||
}
|
||||
|
||||
public string Message { get; }
|
||||
public ulong Number { get; }
|
||||
}
|
||||
|
||||
public class SearchResponse
|
||||
{
|
||||
public SearchHits hits { get; set; } = new SearchHits();
|
||||
}
|
||||
|
||||
public class SearchHits
|
||||
{
|
||||
public SearchHitEntry[] hits { get; set; } = Array.Empty<SearchHitEntry>();
|
||||
}
|
||||
|
||||
public class SearchHitEntry
|
||||
{
|
||||
public SearchHitFields fields { get; set; } = new SearchHitFields();
|
||||
public long[] sort { get; set; } = Array.Empty<long>();
|
||||
}
|
||||
|
||||
public class SearchHitFields
|
||||
{
|
||||
public string[] @timestamp { get; set; } = Array.Empty<string>();
|
||||
public string[] message { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Tools/TraceContract/Input.cs
Normal file
21
Tools/TraceContract/Input.cs
Normal file
@ -0,0 +1,21 @@
|
||||
namespace TraceContract
|
||||
{
|
||||
public class Input
|
||||
{
|
||||
public string PurchaseId
|
||||
{
|
||||
get
|
||||
{
|
||||
var v = Environment.GetEnvironmentVariable("PURCHASE_ID");
|
||||
if (!string.IsNullOrEmpty(v)) return v;
|
||||
|
||||
return
|
||||
// expired:
|
||||
"a7fe97dc32216aba0cbe74b87beb3f919aa116090dd5e0d48085a1a6b0080e82";
|
||||
|
||||
// started:
|
||||
//"066df09a3a2e2587cfd577a0e96186c915b113d02b331b06e56f808494cff2b4";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
Tools/TraceContract/Output.cs
Normal file
134
Tools/TraceContract/Output.cs
Normal file
@ -0,0 +1,134 @@
|
||||
using System.IO.Compression;
|
||||
using System.Numerics;
|
||||
using CodexContractsPlugin.ChainMonitor;
|
||||
using CodexContractsPlugin.Marketplace;
|
||||
using Logging;
|
||||
using Utils;
|
||||
|
||||
namespace TraceContract
|
||||
{
|
||||
public class Output
|
||||
{
|
||||
private class Entry
|
||||
{
|
||||
public Entry(DateTime utc, string msg)
|
||||
{
|
||||
Utc = utc;
|
||||
Msg = msg;
|
||||
}
|
||||
|
||||
public DateTime Utc { get; }
|
||||
public string Msg { get; }
|
||||
}
|
||||
|
||||
private readonly ILog log;
|
||||
private readonly List<Entry> entries = new();
|
||||
private readonly string folder;
|
||||
private readonly List<string> files = new();
|
||||
private readonly Input input;
|
||||
private readonly Config config;
|
||||
|
||||
public Output(ILog log, Input input, Config config)
|
||||
{
|
||||
folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var filename = Path.Combine(folder, $"contract_{input.PurchaseId}");
|
||||
var fileLog = new FileLog(filename);
|
||||
files.Add(fileLog.FullFilename + ".log");
|
||||
foreach (var pair in config.LogReplacements)
|
||||
{
|
||||
fileLog.AddStringReplace(pair.Key, pair.Value);
|
||||
fileLog.AddStringReplace(pair.Key.ToLowerInvariant(), pair.Value);
|
||||
}
|
||||
|
||||
log.Log($"Logging to '{filename}'");
|
||||
this.log = new LogSplitter(fileLog, log);
|
||||
this.input = input;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public void LogRequestCreated(RequestEvent requestEvent)
|
||||
{
|
||||
Add(requestEvent.Block.Utc, $"Storage request created: '{requestEvent.Request.Request.Id}'");
|
||||
}
|
||||
|
||||
public void LogRequestCancelled(RequestEvent requestEvent)
|
||||
{
|
||||
Add(requestEvent.Block.Utc, "Expired");
|
||||
}
|
||||
|
||||
public void LogRequestFailed(RequestEvent requestEvent)
|
||||
{
|
||||
Add(requestEvent.Block.Utc, "Failed");
|
||||
}
|
||||
|
||||
public void LogRequestFinished(RequestEvent requestEvent)
|
||||
{
|
||||
Add(requestEvent.Block.Utc, "Finished");
|
||||
}
|
||||
|
||||
public void LogRequestStarted(RequestEvent requestEvent)
|
||||
{
|
||||
Add(requestEvent.Block.Utc, "Started");
|
||||
}
|
||||
|
||||
public void LogSlotFilled(RequestEvent requestEvent, EthAddress host, BigInteger slotIndex)
|
||||
{
|
||||
Add(requestEvent.Block.Utc, $"Slot filled. Index: {slotIndex} Host: '{host}'");
|
||||
}
|
||||
|
||||
public void LogSlotFreed(RequestEvent requestEvent, BigInteger slotIndex)
|
||||
{
|
||||
Add(requestEvent.Block.Utc, $"Slot freed. Index: {slotIndex}");
|
||||
}
|
||||
|
||||
public void LogSlotReservationsFull(RequestEvent requestEvent, BigInteger slotIndex)
|
||||
{
|
||||
Add(requestEvent.Block.Utc, $"Slot reservations full. Index: {slotIndex}");
|
||||
}
|
||||
|
||||
public void LogReserveSlotCalls(ReserveSlotFunction[] reserveSlotFunctions)
|
||||
{
|
||||
foreach (var call in reserveSlotFunctions) LogReserveSlotCall(call);
|
||||
}
|
||||
|
||||
public void WriteContractEvents()
|
||||
{
|
||||
var sorted = entries.OrderBy(e => e.Utc).ToArray();
|
||||
foreach (var e in sorted) Write(e);
|
||||
}
|
||||
|
||||
public LogFile CreateNodeLogTargetFile(string node)
|
||||
{
|
||||
var file = log.CreateSubfile(node);
|
||||
files.Add(file.Filename);
|
||||
return file;
|
||||
}
|
||||
|
||||
private void Write(Entry e)
|
||||
{
|
||||
log.Log($"[{Time.FormatTimestamp(e.Utc)}] {e.Msg}");
|
||||
}
|
||||
|
||||
private void LogReserveSlotCall(ReserveSlotFunction call)
|
||||
{
|
||||
Add(call.Block.Utc, $"Reserve-slot called. Index: {call.SlotIndex} Host: '{call.FromAddress}'");
|
||||
}
|
||||
|
||||
public string Package()
|
||||
{
|
||||
var outputFolder = config.GetOuputFolder();
|
||||
Directory.CreateDirectory(outputFolder);
|
||||
var filename = Path.Combine(outputFolder, $"contract_{input.PurchaseId}.zip");
|
||||
|
||||
ZipFile.CreateFromDirectory(folder, filename);
|
||||
return filename;
|
||||
}
|
||||
|
||||
private void Add(DateTime utc, string msg)
|
||||
{
|
||||
entries.Add(new Entry(utc, msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
100
Tools/TraceContract/Program.cs
Normal file
100
Tools/TraceContract/Program.cs
Normal file
@ -0,0 +1,100 @@
|
||||
using BlockchainUtils;
|
||||
using CodexContractsPlugin;
|
||||
using CodexContractsPlugin.Marketplace;
|
||||
using Core;
|
||||
using GethPlugin;
|
||||
using Logging;
|
||||
using Utils;
|
||||
|
||||
namespace TraceContract
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
ProjectPlugin.Load<GethPlugin.GethPlugin>();
|
||||
ProjectPlugin.Load<CodexContractsPlugin.CodexContractsPlugin>();
|
||||
|
||||
var p = new Program();
|
||||
p.Run();
|
||||
}
|
||||
|
||||
private readonly ILog log = new ConsoleLog();
|
||||
private readonly Input input = new();
|
||||
private readonly Config config = new();
|
||||
private readonly Output output;
|
||||
|
||||
public Program()
|
||||
{
|
||||
output = new(log, input, config);
|
||||
}
|
||||
|
||||
private void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
TracePurchase();
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
log.Error(exc.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private void TracePurchase()
|
||||
{
|
||||
Log("Setting up...");
|
||||
var entryPoint = new EntryPoint(log, new KubernetesWorkflow.Configuration(null, TimeSpan.FromMinutes(1.0), TimeSpan.FromSeconds(10.0), "_Unused!_"), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData));
|
||||
entryPoint.Announce();
|
||||
var ci = entryPoint.CreateInterface();
|
||||
var contracts = ConnectCodexContracts(ci);
|
||||
|
||||
var chainTracer = new ChainTracer(log, contracts, input, output);
|
||||
var requestTimeRange = chainTracer.TraceChainTimeline();
|
||||
|
||||
Log("Downloading storage nodes logs for the request timerange...");
|
||||
DownloadStorageNodeLogs(requestTimeRange, entryPoint.Tools);
|
||||
|
||||
Log("Packaging...");
|
||||
var zipFilename = output.Package();
|
||||
Log($"Saved to '{zipFilename}'");
|
||||
|
||||
entryPoint.Decommission(false, false, false);
|
||||
Log("Done");
|
||||
}
|
||||
|
||||
private ICodexContracts ConnectCodexContracts(CoreInterface ci)
|
||||
{
|
||||
var account = EthAccountGenerator.GenerateNew();
|
||||
var blockCache = new BlockCache();
|
||||
var geth = new CustomGethNode(log, blockCache, config.RpcEndpoint, config.GethPort, account.PrivateKey);
|
||||
|
||||
var deployment = new CodexContractsDeployment(
|
||||
config: new MarketplaceConfig(),
|
||||
marketplaceAddress: config.MarketplaceAddress,
|
||||
abi: config.Abi,
|
||||
tokenAddress: config.TokenAddress
|
||||
);
|
||||
return ci.WrapCodexContractsDeployment(geth, deployment);
|
||||
}
|
||||
|
||||
private void DownloadStorageNodeLogs(TimeRange requestTimeRange, IPluginTools tools)
|
||||
{
|
||||
var start = requestTimeRange.From - config.LogStartBeforeStorageContractStarts;
|
||||
|
||||
foreach (var node in config.StorageNodesKubernetesPodNames)
|
||||
{
|
||||
Log($"Downloading logs from '{node}'...");
|
||||
|
||||
var targetFile = output.CreateNodeLogTargetFile(node);
|
||||
var downloader = new ElasticSearchLogDownloader(log, tools, config);
|
||||
downloader.Download(targetFile, node, start, requestTimeRange.To);
|
||||
}
|
||||
}
|
||||
|
||||
private void Log(string msg)
|
||||
{
|
||||
log.Log(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Tools/TraceContract/TraceContract.csproj
Normal file
14
Tools/TraceContract/TraceContract.csproj
Normal file
@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\ProjectPlugins\CodexContractsPlugin\CodexContractsPlugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -86,6 +86,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexClient", "ProjectPlugi
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebUtils", "Framework\WebUtils\WebUtils.csproj", "{372C9E5D-5453-4D45-9948-E9324E21AD65}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraceContract", "Tools\TraceContract\TraceContract.csproj", "{58CDACE0-8F8D-2BB7-EA3A-0CB6A994A7F8}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -232,6 +234,10 @@ Global
|
||||
{372C9E5D-5453-4D45-9948-E9324E21AD65}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{372C9E5D-5453-4D45-9948-E9324E21AD65}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{372C9E5D-5453-4D45-9948-E9324E21AD65}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{58CDACE0-8F8D-2BB7-EA3A-0CB6A994A7F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{58CDACE0-8F8D-2BB7-EA3A-0CB6A994A7F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{58CDACE0-8F8D-2BB7-EA3A-0CB6A994A7F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{58CDACE0-8F8D-2BB7-EA3A-0CB6A994A7F8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@ -272,6 +278,7 @@ Global
|
||||
{4648B5AA-A0A7-44BA-89BC-2FD57370943C} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7}
|
||||
{9AF12703-29AF-416D-9781-204223D6D0E5} = {8F1F1C2A-E313-4E0C-BE40-58FB0BA91124}
|
||||
{372C9E5D-5453-4D45-9948-E9324E21AD65} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7}
|
||||
{58CDACE0-8F8D-2BB7-EA3A-0CB6A994A7F8} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {237BF0AA-9EC4-4659-AD9A-65DEB974250C}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user