From 6915e908610b11252e12b36fe08a21f3955d55a7 Mon Sep 17 00:00:00 2001 From: benbierens Date: Mon, 11 Sep 2023 10:43:27 +0200 Subject: [PATCH 01/51] Extracts file manager utils to separate assembly --- ContinuousTests/ContinuousTest.cs | 1 + ContinuousTests/SingleTestRun.cs | 3 +- ContinuousTests/Tests/HoldMyBeerTest.cs | 1 + ContinuousTests/Tests/TwoClientTest.cs | 1 + DistTestCore/DistTest.cs | 5 +- DistTestCore/DistTestCore.csproj | 1 + .../Helpers/PeerDownloadTestHelpers.cs | 12 +- DistTestCore/OnlineCodexNode.cs | 1 + DistTestCore/TestLifecycle.cs | 3 +- {DistTestCore => FileUtils}/FileManager.cs | 106 ++++-------------- FileUtils/FileUtils.csproj | 14 +++ FileUtils/TestFile.cs | 83 ++++++++++++++ LongTests/BasicTests/DownloadTests.cs | 1 + LongTests/BasicTests/UploadTests.cs | 1 + Tests/BasicTests/ExampleTests.cs | 2 +- cs-codex-dist-testing.sln | 8 +- 16 files changed, 146 insertions(+), 97 deletions(-) rename {DistTestCore => FileUtils}/FileManager.cs (58%) create mode 100644 FileUtils/FileUtils.csproj create mode 100644 FileUtils/TestFile.cs diff --git a/ContinuousTests/ContinuousTest.cs b/ContinuousTests/ContinuousTest.cs index 3e76298..edfc05b 100644 --- a/ContinuousTests/ContinuousTest.cs +++ b/ContinuousTests/ContinuousTest.cs @@ -1,6 +1,7 @@ using DistTestCore; using DistTestCore.Codex; using DistTestCore.Logs; +using FileUtils; using KubernetesWorkflow; using Logging; diff --git a/ContinuousTests/SingleTestRun.cs b/ContinuousTests/SingleTestRun.cs index 1e2b3a4..ef95fb9 100644 --- a/ContinuousTests/SingleTestRun.cs +++ b/ContinuousTests/SingleTestRun.cs @@ -6,6 +6,7 @@ using KubernetesWorkflow; using NUnit.Framework.Internal; using System.Reflection; using static Program; +using FileUtils; namespace ContinuousTests { @@ -38,7 +39,7 @@ namespace ContinuousTests nodes = CreateRandomNodes(); dataFolder = config.DataPath + "-" + Guid.NewGuid(); - fileManager = new FileManager(fixtureLog, CreateFileManagerConfiguration()); + fileManager = new FileManager(fixtureLog, CreateFileManagerConfiguration().GetFileManagerFolder()); } public void Run(EventWaitHandle runFinishedHandle) diff --git a/ContinuousTests/Tests/HoldMyBeerTest.cs b/ContinuousTests/Tests/HoldMyBeerTest.cs index 0ec268f..b2dd5d3 100644 --- a/ContinuousTests/Tests/HoldMyBeerTest.cs +++ b/ContinuousTests/Tests/HoldMyBeerTest.cs @@ -1,4 +1,5 @@ using DistTestCore; +using FileUtils; using NUnit.Framework; using Utils; diff --git a/ContinuousTests/Tests/TwoClientTest.cs b/ContinuousTests/Tests/TwoClientTest.cs index e179944..5e2b841 100644 --- a/ContinuousTests/Tests/TwoClientTest.cs +++ b/ContinuousTests/Tests/TwoClientTest.cs @@ -1,4 +1,5 @@ using DistTestCore; +using FileUtils; using NUnit.Framework; using Utils; diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index 155a24f..5439b5a 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -3,6 +3,7 @@ using DistTestCore.Helpers; using DistTestCore.Logs; using DistTestCore.Marketplace; using DistTestCore.Metrics; +using FileUtils; using KubernetesWorkflow; using Logging; using NUnit.Framework; @@ -100,9 +101,7 @@ namespace DistTestCore /// public void ScopedTestFiles(Action action) { - Get().FileManager.PushFileSet(); - action(); - Get().FileManager.PopFileSet(); + Get().FileManager.ScopedFiles(action); } public IOnlineCodexNode SetupCodexBootstrapNode() diff --git a/DistTestCore/DistTestCore.csproj b/DistTestCore/DistTestCore.csproj index 94a2271..565e175 100644 --- a/DistTestCore/DistTestCore.csproj +++ b/DistTestCore/DistTestCore.csproj @@ -27,6 +27,7 @@ + diff --git a/DistTestCore/Helpers/PeerDownloadTestHelpers.cs b/DistTestCore/Helpers/PeerDownloadTestHelpers.cs index c2af415..d541194 100644 --- a/DistTestCore/Helpers/PeerDownloadTestHelpers.cs +++ b/DistTestCore/Helpers/PeerDownloadTestHelpers.cs @@ -1,4 +1,5 @@ using DistTestCore.Codex; +using FileUtils; using Logging; using Utils; using static DistTestCore.Helpers.FullConnectivityHelper; @@ -43,7 +44,11 @@ namespace DistTestCore.Helpers public PeerConnectionState Check(Entry from, Entry to) { - fileManager.PushFileSet(); + return fileManager.ScopedFiles(() => CheckConnectivity(from, to)); + } + + private PeerConnectionState CheckConnectivity(Entry from, Entry to) + { var expectedFile = GenerateTestFile(from.Node, to.Node); using var uploadStream = File.OpenRead(expectedFile.Filename); @@ -61,11 +66,6 @@ namespace DistTestCore.Helpers // We consider that as no-connection for the purpose of this test. return PeerConnectionState.NoConnection; } - finally - { - fileManager.PopFileSet(); - } - // Should an exception occur during upload, then this try is inconclusive and we try again next loop. } diff --git a/DistTestCore/OnlineCodexNode.cs b/DistTestCore/OnlineCodexNode.cs index 52fb81f..2d1ee9d 100644 --- a/DistTestCore/OnlineCodexNode.cs +++ b/DistTestCore/OnlineCodexNode.cs @@ -2,6 +2,7 @@ using DistTestCore.Logs; using DistTestCore.Marketplace; using DistTestCore.Metrics; +using FileUtils; using Logging; using NUnit.Framework; using Utils; diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index d3f3f0f..0a364c8 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -2,6 +2,7 @@ using DistTestCore.Logs; using DistTestCore.Marketplace; using DistTestCore.Metrics; +using FileUtils; using KubernetesWorkflow; using Logging; using Utils; @@ -20,7 +21,7 @@ namespace DistTestCore WorkflowCreator = new WorkflowCreator(log, configuration.GetK8sConfiguration(timeSet), testNamespace); - FileManager = new FileManager(Log, configuration); + FileManager = new FileManager(Log, configuration.GetFileManagerFolder()); CodexStarter = new CodexStarter(this); PrometheusStarter = new PrometheusStarter(this); GrafanaStarter = new GrafanaStarter(this); diff --git a/DistTestCore/FileManager.cs b/FileUtils/FileManager.cs similarity index 58% rename from DistTestCore/FileManager.cs rename to FileUtils/FileManager.cs index ab86473..58bb0a8 100644 --- a/DistTestCore/FileManager.cs +++ b/FileUtils/FileManager.cs @@ -2,15 +2,15 @@ using NUnit.Framework; using Utils; -namespace DistTestCore +namespace FileUtils { public interface IFileManager { TestFile CreateEmptyTestFile(string label = ""); TestFile GenerateTestFile(ByteSize size, string label = ""); void DeleteAllTestFiles(); - void PushFileSet(); - void PopFileSet(); + void ScopedFiles(Action action); + T ScopedFiles(Func action); } public class FileManager : IFileManager @@ -22,9 +22,9 @@ namespace DistTestCore private readonly string folder; private readonly List> fileSetStack = new List>(); - public FileManager(BaseLog log, Configuration configuration) + public FileManager(BaseLog log, string rootFolder) { - folder = Path.Combine(configuration.GetFileManagerFolder(), folderNumberSource.GetNextNumber().ToString("D5")); + folder = Path.Combine(rootFolder, folderNumberSource.GetNextNumber().ToString("D5")); EnsureDirectory(); this.log = log; @@ -52,12 +52,27 @@ namespace DistTestCore DeleteDirectory(); } - public void PushFileSet() + public void ScopedFiles(Action action) + { + PushFileSet(); + action(); + PopFileSet(); + } + + public T ScopedFiles(Func action) + { + PushFileSet(); + var result = action(); + PopFileSet(); + return result; + } + + private void PushFileSet() { fileSetStack.Add(new List()); } - public void PopFileSet() + private void PopFileSet() { if (!fileSetStack.Any()) return; var pop = fileSetStack.Last(); @@ -138,81 +153,4 @@ namespace DistTestCore Directory.Delete(folder, true); } } - - public class TestFile - { - private readonly BaseLog log; - - public TestFile(BaseLog log, string filename, string label) - { - this.log = log; - Filename = filename; - Label = label; - } - - public string Filename { get; } - public string Label { get; } - - public void AssertIsEqual(TestFile? actual) - { - var sw = Stopwatch.Begin(log); - try - { - AssertEqual(actual); - } - finally - { - sw.End($"{nameof(TestFile)}.{nameof(AssertIsEqual)}"); - } - } - - public string Describe() - { - var sizePostfix = $" ({Formatter.FormatByteSize(GetFileSize())})"; - if (!string.IsNullOrEmpty(Label)) return Label + sizePostfix; - return $"'{Filename}'{sizePostfix}"; - } - - private void AssertEqual(TestFile? actual) - { - if (actual == null) Assert.Fail("TestFile is null."); - if (actual == this || actual!.Filename == Filename) Assert.Fail("TestFile is compared to itself."); - - Assert.That(actual.GetFileSize(), Is.EqualTo(GetFileSize()), "Files are not of equal length."); - - using var streamExpected = new FileStream(Filename, FileMode.Open, FileAccess.Read); - using var streamActual = new FileStream(actual.Filename, FileMode.Open, FileAccess.Read); - - var bytesExpected = new byte[FileManager.ChunkSize]; - var bytesActual = new byte[FileManager.ChunkSize]; - - var readExpected = 0; - var readActual = 0; - - while (true) - { - readExpected = streamExpected.Read(bytesExpected, 0, FileManager.ChunkSize); - readActual = streamActual.Read(bytesActual, 0, FileManager.ChunkSize); - - if (readExpected == 0 && readActual == 0) - { - log.Log($"OK: '{Describe()}' is equal to '{actual.Describe()}'."); - return; - } - - Assert.That(readActual, Is.EqualTo(readExpected), "Unable to read buffers of equal length."); - - for (var i = 0; i < readActual; i++) - { - if (bytesExpected[i] != bytesActual[i]) Assert.Fail("File contents not equal."); - } - } - } - - private long GetFileSize() - { - var info = new FileInfo(Filename); - return info.Length; - } - } } diff --git a/FileUtils/FileUtils.csproj b/FileUtils/FileUtils.csproj new file mode 100644 index 0000000..10c714c --- /dev/null +++ b/FileUtils/FileUtils.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + + + + + + + + diff --git a/FileUtils/TestFile.cs b/FileUtils/TestFile.cs new file mode 100644 index 0000000..3f22926 --- /dev/null +++ b/FileUtils/TestFile.cs @@ -0,0 +1,83 @@ +using Logging; +using NUnit.Framework; +using Utils; + +namespace FileUtils +{ + public class TestFile + { + private readonly BaseLog log; + + public TestFile(BaseLog log, string filename, string label) + { + this.log = log; + Filename = filename; + Label = label; + } + + public string Filename { get; } + public string Label { get; } + + public void AssertIsEqual(TestFile? actual) + { + var sw = Stopwatch.Begin(log); + try + { + AssertEqual(actual); + } + finally + { + sw.End($"{nameof(TestFile)}.{nameof(AssertIsEqual)}"); + } + } + + public string Describe() + { + var sizePostfix = $" ({Formatter.FormatByteSize(GetFileSize())})"; + if (!string.IsNullOrEmpty(Label)) return Label + sizePostfix; + return $"'{Filename}'{sizePostfix}"; + } + + private void AssertEqual(TestFile? actual) + { + if (actual == null) Assert.Fail("TestFile is null."); + if (actual == this || actual!.Filename == Filename) Assert.Fail("TestFile is compared to itself."); + + Assert.That(actual.GetFileSize(), Is.EqualTo(GetFileSize()), "Files are not of equal length."); + + using var streamExpected = new FileStream(Filename, FileMode.Open, FileAccess.Read); + using var streamActual = new FileStream(actual.Filename, FileMode.Open, FileAccess.Read); + + var bytesExpected = new byte[FileManager.ChunkSize]; + var bytesActual = new byte[FileManager.ChunkSize]; + + var readExpected = 0; + var readActual = 0; + + while (true) + { + readExpected = streamExpected.Read(bytesExpected, 0, FileManager.ChunkSize); + readActual = streamActual.Read(bytesActual, 0, FileManager.ChunkSize); + + if (readExpected == 0 && readActual == 0) + { + log.Log($"OK: '{Describe()}' is equal to '{actual.Describe()}'."); + return; + } + + Assert.That(readActual, Is.EqualTo(readExpected), "Unable to read buffers of equal length."); + + for (var i = 0; i < readActual; i++) + { + if (bytesExpected[i] != bytesActual[i]) Assert.Fail("File contents not equal."); + } + } + } + + private long GetFileSize() + { + var info = new FileInfo(Filename); + return info.Length; + } + } +} diff --git a/LongTests/BasicTests/DownloadTests.cs b/LongTests/BasicTests/DownloadTests.cs index 5e01e3c..0cf97e9 100644 --- a/LongTests/BasicTests/DownloadTests.cs +++ b/LongTests/BasicTests/DownloadTests.cs @@ -1,4 +1,5 @@ using DistTestCore; +using FileUtils; using NUnit.Framework; using Utils; diff --git a/LongTests/BasicTests/UploadTests.cs b/LongTests/BasicTests/UploadTests.cs index 69823eb..516586e 100644 --- a/LongTests/BasicTests/UploadTests.cs +++ b/LongTests/BasicTests/UploadTests.cs @@ -1,4 +1,5 @@ using DistTestCore; +using FileUtils; using NUnit.Framework; using Utils; diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 1492ce1..76b37ab 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -10,7 +10,7 @@ namespace Tests.BasicTests [Test] public void CodexLogExample() { - var primary = SetupCodexNode(); + var primary = SetupCodexNodes(2)[0]; primary.UploadFile(GenerateTestFile(5.MB())); diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index 8e696b3..b080268 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -23,7 +23,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDeployer", "CodexNe EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArgsUniform", "ArgsUniform\ArgsUniform.csproj", "{634324B1-E359-42B4-A269-BDC429936B3C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexNetDownloader", "CodexNetDownloader\CodexNetDownloader.csproj", "{6CDF35D2-906A-4285-8E1F-4794588B948B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDownloader", "CodexNetDownloader\CodexNetDownloader.csproj", "{6CDF35D2-906A-4285-8E1F-4794588B948B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileUtils", "FileUtils\FileUtils.csproj", "{ECC954DA-8D4E-49EE-83AD-80085A43DEEB}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -75,6 +77,10 @@ Global {6CDF35D2-906A-4285-8E1F-4794588B948B}.Debug|Any CPU.Build.0 = Debug|Any CPU {6CDF35D2-906A-4285-8E1F-4794588B948B}.Release|Any CPU.ActiveCfg = Release|Any CPU {6CDF35D2-906A-4285-8E1F-4794588B948B}.Release|Any CPU.Build.0 = Release|Any CPU + {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 83d184177acc78932b73c677ee7967dd9188fdaf Mon Sep 17 00:00:00 2001 From: benbierens Date: Mon, 11 Sep 2023 11:59:33 +0200 Subject: [PATCH 02/51] Moving all codex details to CodexPlugin --- .../Codex => CodexPlugin}/CodexAccess.cs | 5 +- .../Codex => CodexPlugin}/CodexApiTypes.cs | 2 +- .../CodexContainerRecipe.cs | 67 ++-- .../Codex => CodexPlugin}/CodexDeployment.cs | 15 +- .../Codex => CodexPlugin}/CodexLogLevel.cs | 2 +- CodexPlugin/CodexNodeFactory.cs | 29 ++ .../CodexNodeGroup.cs | 18 +- CodexPlugin/CodexPlugin.csproj | 28 ++ {DistTestCore => CodexPlugin}/CodexSetup.cs | 50 ++- CodexPlugin/CodexStarter.cs | 156 ++++++++ .../CodexStartupConfig.cs | 10 +- CodexPlugin/DistTestExtensions.cs | 28 ++ CodexPlugin/GethStarter.cs | 88 +++++ .../CodexContractsContainerConfig.cs | 16 + .../CodexContractsContainerRecipe.cs | 25 ++ .../Marketplace/CodexContractsStarter.cs | 103 ++++++ .../Marketplace/ContainerInfoExtractor.cs | 149 ++++++++ .../Marketplace/GethBootstrapNodeInfo.cs | 42 +++ .../Marketplace/GethBootstrapNodeStarter.cs | 40 +++ .../Marketplace/GethCompanionNodeInfo.cs | 38 ++ .../Marketplace/GethCompanionNodeStarter.cs | 77 ++++ .../Marketplace/GethContainerRecipe.cs | 73 ++++ CodexPlugin/Marketplace/GethStartResult.cs | 19 + CodexPlugin/Marketplace/GethStartupConfig.cs | 18 + CodexPlugin/Marketplace/MarketplaceAccess.cs | 243 +++++++++++++ .../Marketplace/MarketplaceAccessFactory.cs | 41 +++ .../Marketplace/MarketplaceInitialConfig.cs | 17 + CodexPlugin/Marketplace/MarketplaceNetwork.cs | 21 ++ CodexPlugin/Metrics/GrafanaContainerRecipe.cs | 25 ++ CodexPlugin/Metrics/MetricsAccess.cs | 81 +++++ CodexPlugin/Metrics/MetricsAccessFactory.cs | 35 ++ CodexPlugin/Metrics/MetricsDownloader.cs | 80 +++++ CodexPlugin/Metrics/MetricsMode.cs | 9 + CodexPlugin/Metrics/MetricsQuery.cs | 198 +++++++++++ .../Metrics/PrometheusContainerRecipe.cs | 18 + .../Metrics/PrometheusStartupConfig.cs | 12 + .../Metrics/dashboard.json | 0 .../OnlineCodexNode.cs | 69 ++-- DistTestCore/AutoBootstrapDistTest.cs | 38 +- DistTestCore/CodexNodeFactory.cs | 32 -- DistTestCore/CodexStarter.cs | 158 -------- DistTestCore/Configuration.cs | 19 +- DistTestCore/DistTest.cs | 172 +++++---- DistTestCore/DistTestCore.csproj | 8 - DistTestCore/GethStarter.cs | 88 ----- DistTestCore/GrafanaStarter.cs | 210 ++++++----- .../Helpers/FullConnectivityHelper.cs | 336 +++++++++--------- .../Helpers/PeerConnectionTestHelpers.cs | 122 +++---- .../Helpers/PeerDownloadTestHelpers.cs | 154 ++++---- .../CodexContractsContainerConfig.cs | 16 - .../CodexContractsContainerRecipe.cs | 25 -- .../Marketplace/CodexContractsStarter.cs | 103 ------ .../Marketplace/ContainerInfoExtractor.cs | 149 -------- .../Marketplace/GethBootstrapNodeInfo.cs | 42 --- .../Marketplace/GethBootstrapNodeStarter.cs | 40 --- .../Marketplace/GethCompanionNodeInfo.cs | 38 -- .../Marketplace/GethCompanionNodeStarter.cs | 77 ---- .../Marketplace/GethContainerRecipe.cs | 73 ---- DistTestCore/Marketplace/GethStartResult.cs | 19 - DistTestCore/Marketplace/GethStartupConfig.cs | 18 - DistTestCore/Marketplace/MarketplaceAccess.cs | 243 ------------- .../Marketplace/MarketplaceAccessFactory.cs | 41 --- .../Marketplace/MarketplaceInitialConfig.cs | 17 - .../Marketplace/MarketplaceNetwork.cs | 21 -- .../Metrics/GrafanaContainerRecipe.cs | 25 -- DistTestCore/Metrics/MetricsAccess.cs | 81 ----- DistTestCore/Metrics/MetricsAccessFactory.cs | 35 -- DistTestCore/Metrics/MetricsDownloader.cs | 80 ----- DistTestCore/Metrics/MetricsMode.cs | 9 - DistTestCore/Metrics/MetricsQuery.cs | 198 ----------- .../Metrics/PrometheusContainerRecipe.cs | 18 - .../Metrics/PrometheusStartupConfig.cs | 12 - DistTestCore/PrometheusStarter.cs | 61 ++-- DistTestCore/TestLifecycle.cs | 53 ++- Tests/BasicTests/ContinuousSubstitute.cs | 252 ------------- Tests/BasicTests/ExampleTests.cs | 86 ----- Tests/BasicTests/NetworkIsolationTest.cs | 46 --- Tests/BasicTests/OneClientTests.cs | 41 --- Tests/BasicTests/ThreeClientTest.cs | 25 -- Tests/BasicTests/TwoClientTests.cs | 7 +- .../FullyConnectedDownloadTests.cs | 42 --- .../LayeredDiscoveryTests.cs | 52 --- .../PeerDiscoveryTests/PeerDiscoveryTests.cs | 51 --- Tests/Tests.csproj | 1 + cs-codex-dist-testing.sln | 12 +- 85 files changed, 2414 insertions(+), 2979 deletions(-) rename {DistTestCore/Codex => CodexPlugin}/CodexAccess.cs (98%) rename {DistTestCore/Codex => CodexPlugin}/CodexApiTypes.cs (99%) rename {DistTestCore/Codex => CodexPlugin}/CodexContainerRecipe.cs (62%) rename {DistTestCore/Codex => CodexPlugin}/CodexDeployment.cs (78%) rename {DistTestCore/Codex => CodexPlugin}/CodexLogLevel.cs (78%) create mode 100644 CodexPlugin/CodexNodeFactory.cs rename {DistTestCore => CodexPlugin}/CodexNodeGroup.cs (80%) create mode 100644 CodexPlugin/CodexPlugin.csproj rename {DistTestCore => CodexPlugin}/CodexSetup.cs (66%) create mode 100644 CodexPlugin/CodexStarter.cs rename {DistTestCore/Codex => CodexPlugin}/CodexStartupConfig.cs (70%) create mode 100644 CodexPlugin/DistTestExtensions.cs create mode 100644 CodexPlugin/GethStarter.cs create mode 100644 CodexPlugin/Marketplace/CodexContractsContainerConfig.cs create mode 100644 CodexPlugin/Marketplace/CodexContractsContainerRecipe.cs create mode 100644 CodexPlugin/Marketplace/CodexContractsStarter.cs create mode 100644 CodexPlugin/Marketplace/ContainerInfoExtractor.cs create mode 100644 CodexPlugin/Marketplace/GethBootstrapNodeInfo.cs create mode 100644 CodexPlugin/Marketplace/GethBootstrapNodeStarter.cs create mode 100644 CodexPlugin/Marketplace/GethCompanionNodeInfo.cs create mode 100644 CodexPlugin/Marketplace/GethCompanionNodeStarter.cs create mode 100644 CodexPlugin/Marketplace/GethContainerRecipe.cs create mode 100644 CodexPlugin/Marketplace/GethStartResult.cs create mode 100644 CodexPlugin/Marketplace/GethStartupConfig.cs create mode 100644 CodexPlugin/Marketplace/MarketplaceAccess.cs create mode 100644 CodexPlugin/Marketplace/MarketplaceAccessFactory.cs create mode 100644 CodexPlugin/Marketplace/MarketplaceInitialConfig.cs create mode 100644 CodexPlugin/Marketplace/MarketplaceNetwork.cs create mode 100644 CodexPlugin/Metrics/GrafanaContainerRecipe.cs create mode 100644 CodexPlugin/Metrics/MetricsAccess.cs create mode 100644 CodexPlugin/Metrics/MetricsAccessFactory.cs create mode 100644 CodexPlugin/Metrics/MetricsDownloader.cs create mode 100644 CodexPlugin/Metrics/MetricsMode.cs create mode 100644 CodexPlugin/Metrics/MetricsQuery.cs create mode 100644 CodexPlugin/Metrics/PrometheusContainerRecipe.cs create mode 100644 CodexPlugin/Metrics/PrometheusStartupConfig.cs rename {DistTestCore => CodexPlugin}/Metrics/dashboard.json (100%) rename {DistTestCore => CodexPlugin}/OnlineCodexNode.cs (69%) delete mode 100644 DistTestCore/CodexNodeFactory.cs delete mode 100644 DistTestCore/CodexStarter.cs delete mode 100644 DistTestCore/GethStarter.cs delete mode 100644 DistTestCore/Marketplace/CodexContractsContainerConfig.cs delete mode 100644 DistTestCore/Marketplace/CodexContractsContainerRecipe.cs delete mode 100644 DistTestCore/Marketplace/CodexContractsStarter.cs delete mode 100644 DistTestCore/Marketplace/ContainerInfoExtractor.cs delete mode 100644 DistTestCore/Marketplace/GethBootstrapNodeInfo.cs delete mode 100644 DistTestCore/Marketplace/GethBootstrapNodeStarter.cs delete mode 100644 DistTestCore/Marketplace/GethCompanionNodeInfo.cs delete mode 100644 DistTestCore/Marketplace/GethCompanionNodeStarter.cs delete mode 100644 DistTestCore/Marketplace/GethContainerRecipe.cs delete mode 100644 DistTestCore/Marketplace/GethStartResult.cs delete mode 100644 DistTestCore/Marketplace/GethStartupConfig.cs delete mode 100644 DistTestCore/Marketplace/MarketplaceAccess.cs delete mode 100644 DistTestCore/Marketplace/MarketplaceAccessFactory.cs delete mode 100644 DistTestCore/Marketplace/MarketplaceInitialConfig.cs delete mode 100644 DistTestCore/Marketplace/MarketplaceNetwork.cs delete mode 100644 DistTestCore/Metrics/GrafanaContainerRecipe.cs delete mode 100644 DistTestCore/Metrics/MetricsAccess.cs delete mode 100644 DistTestCore/Metrics/MetricsAccessFactory.cs delete mode 100644 DistTestCore/Metrics/MetricsDownloader.cs delete mode 100644 DistTestCore/Metrics/MetricsMode.cs delete mode 100644 DistTestCore/Metrics/MetricsQuery.cs delete mode 100644 DistTestCore/Metrics/PrometheusContainerRecipe.cs delete mode 100644 DistTestCore/Metrics/PrometheusStartupConfig.cs delete mode 100644 Tests/BasicTests/ContinuousSubstitute.cs delete mode 100644 Tests/BasicTests/ExampleTests.cs delete mode 100644 Tests/BasicTests/NetworkIsolationTest.cs delete mode 100644 Tests/BasicTests/OneClientTests.cs delete mode 100644 Tests/BasicTests/ThreeClientTest.cs delete mode 100644 Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs delete mode 100644 Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs delete mode 100644 Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs diff --git a/DistTestCore/Codex/CodexAccess.cs b/CodexPlugin/CodexAccess.cs similarity index 98% rename from DistTestCore/Codex/CodexAccess.cs rename to CodexPlugin/CodexAccess.cs index 67f4263..13d971c 100644 --- a/DistTestCore/Codex/CodexAccess.cs +++ b/CodexPlugin/CodexAccess.cs @@ -1,8 +1,9 @@ -using KubernetesWorkflow; +using DistTestCore; +using KubernetesWorkflow; using Logging; using Utils; -namespace DistTestCore.Codex +namespace CodexPlugin { public class CodexAccess : ILogHandler { diff --git a/DistTestCore/Codex/CodexApiTypes.cs b/CodexPlugin/CodexApiTypes.cs similarity index 99% rename from DistTestCore/Codex/CodexApiTypes.cs rename to CodexPlugin/CodexApiTypes.cs index 5944b84..127f437 100644 --- a/DistTestCore/Codex/CodexApiTypes.cs +++ b/CodexPlugin/CodexApiTypes.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace DistTestCore.Codex +namespace CodexPlugin { public class CodexDebugResponse { diff --git a/DistTestCore/Codex/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs similarity index 62% rename from DistTestCore/Codex/CodexContainerRecipe.cs rename to CodexPlugin/CodexContainerRecipe.cs index c928b3e..1f7f689 100644 --- a/DistTestCore/Codex/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -1,8 +1,9 @@ -using DistTestCore.Marketplace; +//using DistTestCore.Marketplace; +using DistTestCore; using KubernetesWorkflow; using Utils; -namespace DistTestCore.Codex +namespace CodexPlugin { public class CodexContainerRecipe : DefaultContainerRecipe { @@ -66,36 +67,36 @@ namespace DistTestCore.Codex { AddEnvVar("CODEX_BLOCK_MN", config.BlockMaintenanceNumber.ToString()!); } - if (config.MetricsMode != Metrics.MetricsMode.None) - { - var metricsPort = AddInternalPort(MetricsPortTag); - AddEnvVar("CODEX_METRICS", "true"); - AddEnvVar("CODEX_METRICS_ADDRESS", "0.0.0.0"); - AddEnvVar("CODEX_METRICS_PORT", metricsPort); - AddPodAnnotation("prometheus.io/scrape", "true"); - AddPodAnnotation("prometheus.io/port", metricsPort.Number.ToString()); - } + //if (config.MetricsMode != Metrics.MetricsMode.None) + //{ + // var metricsPort = AddInternalPort(MetricsPortTag); + // AddEnvVar("CODEX_METRICS", "true"); + // AddEnvVar("CODEX_METRICS_ADDRESS", "0.0.0.0"); + // AddEnvVar("CODEX_METRICS_PORT", metricsPort); + // AddPodAnnotation("prometheus.io/scrape", "true"); + // AddPodAnnotation("prometheus.io/port", metricsPort.Number.ToString()); + //} - if (config.MarketplaceConfig != null) - { - var gethConfig = startupConfig.Get(); - var companionNode = gethConfig.CompanionNode; - var companionNodeAccount = companionNode.Accounts[GetAccountIndex(config.MarketplaceConfig)]; - Additional(companionNodeAccount); + //if (config.MarketplaceConfig != null) + //{ + // var gethConfig = startupConfig.Get(); + // var companionNode = gethConfig.CompanionNode; + // var companionNodeAccount = companionNode.Accounts[GetAccountIndex(config.MarketplaceConfig)]; + // Additional(companionNodeAccount); - var ip = companionNode.RunningContainer.Pod.PodInfo.Ip; - var port = companionNode.RunningContainer.Recipe.GetPortByTag(GethContainerRecipe.HttpPortTag).Number; + // var ip = companionNode.RunningContainer.Pod.PodInfo.Ip; + // var port = companionNode.RunningContainer.Recipe.GetPortByTag(GethContainerRecipe.HttpPortTag).Number; - AddEnvVar("CODEX_ETH_PROVIDER", $"ws://{ip}:{port}"); - AddEnvVar("CODEX_ETH_ACCOUNT", companionNodeAccount.Account); - AddEnvVar("CODEX_MARKETPLACE_ADDRESS", gethConfig.MarketplaceNetwork.Marketplace.Address); - AddEnvVar("CODEX_PERSISTENCE", "true"); + // AddEnvVar("CODEX_ETH_PROVIDER", $"ws://{ip}:{port}"); + // AddEnvVar("CODEX_ETH_ACCOUNT", companionNodeAccount.Account); + // AddEnvVar("CODEX_MARKETPLACE_ADDRESS", gethConfig.MarketplaceNetwork.Marketplace.Address); + // AddEnvVar("CODEX_PERSISTENCE", "true"); - if (config.MarketplaceConfig.IsValidator) - { - AddEnvVar("CODEX_VALIDATOR", "true"); - } - } + // if (config.MarketplaceConfig.IsValidator) + // { + // AddEnvVar("CODEX_VALIDATOR", "true"); + // } + //} } private ByteSize GetVolumeCapacity(CodexStartupConfig config) @@ -105,11 +106,11 @@ namespace DistTestCore.Codex return 8.GB().Multiply(1.2); } - private int GetAccountIndex(MarketplaceInitialConfig marketplaceConfig) - { - if (marketplaceConfig.AccountIndexOverride != null) return marketplaceConfig.AccountIndexOverride.Value; - return Index; - } + //private int GetAccountIndex(MarketplaceInitialConfig marketplaceConfig) + //{ + // if (marketplaceConfig.AccountIndexOverride != null) return marketplaceConfig.AccountIndexOverride.Value; + // return Index; + //} private string GetDockerImage() { diff --git a/DistTestCore/Codex/CodexDeployment.cs b/CodexPlugin/CodexDeployment.cs similarity index 78% rename from DistTestCore/Codex/CodexDeployment.cs rename to CodexPlugin/CodexDeployment.cs index 0595a9a..3bca60b 100644 --- a/DistTestCore/Codex/CodexDeployment.cs +++ b/CodexPlugin/CodexDeployment.cs @@ -1,23 +1,22 @@ -using DistTestCore.Marketplace; -using KubernetesWorkflow; +using KubernetesWorkflow; -namespace DistTestCore.Codex +namespace CodexPlugin { public class CodexDeployment { - public CodexDeployment(GethStartResult gethStartResult, RunningContainer[] codexContainers, RunningContainer? prometheusContainer, GrafanaStartInfo? grafanaStartInfo, DeploymentMetadata metadata) + public CodexDeployment(/*GethStartResult gethStartResult,*/ RunningContainer[] codexContainers, RunningContainer? prometheusContainer, /*GrafanaStartInfo? grafanaStartInfo,*/ DeploymentMetadata metadata) { - GethStartResult = gethStartResult; + //GethStartResult = gethStartResult; CodexContainers = codexContainers; PrometheusContainer = prometheusContainer; - GrafanaStartInfo = grafanaStartInfo; + //GrafanaStartInfo = grafanaStartInfo; Metadata = metadata; } - public GethStartResult GethStartResult { get; } + //public GethStartResult GethStartResult { get; } public RunningContainer[] CodexContainers { get; } public RunningContainer? PrometheusContainer { get; } - public GrafanaStartInfo? GrafanaStartInfo { get; } + //public GrafanaStartInfo? GrafanaStartInfo { get; } public DeploymentMetadata Metadata { get; } } diff --git a/DistTestCore/Codex/CodexLogLevel.cs b/CodexPlugin/CodexLogLevel.cs similarity index 78% rename from DistTestCore/Codex/CodexLogLevel.cs rename to CodexPlugin/CodexLogLevel.cs index cde0eb7..a859a0c 100644 --- a/DistTestCore/Codex/CodexLogLevel.cs +++ b/CodexPlugin/CodexLogLevel.cs @@ -1,4 +1,4 @@ -namespace DistTestCore.Codex +namespace CodexPlugin { public enum CodexLogLevel { diff --git a/CodexPlugin/CodexNodeFactory.cs b/CodexPlugin/CodexNodeFactory.cs new file mode 100644 index 0000000..5e3abbd --- /dev/null +++ b/CodexPlugin/CodexNodeFactory.cs @@ -0,0 +1,29 @@ + +namespace CodexPlugin +{ + public interface ICodexNodeFactory + { + //OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group); + } + + public class CodexNodeFactory : ICodexNodeFactory + { + //private readonly TestLifecycle lifecycle; + //private readonly IMetricsAccessFactory metricsAccessFactory; + //private readonly IMarketplaceAccessFactory marketplaceAccessFactory; + + //public CodexNodeFactory(TestLifecycle lifecycle, IMetricsAccessFactory metricsAccessFactory, IMarketplaceAccessFactory marketplaceAccessFactory) + //{ + // this.lifecycle = lifecycle; + // this.metricsAccessFactory = metricsAccessFactory; + // this.marketplaceAccessFactory = marketplaceAccessFactory; + //} + + //public OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group) + //{ + // var metricsAccess = metricsAccessFactory.CreateMetricsAccess(access.Container); + // var marketplaceAccess = marketplaceAccessFactory.CreateMarketplaceAccess(access); + // return new OnlineCodexNode(lifecycle, access, group, metricsAccess, marketplaceAccess); + //} + } +} diff --git a/DistTestCore/CodexNodeGroup.cs b/CodexPlugin/CodexNodeGroup.cs similarity index 80% rename from DistTestCore/CodexNodeGroup.cs rename to CodexPlugin/CodexNodeGroup.cs index 7759ea7..e783c20 100644 --- a/DistTestCore/CodexNodeGroup.cs +++ b/CodexPlugin/CodexNodeGroup.cs @@ -1,8 +1,7 @@ -using DistTestCore.Codex; -using KubernetesWorkflow; +using KubernetesWorkflow; using System.Collections; -namespace DistTestCore +namespace CodexPlugin { public interface ICodexNodeGroup : IEnumerable { @@ -12,11 +11,11 @@ namespace DistTestCore public class CodexNodeGroup : ICodexNodeGroup { - private readonly TestLifecycle lifecycle; + //private readonly TestLifecycle lifecycle; - public CodexNodeGroup(TestLifecycle lifecycle, CodexSetup setup, RunningContainers[] containers, ICodexNodeFactory codexNodeFactory) + public CodexNodeGroup(/*TestLifecycle lifecycle, */CodexSetup setup, RunningContainers[] containers, ICodexNodeFactory codexNodeFactory) { - this.lifecycle = lifecycle; + //this.lifecycle = lifecycle; Setup = setup; Containers = containers; Nodes = containers.Containers().Select(c => CreateOnlineCodexNode(c, codexNodeFactory)).ToArray(); @@ -33,7 +32,7 @@ namespace DistTestCore public ICodexSetup BringOffline() { - lifecycle.CodexStarter.BringOffline(this); + //lifecycle.CodexStarter.BringOffline(this); var result = Setup; // Clear everything. Prevent accidental use. @@ -81,8 +80,9 @@ namespace DistTestCore private OnlineCodexNode CreateOnlineCodexNode(RunningContainer c, ICodexNodeFactory factory) { - var access = new CodexAccess(lifecycle.Log, c, lifecycle.TimeSet, lifecycle.Configuration.GetAddress(c)); - return factory.CreateOnlineCodexNode(access, this); + //var access = new CodexAccess(lifecycle.Log, c, lifecycle.TimeSet, lifecycle.Configuration.GetAddress(c)); + //return factory.CreateOnlineCodexNode(access, this); + return null!; } } } diff --git a/CodexPlugin/CodexPlugin.csproj b/CodexPlugin/CodexPlugin.csproj new file mode 100644 index 0000000..dc720d5 --- /dev/null +++ b/CodexPlugin/CodexPlugin.csproj @@ -0,0 +1,28 @@ + + + + net7.0 + enable + enable + + + + + + + + + Never + + + + + + + + + + + + + diff --git a/DistTestCore/CodexSetup.cs b/CodexPlugin/CodexSetup.cs similarity index 66% rename from DistTestCore/CodexSetup.cs rename to CodexPlugin/CodexSetup.cs index f2ec51e..7602aca 100644 --- a/DistTestCore/CodexSetup.cs +++ b/CodexPlugin/CodexSetup.cs @@ -1,9 +1,7 @@ -using DistTestCore.Codex; -using DistTestCore.Marketplace; -using KubernetesWorkflow; +using KubernetesWorkflow; using Utils; -namespace DistTestCore +namespace CodexPlugin { public interface ICodexSetup { @@ -14,10 +12,10 @@ namespace DistTestCore ICodexSetup WithBlockTTL(TimeSpan duration); ICodexSetup WithBlockMaintenanceInterval(TimeSpan duration); ICodexSetup WithBlockMaintenanceNumber(int numberOfBlocks); - ICodexSetup EnableMetrics(); - ICodexSetup EnableMarketplace(TestToken initialBalance); - ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther); - ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther, bool isValidator); + //ICodexSetup EnableMetrics(); + //ICodexSetup EnableMarketplace(TestToken initialBalance); + //ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther); + //ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther, bool isValidator); } public class CodexSetup : CodexStartupConfig, ICodexSetup @@ -72,27 +70,27 @@ namespace DistTestCore return this; } - public ICodexSetup EnableMetrics() - { - MetricsMode = Metrics.MetricsMode.Record; - return this; - } + //public ICodexSetup EnableMetrics() + //{ + // MetricsMode = Metrics.MetricsMode.Record; + // return this; + //} - public ICodexSetup EnableMarketplace(TestToken initialBalance) - { - return EnableMarketplace(initialBalance, 1000.Eth()); - } + //public ICodexSetup EnableMarketplace(TestToken initialBalance) + //{ + // return EnableMarketplace(initialBalance, 1000.Eth()); + //} - public ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther) - { - return EnableMarketplace(initialBalance, initialEther, false); - } + //public ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther) + //{ + // return EnableMarketplace(initialBalance, initialEther, false); + //} - public ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther, bool isValidator) - { - MarketplaceConfig = new MarketplaceInitialConfig(initialEther, initialBalance, isValidator); - return this; - } + //public ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther, bool isValidator) + //{ + // MarketplaceConfig = new MarketplaceInitialConfig(initialEther, initialBalance, isValidator); + // return this; + //} public string Describe() { diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs new file mode 100644 index 0000000..dcd9908 --- /dev/null +++ b/CodexPlugin/CodexStarter.cs @@ -0,0 +1,156 @@ +using KubernetesWorkflow; +using Logging; + +namespace CodexPlugin +{ + public class CodexStarter //: BaseStarter + { + //public CodexStarter(TestLifecycle lifecycle) + // : base(lifecycle) + //{ + //} + + public List RunningGroups { get; } = new List(); + + public ICodexNodeGroup BringOnline(CodexSetup codexSetup) + { + //LogSeparator(); + //LogStart($"Starting {codexSetup.Describe()}..."); + //var gethStartResult = lifecycle.GethStarter.BringOnlineMarketplaceFor(codexSetup); + + //var startupConfig = CreateStartupConfig(gethStartResult, codexSetup); + + //var containers = StartCodexContainers(startupConfig, codexSetup.NumberOfNodes, codexSetup.Location); + + //var metricAccessFactory = CollectMetrics(codexSetup, containers); + + //var codexNodeFactory = new CodexNodeFactory(lifecycle, metricAccessFactory, gethStartResult.MarketplaceAccessFactory); + + //var group = CreateCodexGroup(codexSetup, containers, codexNodeFactory); + //lifecycle.SetCodexVersion(group.Version); + + //var nl = Environment.NewLine; + //var podInfos = string.Join(nl, containers.Containers().Select(c => $"Container: '{c.Name}' runs at '{c.Pod.PodInfo.K8SNodeName}'={c.Pod.PodInfo.Ip}")); + //LogEnd($"Started {codexSetup.NumberOfNodes} nodes " + + // $"of image '{containers.Containers().First().Recipe.Image}' " + + // $"and version '{group.Version}'{nl}" + + // podInfos); + //LogSeparator(); + + //return group; + return null!; + } + + public void BringOffline(CodexNodeGroup group) + { + //LogStart($"Stopping {group.Describe()}..."); + //var workflow = CreateWorkflow(); + //foreach (var c in group.Containers) + //{ + // StopCrashWatcher(c); + // workflow.Stop(c); + //} + //RunningGroups.Remove(group); + //LogEnd("Stopped."); + } + + public void DeleteAllResources() + { + //var workflow = CreateWorkflow(); + //workflow.DeleteTestResources(); + + //RunningGroups.Clear(); + } + + public void DownloadLog(RunningContainer container, ILogHandler logHandler, int? tailLines) + { + //var workflow = CreateWorkflow(); + //workflow.DownloadContainerLog(container, logHandler, tailLines); + } + + //private IMetricsAccessFactory CollectMetrics(CodexSetup codexSetup, RunningContainers[] containers) + //{ + // if (codexSetup.MetricsMode == MetricsMode.None) return new MetricsUnavailableAccessFactory(); + + // var runningContainers = lifecycle.PrometheusStarter.CollectMetricsFor(containers); + + // if (codexSetup.MetricsMode == MetricsMode.Dashboard) + // { + // lifecycle.GrafanaStarter.StartDashboard(runningContainers.Containers.First(), codexSetup); + // } + + // return new CodexNodeMetricsAccessFactory(lifecycle, runningContainers); + //} + + //private StartupConfig CreateStartupConfig(GethStartResult gethStartResult, CodexSetup codexSetup) + //{ + // var startupConfig = new StartupConfig(); + // startupConfig.NameOverride = codexSetup.NameOverride; + // startupConfig.Add(codexSetup); + // startupConfig.Add(gethStartResult); + // return startupConfig; + //} + + //private RunningContainers[] StartCodexContainers(StartupConfig startupConfig, int numberOfNodes, Location location) + //{ + // var result = new List(); + // var recipe = new CodexContainerRecipe(); + // for (var i = 0; i < numberOfNodes; i++) + // { + // var workflow = CreateWorkflow(); + // var rc = workflow.Start(1, location, recipe, startupConfig); + // CreateCrashWatcher(workflow, rc); + // result.Add(rc); + // } + // return result.ToArray(); + //} + + //private CodexNodeGroup CreateCodexGroup(CodexSetup codexSetup, RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) + //{ + // var group = new CodexNodeGroup(lifecycle, codexSetup, runningContainers, codexNodeFactory); + // RunningGroups.Add(group); + + // try + // { + // Stopwatch.Measure(lifecycle.Log, "EnsureOnline", group.EnsureOnline, debug: true); + // } + // catch + // { + // CodexNodesNotOnline(runningContainers); + // throw; + // } + + // return group; + //} + + //private void CodexNodesNotOnline(RunningContainers[] runningContainers) + //{ + // Log("Codex nodes failed to start"); + // foreach (var container in runningContainers.Containers()) lifecycle.DownloadLog(container); + //} + + //private StartupWorkflow CreateWorkflow() + //{ + // return lifecycle.WorkflowCreator.CreateWorkflow(); + //} + + //private void LogSeparator() + //{ + // Log("----------------------------------------------------------------------------"); + //} + + //private void CreateCrashWatcher(StartupWorkflow workflow, RunningContainers rc) + //{ + // var c = rc.Containers.Single(); + // c.CrashWatcher = workflow.CreateCrashWatcher(c); + //} + + //private void StopCrashWatcher(RunningContainers containers) + //{ + // foreach (var c in containers.Containers) + // { + // c.CrashWatcher?.Stop(); + // } + //} + } +} diff --git a/DistTestCore/Codex/CodexStartupConfig.cs b/CodexPlugin/CodexStartupConfig.cs similarity index 70% rename from DistTestCore/Codex/CodexStartupConfig.cs rename to CodexPlugin/CodexStartupConfig.cs index 36e4757..8563959 100644 --- a/DistTestCore/Codex/CodexStartupConfig.cs +++ b/CodexPlugin/CodexStartupConfig.cs @@ -1,9 +1,7 @@ -using DistTestCore.Marketplace; -using DistTestCore.Metrics; -using KubernetesWorkflow; +using KubernetesWorkflow; using Utils; -namespace DistTestCore.Codex +namespace CodexPlugin { public class CodexStartupConfig { @@ -16,8 +14,8 @@ namespace DistTestCore.Codex public Location Location { get; set; } public CodexLogLevel LogLevel { get; } public ByteSize? StorageQuota { get; set; } - public MetricsMode MetricsMode { get; set; } - public MarketplaceInitialConfig? MarketplaceConfig { get; set; } + //public MetricsMode MetricsMode { get; set; } + //public MarketplaceInitialConfig? MarketplaceConfig { get; set; } public string? BootstrapSpr { get; set; } public int? BlockTTL { get; set; } public TimeSpan? BlockMaintenanceInterval { get; set; } diff --git a/CodexPlugin/DistTestExtensions.cs b/CodexPlugin/DistTestExtensions.cs new file mode 100644 index 0000000..e699627 --- /dev/null +++ b/CodexPlugin/DistTestExtensions.cs @@ -0,0 +1,28 @@ +using DistTestCore; +using KubernetesWorkflow; + +namespace CodexPlugin +{ + public static class DistTestExtensions + { + public static RunningContainers StartCodexNodes(this DistTest distTest, int number, Action setup) + { + return null!; + } + + public static ICodexNodeGroup WrapCodexContainers(this DistTest distTest, RunningContainers containers) + { + return null!; + } + + public static IOnlineCodexNode SetupCodexNode(this DistTest distTest, Action setup) + { + return null!; + } + + public static ICodexNodeGroup SetupCodexNodes(this DistTest distTest, int number) + { + return null!; + } + } +} diff --git a/CodexPlugin/GethStarter.cs b/CodexPlugin/GethStarter.cs new file mode 100644 index 0000000..f6381b3 --- /dev/null +++ b/CodexPlugin/GethStarter.cs @@ -0,0 +1,88 @@ +//using DistTestCore.Marketplace; + +//namespace CodexPlugin +//{ +// public class GethStarter : BaseStarter +// { +// private readonly MarketplaceNetworkCache marketplaceNetworkCache; +// private readonly GethCompanionNodeStarter companionNodeStarter; + +// public GethStarter(TestLifecycle lifecycle) +// : base(lifecycle) +// { +// marketplaceNetworkCache = new MarketplaceNetworkCache( +// new GethBootstrapNodeStarter(lifecycle), +// new CodexContractsStarter(lifecycle)); +// companionNodeStarter = new GethCompanionNodeStarter(lifecycle); +// } + +// public GethStartResult BringOnlineMarketplaceFor(CodexSetup codexSetup) +// { +// if (codexSetup.MarketplaceConfig == null) return CreateMarketplaceUnavailableResult(); + +// var marketplaceNetwork = marketplaceNetworkCache.Get(); +// var companionNode = StartCompanionNode(codexSetup, marketplaceNetwork); + +// LogStart("Setting up initial balance..."); +// TransferInitialBalance(marketplaceNetwork, codexSetup.MarketplaceConfig, companionNode); +// LogEnd($"Initial balance of {codexSetup.MarketplaceConfig.InitialTestTokens} set for {codexSetup.NumberOfNodes} nodes."); + +// return CreateGethStartResult(marketplaceNetwork, companionNode); +// } + +// private void TransferInitialBalance(MarketplaceNetwork marketplaceNetwork, MarketplaceInitialConfig marketplaceConfig, GethCompanionNodeInfo companionNode) +// { +// if (marketplaceConfig.InitialTestTokens.Amount == 0) return; + +// var interaction = marketplaceNetwork.StartInteraction(lifecycle); +// var tokenAddress = marketplaceNetwork.Marketplace.TokenAddress; + +// var accounts = companionNode.Accounts.Select(a => a.Account).ToArray(); +// interaction.MintTestTokens(accounts, marketplaceConfig.InitialTestTokens.Amount, tokenAddress); +// } + +// private GethStartResult CreateGethStartResult(MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) +// { +// return new GethStartResult(CreateMarketplaceAccessFactory(marketplaceNetwork), marketplaceNetwork, companionNode); +// } + +// private GethStartResult CreateMarketplaceUnavailableResult() +// { +// return new GethStartResult(new MarketplaceUnavailableAccessFactory(), null!, null!); +// } + +// private IMarketplaceAccessFactory CreateMarketplaceAccessFactory(MarketplaceNetwork marketplaceNetwork) +// { +// return new GethMarketplaceAccessFactory(lifecycle, marketplaceNetwork); +// } + +// private GethCompanionNodeInfo StartCompanionNode(CodexSetup codexSetup, MarketplaceNetwork marketplaceNetwork) +// { +// return companionNodeStarter.StartCompanionNodeFor(codexSetup, marketplaceNetwork); +// } +// } + +// public class MarketplaceNetworkCache +// { +// private readonly GethBootstrapNodeStarter bootstrapNodeStarter; +// private readonly CodexContractsStarter codexContractsStarter; +// private MarketplaceNetwork? network; + +// public MarketplaceNetworkCache(GethBootstrapNodeStarter bootstrapNodeStarter, CodexContractsStarter codexContractsStarter) +// { +// this.bootstrapNodeStarter = bootstrapNodeStarter; +// this.codexContractsStarter = codexContractsStarter; +// } + +// public MarketplaceNetwork Get() +// { +// if (network == null) +// { +// var bootstrapInfo = bootstrapNodeStarter.StartGethBootstrapNode(); +// var marketplaceInfo = codexContractsStarter.Start(bootstrapInfo); +// network = new MarketplaceNetwork(bootstrapInfo, marketplaceInfo ); +// } +// return network; +// } +// } +//} diff --git a/CodexPlugin/Marketplace/CodexContractsContainerConfig.cs b/CodexPlugin/Marketplace/CodexContractsContainerConfig.cs new file mode 100644 index 0000000..f6e9896 --- /dev/null +++ b/CodexPlugin/Marketplace/CodexContractsContainerConfig.cs @@ -0,0 +1,16 @@ +//using KubernetesWorkflow; + +//namespace DistTestCore.Marketplace +//{ +// public class CodexContractsContainerConfig +// { +// public CodexContractsContainerConfig(string bootstrapNodeIp, Port jsonRpcPort) +// { +// BootstrapNodeIp = bootstrapNodeIp; +// JsonRpcPort = jsonRpcPort; +// } + +// public string BootstrapNodeIp { get; } +// public Port JsonRpcPort { get; } +// } +//} diff --git a/CodexPlugin/Marketplace/CodexContractsContainerRecipe.cs b/CodexPlugin/Marketplace/CodexContractsContainerRecipe.cs new file mode 100644 index 0000000..db8e88f --- /dev/null +++ b/CodexPlugin/Marketplace/CodexContractsContainerRecipe.cs @@ -0,0 +1,25 @@ +//using KubernetesWorkflow; + +//namespace DistTestCore.Marketplace +//{ +// public class CodexContractsContainerRecipe : DefaultContainerRecipe +// { +// public const string MarketplaceAddressFilename = "/hardhat/deployments/codexdisttestnetwork/Marketplace.json"; +// public const string MarketplaceArtifactFilename = "/hardhat/artifacts/contracts/Marketplace.sol/Marketplace.json"; + +// public override string AppName => "codex-contracts"; +// public override string Image => "codexstorage/codex-contracts-eth:latest-dist-tests"; + +// protected override void InitializeRecipe(StartupConfig startupConfig) +// { +// var config = startupConfig.Get(); + +// var ip = config.BootstrapNodeIp; +// var port = config.JsonRpcPort.Number; + +// AddEnvVar("DISTTEST_NETWORK_URL", $"http://{ip}:{port}"); +// AddEnvVar("HARDHAT_NETWORK", "codexdisttestnetwork"); +// AddEnvVar("KEEP_ALIVE", "1"); +// } +// } +//} diff --git a/CodexPlugin/Marketplace/CodexContractsStarter.cs b/CodexPlugin/Marketplace/CodexContractsStarter.cs new file mode 100644 index 0000000..e09b1f1 --- /dev/null +++ b/CodexPlugin/Marketplace/CodexContractsStarter.cs @@ -0,0 +1,103 @@ +//using KubernetesWorkflow; +//using Utils; + +//namespace DistTestCore.Marketplace +//{ +// public class CodexContractsStarter : BaseStarter +// { + +// public CodexContractsStarter(TestLifecycle lifecycle) +// : base(lifecycle) +// { +// } + +// public MarketplaceInfo Start(GethBootstrapNodeInfo bootstrapNode) +// { +// LogStart("Deploying Codex Marketplace..."); + +// var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); +// var startupConfig = CreateStartupConfig(bootstrapNode.RunningContainers.Containers[0]); + +// var containers = workflow.Start(1, Location.Unspecified, new CodexContractsContainerRecipe(), startupConfig); +// if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Codex contracts container to be created. Test infra failure."); +// var container = containers.Containers[0]; + +// WaitUntil(() => +// { +// var logHandler = new ContractsReadyLogHandler(Debug); +// workflow.DownloadContainerLog(container, logHandler, null); +// return logHandler.Found; +// }); +// Log("Contracts deployed. Extracting addresses..."); + +// var extractor = new ContainerInfoExtractor(lifecycle.Log, workflow, container); +// var marketplaceAddress = extractor.ExtractMarketplaceAddress(); +// var abi = extractor.ExtractMarketplaceAbi(); + +// var interaction = bootstrapNode.StartInteraction(lifecycle); +// var tokenAddress = interaction.GetTokenAddress(marketplaceAddress); + +// LogEnd("Extract completed. Marketplace deployed."); + +// return new MarketplaceInfo(marketplaceAddress, abi, tokenAddress); +// } + +// private void WaitUntil(Func predicate) +// { +// Time.WaitUntil(predicate, TimeSpan.FromMinutes(3), TimeSpan.FromSeconds(2)); +// } + +// private StartupConfig CreateStartupConfig(RunningContainer bootstrapContainer) +// { +// var startupConfig = new StartupConfig(); +// var contractsConfig = new CodexContractsContainerConfig(bootstrapContainer.Pod.PodInfo.Ip, bootstrapContainer.Recipe.GetPortByTag(GethContainerRecipe.HttpPortTag)); +// startupConfig.Add(contractsConfig); +// return startupConfig; +// } +// } + +// public class MarketplaceInfo +// { +// public MarketplaceInfo(string address, string abi, string tokenAddress) +// { +// Address = address; +// Abi = abi; +// TokenAddress = tokenAddress; +// } + +// public string Address { get; } +// public string Abi { get; } +// public string TokenAddress { get; } +// } + +// public class ContractsReadyLogHandler : LogHandler +// { +// // Log should contain 'Compiled 15 Solidity files successfully' at some point. +// private const string RequiredCompiledString = "Solidity files successfully"; +// // When script is done, it prints the ready-string. +// private const string ReadyString = "Done! Sleeping indefinitely..."; +// private readonly Action debug; + +// public ContractsReadyLogHandler(Action debug) +// { +// this.debug = debug; +// debug($"Looking for '{RequiredCompiledString}' and '{ReadyString}' in container logs..."); +// } + +// public bool SeenCompileString { get; private set; } +// public bool Found { get; private set; } + +// protected override void ProcessLine(string line) +// { +// debug(line); +// if (line.Contains(RequiredCompiledString)) SeenCompileString = true; +// if (line.Contains(ReadyString)) +// { +// if (!SeenCompileString) throw new Exception("CodexContracts deployment failed. " + +// "Solidity files not compiled before process exited."); + +// Found = true; +// } +// } +// } +//} diff --git a/CodexPlugin/Marketplace/ContainerInfoExtractor.cs b/CodexPlugin/Marketplace/ContainerInfoExtractor.cs new file mode 100644 index 0000000..ced47e1 --- /dev/null +++ b/CodexPlugin/Marketplace/ContainerInfoExtractor.cs @@ -0,0 +1,149 @@ +//using KubernetesWorkflow; +//using Logging; +//using Newtonsoft.Json; +//using Newtonsoft.Json.Linq; +//using Utils; + +//namespace DistTestCore.Marketplace +//{ +// public class ContainerInfoExtractor +// { +// private readonly BaseLog log; +// private readonly StartupWorkflow workflow; +// private readonly RunningContainer container; + +// public ContainerInfoExtractor(BaseLog log, StartupWorkflow workflow, RunningContainer container) +// { +// this.log = log; +// this.workflow = workflow; +// this.container = container; +// } + +// public AllGethAccounts ExtractAccounts() +// { +// log.Debug(); +// var accountsCsv = Retry(() => FetchAccountsCsv()); +// if (string.IsNullOrEmpty(accountsCsv)) throw new InvalidOperationException("Unable to fetch accounts.csv for geth node. Test infra failure."); + +// var lines = accountsCsv.Split('\n'); +// return new AllGethAccounts(lines.Select(ParseLineToAccount).ToArray()); +// } + +// public string ExtractPubKey() +// { +// log.Debug(); +// var pubKey = Retry(FetchPubKey); +// if (string.IsNullOrEmpty(pubKey)) throw new InvalidOperationException("Unable to fetch enode from geth node. Test infra failure."); + +// return pubKey; +// } + +// public string ExtractMarketplaceAddress() +// { +// log.Debug(); +// var marketplaceAddress = Retry(FetchMarketplaceAddress); +// if (string.IsNullOrEmpty(marketplaceAddress)) throw new InvalidOperationException("Unable to fetch marketplace account from codex-contracts node. Test infra failure."); + +// return marketplaceAddress; +// } + +// public string ExtractMarketplaceAbi() +// { +// log.Debug(); +// var marketplaceAbi = Retry(FetchMarketplaceAbi); +// if (string.IsNullOrEmpty(marketplaceAbi)) throw new InvalidOperationException("Unable to fetch marketplace artifacts from codex-contracts node. Test infra failure."); + +// return marketplaceAbi; +// } + +// private string FetchAccountsCsv() +// { +// return workflow.ExecuteCommand(container, "cat", GethContainerRecipe.AccountsFilename); +// } + +// private string FetchMarketplaceAddress() +// { +// var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceAddressFilename); +// var marketplace = JsonConvert.DeserializeObject(json); +// return marketplace!.address; +// } + +// private string FetchMarketplaceAbi() +// { +// var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceArtifactFilename); + +// var artifact = JObject.Parse(json); +// var abi = artifact["abi"]; +// return abi!.ToString(Formatting.None); +// } + +// private string FetchPubKey() +// { +// var enodeFinder = new PubKeyFinder(s => log.Debug(s)); +// workflow.DownloadContainerLog(container, enodeFinder, null); +// return enodeFinder.GetPubKey(); +// } + +// private GethAccount ParseLineToAccount(string l) +// { +// var tokens = l.Replace("\r", "").Split(','); +// if (tokens.Length != 2) throw new InvalidOperationException(); +// var account = tokens[0]; +// var privateKey = tokens[1]; +// return new GethAccount(account, privateKey); +// } + +// private static string Retry(Func fetch) +// { +// return Time.Retry(fetch, nameof(ContainerInfoExtractor)); +// } +// } + +// public class PubKeyFinder : LogHandler, ILogHandler +// { +// private const string openTag = "self=enode://"; +// private const string openTagQuote = "self=\"enode://"; +// private readonly Action debug; +// private string pubKey = string.Empty; + +// public PubKeyFinder(Action debug) +// { +// this.debug = debug; +// debug($"Looking for '{openTag}' in container logs..."); +// } + +// public string GetPubKey() +// { +// if (string.IsNullOrEmpty(pubKey)) throw new Exception("Not found yet exception."); +// return pubKey; +// } + +// protected override void ProcessLine(string line) +// { +// debug(line); +// if (line.Contains(openTag)) +// { +// ExtractPubKey(openTag, line); +// } +// else if (line.Contains(openTagQuote)) +// { +// ExtractPubKey(openTagQuote, line); +// } +// } + +// private void ExtractPubKey(string tag, string line) +// { +// var openIndex = line.IndexOf(tag) + tag.Length; +// var closeIndex = line.IndexOf("@"); + +// pubKey = line.Substring( +// startIndex: openIndex, +// length: closeIndex - openIndex); +// } +// } + +// public class MarketplaceJson +// { +// public string address { get; set; } = string.Empty; +// } +//} diff --git a/CodexPlugin/Marketplace/GethBootstrapNodeInfo.cs b/CodexPlugin/Marketplace/GethBootstrapNodeInfo.cs new file mode 100644 index 0000000..3e84dec --- /dev/null +++ b/CodexPlugin/Marketplace/GethBootstrapNodeInfo.cs @@ -0,0 +1,42 @@ +//using KubernetesWorkflow; +//using NethereumWorkflow; + +//namespace DistTestCore.Marketplace +//{ +// public class GethBootstrapNodeInfo +// { +// public GethBootstrapNodeInfo(RunningContainers runningContainers, AllGethAccounts allAccounts, string pubKey, Port discoveryPort) +// { +// RunningContainers = runningContainers; +// AllAccounts = allAccounts; +// Account = allAccounts.Accounts[0]; +// PubKey = pubKey; +// DiscoveryPort = discoveryPort; +// } + +// public RunningContainers RunningContainers { get; } +// public AllGethAccounts AllAccounts { get; } +// public GethAccount Account { get; } +// public string PubKey { get; } +// public Port DiscoveryPort { get; } + +// public NethereumInteraction StartInteraction(TestLifecycle lifecycle) +// { +// var address = lifecycle.Configuration.GetAddress(RunningContainers.Containers[0]); +// var account = Account; + +// var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, account.PrivateKey); +// return creator.CreateWorkflow(); +// } +// } + +// public class AllGethAccounts +// { +// public GethAccount[] Accounts { get; } + +// public AllGethAccounts(GethAccount[] accounts) +// { +// Accounts = accounts; +// } +// } +//} diff --git a/CodexPlugin/Marketplace/GethBootstrapNodeStarter.cs b/CodexPlugin/Marketplace/GethBootstrapNodeStarter.cs new file mode 100644 index 0000000..b94d041 --- /dev/null +++ b/CodexPlugin/Marketplace/GethBootstrapNodeStarter.cs @@ -0,0 +1,40 @@ +//using KubernetesWorkflow; + +//namespace DistTestCore.Marketplace +//{ +// public class GethBootstrapNodeStarter : BaseStarter +// { +// public GethBootstrapNodeStarter(TestLifecycle lifecycle) +// : base(lifecycle) +// { +// } + +// public GethBootstrapNodeInfo StartGethBootstrapNode() +// { +// LogStart("Starting Geth bootstrap node..."); +// var startupConfig = CreateBootstrapStartupConfig(); + +// var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); +// var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), startupConfig); +// if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Geth bootstrap node to be created. Test infra failure."); +// var bootstrapContainer = containers.Containers[0]; + +// var extractor = new ContainerInfoExtractor(lifecycle.Log, workflow, bootstrapContainer); +// var accounts = extractor.ExtractAccounts(); +// var pubKey = extractor.ExtractPubKey(); +// var discoveryPort = bootstrapContainer.Recipe.GetPortByTag(GethContainerRecipe.DiscoveryPortTag); +// var result = new GethBootstrapNodeInfo(containers, accounts, pubKey, discoveryPort); + +// LogEnd($"Geth bootstrap node started with account '{result.Account.Account}'"); + +// return result; +// } + +// private StartupConfig CreateBootstrapStartupConfig() +// { +// var config = new StartupConfig(); +// config.Add(new GethStartupConfig(true, null!, 0, 0)); +// return config; +// } +// } +//} diff --git a/CodexPlugin/Marketplace/GethCompanionNodeInfo.cs b/CodexPlugin/Marketplace/GethCompanionNodeInfo.cs new file mode 100644 index 0000000..30f2e78 --- /dev/null +++ b/CodexPlugin/Marketplace/GethCompanionNodeInfo.cs @@ -0,0 +1,38 @@ +//using KubernetesWorkflow; +//using NethereumWorkflow; + +//namespace DistTestCore.Marketplace +//{ +// public class GethCompanionNodeInfo +// { +// public GethCompanionNodeInfo(RunningContainer runningContainer, GethAccount[] accounts) +// { +// RunningContainer = runningContainer; +// Accounts = accounts; +// } + +// public RunningContainer RunningContainer { get; } +// public GethAccount[] Accounts { get; } + +// public NethereumInteraction StartInteraction(TestLifecycle lifecycle, GethAccount account) +// { +// var address = lifecycle.Configuration.GetAddress(RunningContainer); +// var privateKey = account.PrivateKey; + +// var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, privateKey); +// return creator.CreateWorkflow(); +// } +// } + +// public class GethAccount +// { +// public GethAccount(string account, string privateKey) +// { +// Account = account; +// PrivateKey = privateKey; +// } + +// public string Account { get; } +// public string PrivateKey { get; } +// } +//} diff --git a/CodexPlugin/Marketplace/GethCompanionNodeStarter.cs b/CodexPlugin/Marketplace/GethCompanionNodeStarter.cs new file mode 100644 index 0000000..9c8a303 --- /dev/null +++ b/CodexPlugin/Marketplace/GethCompanionNodeStarter.cs @@ -0,0 +1,77 @@ +//using KubernetesWorkflow; +//using Utils; + +//namespace DistTestCore.Marketplace +//{ +// public class GethCompanionNodeStarter : BaseStarter +// { +// private int companionAccountIndex = 0; + +// public GethCompanionNodeStarter(TestLifecycle lifecycle) +// : base(lifecycle) +// { +// } + +// public GethCompanionNodeInfo StartCompanionNodeFor(CodexSetup codexSetup, MarketplaceNetwork marketplace) +// { +// LogStart($"Initializing companion for {codexSetup.NumberOfNodes} Codex nodes."); + +// var config = CreateCompanionNodeStartupConfig(marketplace.Bootstrap, codexSetup.NumberOfNodes); + +// var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); +// var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), CreateStartupConfig(config)); +// if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected one Geth companion node to be created. Test infra failure."); +// var container = containers.Containers[0]; + +// var node = CreateCompanionInfo(container, marketplace, config); +// EnsureCompanionNodeIsSynced(node, marketplace); + +// LogEnd($"Initialized one companion node for {codexSetup.NumberOfNodes} Codex nodes. Their accounts: [{string.Join(",", node.Accounts.Select(a => a.Account))}]"); +// return node; +// } + +// private GethCompanionNodeInfo CreateCompanionInfo(RunningContainer container, MarketplaceNetwork marketplace, GethStartupConfig config) +// { +// var accounts = ExtractAccounts(marketplace, config); +// return new GethCompanionNodeInfo(container, accounts); +// } + +// private static GethAccount[] ExtractAccounts(MarketplaceNetwork marketplace, GethStartupConfig config) +// { +// return marketplace.Bootstrap.AllAccounts.Accounts +// .Skip(1 + config.CompanionAccountStartIndex) +// .Take(config.NumberOfCompanionAccounts) +// .ToArray(); +// } + +// private void EnsureCompanionNodeIsSynced(GethCompanionNodeInfo node, MarketplaceNetwork marketplace) +// { +// try +// { +// Time.WaitUntil(() => +// { +// var interaction = node.StartInteraction(lifecycle, node.Accounts.First()); +// return interaction.IsSynced(marketplace.Marketplace.Address, marketplace.Marketplace.Abi); +// }, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(3)); +// } +// catch (Exception e) +// { +// throw new Exception("Geth companion node did not sync within timeout. Test infra failure.", e); +// } +// } + +// private GethStartupConfig CreateCompanionNodeStartupConfig(GethBootstrapNodeInfo bootstrapNode, int numberOfAccounts) +// { +// var config = new GethStartupConfig(false, bootstrapNode, companionAccountIndex, numberOfAccounts); +// companionAccountIndex += numberOfAccounts; +// return config; +// } + +// private StartupConfig CreateStartupConfig(GethStartupConfig gethConfig) +// { +// var config = new StartupConfig(); +// config.Add(gethConfig); +// return config; +// } +// } +//} diff --git a/CodexPlugin/Marketplace/GethContainerRecipe.cs b/CodexPlugin/Marketplace/GethContainerRecipe.cs new file mode 100644 index 0000000..e5b2f9b --- /dev/null +++ b/CodexPlugin/Marketplace/GethContainerRecipe.cs @@ -0,0 +1,73 @@ +//using KubernetesWorkflow; + +//namespace DistTestCore.Marketplace +//{ +// public class GethContainerRecipe : DefaultContainerRecipe +// { +// private const string defaultArgs = "--ipcdisable --syncmode full"; + +// public const string HttpPortTag = "http_port"; +// public const string DiscoveryPortTag = "disc_port"; +// public const string AccountsFilename = "accounts.csv"; + +// public override string AppName => "geth"; +// public override string Image => "codexstorage/dist-tests-geth:latest"; + +// protected override void InitializeRecipe(StartupConfig startupConfig) +// { +// var config = startupConfig.Get(); + +// var args = CreateArgs(config); + +// AddEnvVar("GETH_ARGS", args); +// } + +// private string CreateArgs(GethStartupConfig config) +// { +// var discovery = AddInternalPort(tag: DiscoveryPortTag); + +// if (config.IsBootstrapNode) +// { +// return CreateBootstapArgs(discovery); +// } + +// return CreateCompanionArgs(discovery, config); +// } + +// private string CreateBootstapArgs(Port discovery) +// { +// AddEnvVar("ENABLE_MINER", "1"); +// UnlockAccounts(0, 1); +// var exposedPort = AddExposedPort(tag: HttpPortTag); +// return $"--http.port {exposedPort.Number} --port {discovery.Number} --discovery.port {discovery.Number} {defaultArgs}"; +// } + +// private string CreateCompanionArgs(Port discovery, GethStartupConfig config) +// { +// UnlockAccounts( +// config.CompanionAccountStartIndex + 1, +// config.NumberOfCompanionAccounts); + +// var port = AddInternalPort(); +// var authRpc = AddInternalPort(); +// var httpPort = AddExposedPort(tag: HttpPortTag); + +// var bootPubKey = config.BootstrapNode.PubKey; +// var bootIp = config.BootstrapNode.RunningContainers.Containers[0].Pod.PodInfo.Ip; +// var bootPort = config.BootstrapNode.DiscoveryPort.Number; +// var bootstrapArg = $"--bootnodes enode://{bootPubKey}@{bootIp}:{bootPort} --nat=extip:{bootIp}"; + +// return $"--port {port.Number} --discovery.port {discovery.Number} --authrpc.port {authRpc.Number} --http.addr 0.0.0.0 --http.port {httpPort.Number} --ws --ws.addr 0.0.0.0 --ws.port {httpPort.Number} {bootstrapArg} {defaultArgs}"; +// } + +// private void UnlockAccounts(int startIndex, int numberOfAccounts) +// { +// if (startIndex < 0) throw new ArgumentException(); +// if (numberOfAccounts < 1) throw new ArgumentException(); +// if (startIndex + numberOfAccounts > 1000) throw new ArgumentException("Out of accounts!"); + +// AddEnvVar("UNLOCK_START_INDEX", startIndex.ToString()); +// AddEnvVar("UNLOCK_NUMBER", numberOfAccounts.ToString()); +// } +// } +//} diff --git a/CodexPlugin/Marketplace/GethStartResult.cs b/CodexPlugin/Marketplace/GethStartResult.cs new file mode 100644 index 0000000..f9e1048 --- /dev/null +++ b/CodexPlugin/Marketplace/GethStartResult.cs @@ -0,0 +1,19 @@ +//using Newtonsoft.Json; + +//namespace DistTestCore.Marketplace +//{ +// public class GethStartResult +// { +// public GethStartResult(IMarketplaceAccessFactory marketplaceAccessFactory, MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) +// { +// MarketplaceAccessFactory = marketplaceAccessFactory; +// MarketplaceNetwork = marketplaceNetwork; +// CompanionNode = companionNode; +// } + +// [JsonIgnore] +// public IMarketplaceAccessFactory MarketplaceAccessFactory { get; } +// public MarketplaceNetwork MarketplaceNetwork { get; } +// public GethCompanionNodeInfo CompanionNode { get; } +// } +//} diff --git a/CodexPlugin/Marketplace/GethStartupConfig.cs b/CodexPlugin/Marketplace/GethStartupConfig.cs new file mode 100644 index 0000000..67ad0d5 --- /dev/null +++ b/CodexPlugin/Marketplace/GethStartupConfig.cs @@ -0,0 +1,18 @@ +//namespace DistTestCore.Marketplace +//{ +// public class GethStartupConfig +// { +// public GethStartupConfig(bool isBootstrapNode, GethBootstrapNodeInfo bootstrapNode, int companionAccountStartIndex, int numberOfCompanionAccounts) +// { +// IsBootstrapNode = isBootstrapNode; +// BootstrapNode = bootstrapNode; +// CompanionAccountStartIndex = companionAccountStartIndex; +// NumberOfCompanionAccounts = numberOfCompanionAccounts; +// } + +// public bool IsBootstrapNode { get; } +// public GethBootstrapNodeInfo BootstrapNode { get; } +// public int CompanionAccountStartIndex { get; } +// public int NumberOfCompanionAccounts { get; } +// } +//} diff --git a/CodexPlugin/Marketplace/MarketplaceAccess.cs b/CodexPlugin/Marketplace/MarketplaceAccess.cs new file mode 100644 index 0000000..bee6cb7 --- /dev/null +++ b/CodexPlugin/Marketplace/MarketplaceAccess.cs @@ -0,0 +1,243 @@ +//using DistTestCore.Codex; +//using DistTestCore.Helpers; +//using Logging; +//using Newtonsoft.Json; +//using NUnit.Framework; +//using NUnit.Framework.Constraints; +//using System.Numerics; +//using Utils; + +//namespace DistTestCore.Marketplace +//{ +// public interface IMarketplaceAccess +// { +// string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan maxDuration); +// StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration); +// void AssertThatBalance(IResolveConstraint constraint, string message = ""); +// TestToken GetBalance(); +// } + +// public class MarketplaceAccess : IMarketplaceAccess +// { +// private readonly TestLifecycle lifecycle; +// private readonly MarketplaceNetwork marketplaceNetwork; +// private readonly GethAccount account; +// private readonly CodexAccess codexAccess; + +// public MarketplaceAccess(TestLifecycle lifecycle, MarketplaceNetwork marketplaceNetwork, GethAccount account, CodexAccess codexAccess) +// { +// this.lifecycle = lifecycle; +// this.marketplaceNetwork = marketplaceNetwork; +// this.account = account; +// this.codexAccess = codexAccess; +// } + +// public StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration) +// { +// var request = new CodexSalesRequestStorageRequest +// { +// duration = ToDecInt(duration.TotalSeconds), +// proofProbability = ToDecInt(proofProbability), +// reward = ToDecInt(pricePerSlotPerSecond), +// collateral = ToDecInt(requiredCollateral), +// expiry = null, +// nodes = minRequiredNumberOfNodes, +// tolerance = null, +// }; + +// Log($"Requesting storage for: {contentId.Id}... (" + +// $"pricePerSlotPerSecond: {pricePerSlotPerSecond}, " + +// $"requiredCollateral: {requiredCollateral}, " + +// $"minRequiredNumberOfNodes: {minRequiredNumberOfNodes}, " + +// $"proofProbability: {proofProbability}, " + +// $"duration: {Time.FormatDuration(duration)})"); + +// var response = codexAccess.RequestStorage(request, contentId.Id); + +// if (response == "Purchasing not available") +// { +// throw new InvalidOperationException(response); +// } + +// Log($"Storage requested successfully. PurchaseId: '{response}'."); + +// return new StoragePurchaseContract(lifecycle.Log, codexAccess, response, duration); +// } + +// public string MakeStorageAvailable(ByteSize totalSpace, TestToken minPriceForTotalSpace, TestToken maxCollateral, TimeSpan maxDuration) +// { +// var request = new CodexSalesAvailabilityRequest +// { +// size = ToDecInt(totalSpace.SizeInBytes), +// duration = ToDecInt(maxDuration.TotalSeconds), +// maxCollateral = ToDecInt(maxCollateral), +// minPrice = ToDecInt(minPriceForTotalSpace) +// }; + +// Log($"Making storage available... (" + +// $"size: {totalSpace}, " + +// $"minPriceForTotalSpace: {minPriceForTotalSpace}, " + +// $"maxCollateral: {maxCollateral}, " + +// $"maxDuration: {Time.FormatDuration(maxDuration)})"); + +// var response = codexAccess.SalesAvailability(request); + +// Log($"Storage successfully made available. Id: {response.id}"); + +// return response.id; +// } + +// private string ToDecInt(double d) +// { +// var i = new BigInteger(d); +// return i.ToString("D"); +// } + +// public string ToDecInt(TestToken t) +// { +// var i = new BigInteger(t.Amount); +// return i.ToString("D"); +// } + +// public void AssertThatBalance(IResolveConstraint constraint, string message = "") +// { +// AssertHelpers.RetryAssert(constraint, GetBalance, message); +// } + +// public TestToken GetBalance() +// { +// var interaction = marketplaceNetwork.StartInteraction(lifecycle); +// var amount = interaction.GetBalance(marketplaceNetwork.Marketplace.TokenAddress, account.Account); +// var balance = new TestToken(amount); + +// Log($"Balance of {account.Account} is {balance}."); + +// return balance; +// } + +// private void Log(string msg) +// { +// lifecycle.Log.Log($"{codexAccess.Container.Name} {msg}"); +// } +// } + +// public class MarketplaceUnavailable : IMarketplaceAccess +// { +// public StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerBytePerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration) +// { +// Unavailable(); +// return null!; +// } + +// public string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan duration) +// { +// Unavailable(); +// return string.Empty; +// } + +// public void AssertThatBalance(IResolveConstraint constraint, string message = "") +// { +// Unavailable(); +// } + +// public TestToken GetBalance() +// { +// Unavailable(); +// return new TestToken(0); +// } + +// private void Unavailable() +// { +// Assert.Fail("Incorrect test setup: Marketplace was not enabled for this group of Codex nodes. Add 'EnableMarketplace(...)' after 'SetupCodexNodes()' to enable it."); +// throw new InvalidOperationException(); +// } +// } + +// public class StoragePurchaseContract +// { +// private readonly BaseLog log; +// private readonly CodexAccess codexAccess; +// private DateTime? contractStartUtc; + +// public StoragePurchaseContract(BaseLog log, CodexAccess codexAccess, string purchaseId, TimeSpan contractDuration) +// { +// this.log = log; +// this.codexAccess = codexAccess; +// PurchaseId = purchaseId; +// ContractDuration = contractDuration; +// } + +// public string PurchaseId { get; } +// public TimeSpan ContractDuration { get; } + +// public void WaitForStorageContractStarted() +// { +// WaitForStorageContractStarted(TimeSpan.FromSeconds(30)); +// } + +// public void WaitForStorageContractFinished() +// { +// if (!contractStartUtc.HasValue) +// { +// WaitForStorageContractStarted(); +// } +// var gracePeriod = TimeSpan.FromSeconds(10); +// var currentContractTime = DateTime.UtcNow - contractStartUtc!.Value; +// var timeout = (ContractDuration - currentContractTime) + gracePeriod; +// WaitForStorageContractState(timeout, "finished"); +// } + +// /// +// /// Wait for contract to start. Max timeout depends on contract filesize. Allows more time for larger files. +// /// +// public void WaitForStorageContractStarted(ByteSize contractFileSize) +// { +// var filesizeInMb = contractFileSize.SizeInBytes / (1024 * 1024); +// var maxWaitTime = TimeSpan.FromSeconds(filesizeInMb * 10.0); + +// WaitForStorageContractStarted(maxWaitTime); +// } + +// public void WaitForStorageContractStarted(TimeSpan timeout) +// { +// WaitForStorageContractState(timeout, "started"); +// contractStartUtc = DateTime.UtcNow; +// } + +// private void WaitForStorageContractState(TimeSpan timeout, string desiredState) +// { +// var lastState = ""; +// var waitStart = DateTime.UtcNow; + +// log.Log($"Waiting for {Time.FormatDuration(timeout)} for contract '{PurchaseId}' to reach state '{desiredState}'."); +// while (lastState != desiredState) +// { +// var purchaseStatus = codexAccess.GetPurchaseStatus(PurchaseId); +// var statusJson = JsonConvert.SerializeObject(purchaseStatus); +// if (purchaseStatus != null && purchaseStatus.state != lastState) +// { +// lastState = purchaseStatus.state; +// log.Debug("Purchase status: " + statusJson); +// } + +// Thread.Sleep(1000); + +// if (lastState == "errored") +// { +// Assert.Fail("Contract errored: " + statusJson); +// } + +// if (DateTime.UtcNow - waitStart > timeout) +// { +// Assert.Fail($"Contract did not reach '{desiredState}' within timeout. {statusJson}"); +// } +// } +// log.Log($"Contract '{desiredState}'."); +// } + +// public CodexStoragePurchase GetPurchaseStatus(string purchaseId) +// { +// return codexAccess.GetPurchaseStatus(purchaseId); +// } +// } +//} diff --git a/CodexPlugin/Marketplace/MarketplaceAccessFactory.cs b/CodexPlugin/Marketplace/MarketplaceAccessFactory.cs new file mode 100644 index 0000000..efc6841 --- /dev/null +++ b/CodexPlugin/Marketplace/MarketplaceAccessFactory.cs @@ -0,0 +1,41 @@ +//using DistTestCore.Codex; + +//namespace DistTestCore.Marketplace +//{ +// public interface IMarketplaceAccessFactory +// { +// IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access); +// } + +// public class MarketplaceUnavailableAccessFactory : IMarketplaceAccessFactory +// { +// public IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access) +// { +// return new MarketplaceUnavailable(); +// } +// } + +// public class GethMarketplaceAccessFactory : IMarketplaceAccessFactory +// { +// private readonly TestLifecycle lifecycle; +// private readonly MarketplaceNetwork marketplaceNetwork; + +// public GethMarketplaceAccessFactory(TestLifecycle lifecycle, MarketplaceNetwork marketplaceNetwork) +// { +// this.lifecycle = lifecycle; +// this.marketplaceNetwork = marketplaceNetwork; +// } + +// public IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access) +// { +// var companionNode = GetGethCompanionNode(access); +// return new MarketplaceAccess(lifecycle, marketplaceNetwork, companionNode, access); +// } + +// private GethAccount GetGethCompanionNode(CodexAccess access) +// { +// var account = access.Container.Recipe.Additionals.Single(a => a is GethAccount); +// return (GethAccount)account; +// } +// } +//} diff --git a/CodexPlugin/Marketplace/MarketplaceInitialConfig.cs b/CodexPlugin/Marketplace/MarketplaceInitialConfig.cs new file mode 100644 index 0000000..da16bca --- /dev/null +++ b/CodexPlugin/Marketplace/MarketplaceInitialConfig.cs @@ -0,0 +1,17 @@ +//namespace DistTestCore.Marketplace +//{ +// public class MarketplaceInitialConfig +// { +// public MarketplaceInitialConfig(Ether initialEth, TestToken initialTestTokens, bool isValidator) +// { +// InitialEth = initialEth; +// InitialTestTokens = initialTestTokens; +// IsValidator = isValidator; +// } + +// public Ether InitialEth { get; } +// public TestToken InitialTestTokens { get; } +// public bool IsValidator { get; } +// public int? AccountIndexOverride { get; set; } +// } +//} diff --git a/CodexPlugin/Marketplace/MarketplaceNetwork.cs b/CodexPlugin/Marketplace/MarketplaceNetwork.cs new file mode 100644 index 0000000..d828f37 --- /dev/null +++ b/CodexPlugin/Marketplace/MarketplaceNetwork.cs @@ -0,0 +1,21 @@ +//using NethereumWorkflow; + +//namespace DistTestCore.Marketplace +//{ +// public class MarketplaceNetwork +// { +// public MarketplaceNetwork(GethBootstrapNodeInfo bootstrap, MarketplaceInfo marketplace) +// { +// Bootstrap = bootstrap; +// Marketplace = marketplace; +// } + +// public GethBootstrapNodeInfo Bootstrap { get; } +// public MarketplaceInfo Marketplace { get; } + +// public NethereumInteraction StartInteraction(TestLifecycle lifecycle) +// { +// return Bootstrap.StartInteraction(lifecycle); +// } +// } +//} diff --git a/CodexPlugin/Metrics/GrafanaContainerRecipe.cs b/CodexPlugin/Metrics/GrafanaContainerRecipe.cs new file mode 100644 index 0000000..db38461 --- /dev/null +++ b/CodexPlugin/Metrics/GrafanaContainerRecipe.cs @@ -0,0 +1,25 @@ +//using KubernetesWorkflow; + +//namespace DistTestCore.Metrics +//{ +// public class GrafanaContainerRecipe : DefaultContainerRecipe +// { +// public override string AppName => "grafana"; +// public override string Image => "grafana/grafana-oss:10.0.3"; + +// public const string DefaultAdminUser = "adminium"; +// public const string DefaultAdminPassword = "passwordium"; + +// protected override void InitializeRecipe(StartupConfig startupConfig) +// { +// AddExposedPort(3000); + +// AddEnvVar("GF_AUTH_ANONYMOUS_ENABLED", "true"); +// AddEnvVar("GF_AUTH_ANONYMOUS_ORG_NAME", "Main Org."); +// AddEnvVar("GF_AUTH_ANONYMOUS_ORG_ROLE", "Editor"); + +// AddEnvVar("GF_SECURITY_ADMIN_USER", DefaultAdminUser); +// AddEnvVar("GF_SECURITY_ADMIN_PASSWORD", DefaultAdminPassword); +// } +// } +//} diff --git a/CodexPlugin/Metrics/MetricsAccess.cs b/CodexPlugin/Metrics/MetricsAccess.cs new file mode 100644 index 0000000..3641d4f --- /dev/null +++ b/CodexPlugin/Metrics/MetricsAccess.cs @@ -0,0 +1,81 @@ +//using DistTestCore.Helpers; +//using KubernetesWorkflow; +//using Logging; +//using NUnit.Framework; +//using NUnit.Framework.Constraints; +//using Utils; + +//namespace DistTestCore.Metrics +//{ +// public interface IMetricsAccess +// { +// void AssertThat(string metricName, IResolveConstraint constraint, string message = ""); +// } + +// public class MetricsAccess : IMetricsAccess +// { +// private readonly BaseLog log; +// private readonly ITimeSet timeSet; +// private readonly MetricsQuery query; +// private readonly RunningContainer node; + +// public MetricsAccess(BaseLog log, ITimeSet timeSet, MetricsQuery query, RunningContainer node) +// { +// this.log = log; +// this.timeSet = timeSet; +// this.query = query; +// this.node = node; +// } + +// public void AssertThat(string metricName, IResolveConstraint constraint, string message = "") +// { +// AssertHelpers.RetryAssert(constraint, () => +// { +// var metricSet = GetMetricWithTimeout(metricName); +// var metricValue = metricSet.Values[0].Value; + +// log.Log($"{node.Name} metric '{metricName}' = {metricValue}"); +// return metricValue; +// }, message); +// } + +// public Metrics? GetAllMetrics() +// { +// return query.GetAllMetricsForNode(node); +// } + +// private MetricsSet GetMetricWithTimeout(string metricName) +// { +// var start = DateTime.UtcNow; + +// while (true) +// { +// var mostRecent = GetMostRecent(metricName); +// if (mostRecent != null) return mostRecent; +// if (DateTime.UtcNow - start > timeSet.WaitForMetricTimeout()) +// { +// Assert.Fail($"Timeout: Unable to get metric '{metricName}'."); +// throw new TimeoutException(); +// } + +// Time.Sleep(TimeSpan.FromSeconds(2)); +// } +// } + +// private MetricsSet? GetMostRecent(string metricName) +// { +// var result = query.GetMostRecent(metricName, node); +// if (result == null) return null; +// return result.Sets.LastOrDefault(); +// } +// } + +// public class MetricsUnavailable : IMetricsAccess +// { +// public void AssertThat(string metricName, IResolveConstraint constraint, string message = "") +// { +// Assert.Fail("Incorrect test setup: Metrics were not enabled for this group of Codex nodes. Add 'EnableMetrics()' after 'SetupCodexNodes()' to enable it."); +// throw new InvalidOperationException(); +// } +// } +//} diff --git a/CodexPlugin/Metrics/MetricsAccessFactory.cs b/CodexPlugin/Metrics/MetricsAccessFactory.cs new file mode 100644 index 0000000..c185fef --- /dev/null +++ b/CodexPlugin/Metrics/MetricsAccessFactory.cs @@ -0,0 +1,35 @@ +//using KubernetesWorkflow; + +//namespace DistTestCore.Metrics +//{ +// public interface IMetricsAccessFactory +// { +// IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer); +// } + +// public class MetricsUnavailableAccessFactory : IMetricsAccessFactory +// { +// public IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer) +// { +// return new MetricsUnavailable(); +// } +// } + +// public class CodexNodeMetricsAccessFactory : IMetricsAccessFactory +// { +// private readonly TestLifecycle lifecycle; +// private readonly RunningContainers prometheusContainer; + +// public CodexNodeMetricsAccessFactory(TestLifecycle lifecycle, RunningContainers prometheusContainer) +// { +// this.lifecycle = lifecycle; +// this.prometheusContainer = prometheusContainer; +// } + +// public IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer) +// { +// var query = new MetricsQuery(lifecycle, prometheusContainer); +// return new MetricsAccess(lifecycle.Log, lifecycle.TimeSet, query, codexContainer); +// } +// } +//} diff --git a/CodexPlugin/Metrics/MetricsDownloader.cs b/CodexPlugin/Metrics/MetricsDownloader.cs new file mode 100644 index 0000000..5efa085 --- /dev/null +++ b/CodexPlugin/Metrics/MetricsDownloader.cs @@ -0,0 +1,80 @@ +//using Logging; +//using System.Globalization; + +//namespace DistTestCore.Metrics +//{ +// public class MetricsDownloader +// { +// private readonly BaseLog log; + +// public MetricsDownloader(BaseLog log) +// { +// this.log = log; +// } + +// public void DownloadAllMetricsForNode(string nodeName, MetricsAccess access) +// { +// var metrics = access.GetAllMetrics(); +// if (metrics == null || metrics.Sets.Length == 0 || metrics.Sets.All(s => s.Values.Length == 0)) return; + +// var headers = new[] { "timestamp" }.Concat(metrics.Sets.Select(s => s.Name)).ToArray(); +// var map = CreateValueMap(metrics); + +// WriteToFile(nodeName, headers, map); +// } + +// private void WriteToFile(string nodeName, string[] headers, Dictionary> map) +// { +// var file = log.CreateSubfile("csv"); +// log.Log($"Downloading metrics for {nodeName} to file {file.FullFilename}"); + +// file.WriteRaw(string.Join(",", headers)); + +// foreach (var pair in map) +// { +// file.WriteRaw(string.Join(",", new[] { FormatTimestamp(pair.Key) }.Concat(pair.Value))); +// } +// } + +// private Dictionary> CreateValueMap(Metrics metrics) +// { +// var map = CreateForAllTimestamps(metrics); +// foreach (var metric in metrics.Sets) +// { +// AddToMap(map, metric); +// } +// return map; + +// } + +// private Dictionary> CreateForAllTimestamps(Metrics metrics) +// { +// var result = new Dictionary>(); +// var timestamps = metrics.Sets.SelectMany(s => s.Values).Select(v => v.Timestamp).Distinct().ToArray(); +// foreach (var timestamp in timestamps) result.Add(timestamp, new List()); +// return result; +// } + +// private void AddToMap(Dictionary> map, MetricsSet metric) +// { +// foreach (var key in map.Keys) +// { +// map[key].Add(GetValueAtTimestamp(key, metric)); +// } +// } + +// private string GetValueAtTimestamp(DateTime key, MetricsSet metric) +// { +// var value = metric.Values.SingleOrDefault(v => v.Timestamp == key); +// if (value == null) return ""; +// return value.Value.ToString(CultureInfo.InvariantCulture); +// } + +// private string FormatTimestamp(DateTime key) +// { +// var origin = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); +// var diff = key - origin; +// return Math.Floor(diff.TotalSeconds).ToString(CultureInfo.InvariantCulture); +// } +// } +//} diff --git a/CodexPlugin/Metrics/MetricsMode.cs b/CodexPlugin/Metrics/MetricsMode.cs new file mode 100644 index 0000000..44a99e9 --- /dev/null +++ b/CodexPlugin/Metrics/MetricsMode.cs @@ -0,0 +1,9 @@ +//namespace DistTestCore.Metrics +//{ +// public enum MetricsMode +// { +// None, +// Record, +// Dashboard +// } +//} diff --git a/CodexPlugin/Metrics/MetricsQuery.cs b/CodexPlugin/Metrics/MetricsQuery.cs new file mode 100644 index 0000000..668ee9f --- /dev/null +++ b/CodexPlugin/Metrics/MetricsQuery.cs @@ -0,0 +1,198 @@ +//using DistTestCore.Codex; +//using KubernetesWorkflow; +//using System.Globalization; + +//namespace DistTestCore.Metrics +//{ +// public class MetricsQuery +// { +// private readonly Http http; + +// public MetricsQuery(TestLifecycle lifecycle, RunningContainers runningContainers) +// { +// RunningContainers = runningContainers; + +// var address = lifecycle.Configuration.GetAddress(runningContainers.Containers[0]); + +// http = new Http( +// lifecycle.Log, +// lifecycle.TimeSet, +// address, +// "api/v1"); +// } + +// public RunningContainers RunningContainers { get; } + +// public Metrics? GetMostRecent(string metricName, RunningContainer node) +// { +// var response = GetLastOverTime(metricName, GetInstanceStringForNode(node)); +// if (response == null) return null; + +// return new Metrics +// { +// Sets = response.data.result.Select(r => +// { +// return new MetricsSet +// { +// Instance = r.metric.instance, +// Values = MapSingleValue(r.value) +// }; +// }).ToArray() +// }; +// } + +// public Metrics? GetMetrics(string metricName) +// { +// var response = GetAll(metricName); +// if (response == null) return null; +// return MapResponseToMetrics(response); +// } + +// public Metrics? GetAllMetricsForNode(RunningContainer node) +// { +// var response = http.HttpGetJson($"query?query={GetInstanceStringForNode(node)}{GetQueryTimeRange()}"); +// if (response.status != "success") return null; +// return MapResponseToMetrics(response); +// } + +// private PrometheusQueryResponse? GetLastOverTime(string metricName, string instanceString) +// { +// var response = http.HttpGetJson($"query?query=last_over_time({metricName}{instanceString}{GetQueryTimeRange()})"); +// if (response.status != "success") return null; +// return response; +// } + +// private PrometheusQueryResponse? GetAll(string metricName) +// { +// var response = http.HttpGetJson($"query?query={metricName}{GetQueryTimeRange()}"); +// if (response.status != "success") return null; +// return response; +// } + +// private Metrics MapResponseToMetrics(PrometheusQueryResponse response) +// { +// return new Metrics +// { +// Sets = response.data.result.Select(r => +// { +// return new MetricsSet +// { +// Name = r.metric.__name__, +// Instance = r.metric.instance, +// Values = MapMultipleValues(r.values) +// }; +// }).ToArray() +// }; +// } + +// private MetricsSetValue[] MapSingleValue(object[] value) +// { +// if (value != null && value.Length > 0) +// { +// return new[] +// { +// MapValue(value) +// }; +// } +// return Array.Empty(); +// } + +// private MetricsSetValue[] MapMultipleValues(object[][] values) +// { +// if (values != null && values.Length > 0) +// { +// return values.Select(v => MapValue(v)).ToArray(); +// } +// return Array.Empty(); +// } + +// private MetricsSetValue MapValue(object[] value) +// { +// if (value.Length != 2) throw new InvalidOperationException("Expected value to be [double, string]."); + +// return new MetricsSetValue +// { +// Timestamp = ToTimestamp(value[0]), +// Value = ToValue(value[1]) +// }; +// } + +// private string GetInstanceNameForNode(RunningContainer node) +// { +// var ip = node.Pod.PodInfo.Ip; +// var port = node.Recipe.GetPortByTag(CodexContainerRecipe.MetricsPortTag).Number; +// return $"{ip}:{port}"; +// } + +// private string GetInstanceStringForNode(RunningContainer node) +// { +// return "{instance=\"" + GetInstanceNameForNode(node) + "\"}"; +// } + +// private string GetQueryTimeRange() +// { +// return "[12h]"; +// } + +// private double ToValue(object v) +// { +// return Convert.ToDouble(v, CultureInfo.InvariantCulture); +// } + +// private DateTime ToTimestamp(object v) +// { +// var unixSeconds = ToValue(v); +// return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(unixSeconds); +// } +// } + +// public class Metrics +// { +// public MetricsSet[] Sets { get; set; } = Array.Empty(); +// } + +// public class MetricsSet +// { +// public string Name { get; set; } = string.Empty; +// public string Instance { get; set; } = string.Empty; +// public MetricsSetValue[] Values { get; set; } = Array.Empty(); +// } + +// public class MetricsSetValue +// { +// public DateTime Timestamp { get; set; } +// public double Value { get; set; } +// } + +// public class PrometheusQueryResponse +// { +// public string status { get; set; } = string.Empty; +// public PrometheusQueryResponseData data { get; set; } = new(); +// } + +// public class PrometheusQueryResponseData +// { +// public string resultType { get; set; } = string.Empty; +// public PrometheusQueryResponseDataResultEntry[] result { get; set; } = Array.Empty(); +// } + +// public class PrometheusQueryResponseDataResultEntry +// { +// public ResultEntryMetric metric { get; set; } = new(); +// public object[] value { get; set; } = Array.Empty(); +// public object[][] values { get; set; } = Array.Empty(); +// } + +// public class ResultEntryMetric +// { +// public string __name__ { get; set; } = string.Empty; +// public string instance { get; set; } = string.Empty; +// public string job { get; set; } = string.Empty; +// } + +// public class PrometheusAllNamesResponse +// { +// public string status { get; set; } = string.Empty; +// public string[] data { get; set; } = Array.Empty(); +// } +//} diff --git a/CodexPlugin/Metrics/PrometheusContainerRecipe.cs b/CodexPlugin/Metrics/PrometheusContainerRecipe.cs new file mode 100644 index 0000000..c3b5706 --- /dev/null +++ b/CodexPlugin/Metrics/PrometheusContainerRecipe.cs @@ -0,0 +1,18 @@ +//using KubernetesWorkflow; + +//namespace DistTestCore.Metrics +//{ +// public class PrometheusContainerRecipe : DefaultContainerRecipe +// { +// public override string AppName => "prometheus"; +// public override string Image => "codexstorage/dist-tests-prometheus:latest"; + +// protected override void InitializeRecipe(StartupConfig startupConfig) +// { +// var config = startupConfig.Get(); + +// AddExposedPortAndVar("PROM_PORT"); +// AddEnvVar("PROM_CONFIG", config.PrometheusConfigBase64); +// } +// } +//} diff --git a/CodexPlugin/Metrics/PrometheusStartupConfig.cs b/CodexPlugin/Metrics/PrometheusStartupConfig.cs new file mode 100644 index 0000000..57434eb --- /dev/null +++ b/CodexPlugin/Metrics/PrometheusStartupConfig.cs @@ -0,0 +1,12 @@ +//namespace DistTestCore.Metrics +//{ +// public class PrometheusStartupConfig +// { +// public PrometheusStartupConfig(string prometheusConfigBase64) +// { +// PrometheusConfigBase64 = prometheusConfigBase64; +// } + +// public string PrometheusConfigBase64 { get; } +// } +//} diff --git a/DistTestCore/Metrics/dashboard.json b/CodexPlugin/Metrics/dashboard.json similarity index 100% rename from DistTestCore/Metrics/dashboard.json rename to CodexPlugin/Metrics/dashboard.json diff --git a/DistTestCore/OnlineCodexNode.cs b/CodexPlugin/OnlineCodexNode.cs similarity index 69% rename from DistTestCore/OnlineCodexNode.cs rename to CodexPlugin/OnlineCodexNode.cs index 2d1ee9d..0a6a094 100644 --- a/DistTestCore/OnlineCodexNode.cs +++ b/CodexPlugin/OnlineCodexNode.cs @@ -1,13 +1,10 @@ -using DistTestCore.Codex; -using DistTestCore.Logs; -using DistTestCore.Marketplace; -using DistTestCore.Metrics; +using DistTestCore.Logs; using FileUtils; using Logging; using NUnit.Framework; using Utils; -namespace DistTestCore +namespace CodexPlugin { public interface IOnlineCodexNode { @@ -18,8 +15,8 @@ namespace DistTestCore TestFile? DownloadContent(ContentId contentId, string fileLabel = ""); void ConnectToPeer(IOnlineCodexNode node); IDownloadedLog DownloadLog(int? tailLines = null); - IMetricsAccess Metrics { get; } - IMarketplaceAccess Marketplace { get; } + //IMetricsAccess Metrics { get; } + //IMarketplaceAccess Marketplace { get; } CodexDebugVersionResponse Version { get; } ICodexSetup BringOffline(); } @@ -28,22 +25,22 @@ namespace DistTestCore { private const string SuccessfullyConnectedMessage = "Successfully connected to peer"; private const string UploadFailedMessage = "Unable to store block"; - private readonly TestLifecycle lifecycle; + //private readonly TestLifecycle lifecycle; - public OnlineCodexNode(TestLifecycle lifecycle, CodexAccess codexAccess, CodexNodeGroup group, IMetricsAccess metricsAccess, IMarketplaceAccess marketplaceAccess) + public OnlineCodexNode(/*TestLifecycle lifecycle, */CodexAccess codexAccess, CodexNodeGroup group/*, IMetricsAccess metricsAccess, IMarketplaceAccess marketplaceAccess*/) { - this.lifecycle = lifecycle; + //this.lifecycle = lifecycle; CodexAccess = codexAccess; Group = group; - Metrics = metricsAccess; - Marketplace = marketplaceAccess; + //Metrics = metricsAccess; + //Marketplace = marketplaceAccess; Version = new CodexDebugVersionResponse(); } public CodexAccess CodexAccess { get; } public CodexNodeGroup Group { get; } - public IMetricsAccess Metrics { get; } - public IMarketplaceAccess Marketplace { get; } + //public IMetricsAccess Metrics { get; } + //public IMarketplaceAccess Marketplace { get; } public CodexDebugVersionResponse Version { get; private set; } public string GetName() @@ -66,30 +63,32 @@ namespace DistTestCore public ContentId UploadFile(TestFile file) { - using var fileStream = File.OpenRead(file.Filename); + //using var fileStream = File.OpenRead(file.Filename); - var logMessage = $"Uploading file {file.Describe()}..."; - Log(logMessage); - var response = Stopwatch.Measure(lifecycle.Log, logMessage, () => - { - return CodexAccess.UploadFile(fileStream); - }); + //var logMessage = $"Uploading file {file.Describe()}..."; + //Log(logMessage); + //var response = Stopwatch.Measure(lifecycle.Log, logMessage, () => + //{ + // return CodexAccess.UploadFile(fileStream); + //}); - if (string.IsNullOrEmpty(response)) Assert.Fail("Received empty response."); - if (response.StartsWith(UploadFailedMessage)) Assert.Fail("Node failed to store block."); + //if (string.IsNullOrEmpty(response)) Assert.Fail("Received empty response."); + //if (response.StartsWith(UploadFailedMessage)) Assert.Fail("Node failed to store block."); - Log($"Uploaded file. Received contentId: '{response}'."); - return new ContentId(response); + //Log($"Uploaded file. Received contentId: '{response}'."); + //return new ContentId(response); + return null!; } public TestFile? DownloadContent(ContentId contentId, string fileLabel = "") { - var logMessage = $"Downloading for contentId: '{contentId.Id}'..."; - Log(logMessage); - var file = lifecycle.FileManager.CreateEmptyTestFile(fileLabel); - Stopwatch.Measure(lifecycle.Log, logMessage, () => DownloadToFile(contentId.Id, file)); - Log($"Downloaded file {file.Describe()} to '{file.Filename}'."); - return file; + //var logMessage = $"Downloading for contentId: '{contentId.Id}'..."; + //Log(logMessage); + //var file = lifecycle.FileManager.CreateEmptyTestFile(fileLabel); + //Stopwatch.Measure(lifecycle.Log, logMessage, () => DownloadToFile(contentId.Id, file)); + //Log($"Downloaded file {file.Describe()} to '{file.Filename}'."); + //return file; + return null!; } public void ConnectToPeer(IOnlineCodexNode node) @@ -106,7 +105,7 @@ namespace DistTestCore public IDownloadedLog DownloadLog(int? tailLines = null) { - return lifecycle.DownloadLog(CodexAccess.Container, tailLines); + return null!; // lifecycle.DownloadLog(CodexAccess.Container, tailLines); } public ICodexSetup BringOffline() @@ -129,8 +128,8 @@ namespace DistTestCore throw new Exception($"Invalid version information received from Codex node {GetName()}: {debugInfo.codex}"); } - lifecycle.Log.AddStringReplace(nodePeerId, nodeName); - lifecycle.Log.AddStringReplace(debugInfo.table.localNode.nodeId, nodeName); + //lifecycle.Log.AddStringReplace(nodePeerId, nodeName); + //lifecycle.Log.AddStringReplace(debugInfo.table.localNode.nodeId, nodeName); Version = debugInfo.codex; } @@ -161,7 +160,7 @@ namespace DistTestCore private void Log(string msg) { - lifecycle.Log.Log($"{GetName()}: {msg}"); + //lifecycle.Log.Log($"{GetName()}: {msg}"); } } diff --git a/DistTestCore/AutoBootstrapDistTest.cs b/DistTestCore/AutoBootstrapDistTest.cs index 25bb155..6fadf9b 100644 --- a/DistTestCore/AutoBootstrapDistTest.cs +++ b/DistTestCore/AutoBootstrapDistTest.cs @@ -4,27 +4,27 @@ namespace DistTestCore { public class AutoBootstrapDistTest : DistTest { - public override IOnlineCodexNode SetupCodexBootstrapNode(Action setup) - { - throw new Exception("AutoBootstrapDistTest creates and attaches a single bootstrap node for you. " + - "If you want to control the bootstrap node from your test, please use DistTest instead."); - } + //public override IOnlineCodexNode SetupCodexBootstrapNode(Action setup) + //{ + // throw new Exception("AutoBootstrapDistTest creates and attaches a single bootstrap node for you. " + + // "If you want to control the bootstrap node from your test, please use DistTest instead."); + //} - public override ICodexNodeGroup SetupCodexNodes(int numberOfNodes, Action setup) - { - var codexSetup = CreateCodexSetup(numberOfNodes); - setup(codexSetup); - codexSetup.WithBootstrapNode(BootstrapNode); - return BringOnline(codexSetup); - } + //public override ICodexNodeGroup SetupCodexNodes(int numberOfNodes, Action setup) + //{ + // var codexSetup = CreateCodexSetup(numberOfNodes); + // setup(codexSetup); + // codexSetup.WithBootstrapNode(BootstrapNode); + // return BringOnline(codexSetup); + //} - [SetUp] - public void SetUpBootstrapNode() - { - var setup = CreateCodexSetup(1).WithName("BOOTSTRAP"); - BootstrapNode = BringOnline(setup)[0]; - } + //[SetUp] + //public void SetUpBootstrapNode() + //{ + // var setup = CreateCodexSetup(1).WithName("BOOTSTRAP"); + // BootstrapNode = BringOnline(setup)[0]; + //} - protected IOnlineCodexNode BootstrapNode { get; private set; } = null!; + //protected IOnlineCodexNode BootstrapNode { get; private set; } = null!; } } diff --git a/DistTestCore/CodexNodeFactory.cs b/DistTestCore/CodexNodeFactory.cs deleted file mode 100644 index 9b67158..0000000 --- a/DistTestCore/CodexNodeFactory.cs +++ /dev/null @@ -1,32 +0,0 @@ -using DistTestCore.Codex; -using DistTestCore.Marketplace; -using DistTestCore.Metrics; - -namespace DistTestCore -{ - public interface ICodexNodeFactory - { - OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group); - } - - public class CodexNodeFactory : ICodexNodeFactory - { - private readonly TestLifecycle lifecycle; - private readonly IMetricsAccessFactory metricsAccessFactory; - private readonly IMarketplaceAccessFactory marketplaceAccessFactory; - - public CodexNodeFactory(TestLifecycle lifecycle, IMetricsAccessFactory metricsAccessFactory, IMarketplaceAccessFactory marketplaceAccessFactory) - { - this.lifecycle = lifecycle; - this.metricsAccessFactory = metricsAccessFactory; - this.marketplaceAccessFactory = marketplaceAccessFactory; - } - - public OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group) - { - var metricsAccess = metricsAccessFactory.CreateMetricsAccess(access.Container); - var marketplaceAccess = marketplaceAccessFactory.CreateMarketplaceAccess(access); - return new OnlineCodexNode(lifecycle, access, group, metricsAccess, marketplaceAccess); - } - } -} diff --git a/DistTestCore/CodexStarter.cs b/DistTestCore/CodexStarter.cs deleted file mode 100644 index 86a839e..0000000 --- a/DistTestCore/CodexStarter.cs +++ /dev/null @@ -1,158 +0,0 @@ -using DistTestCore.Codex; -using DistTestCore.Marketplace; -using DistTestCore.Metrics; -using KubernetesWorkflow; -using Logging; - -namespace DistTestCore -{ - public class CodexStarter : BaseStarter - { - public CodexStarter(TestLifecycle lifecycle) - : base(lifecycle) - { - } - - public List RunningGroups { get; } = new List(); - - public ICodexNodeGroup BringOnline(CodexSetup codexSetup) - { - LogSeparator(); - LogStart($"Starting {codexSetup.Describe()}..."); - var gethStartResult = lifecycle.GethStarter.BringOnlineMarketplaceFor(codexSetup); - - var startupConfig = CreateStartupConfig(gethStartResult, codexSetup); - - var containers = StartCodexContainers(startupConfig, codexSetup.NumberOfNodes, codexSetup.Location); - - var metricAccessFactory = CollectMetrics(codexSetup, containers); - - var codexNodeFactory = new CodexNodeFactory(lifecycle, metricAccessFactory, gethStartResult.MarketplaceAccessFactory); - - var group = CreateCodexGroup(codexSetup, containers, codexNodeFactory); - lifecycle.SetCodexVersion(group.Version); - - var nl = Environment.NewLine; - var podInfos = string.Join(nl, containers.Containers().Select(c => $"Container: '{c.Name}' runs at '{c.Pod.PodInfo.K8SNodeName}'={c.Pod.PodInfo.Ip}")); - LogEnd($"Started {codexSetup.NumberOfNodes} nodes " + - $"of image '{containers.Containers().First().Recipe.Image}' " + - $"and version '{group.Version}'{nl}" + - podInfos); - LogSeparator(); - - return group; - } - - public void BringOffline(CodexNodeGroup group) - { - LogStart($"Stopping {group.Describe()}..."); - var workflow = CreateWorkflow(); - foreach (var c in group.Containers) - { - StopCrashWatcher(c); - workflow.Stop(c); - } - RunningGroups.Remove(group); - LogEnd("Stopped."); - } - - public void DeleteAllResources() - { - var workflow = CreateWorkflow(); - workflow.DeleteTestResources(); - - RunningGroups.Clear(); - } - - public void DownloadLog(RunningContainer container, ILogHandler logHandler, int? tailLines) - { - var workflow = CreateWorkflow(); - workflow.DownloadContainerLog(container, logHandler, tailLines); - } - - private IMetricsAccessFactory CollectMetrics(CodexSetup codexSetup, RunningContainers[] containers) - { - if (codexSetup.MetricsMode == MetricsMode.None) return new MetricsUnavailableAccessFactory(); - - var runningContainers = lifecycle.PrometheusStarter.CollectMetricsFor(containers); - - if (codexSetup.MetricsMode == MetricsMode.Dashboard) - { - lifecycle.GrafanaStarter.StartDashboard(runningContainers.Containers.First(), codexSetup); - } - - return new CodexNodeMetricsAccessFactory(lifecycle, runningContainers); - } - - private StartupConfig CreateStartupConfig(GethStartResult gethStartResult, CodexSetup codexSetup) - { - var startupConfig = new StartupConfig(); - startupConfig.NameOverride = codexSetup.NameOverride; - startupConfig.Add(codexSetup); - startupConfig.Add(gethStartResult); - return startupConfig; - } - - private RunningContainers[] StartCodexContainers(StartupConfig startupConfig, int numberOfNodes, Location location) - { - var result = new List(); - var recipe = new CodexContainerRecipe(); - for (var i = 0; i < numberOfNodes; i++) - { - var workflow = CreateWorkflow(); - var rc = workflow.Start(1, location, recipe, startupConfig); - CreateCrashWatcher(workflow, rc); - result.Add(rc); - } - return result.ToArray(); - } - - private CodexNodeGroup CreateCodexGroup(CodexSetup codexSetup, RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) - { - var group = new CodexNodeGroup(lifecycle, codexSetup, runningContainers, codexNodeFactory); - RunningGroups.Add(group); - - try - { - Stopwatch.Measure(lifecycle.Log, "EnsureOnline", group.EnsureOnline, debug: true); - } - catch - { - CodexNodesNotOnline(runningContainers); - throw; - } - - return group; - } - - private void CodexNodesNotOnline(RunningContainers[] runningContainers) - { - Log("Codex nodes failed to start"); - foreach (var container in runningContainers.Containers()) lifecycle.DownloadLog(container); - } - - private StartupWorkflow CreateWorkflow() - { - return lifecycle.WorkflowCreator.CreateWorkflow(); - } - - private void LogSeparator() - { - Log("----------------------------------------------------------------------------"); - } - - private void CreateCrashWatcher(StartupWorkflow workflow, RunningContainers rc) - { - var c = rc.Containers.Single(); - c.CrashWatcher = workflow.CreateCrashWatcher(c); - } - - private void StopCrashWatcher(RunningContainers containers) - { - foreach (var c in containers.Containers) - { - c.CrashWatcher?.Stop(); - } - } - } -} diff --git a/DistTestCore/Configuration.cs b/DistTestCore/Configuration.cs index a817022..86dc238 100644 --- a/DistTestCore/Configuration.cs +++ b/DistTestCore/Configuration.cs @@ -1,5 +1,4 @@ -using DistTestCore.Codex; -using KubernetesWorkflow; +using KubernetesWorkflow; using System.Net.NetworkInformation; using Utils; @@ -11,7 +10,7 @@ namespace DistTestCore private readonly string logPath; private readonly bool logDebug; private readonly string dataFilesPath; - private readonly CodexLogLevel codexLogLevel; + //private readonly CodexLogLevel codexLogLevel; private readonly string k8sNamespacePrefix; private static RunnerLocation? runnerLocation = null; @@ -21,17 +20,17 @@ namespace DistTestCore logPath = GetEnvVarOrDefault("LOGPATH", "CodexTestLogs"); logDebug = GetEnvVarOrDefault("LOGDEBUG", "false").ToLowerInvariant() == "true"; dataFilesPath = GetEnvVarOrDefault("DATAFILEPATH", "TestDataFiles"); - codexLogLevel = ParseEnum.Parse(GetEnvVarOrDefault("LOGLEVEL", nameof(CodexLogLevel.Trace))); + //codexLogLevel = ParseEnum.Parse(GetEnvVarOrDefault("LOGLEVEL", nameof(CodexLogLevel.Trace))); k8sNamespacePrefix = "ct-"; } - public Configuration(string? kubeConfigFile, string logPath, bool logDebug, string dataFilesPath, CodexLogLevel codexLogLevel, string k8sNamespacePrefix) + public Configuration(string? kubeConfigFile, string logPath, bool logDebug, string dataFilesPath, /*CodexLogLevel codexLogLevel,*/ string k8sNamespacePrefix) { this.kubeConfigFile = kubeConfigFile; this.logPath = logPath; this.logDebug = logDebug; this.dataFilesPath = dataFilesPath; - this.codexLogLevel = codexLogLevel; + //this.codexLogLevel = codexLogLevel; this.k8sNamespacePrefix = k8sNamespacePrefix; } @@ -55,10 +54,10 @@ namespace DistTestCore return dataFilesPath; } - public CodexLogLevel GetCodexLogLevel() - { - return codexLogLevel; - } + //public CodexLogLevel GetCodexLogLevel() + //{ + // return codexLogLevel; + //} public Address GetAddress(RunningContainer container) { diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index 5439b5a..e965514 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -1,8 +1,4 @@ -using DistTestCore.Codex; -using DistTestCore.Helpers; -using DistTestCore.Logs; -using DistTestCore.Marketplace; -using DistTestCore.Metrics; +using DistTestCore.Logs; using FileUtils; using KubernetesWorkflow; using Logging; @@ -38,10 +34,10 @@ namespace DistTestCore public void GlobalSetup() { fixtureLog.Log($"Codex Distributed Tests are starting..."); - fixtureLog.Log($"Codex image: '{new CodexContainerRecipe().Image}'"); - fixtureLog.Log($"CodexContracts image: '{new CodexContractsContainerRecipe().Image}'"); - fixtureLog.Log($"Prometheus image: '{new PrometheusContainerRecipe().Image}'"); - fixtureLog.Log($"Geth image: '{new GethContainerRecipe().Image}'"); + //fixtureLog.Log($"Codex image: '{new CodexContainerRecipe().Image}'"); + //fixtureLog.Log($"CodexContracts image: '{new CodexContractsContainerRecipe().Image}'"); + //fixtureLog.Log($"Prometheus image: '{new PrometheusContainerRecipe().Image}'"); + //fixtureLog.Log($"Geth image: '{new GethContainerRecipe().Image}'"); // Previous test run may have been interrupted. // Begin by cleaning everything up. @@ -104,53 +100,53 @@ namespace DistTestCore Get().FileManager.ScopedFiles(action); } - public IOnlineCodexNode SetupCodexBootstrapNode() - { - return SetupCodexBootstrapNode(s => { }); - } + //public IOnlineCodexNode SetupCodexBootstrapNode() + //{ + // return SetupCodexBootstrapNode(s => { }); + //} - public virtual IOnlineCodexNode SetupCodexBootstrapNode(Action setup) - { - return SetupCodexNode(s => - { - setup(s); - s.WithName("Bootstrap"); - }); - } + //public virtual IOnlineCodexNode SetupCodexBootstrapNode(Action setup) + //{ + // return SetupCodexNode(s => + // { + // setup(s); + // s.WithName("Bootstrap"); + // }); + //} - public IOnlineCodexNode SetupCodexNode() - { - return SetupCodexNode(s => { }); - } + //public IOnlineCodexNode SetupCodexNode() + //{ + // return SetupCodexNode(s => { }); + //} - public IOnlineCodexNode SetupCodexNode(Action setup) - { - return SetupCodexNodes(1, setup)[0]; - } + //public IOnlineCodexNode SetupCodexNode(Action setup) + //{ + // return SetupCodexNodes(1, setup)[0]; + //} - public ICodexNodeGroup SetupCodexNodes(int numberOfNodes) - { - return SetupCodexNodes(numberOfNodes, s => { }); - } + //public ICodexNodeGroup SetupCodexNodes(int numberOfNodes) + //{ + // return SetupCodexNodes(numberOfNodes, s => { }); + //} - public virtual ICodexNodeGroup SetupCodexNodes(int numberOfNodes, Action setup) - { - var codexSetup = CreateCodexSetup(numberOfNodes); + //public virtual ICodexNodeGroup SetupCodexNodes(int numberOfNodes, Action setup) + //{ + // var codexSetup = CreateCodexSetup(numberOfNodes); - setup(codexSetup); + // setup(codexSetup); - return BringOnline(codexSetup); - } + // return BringOnline(codexSetup); + //} - public ICodexNodeGroup BringOnline(ICodexSetup codexSetup) - { - return Get().CodexStarter.BringOnline((CodexSetup)codexSetup); - } + //public ICodexNodeGroup BringOnline(ICodexSetup codexSetup) + //{ + // return Get().CodexStarter.BringOnline((CodexSetup)codexSetup); + //} - public IEnumerable GetAllOnlineCodexNodes() - { - return Get().CodexStarter.RunningGroups.SelectMany(g => g.Nodes); - } + //public IEnumerable GetAllOnlineCodexNodes() + //{ + // return Get().CodexStarter.RunningGroups.SelectMany(g => g.Nodes); + //} public BaseLog GetTestLog() { @@ -169,25 +165,25 @@ namespace DistTestCore GetTestLog().Debug(msg); } - public PeerConnectionTestHelpers CreatePeerConnectionTestHelpers() - { - return new PeerConnectionTestHelpers(GetTestLog()); - } + //public PeerConnectionTestHelpers CreatePeerConnectionTestHelpers() + //{ + // return new PeerConnectionTestHelpers(GetTestLog()); + //} - public PeerDownloadTestHelpers CreatePeerDownloadTestHelpers() - { - return new PeerDownloadTestHelpers(GetTestLog(), Get().FileManager); - } + //public PeerDownloadTestHelpers CreatePeerDownloadTestHelpers() + //{ + // return new PeerDownloadTestHelpers(GetTestLog(), Get().FileManager); + //} public void Measure(string name, Action action) { Stopwatch.Measure(Get().Log, name, action); } - protected CodexSetup CreateCodexSetup(int numberOfNodes) - { - return new CodexSetup(numberOfNodes, configuration.GetCodexLogLevel()); - } + //protected CodexSetup CreateCodexSetup(int numberOfNodes) + //{ + // return new CodexSetup(numberOfNodes, configuration.GetCodexLogLevel()); + //} private TestLifecycle Get() { @@ -261,8 +257,8 @@ namespace DistTestCore if (IsDownloadingLogsAndMetricsEnabled()) { lifecycle.Log.Log("Downloading all CodexNode logs and metrics because of test failure..."); - DownloadAllLogs(lifecycle); - DownloadAllMetrics(lifecycle); + //DownloadAllLogs(lifecycle); + //DownloadAllMetrics(lifecycle); } else { @@ -271,36 +267,36 @@ namespace DistTestCore } } - private void DownloadAllLogs(TestLifecycle lifecycle) - { - OnEachCodexNode(lifecycle, node => - { - lifecycle.DownloadLog(node.CodexAccess.Container); - }); - } + //private void DownloadAllLogs(TestLifecycle lifecycle) + //{ + // OnEachCodexNode(lifecycle, node => + // { + // lifecycle.DownloadLog(node.CodexAccess.Container); + // }); + //} - private void DownloadAllMetrics(TestLifecycle lifecycle) - { - var metricsDownloader = new MetricsDownloader(lifecycle.Log); + //private void DownloadAllMetrics(TestLifecycle lifecycle) + //{ + // var metricsDownloader = new MetricsDownloader(lifecycle.Log); - OnEachCodexNode(lifecycle, node => - { - var m = node.Metrics as MetricsAccess; - if (m != null) - { - metricsDownloader.DownloadAllMetricsForNode(node.GetName(), m); - } - }); - } + // OnEachCodexNode(lifecycle, node => + // { + // var m = node.Metrics as MetricsAccess; + // if (m != null) + // { + // metricsDownloader.DownloadAllMetricsForNode(node.GetName(), m); + // } + // }); + //} - private void OnEachCodexNode(TestLifecycle lifecycle, Action action) - { - var allNodes = lifecycle.CodexStarter.RunningGroups.SelectMany(g => g.Nodes); - foreach (var node in allNodes) - { - action(node); - } - } + //private void OnEachCodexNode(TestLifecycle lifecycle, Action action) + //{ + // var allNodes = lifecycle.CodexStarter.RunningGroups.SelectMany(g => g.Nodes); + // foreach (var node in allNodes) + // { + // action(node); + // } + //} private string GetCurrentTestName() { diff --git a/DistTestCore/DistTestCore.csproj b/DistTestCore/DistTestCore.csproj index 565e175..6005043 100644 --- a/DistTestCore/DistTestCore.csproj +++ b/DistTestCore/DistTestCore.csproj @@ -10,14 +10,6 @@ Arm64 - - - - - - Never - - diff --git a/DistTestCore/GethStarter.cs b/DistTestCore/GethStarter.cs deleted file mode 100644 index 578cc30..0000000 --- a/DistTestCore/GethStarter.cs +++ /dev/null @@ -1,88 +0,0 @@ -using DistTestCore.Marketplace; - -namespace DistTestCore -{ - public class GethStarter : BaseStarter - { - private readonly MarketplaceNetworkCache marketplaceNetworkCache; - private readonly GethCompanionNodeStarter companionNodeStarter; - - public GethStarter(TestLifecycle lifecycle) - : base(lifecycle) - { - marketplaceNetworkCache = new MarketplaceNetworkCache( - new GethBootstrapNodeStarter(lifecycle), - new CodexContractsStarter(lifecycle)); - companionNodeStarter = new GethCompanionNodeStarter(lifecycle); - } - - public GethStartResult BringOnlineMarketplaceFor(CodexSetup codexSetup) - { - if (codexSetup.MarketplaceConfig == null) return CreateMarketplaceUnavailableResult(); - - var marketplaceNetwork = marketplaceNetworkCache.Get(); - var companionNode = StartCompanionNode(codexSetup, marketplaceNetwork); - - LogStart("Setting up initial balance..."); - TransferInitialBalance(marketplaceNetwork, codexSetup.MarketplaceConfig, companionNode); - LogEnd($"Initial balance of {codexSetup.MarketplaceConfig.InitialTestTokens} set for {codexSetup.NumberOfNodes} nodes."); - - return CreateGethStartResult(marketplaceNetwork, companionNode); - } - - private void TransferInitialBalance(MarketplaceNetwork marketplaceNetwork, MarketplaceInitialConfig marketplaceConfig, GethCompanionNodeInfo companionNode) - { - if (marketplaceConfig.InitialTestTokens.Amount == 0) return; - - var interaction = marketplaceNetwork.StartInteraction(lifecycle); - var tokenAddress = marketplaceNetwork.Marketplace.TokenAddress; - - var accounts = companionNode.Accounts.Select(a => a.Account).ToArray(); - interaction.MintTestTokens(accounts, marketplaceConfig.InitialTestTokens.Amount, tokenAddress); - } - - private GethStartResult CreateGethStartResult(MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) - { - return new GethStartResult(CreateMarketplaceAccessFactory(marketplaceNetwork), marketplaceNetwork, companionNode); - } - - private GethStartResult CreateMarketplaceUnavailableResult() - { - return new GethStartResult(new MarketplaceUnavailableAccessFactory(), null!, null!); - } - - private IMarketplaceAccessFactory CreateMarketplaceAccessFactory(MarketplaceNetwork marketplaceNetwork) - { - return new GethMarketplaceAccessFactory(lifecycle, marketplaceNetwork); - } - - private GethCompanionNodeInfo StartCompanionNode(CodexSetup codexSetup, MarketplaceNetwork marketplaceNetwork) - { - return companionNodeStarter.StartCompanionNodeFor(codexSetup, marketplaceNetwork); - } - } - - public class MarketplaceNetworkCache - { - private readonly GethBootstrapNodeStarter bootstrapNodeStarter; - private readonly CodexContractsStarter codexContractsStarter; - private MarketplaceNetwork? network; - - public MarketplaceNetworkCache(GethBootstrapNodeStarter bootstrapNodeStarter, CodexContractsStarter codexContractsStarter) - { - this.bootstrapNodeStarter = bootstrapNodeStarter; - this.codexContractsStarter = codexContractsStarter; - } - - public MarketplaceNetwork Get() - { - if (network == null) - { - var bootstrapInfo = bootstrapNodeStarter.StartGethBootstrapNode(); - var marketplaceInfo = codexContractsStarter.Start(bootstrapInfo); - network = new MarketplaceNetwork(bootstrapInfo, marketplaceInfo ); - } - return network; - } - } -} diff --git a/DistTestCore/GrafanaStarter.cs b/DistTestCore/GrafanaStarter.cs index d4a3cd8..f6dd43f 100644 --- a/DistTestCore/GrafanaStarter.cs +++ b/DistTestCore/GrafanaStarter.cs @@ -1,9 +1,4 @@ -using DistTestCore.Metrics; -using IdentityModel.Client; -using KubernetesWorkflow; -using Newtonsoft.Json; -using System.Reflection; -using Utils; +using KubernetesWorkflow; namespace DistTestCore { @@ -17,133 +12,134 @@ namespace DistTestCore { } - public GrafanaStartInfo StartDashboard(RunningContainer prometheusContainer, CodexSetup codexSetup) + public GrafanaStartInfo StartDashboard(RunningContainer prometheusContainer)//, CodexSetup codexSetup) { - LogStart($"Starting dashboard server"); + return null!; + //LogStart($"Starting dashboard server"); - var grafanaContainer = StartGrafanaContainer(); - var grafanaAddress = lifecycle.Configuration.GetAddress(grafanaContainer); + //var grafanaContainer = StartGrafanaContainer(); + //var grafanaAddress = lifecycle.Configuration.GetAddress(grafanaContainer); - var http = new Http(lifecycle.Log, new DefaultTimeSet(), grafanaAddress, "api/", AddBasicAuth); + //var http = new Http(lifecycle.Log, new DefaultTimeSet(), grafanaAddress, "api/", AddBasicAuth); - Log("Connecting datasource..."); - AddDataSource(http, prometheusContainer); + //Log("Connecting datasource..."); + //AddDataSource(http, prometheusContainer); - Log("Uploading dashboard configurations..."); - var jsons = ReadEachDashboardJsonFile(codexSetup); - var dashboardUrls = jsons.Select(j => UploadDashboard(http, grafanaContainer, j)).ToArray(); + //Log("Uploading dashboard configurations..."); + //var jsons = ReadEachDashboardJsonFile(codexSetup); + //var dashboardUrls = jsons.Select(j => UploadDashboard(http, grafanaContainer, j)).ToArray(); - LogEnd("Dashboard server started."); + //LogEnd("Dashboard server started."); - return new GrafanaStartInfo(dashboardUrls, grafanaContainer); + //return new GrafanaStartInfo(dashboardUrls, grafanaContainer); } - private RunningContainer StartGrafanaContainer() - { - var startupConfig = new StartupConfig(); + //private RunningContainer StartGrafanaContainer() + //{ + // var startupConfig = new StartupConfig(); - var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - var grafanaContainers = workflow.Start(1, Location.Unspecified, new GrafanaContainerRecipe(), startupConfig); - if (grafanaContainers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 dashboard container to be created."); + // var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); + // var grafanaContainers = workflow.Start(1, Location.Unspecified, new GrafanaContainerRecipe(), startupConfig); + // if (grafanaContainers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 dashboard container to be created."); - return grafanaContainers.Containers.First(); - } + // return grafanaContainers.Containers.First(); + //} - private void AddBasicAuth(HttpClient client) - { - client.SetBasicAuthentication( - GrafanaContainerRecipe.DefaultAdminUser, - GrafanaContainerRecipe.DefaultAdminPassword); - } + //private void AddBasicAuth(HttpClient client) + //{ + // client.SetBasicAuthentication( + // GrafanaContainerRecipe.DefaultAdminUser, + // GrafanaContainerRecipe.DefaultAdminPassword); + //} - private static void AddDataSource(Http http, RunningContainer prometheusContainer) - { - var prometheusAddress = prometheusContainer.ClusterExternalAddress; - var prometheusUrl = prometheusAddress.Host + ":" + prometheusAddress.Port; - var response = http.HttpPostJson("datasources", new GrafanaDataSourceRequest - { - uid = "c89eaad3-9184-429f-ac94-8ba0b1824dbb", - name = "CodexPrometheus", - type = "prometheus", - url = prometheusUrl, - access = "proxy", - basicAuth = false, - jsonData = new GrafanaDataSourceJsonData - { - httpMethod = "POST" - } - }); + //private static void AddDataSource(Http http, RunningContainer prometheusContainer) + //{ + // var prometheusAddress = prometheusContainer.ClusterExternalAddress; + // var prometheusUrl = prometheusAddress.Host + ":" + prometheusAddress.Port; + // var response = http.HttpPostJson("datasources", new GrafanaDataSourceRequest + // { + // uid = "c89eaad3-9184-429f-ac94-8ba0b1824dbb", + // name = "CodexPrometheus", + // type = "prometheus", + // url = prometheusUrl, + // access = "proxy", + // basicAuth = false, + // jsonData = new GrafanaDataSourceJsonData + // { + // httpMethod = "POST" + // } + // }); - if (response.message != "Datasource added") - { - throw new Exception("Test infra failure: Failed to add datasource to dashboard: " + response.message); - } - } + // if (response.message != "Datasource added") + // { + // throw new Exception("Test infra failure: Failed to add datasource to dashboard: " + response.message); + // } + //} - public static string UploadDashboard(Http http, RunningContainer grafanaContainer, string dashboardJson) - { - var request = GetDashboardCreateRequest(dashboardJson); - var response = http.HttpPostString("dashboards/db", request); - var jsonResponse = JsonConvert.DeserializeObject(response); - if (jsonResponse == null || string.IsNullOrEmpty(jsonResponse.url)) throw new Exception("Failed to upload dashboard."); + //public static string UploadDashboard(Http http, RunningContainer grafanaContainer, string dashboardJson) + //{ + // var request = GetDashboardCreateRequest(dashboardJson); + // var response = http.HttpPostString("dashboards/db", request); + // var jsonResponse = JsonConvert.DeserializeObject(response); + // if (jsonResponse == null || string.IsNullOrEmpty(jsonResponse.url)) throw new Exception("Failed to upload dashboard."); - var grafanaAddress = grafanaContainer.ClusterExternalAddress; - return grafanaAddress.Host + ":" + grafanaAddress.Port + jsonResponse.url; - } + // var grafanaAddress = grafanaContainer.ClusterExternalAddress; + // return grafanaAddress.Host + ":" + grafanaAddress.Port + jsonResponse.url; + //} - private static string[] ReadEachDashboardJsonFile(CodexSetup codexSetup) - { - var assembly = Assembly.GetExecutingAssembly(); - var resourceNames = new[] - { - "DistTestCore.Metrics.dashboard.json" - }; + //private static string[] ReadEachDashboardJsonFile(CodexSetup codexSetup) + //{ + // var assembly = Assembly.GetExecutingAssembly(); + // var resourceNames = new[] + // { + // "DistTestCore.Metrics.dashboard.json" + // }; - return resourceNames.Select(r => GetManifestResource(assembly, r, codexSetup)).ToArray(); - } + // return resourceNames.Select(r => GetManifestResource(assembly, r, codexSetup)).ToArray(); + //} - private static string GetManifestResource(Assembly assembly, string resourceName, CodexSetup codexSetup) - { - using var stream = assembly.GetManifestResourceStream(resourceName); - if (stream == null) throw new Exception("Unable to find resource " + resourceName); - using var reader = new StreamReader(stream); - return ApplyReplacements(reader.ReadToEnd(), codexSetup); - } + //private static string GetManifestResource(Assembly assembly, string resourceName, CodexSetup codexSetup) + //{ + // using var stream = assembly.GetManifestResourceStream(resourceName); + // if (stream == null) throw new Exception("Unable to find resource " + resourceName); + // using var reader = new StreamReader(stream); + // return ApplyReplacements(reader.ReadToEnd(), codexSetup); + //} - private static string ApplyReplacements(string input, CodexSetup codexSetup) - { - var quotaString = GetQuotaString(codexSetup); - var softMaxString = GetSoftMaxString(codexSetup); + //private static string ApplyReplacements(string input, CodexSetup codexSetup) + //{ + // var quotaString = GetQuotaString(codexSetup); + // var softMaxString = GetSoftMaxString(codexSetup); - return input - .Replace(StorageQuotaThresholdReplaceToken, quotaString) - .Replace(BytesUsedGraphAxisSoftMaxReplaceToken, softMaxString); - } + // return input + // .Replace(StorageQuotaThresholdReplaceToken, quotaString) + // .Replace(BytesUsedGraphAxisSoftMaxReplaceToken, softMaxString); + //} - private static string GetQuotaString(CodexSetup codexSetup) - { - return GetCodexStorageQuotaInBytes(codexSetup).ToString(); - } + //private static string GetQuotaString(CodexSetup codexSetup) + //{ + // return GetCodexStorageQuotaInBytes(codexSetup).ToString(); + //} - private static string GetSoftMaxString(CodexSetup codexSetup) - { - var quota = GetCodexStorageQuotaInBytes(codexSetup); - var softMax = Convert.ToInt64(quota * 1.1); // + 10%, for nice viewing. - return softMax.ToString(); - } + //private static string GetSoftMaxString(CodexSetup codexSetup) + //{ + // var quota = GetCodexStorageQuotaInBytes(codexSetup); + // var softMax = Convert.ToInt64(quota * 1.1); // + 10%, for nice viewing. + // return softMax.ToString(); + //} - private static long GetCodexStorageQuotaInBytes(CodexSetup codexSetup) - { - if (codexSetup.StorageQuota != null) return codexSetup.StorageQuota.SizeInBytes; + //private static long GetCodexStorageQuotaInBytes(CodexSetup codexSetup) + //{ + // if (codexSetup.StorageQuota != null) return codexSetup.StorageQuota.SizeInBytes; - // Codex default: 8GB - return 8.GB().SizeInBytes; - } + // // Codex default: 8GB + // return 8.GB().SizeInBytes; + //} - private static string GetDashboardCreateRequest(string dashboardJson) - { - return $"{{\"dashboard\": {dashboardJson} ,\"message\": \"Default Codex Dashboard\",\"overwrite\": false}}"; - } + //private static string GetDashboardCreateRequest(string dashboardJson) + //{ + // return $"{{\"dashboard\": {dashboardJson} ,\"message\": \"Default Codex Dashboard\",\"overwrite\": false}}"; + //} } public class GrafanaStartInfo diff --git a/DistTestCore/Helpers/FullConnectivityHelper.cs b/DistTestCore/Helpers/FullConnectivityHelper.cs index b69af0c..bad45f0 100644 --- a/DistTestCore/Helpers/FullConnectivityHelper.cs +++ b/DistTestCore/Helpers/FullConnectivityHelper.cs @@ -1,199 +1,199 @@ -using DistTestCore.Codex; -using Logging; -using NUnit.Framework; +//using DistTestCore.Codex; +//using Logging; +//using NUnit.Framework; -namespace DistTestCore.Helpers -{ - public interface IFullConnectivityImplementation - { - string Description(); - string ValidateEntry(FullConnectivityHelper.Entry entry, FullConnectivityHelper.Entry[] allEntries); - FullConnectivityHelper.PeerConnectionState Check(FullConnectivityHelper.Entry from, FullConnectivityHelper.Entry to); - } +//namespace DistTestCore.Helpers +//{ +// public interface IFullConnectivityImplementation +// { +// string Description(); +// string ValidateEntry(FullConnectivityHelper.Entry entry, FullConnectivityHelper.Entry[] allEntries); +// FullConnectivityHelper.PeerConnectionState Check(FullConnectivityHelper.Entry from, FullConnectivityHelper.Entry to); +// } - public class FullConnectivityHelper - { - private static string Nl = Environment.NewLine; - private readonly BaseLog log; - private readonly IFullConnectivityImplementation implementation; +// public class FullConnectivityHelper +// { +// private static string Nl = Environment.NewLine; +// private readonly BaseLog log; +// private readonly IFullConnectivityImplementation implementation; - public FullConnectivityHelper(BaseLog log, IFullConnectivityImplementation implementation) - { - this.log = log; - this.implementation = implementation; - } +// public FullConnectivityHelper(BaseLog log, IFullConnectivityImplementation implementation) +// { +// this.log = log; +// this.implementation = implementation; +// } - public void AssertFullyConnected(IEnumerable nodes) - { - AssertFullyConnected(nodes.ToArray()); - } +// public void AssertFullyConnected(IEnumerable nodes) +// { +// AssertFullyConnected(nodes.ToArray()); +// } - private void AssertFullyConnected(CodexAccess[] nodes) - { - Log($"Asserting '{implementation.Description()}' for nodes: '{string.Join(",", nodes.Select(n => n.GetName()))}'..."); - var entries = CreateEntries(nodes); - var pairs = CreatePairs(entries); +// private void AssertFullyConnected(CodexAccess[] nodes) +// { +// Log($"Asserting '{implementation.Description()}' for nodes: '{string.Join(",", nodes.Select(n => n.GetName()))}'..."); +// var entries = CreateEntries(nodes); +// var pairs = CreatePairs(entries); - // Each pair gets two chances. - CheckAndRemoveSuccessful(pairs); - CheckAndRemoveSuccessful(pairs); +// // Each pair gets two chances. +// CheckAndRemoveSuccessful(pairs); +// CheckAndRemoveSuccessful(pairs); - if (pairs.Any()) - { - var pairDetails = string.Join(Nl, pairs.SelectMany(p => p.GetResultMessages())); +// if (pairs.Any()) +// { +// var pairDetails = string.Join(Nl, pairs.SelectMany(p => p.GetResultMessages())); - Log($"Connections failed:{Nl}{pairDetails}"); +// Log($"Connections failed:{Nl}{pairDetails}"); - Assert.Fail(string.Join(Nl, pairs.SelectMany(p => p.GetResultMessages()))); - } - else - { - Log($"'{implementation.Description()}' = Success! for nodes: {string.Join(",", nodes.Select(n => n.GetName()))}"); - } - } +// Assert.Fail(string.Join(Nl, pairs.SelectMany(p => p.GetResultMessages()))); +// } +// else +// { +// Log($"'{implementation.Description()}' = Success! for nodes: {string.Join(",", nodes.Select(n => n.GetName()))}"); +// } +// } - private void CheckAndRemoveSuccessful(List pairs) - { - var results = new List(); - foreach (var pair in pairs.ToArray()) - { - pair.Check(); - if (pair.Success) - { - results.AddRange(pair.GetResultMessages()); - pairs.Remove(pair); - } - } - Log($"Connections successful:{Nl}{string.Join(Nl, results)}"); - } +// private void CheckAndRemoveSuccessful(List pairs) +// { +// var results = new List(); +// foreach (var pair in pairs.ToArray()) +// { +// pair.Check(); +// if (pair.Success) +// { +// results.AddRange(pair.GetResultMessages()); +// pairs.Remove(pair); +// } +// } +// Log($"Connections successful:{Nl}{string.Join(Nl, results)}"); +// } - private Entry[] CreateEntries(CodexAccess[] nodes) - { - var entries = nodes.Select(n => new Entry(n)).ToArray(); +// private Entry[] CreateEntries(CodexAccess[] nodes) +// { +// var entries = nodes.Select(n => new Entry(n)).ToArray(); - var errors = entries - .Select(e => implementation.ValidateEntry(e, entries)) - .Where(s => !string.IsNullOrEmpty(s)) - .ToArray(); +// var errors = entries +// .Select(e => implementation.ValidateEntry(e, entries)) +// .Where(s => !string.IsNullOrEmpty(s)) +// .ToArray(); - if (errors.Any()) - { - Assert.Fail("Some node entries failed to validate: " + string.Join(Nl, errors)); - } +// if (errors.Any()) +// { +// Assert.Fail("Some node entries failed to validate: " + string.Join(Nl, errors)); +// } - return entries; - } +// return entries; +// } - private List CreatePairs(Entry[] entries) - { - return CreatePairsIterator(entries).ToList(); - } +// private List CreatePairs(Entry[] entries) +// { +// return CreatePairsIterator(entries).ToList(); +// } - private IEnumerable CreatePairsIterator(Entry[] entries) - { - for (var x = 0; x < entries.Length; x++) - { - for (var y = x + 1; y < entries.Length; y++) - { - yield return new Pair(implementation, entries[x], entries[y]); - } - } - } +// private IEnumerable CreatePairsIterator(Entry[] entries) +// { +// for (var x = 0; x < entries.Length; x++) +// { +// for (var y = x + 1; y < entries.Length; y++) +// { +// yield return new Pair(implementation, entries[x], entries[y]); +// } +// } +// } - private void Log(string msg) - { - log.Log(msg); - } +// private void Log(string msg) +// { +// log.Log(msg); +// } - public class Entry - { - public Entry(CodexAccess node) - { - Node = node; - Response = node.GetDebugInfo(); - } +// public class Entry +// { +// public Entry(CodexAccess node) +// { +// Node = node; +// Response = node.GetDebugInfo(); +// } - public CodexAccess Node { get; } - public CodexDebugResponse Response { get; } +// public CodexAccess Node { get; } +// public CodexDebugResponse Response { get; } - public override string ToString() - { - if (Response == null || string.IsNullOrEmpty(Response.id)) return "UNKNOWN"; - return Response.id; - } - } +// public override string ToString() +// { +// if (Response == null || string.IsNullOrEmpty(Response.id)) return "UNKNOWN"; +// return Response.id; +// } +// } - public enum PeerConnectionState - { - Unknown, - Connection, - NoConnection, - } +// public enum PeerConnectionState +// { +// Unknown, +// Connection, +// NoConnection, +// } - public class Pair - { - private TimeSpan aToBTime = TimeSpan.FromSeconds(0); - private TimeSpan bToATime = TimeSpan.FromSeconds(0); - private readonly IFullConnectivityImplementation implementation; +// public class Pair +// { +// private TimeSpan aToBTime = TimeSpan.FromSeconds(0); +// private TimeSpan bToATime = TimeSpan.FromSeconds(0); +// private readonly IFullConnectivityImplementation implementation; - public Pair(IFullConnectivityImplementation implementation, Entry a, Entry b) - { - this.implementation = implementation; - A = a; - B = b; - } +// public Pair(IFullConnectivityImplementation implementation, Entry a, Entry b) +// { +// this.implementation = implementation; +// A = a; +// B = b; +// } - public Entry A { get; } - public Entry B { get; } - public PeerConnectionState AKnowsB { get; private set; } - public PeerConnectionState BKnowsA { get; private set; } - public bool Success { get { return AKnowsB == PeerConnectionState.Connection && BKnowsA == PeerConnectionState.Connection; } } - public bool Inconclusive { get { return AKnowsB == PeerConnectionState.Unknown || BKnowsA == PeerConnectionState.Unknown; } } +// public Entry A { get; } +// public Entry B { get; } +// public PeerConnectionState AKnowsB { get; private set; } +// public PeerConnectionState BKnowsA { get; private set; } +// public bool Success { get { return AKnowsB == PeerConnectionState.Connection && BKnowsA == PeerConnectionState.Connection; } } +// public bool Inconclusive { get { return AKnowsB == PeerConnectionState.Unknown || BKnowsA == PeerConnectionState.Unknown; } } - public void Check() - { - aToBTime = Measure(() => AKnowsB = Check(A, B)); - bToATime = Measure(() => BKnowsA = Check(B, A)); - } +// public void Check() +// { +// aToBTime = Measure(() => AKnowsB = Check(A, B)); +// bToATime = Measure(() => BKnowsA = Check(B, A)); +// } - public override string ToString() - { - return $"[{string.Join(",", GetResultMessages())}]"; - } +// public override string ToString() +// { +// return $"[{string.Join(",", GetResultMessages())}]"; +// } - public string[] GetResultMessages() - { - var aName = A.ToString(); - var bName = B.ToString(); +// public string[] GetResultMessages() +// { +// var aName = A.ToString(); +// var bName = B.ToString(); - return new[] - { - $"[{aName} --> {bName}] = {AKnowsB} ({aToBTime.TotalSeconds} seconds)", - $"[{aName} <-- {bName}] = {BKnowsA} ({bToATime.TotalSeconds} seconds)" - }; - } +// return new[] +// { +// $"[{aName} --> {bName}] = {AKnowsB} ({aToBTime.TotalSeconds} seconds)", +// $"[{aName} <-- {bName}] = {BKnowsA} ({bToATime.TotalSeconds} seconds)" +// }; +// } - private static TimeSpan Measure(Action action) - { - var start = DateTime.UtcNow; - action(); - return DateTime.UtcNow - start; - } +// private static TimeSpan Measure(Action action) +// { +// var start = DateTime.UtcNow; +// action(); +// return DateTime.UtcNow - start; +// } - private PeerConnectionState Check(Entry from, Entry to) - { - Thread.Sleep(10); +// private PeerConnectionState Check(Entry from, Entry to) +// { +// Thread.Sleep(10); - try - { - return implementation.Check(from, to); - } - catch - { - // Didn't get a conclusive answer. Try again later. - return PeerConnectionState.Unknown; - } - } - } - } -} +// try +// { +// return implementation.Check(from, to); +// } +// catch +// { +// // Didn't get a conclusive answer. Try again later. +// return PeerConnectionState.Unknown; +// } +// } +// } +// } +//} diff --git a/DistTestCore/Helpers/PeerConnectionTestHelpers.cs b/DistTestCore/Helpers/PeerConnectionTestHelpers.cs index 65bb4ce..39b7004 100644 --- a/DistTestCore/Helpers/PeerConnectionTestHelpers.cs +++ b/DistTestCore/Helpers/PeerConnectionTestHelpers.cs @@ -1,71 +1,71 @@ -using DistTestCore.Codex; -using Logging; -using static DistTestCore.Helpers.FullConnectivityHelper; +//using DistTestCore.Codex; +//using Logging; +//using static DistTestCore.Helpers.FullConnectivityHelper; -namespace DistTestCore.Helpers -{ - public class PeerConnectionTestHelpers : IFullConnectivityImplementation - { - private readonly FullConnectivityHelper helper; +//namespace DistTestCore.Helpers +//{ +// public class PeerConnectionTestHelpers : IFullConnectivityImplementation +// { +// private readonly FullConnectivityHelper helper; - public PeerConnectionTestHelpers(BaseLog log) - { - helper = new FullConnectivityHelper(log, this); - } +// public PeerConnectionTestHelpers(BaseLog log) +// { +// helper = new FullConnectivityHelper(log, this); +// } - public void AssertFullyConnected(IEnumerable nodes) - { - AssertFullyConnected(nodes.Select(n => ((OnlineCodexNode)n).CodexAccess)); - } +// public void AssertFullyConnected(IEnumerable nodes) +// { +// AssertFullyConnected(nodes.Select(n => ((OnlineCodexNode)n).CodexAccess)); +// } - public void AssertFullyConnected(IEnumerable nodes) - { - helper.AssertFullyConnected(nodes); - } +// public void AssertFullyConnected(IEnumerable nodes) +// { +// helper.AssertFullyConnected(nodes); +// } - public string Description() - { - return "Peer Discovery"; - } +// public string Description() +// { +// return "Peer Discovery"; +// } - public string ValidateEntry(Entry entry, Entry[] allEntries) - { - var result = string.Empty; - foreach (var peer in entry.Response.table.nodes) - { - var expected = GetExpectedDiscoveryEndpoint(allEntries, peer); - if (expected != peer.address) - { - result += $"Node:{entry.Node.GetName()} has incorrect peer table entry. Was: '{peer.address}', expected: '{expected}'. "; - } - } - return result; - } +// public string ValidateEntry(Entry entry, Entry[] allEntries) +// { +// var result = string.Empty; +// foreach (var peer in entry.Response.table.nodes) +// { +// var expected = GetExpectedDiscoveryEndpoint(allEntries, peer); +// if (expected != peer.address) +// { +// result += $"Node:{entry.Node.GetName()} has incorrect peer table entry. Was: '{peer.address}', expected: '{expected}'. "; +// } +// } +// return result; +// } - public PeerConnectionState Check(Entry from, Entry to) - { - var peerId = to.Response.id; +// public PeerConnectionState Check(Entry from, Entry to) +// { +// var peerId = to.Response.id; - var response = from.Node.GetDebugPeer(peerId); - if (!response.IsPeerFound) - { - return PeerConnectionState.NoConnection; - } - if (!string.IsNullOrEmpty(response.peerId) && response.addresses.Any()) - { - return PeerConnectionState.Connection; - } - return PeerConnectionState.Unknown; - } +// var response = from.Node.GetDebugPeer(peerId); +// if (!response.IsPeerFound) +// { +// return PeerConnectionState.NoConnection; +// } +// if (!string.IsNullOrEmpty(response.peerId) && response.addresses.Any()) +// { +// return PeerConnectionState.Connection; +// } +// return PeerConnectionState.Unknown; +// } - private static string GetExpectedDiscoveryEndpoint(Entry[] allEntries, CodexDebugTableNodeResponse node) - { - var peer = allEntries.SingleOrDefault(e => e.Response.table.localNode.peerId == node.peerId); - if (peer == null) return $"peerId: {node.peerId} is not known."; +// private static string GetExpectedDiscoveryEndpoint(Entry[] allEntries, CodexDebugTableNodeResponse node) +// { +// var peer = allEntries.SingleOrDefault(e => e.Response.table.localNode.peerId == node.peerId); +// if (peer == null) return $"peerId: {node.peerId} is not known."; - var ip = peer.Node.Container.Pod.PodInfo.Ip; - var discPort = peer.Node.Container.Recipe.GetPortByTag(CodexContainerRecipe.DiscoveryPortTag); - return $"{ip}:{discPort.Number}"; - } - } -} +// var ip = peer.Node.Container.Pod.PodInfo.Ip; +// var discPort = peer.Node.Container.Recipe.GetPortByTag(CodexContainerRecipe.DiscoveryPortTag); +// return $"{ip}:{discPort.Number}"; +// } +// } +//} diff --git a/DistTestCore/Helpers/PeerDownloadTestHelpers.cs b/DistTestCore/Helpers/PeerDownloadTestHelpers.cs index d541194..6c535cc 100644 --- a/DistTestCore/Helpers/PeerDownloadTestHelpers.cs +++ b/DistTestCore/Helpers/PeerDownloadTestHelpers.cs @@ -1,89 +1,89 @@ -using DistTestCore.Codex; -using FileUtils; -using Logging; -using Utils; -using static DistTestCore.Helpers.FullConnectivityHelper; +//using DistTestCore.Codex; +//using FileUtils; +//using Logging; +//using Utils; +//using static DistTestCore.Helpers.FullConnectivityHelper; -namespace DistTestCore.Helpers -{ - public class PeerDownloadTestHelpers : IFullConnectivityImplementation - { - private readonly FullConnectivityHelper helper; - private readonly BaseLog log; - private readonly FileManager fileManager; - private ByteSize testFileSize; +//namespace DistTestCore.Helpers +//{ +// public class PeerDownloadTestHelpers : IFullConnectivityImplementation +// { +// private readonly FullConnectivityHelper helper; +// private readonly BaseLog log; +// private readonly FileManager fileManager; +// private ByteSize testFileSize; - public PeerDownloadTestHelpers(BaseLog log, FileManager fileManager) - { - helper = new FullConnectivityHelper(log, this); - testFileSize = 1.MB(); - this.log = log; - this.fileManager = fileManager; - } +// public PeerDownloadTestHelpers(BaseLog log, FileManager fileManager) +// { +// helper = new FullConnectivityHelper(log, this); +// testFileSize = 1.MB(); +// this.log = log; +// this.fileManager = fileManager; +// } - public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) - { - AssertFullDownloadInterconnectivity(nodes.Select(n => ((OnlineCodexNode)n).CodexAccess), testFileSize); - } +// public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) +// { +// AssertFullDownloadInterconnectivity(nodes.Select(n => ((OnlineCodexNode)n).CodexAccess), testFileSize); +// } - public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) - { - this.testFileSize = testFileSize; - helper.AssertFullyConnected(nodes); - } +// public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) +// { +// this.testFileSize = testFileSize; +// helper.AssertFullyConnected(nodes); +// } - public string Description() - { - return "Download Connectivity"; - } +// public string Description() +// { +// return "Download Connectivity"; +// } - public string ValidateEntry(Entry entry, Entry[] allEntries) - { - return string.Empty; - } +// public string ValidateEntry(Entry entry, Entry[] allEntries) +// { +// return string.Empty; +// } - public PeerConnectionState Check(Entry from, Entry to) - { - return fileManager.ScopedFiles(() => CheckConnectivity(from, to)); - } +// public PeerConnectionState Check(Entry from, Entry to) +// { +// return fileManager.ScopedFiles(() => CheckConnectivity(from, to)); +// } - private PeerConnectionState CheckConnectivity(Entry from, Entry to) - { - var expectedFile = GenerateTestFile(from.Node, to.Node); +// private PeerConnectionState CheckConnectivity(Entry from, Entry to) +// { +// var expectedFile = GenerateTestFile(from.Node, to.Node); - using var uploadStream = File.OpenRead(expectedFile.Filename); - var contentId = Stopwatch.Measure(log, "Upload", () => from.Node.UploadFile(uploadStream)); +// using var uploadStream = File.OpenRead(expectedFile.Filename); +// var contentId = Stopwatch.Measure(log, "Upload", () => from.Node.UploadFile(uploadStream)); - try - { - var downloadedFile = Stopwatch.Measure(log, "Download", () => DownloadFile(to.Node, contentId, expectedFile.Label + "_downloaded")); - expectedFile.AssertIsEqual(downloadedFile); - return PeerConnectionState.Connection; - } - catch - { - // Should an exception occur during the download or file-content assertion, - // We consider that as no-connection for the purpose of this test. - return PeerConnectionState.NoConnection; - } - // Should an exception occur during upload, then this try is inconclusive and we try again next loop. - } +// try +// { +// var downloadedFile = Stopwatch.Measure(log, "Download", () => DownloadFile(to.Node, contentId, expectedFile.Label + "_downloaded")); +// expectedFile.AssertIsEqual(downloadedFile); +// return PeerConnectionState.Connection; +// } +// catch +// { +// // Should an exception occur during the download or file-content assertion, +// // We consider that as no-connection for the purpose of this test. +// return PeerConnectionState.NoConnection; +// } +// // Should an exception occur during upload, then this try is inconclusive and we try again next loop. +// } - private TestFile DownloadFile(CodexAccess node, string contentId, string label) - { - var downloadedFile = fileManager.CreateEmptyTestFile(label); - using var downloadStream = File.OpenWrite(downloadedFile.Filename); - using var stream = node.DownloadFile(contentId); - stream.CopyTo(downloadStream); - return downloadedFile; - } +// private TestFile DownloadFile(CodexAccess node, string contentId, string label) +// { +// var downloadedFile = fileManager.CreateEmptyTestFile(label); +// using var downloadStream = File.OpenWrite(downloadedFile.Filename); +// using var stream = node.DownloadFile(contentId); +// stream.CopyTo(downloadStream); +// return downloadedFile; +// } - private TestFile GenerateTestFile(CodexAccess uploader, CodexAccess downloader) - { - var up = uploader.GetName().Replace("<", "").Replace(">", ""); - var down = downloader.GetName().Replace("<", "").Replace(">", ""); - var label = $"~from:{up}-to:{down}~"; - return fileManager.GenerateTestFile(testFileSize, label); - } - } -} +// private TestFile GenerateTestFile(CodexAccess uploader, CodexAccess downloader) +// { +// var up = uploader.GetName().Replace("<", "").Replace(">", ""); +// var down = downloader.GetName().Replace("<", "").Replace(">", ""); +// var label = $"~from:{up}-to:{down}~"; +// return fileManager.GenerateTestFile(testFileSize, label); +// } +// } +//} diff --git a/DistTestCore/Marketplace/CodexContractsContainerConfig.cs b/DistTestCore/Marketplace/CodexContractsContainerConfig.cs deleted file mode 100644 index 3b669a4..0000000 --- a/DistTestCore/Marketplace/CodexContractsContainerConfig.cs +++ /dev/null @@ -1,16 +0,0 @@ -using KubernetesWorkflow; - -namespace DistTestCore.Marketplace -{ - public class CodexContractsContainerConfig - { - public CodexContractsContainerConfig(string bootstrapNodeIp, Port jsonRpcPort) - { - BootstrapNodeIp = bootstrapNodeIp; - JsonRpcPort = jsonRpcPort; - } - - public string BootstrapNodeIp { get; } - public Port JsonRpcPort { get; } - } -} diff --git a/DistTestCore/Marketplace/CodexContractsContainerRecipe.cs b/DistTestCore/Marketplace/CodexContractsContainerRecipe.cs deleted file mode 100644 index bd2c906..0000000 --- a/DistTestCore/Marketplace/CodexContractsContainerRecipe.cs +++ /dev/null @@ -1,25 +0,0 @@ -using KubernetesWorkflow; - -namespace DistTestCore.Marketplace -{ - public class CodexContractsContainerRecipe : DefaultContainerRecipe - { - public const string MarketplaceAddressFilename = "/hardhat/deployments/codexdisttestnetwork/Marketplace.json"; - public const string MarketplaceArtifactFilename = "/hardhat/artifacts/contracts/Marketplace.sol/Marketplace.json"; - - public override string AppName => "codex-contracts"; - public override string Image => "codexstorage/codex-contracts-eth:latest-dist-tests"; - - protected override void InitializeRecipe(StartupConfig startupConfig) - { - var config = startupConfig.Get(); - - var ip = config.BootstrapNodeIp; - var port = config.JsonRpcPort.Number; - - AddEnvVar("DISTTEST_NETWORK_URL", $"http://{ip}:{port}"); - AddEnvVar("HARDHAT_NETWORK", "codexdisttestnetwork"); - AddEnvVar("KEEP_ALIVE", "1"); - } - } -} diff --git a/DistTestCore/Marketplace/CodexContractsStarter.cs b/DistTestCore/Marketplace/CodexContractsStarter.cs deleted file mode 100644 index 4d628b6..0000000 --- a/DistTestCore/Marketplace/CodexContractsStarter.cs +++ /dev/null @@ -1,103 +0,0 @@ -using KubernetesWorkflow; -using Utils; - -namespace DistTestCore.Marketplace -{ - public class CodexContractsStarter : BaseStarter - { - - public CodexContractsStarter(TestLifecycle lifecycle) - : base(lifecycle) - { - } - - public MarketplaceInfo Start(GethBootstrapNodeInfo bootstrapNode) - { - LogStart("Deploying Codex Marketplace..."); - - var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - var startupConfig = CreateStartupConfig(bootstrapNode.RunningContainers.Containers[0]); - - var containers = workflow.Start(1, Location.Unspecified, new CodexContractsContainerRecipe(), startupConfig); - if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Codex contracts container to be created. Test infra failure."); - var container = containers.Containers[0]; - - WaitUntil(() => - { - var logHandler = new ContractsReadyLogHandler(Debug); - workflow.DownloadContainerLog(container, logHandler, null); - return logHandler.Found; - }); - Log("Contracts deployed. Extracting addresses..."); - - var extractor = new ContainerInfoExtractor(lifecycle.Log, workflow, container); - var marketplaceAddress = extractor.ExtractMarketplaceAddress(); - var abi = extractor.ExtractMarketplaceAbi(); - - var interaction = bootstrapNode.StartInteraction(lifecycle); - var tokenAddress = interaction.GetTokenAddress(marketplaceAddress); - - LogEnd("Extract completed. Marketplace deployed."); - - return new MarketplaceInfo(marketplaceAddress, abi, tokenAddress); - } - - private void WaitUntil(Func predicate) - { - Time.WaitUntil(predicate, TimeSpan.FromMinutes(3), TimeSpan.FromSeconds(2)); - } - - private StartupConfig CreateStartupConfig(RunningContainer bootstrapContainer) - { - var startupConfig = new StartupConfig(); - var contractsConfig = new CodexContractsContainerConfig(bootstrapContainer.Pod.PodInfo.Ip, bootstrapContainer.Recipe.GetPortByTag(GethContainerRecipe.HttpPortTag)); - startupConfig.Add(contractsConfig); - return startupConfig; - } - } - - public class MarketplaceInfo - { - public MarketplaceInfo(string address, string abi, string tokenAddress) - { - Address = address; - Abi = abi; - TokenAddress = tokenAddress; - } - - public string Address { get; } - public string Abi { get; } - public string TokenAddress { get; } - } - - public class ContractsReadyLogHandler : LogHandler - { - // Log should contain 'Compiled 15 Solidity files successfully' at some point. - private const string RequiredCompiledString = "Solidity files successfully"; - // When script is done, it prints the ready-string. - private const string ReadyString = "Done! Sleeping indefinitely..."; - private readonly Action debug; - - public ContractsReadyLogHandler(Action debug) - { - this.debug = debug; - debug($"Looking for '{RequiredCompiledString}' and '{ReadyString}' in container logs..."); - } - - public bool SeenCompileString { get; private set; } - public bool Found { get; private set; } - - protected override void ProcessLine(string line) - { - debug(line); - if (line.Contains(RequiredCompiledString)) SeenCompileString = true; - if (line.Contains(ReadyString)) - { - if (!SeenCompileString) throw new Exception("CodexContracts deployment failed. " + - "Solidity files not compiled before process exited."); - - Found = true; - } - } - } -} diff --git a/DistTestCore/Marketplace/ContainerInfoExtractor.cs b/DistTestCore/Marketplace/ContainerInfoExtractor.cs deleted file mode 100644 index 1eac5cb..0000000 --- a/DistTestCore/Marketplace/ContainerInfoExtractor.cs +++ /dev/null @@ -1,149 +0,0 @@ -using KubernetesWorkflow; -using Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Utils; - -namespace DistTestCore.Marketplace -{ - public class ContainerInfoExtractor - { - private readonly BaseLog log; - private readonly StartupWorkflow workflow; - private readonly RunningContainer container; - - public ContainerInfoExtractor(BaseLog log, StartupWorkflow workflow, RunningContainer container) - { - this.log = log; - this.workflow = workflow; - this.container = container; - } - - public AllGethAccounts ExtractAccounts() - { - log.Debug(); - var accountsCsv = Retry(() => FetchAccountsCsv()); - if (string.IsNullOrEmpty(accountsCsv)) throw new InvalidOperationException("Unable to fetch accounts.csv for geth node. Test infra failure."); - - var lines = accountsCsv.Split('\n'); - return new AllGethAccounts(lines.Select(ParseLineToAccount).ToArray()); - } - - public string ExtractPubKey() - { - log.Debug(); - var pubKey = Retry(FetchPubKey); - if (string.IsNullOrEmpty(pubKey)) throw new InvalidOperationException("Unable to fetch enode from geth node. Test infra failure."); - - return pubKey; - } - - public string ExtractMarketplaceAddress() - { - log.Debug(); - var marketplaceAddress = Retry(FetchMarketplaceAddress); - if (string.IsNullOrEmpty(marketplaceAddress)) throw new InvalidOperationException("Unable to fetch marketplace account from codex-contracts node. Test infra failure."); - - return marketplaceAddress; - } - - public string ExtractMarketplaceAbi() - { - log.Debug(); - var marketplaceAbi = Retry(FetchMarketplaceAbi); - if (string.IsNullOrEmpty(marketplaceAbi)) throw new InvalidOperationException("Unable to fetch marketplace artifacts from codex-contracts node. Test infra failure."); - - return marketplaceAbi; - } - - private string FetchAccountsCsv() - { - return workflow.ExecuteCommand(container, "cat", GethContainerRecipe.AccountsFilename); - } - - private string FetchMarketplaceAddress() - { - var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceAddressFilename); - var marketplace = JsonConvert.DeserializeObject(json); - return marketplace!.address; - } - - private string FetchMarketplaceAbi() - { - var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceArtifactFilename); - - var artifact = JObject.Parse(json); - var abi = artifact["abi"]; - return abi!.ToString(Formatting.None); - } - - private string FetchPubKey() - { - var enodeFinder = new PubKeyFinder(s => log.Debug(s)); - workflow.DownloadContainerLog(container, enodeFinder, null); - return enodeFinder.GetPubKey(); - } - - private GethAccount ParseLineToAccount(string l) - { - var tokens = l.Replace("\r", "").Split(','); - if (tokens.Length != 2) throw new InvalidOperationException(); - var account = tokens[0]; - var privateKey = tokens[1]; - return new GethAccount(account, privateKey); - } - - private static string Retry(Func fetch) - { - return Time.Retry(fetch, nameof(ContainerInfoExtractor)); - } - } - - public class PubKeyFinder : LogHandler, ILogHandler - { - private const string openTag = "self=enode://"; - private const string openTagQuote = "self=\"enode://"; - private readonly Action debug; - private string pubKey = string.Empty; - - public PubKeyFinder(Action debug) - { - this.debug = debug; - debug($"Looking for '{openTag}' in container logs..."); - } - - public string GetPubKey() - { - if (string.IsNullOrEmpty(pubKey)) throw new Exception("Not found yet exception."); - return pubKey; - } - - protected override void ProcessLine(string line) - { - debug(line); - if (line.Contains(openTag)) - { - ExtractPubKey(openTag, line); - } - else if (line.Contains(openTagQuote)) - { - ExtractPubKey(openTagQuote, line); - } - } - - private void ExtractPubKey(string tag, string line) - { - var openIndex = line.IndexOf(tag) + tag.Length; - var closeIndex = line.IndexOf("@"); - - pubKey = line.Substring( - startIndex: openIndex, - length: closeIndex - openIndex); - } - } - - public class MarketplaceJson - { - public string address { get; set; } = string.Empty; - } -} diff --git a/DistTestCore/Marketplace/GethBootstrapNodeInfo.cs b/DistTestCore/Marketplace/GethBootstrapNodeInfo.cs deleted file mode 100644 index 6825ba5..0000000 --- a/DistTestCore/Marketplace/GethBootstrapNodeInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -using KubernetesWorkflow; -using NethereumWorkflow; - -namespace DistTestCore.Marketplace -{ - public class GethBootstrapNodeInfo - { - public GethBootstrapNodeInfo(RunningContainers runningContainers, AllGethAccounts allAccounts, string pubKey, Port discoveryPort) - { - RunningContainers = runningContainers; - AllAccounts = allAccounts; - Account = allAccounts.Accounts[0]; - PubKey = pubKey; - DiscoveryPort = discoveryPort; - } - - public RunningContainers RunningContainers { get; } - public AllGethAccounts AllAccounts { get; } - public GethAccount Account { get; } - public string PubKey { get; } - public Port DiscoveryPort { get; } - - public NethereumInteraction StartInteraction(TestLifecycle lifecycle) - { - var address = lifecycle.Configuration.GetAddress(RunningContainers.Containers[0]); - var account = Account; - - var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, account.PrivateKey); - return creator.CreateWorkflow(); - } - } - - public class AllGethAccounts - { - public GethAccount[] Accounts { get; } - - public AllGethAccounts(GethAccount[] accounts) - { - Accounts = accounts; - } - } -} diff --git a/DistTestCore/Marketplace/GethBootstrapNodeStarter.cs b/DistTestCore/Marketplace/GethBootstrapNodeStarter.cs deleted file mode 100644 index d1ebb54..0000000 --- a/DistTestCore/Marketplace/GethBootstrapNodeStarter.cs +++ /dev/null @@ -1,40 +0,0 @@ -using KubernetesWorkflow; - -namespace DistTestCore.Marketplace -{ - public class GethBootstrapNodeStarter : BaseStarter - { - public GethBootstrapNodeStarter(TestLifecycle lifecycle) - : base(lifecycle) - { - } - - public GethBootstrapNodeInfo StartGethBootstrapNode() - { - LogStart("Starting Geth bootstrap node..."); - var startupConfig = CreateBootstrapStartupConfig(); - - var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), startupConfig); - if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Geth bootstrap node to be created. Test infra failure."); - var bootstrapContainer = containers.Containers[0]; - - var extractor = new ContainerInfoExtractor(lifecycle.Log, workflow, bootstrapContainer); - var accounts = extractor.ExtractAccounts(); - var pubKey = extractor.ExtractPubKey(); - var discoveryPort = bootstrapContainer.Recipe.GetPortByTag(GethContainerRecipe.DiscoveryPortTag); - var result = new GethBootstrapNodeInfo(containers, accounts, pubKey, discoveryPort); - - LogEnd($"Geth bootstrap node started with account '{result.Account.Account}'"); - - return result; - } - - private StartupConfig CreateBootstrapStartupConfig() - { - var config = new StartupConfig(); - config.Add(new GethStartupConfig(true, null!, 0, 0)); - return config; - } - } -} diff --git a/DistTestCore/Marketplace/GethCompanionNodeInfo.cs b/DistTestCore/Marketplace/GethCompanionNodeInfo.cs deleted file mode 100644 index 3230d2a..0000000 --- a/DistTestCore/Marketplace/GethCompanionNodeInfo.cs +++ /dev/null @@ -1,38 +0,0 @@ -using KubernetesWorkflow; -using NethereumWorkflow; - -namespace DistTestCore.Marketplace -{ - public class GethCompanionNodeInfo - { - public GethCompanionNodeInfo(RunningContainer runningContainer, GethAccount[] accounts) - { - RunningContainer = runningContainer; - Accounts = accounts; - } - - public RunningContainer RunningContainer { get; } - public GethAccount[] Accounts { get; } - - public NethereumInteraction StartInteraction(TestLifecycle lifecycle, GethAccount account) - { - var address = lifecycle.Configuration.GetAddress(RunningContainer); - var privateKey = account.PrivateKey; - - var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, privateKey); - return creator.CreateWorkflow(); - } - } - - public class GethAccount - { - public GethAccount(string account, string privateKey) - { - Account = account; - PrivateKey = privateKey; - } - - public string Account { get; } - public string PrivateKey { get; } - } -} diff --git a/DistTestCore/Marketplace/GethCompanionNodeStarter.cs b/DistTestCore/Marketplace/GethCompanionNodeStarter.cs deleted file mode 100644 index 6759e7b..0000000 --- a/DistTestCore/Marketplace/GethCompanionNodeStarter.cs +++ /dev/null @@ -1,77 +0,0 @@ -using KubernetesWorkflow; -using Utils; - -namespace DistTestCore.Marketplace -{ - public class GethCompanionNodeStarter : BaseStarter - { - private int companionAccountIndex = 0; - - public GethCompanionNodeStarter(TestLifecycle lifecycle) - : base(lifecycle) - { - } - - public GethCompanionNodeInfo StartCompanionNodeFor(CodexSetup codexSetup, MarketplaceNetwork marketplace) - { - LogStart($"Initializing companion for {codexSetup.NumberOfNodes} Codex nodes."); - - var config = CreateCompanionNodeStartupConfig(marketplace.Bootstrap, codexSetup.NumberOfNodes); - - var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), CreateStartupConfig(config)); - if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected one Geth companion node to be created. Test infra failure."); - var container = containers.Containers[0]; - - var node = CreateCompanionInfo(container, marketplace, config); - EnsureCompanionNodeIsSynced(node, marketplace); - - LogEnd($"Initialized one companion node for {codexSetup.NumberOfNodes} Codex nodes. Their accounts: [{string.Join(",", node.Accounts.Select(a => a.Account))}]"); - return node; - } - - private GethCompanionNodeInfo CreateCompanionInfo(RunningContainer container, MarketplaceNetwork marketplace, GethStartupConfig config) - { - var accounts = ExtractAccounts(marketplace, config); - return new GethCompanionNodeInfo(container, accounts); - } - - private static GethAccount[] ExtractAccounts(MarketplaceNetwork marketplace, GethStartupConfig config) - { - return marketplace.Bootstrap.AllAccounts.Accounts - .Skip(1 + config.CompanionAccountStartIndex) - .Take(config.NumberOfCompanionAccounts) - .ToArray(); - } - - private void EnsureCompanionNodeIsSynced(GethCompanionNodeInfo node, MarketplaceNetwork marketplace) - { - try - { - Time.WaitUntil(() => - { - var interaction = node.StartInteraction(lifecycle, node.Accounts.First()); - return interaction.IsSynced(marketplace.Marketplace.Address, marketplace.Marketplace.Abi); - }, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(3)); - } - catch (Exception e) - { - throw new Exception("Geth companion node did not sync within timeout. Test infra failure.", e); - } - } - - private GethStartupConfig CreateCompanionNodeStartupConfig(GethBootstrapNodeInfo bootstrapNode, int numberOfAccounts) - { - var config = new GethStartupConfig(false, bootstrapNode, companionAccountIndex, numberOfAccounts); - companionAccountIndex += numberOfAccounts; - return config; - } - - private StartupConfig CreateStartupConfig(GethStartupConfig gethConfig) - { - var config = new StartupConfig(); - config.Add(gethConfig); - return config; - } - } -} diff --git a/DistTestCore/Marketplace/GethContainerRecipe.cs b/DistTestCore/Marketplace/GethContainerRecipe.cs deleted file mode 100644 index 540b1d3..0000000 --- a/DistTestCore/Marketplace/GethContainerRecipe.cs +++ /dev/null @@ -1,73 +0,0 @@ -using KubernetesWorkflow; - -namespace DistTestCore.Marketplace -{ - public class GethContainerRecipe : DefaultContainerRecipe - { - private const string defaultArgs = "--ipcdisable --syncmode full"; - - public const string HttpPortTag = "http_port"; - public const string DiscoveryPortTag = "disc_port"; - public const string AccountsFilename = "accounts.csv"; - - public override string AppName => "geth"; - public override string Image => "codexstorage/dist-tests-geth:latest"; - - protected override void InitializeRecipe(StartupConfig startupConfig) - { - var config = startupConfig.Get(); - - var args = CreateArgs(config); - - AddEnvVar("GETH_ARGS", args); - } - - private string CreateArgs(GethStartupConfig config) - { - var discovery = AddInternalPort(tag: DiscoveryPortTag); - - if (config.IsBootstrapNode) - { - return CreateBootstapArgs(discovery); - } - - return CreateCompanionArgs(discovery, config); - } - - private string CreateBootstapArgs(Port discovery) - { - AddEnvVar("ENABLE_MINER", "1"); - UnlockAccounts(0, 1); - var exposedPort = AddExposedPort(tag: HttpPortTag); - return $"--http.port {exposedPort.Number} --port {discovery.Number} --discovery.port {discovery.Number} {defaultArgs}"; - } - - private string CreateCompanionArgs(Port discovery, GethStartupConfig config) - { - UnlockAccounts( - config.CompanionAccountStartIndex + 1, - config.NumberOfCompanionAccounts); - - var port = AddInternalPort(); - var authRpc = AddInternalPort(); - var httpPort = AddExposedPort(tag: HttpPortTag); - - var bootPubKey = config.BootstrapNode.PubKey; - var bootIp = config.BootstrapNode.RunningContainers.Containers[0].Pod.PodInfo.Ip; - var bootPort = config.BootstrapNode.DiscoveryPort.Number; - var bootstrapArg = $"--bootnodes enode://{bootPubKey}@{bootIp}:{bootPort} --nat=extip:{bootIp}"; - - return $"--port {port.Number} --discovery.port {discovery.Number} --authrpc.port {authRpc.Number} --http.addr 0.0.0.0 --http.port {httpPort.Number} --ws --ws.addr 0.0.0.0 --ws.port {httpPort.Number} {bootstrapArg} {defaultArgs}"; - } - - private void UnlockAccounts(int startIndex, int numberOfAccounts) - { - if (startIndex < 0) throw new ArgumentException(); - if (numberOfAccounts < 1) throw new ArgumentException(); - if (startIndex + numberOfAccounts > 1000) throw new ArgumentException("Out of accounts!"); - - AddEnvVar("UNLOCK_START_INDEX", startIndex.ToString()); - AddEnvVar("UNLOCK_NUMBER", numberOfAccounts.ToString()); - } - } -} diff --git a/DistTestCore/Marketplace/GethStartResult.cs b/DistTestCore/Marketplace/GethStartResult.cs deleted file mode 100644 index 0ba1e58..0000000 --- a/DistTestCore/Marketplace/GethStartResult.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Newtonsoft.Json; - -namespace DistTestCore.Marketplace -{ - public class GethStartResult - { - public GethStartResult(IMarketplaceAccessFactory marketplaceAccessFactory, MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) - { - MarketplaceAccessFactory = marketplaceAccessFactory; - MarketplaceNetwork = marketplaceNetwork; - CompanionNode = companionNode; - } - - [JsonIgnore] - public IMarketplaceAccessFactory MarketplaceAccessFactory { get; } - public MarketplaceNetwork MarketplaceNetwork { get; } - public GethCompanionNodeInfo CompanionNode { get; } - } -} diff --git a/DistTestCore/Marketplace/GethStartupConfig.cs b/DistTestCore/Marketplace/GethStartupConfig.cs deleted file mode 100644 index 7aee078..0000000 --- a/DistTestCore/Marketplace/GethStartupConfig.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace DistTestCore.Marketplace -{ - public class GethStartupConfig - { - public GethStartupConfig(bool isBootstrapNode, GethBootstrapNodeInfo bootstrapNode, int companionAccountStartIndex, int numberOfCompanionAccounts) - { - IsBootstrapNode = isBootstrapNode; - BootstrapNode = bootstrapNode; - CompanionAccountStartIndex = companionAccountStartIndex; - NumberOfCompanionAccounts = numberOfCompanionAccounts; - } - - public bool IsBootstrapNode { get; } - public GethBootstrapNodeInfo BootstrapNode { get; } - public int CompanionAccountStartIndex { get; } - public int NumberOfCompanionAccounts { get; } - } -} diff --git a/DistTestCore/Marketplace/MarketplaceAccess.cs b/DistTestCore/Marketplace/MarketplaceAccess.cs deleted file mode 100644 index 0671582..0000000 --- a/DistTestCore/Marketplace/MarketplaceAccess.cs +++ /dev/null @@ -1,243 +0,0 @@ -using DistTestCore.Codex; -using DistTestCore.Helpers; -using Logging; -using Newtonsoft.Json; -using NUnit.Framework; -using NUnit.Framework.Constraints; -using System.Numerics; -using Utils; - -namespace DistTestCore.Marketplace -{ - public interface IMarketplaceAccess - { - string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan maxDuration); - StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration); - void AssertThatBalance(IResolveConstraint constraint, string message = ""); - TestToken GetBalance(); - } - - public class MarketplaceAccess : IMarketplaceAccess - { - private readonly TestLifecycle lifecycle; - private readonly MarketplaceNetwork marketplaceNetwork; - private readonly GethAccount account; - private readonly CodexAccess codexAccess; - - public MarketplaceAccess(TestLifecycle lifecycle, MarketplaceNetwork marketplaceNetwork, GethAccount account, CodexAccess codexAccess) - { - this.lifecycle = lifecycle; - this.marketplaceNetwork = marketplaceNetwork; - this.account = account; - this.codexAccess = codexAccess; - } - - public StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration) - { - var request = new CodexSalesRequestStorageRequest - { - duration = ToDecInt(duration.TotalSeconds), - proofProbability = ToDecInt(proofProbability), - reward = ToDecInt(pricePerSlotPerSecond), - collateral = ToDecInt(requiredCollateral), - expiry = null, - nodes = minRequiredNumberOfNodes, - tolerance = null, - }; - - Log($"Requesting storage for: {contentId.Id}... (" + - $"pricePerSlotPerSecond: {pricePerSlotPerSecond}, " + - $"requiredCollateral: {requiredCollateral}, " + - $"minRequiredNumberOfNodes: {minRequiredNumberOfNodes}, " + - $"proofProbability: {proofProbability}, " + - $"duration: {Time.FormatDuration(duration)})"); - - var response = codexAccess.RequestStorage(request, contentId.Id); - - if (response == "Purchasing not available") - { - throw new InvalidOperationException(response); - } - - Log($"Storage requested successfully. PurchaseId: '{response}'."); - - return new StoragePurchaseContract(lifecycle.Log, codexAccess, response, duration); - } - - public string MakeStorageAvailable(ByteSize totalSpace, TestToken minPriceForTotalSpace, TestToken maxCollateral, TimeSpan maxDuration) - { - var request = new CodexSalesAvailabilityRequest - { - size = ToDecInt(totalSpace.SizeInBytes), - duration = ToDecInt(maxDuration.TotalSeconds), - maxCollateral = ToDecInt(maxCollateral), - minPrice = ToDecInt(minPriceForTotalSpace) - }; - - Log($"Making storage available... (" + - $"size: {totalSpace}, " + - $"minPriceForTotalSpace: {minPriceForTotalSpace}, " + - $"maxCollateral: {maxCollateral}, " + - $"maxDuration: {Time.FormatDuration(maxDuration)})"); - - var response = codexAccess.SalesAvailability(request); - - Log($"Storage successfully made available. Id: {response.id}"); - - return response.id; - } - - private string ToDecInt(double d) - { - var i = new BigInteger(d); - return i.ToString("D"); - } - - public string ToDecInt(TestToken t) - { - var i = new BigInteger(t.Amount); - return i.ToString("D"); - } - - public void AssertThatBalance(IResolveConstraint constraint, string message = "") - { - AssertHelpers.RetryAssert(constraint, GetBalance, message); - } - - public TestToken GetBalance() - { - var interaction = marketplaceNetwork.StartInteraction(lifecycle); - var amount = interaction.GetBalance(marketplaceNetwork.Marketplace.TokenAddress, account.Account); - var balance = new TestToken(amount); - - Log($"Balance of {account.Account} is {balance}."); - - return balance; - } - - private void Log(string msg) - { - lifecycle.Log.Log($"{codexAccess.Container.Name} {msg}"); - } - } - - public class MarketplaceUnavailable : IMarketplaceAccess - { - public StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerBytePerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration) - { - Unavailable(); - return null!; - } - - public string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan duration) - { - Unavailable(); - return string.Empty; - } - - public void AssertThatBalance(IResolveConstraint constraint, string message = "") - { - Unavailable(); - } - - public TestToken GetBalance() - { - Unavailable(); - return new TestToken(0); - } - - private void Unavailable() - { - Assert.Fail("Incorrect test setup: Marketplace was not enabled for this group of Codex nodes. Add 'EnableMarketplace(...)' after 'SetupCodexNodes()' to enable it."); - throw new InvalidOperationException(); - } - } - - public class StoragePurchaseContract - { - private readonly BaseLog log; - private readonly CodexAccess codexAccess; - private DateTime? contractStartUtc; - - public StoragePurchaseContract(BaseLog log, CodexAccess codexAccess, string purchaseId, TimeSpan contractDuration) - { - this.log = log; - this.codexAccess = codexAccess; - PurchaseId = purchaseId; - ContractDuration = contractDuration; - } - - public string PurchaseId { get; } - public TimeSpan ContractDuration { get; } - - public void WaitForStorageContractStarted() - { - WaitForStorageContractStarted(TimeSpan.FromSeconds(30)); - } - - public void WaitForStorageContractFinished() - { - if (!contractStartUtc.HasValue) - { - WaitForStorageContractStarted(); - } - var gracePeriod = TimeSpan.FromSeconds(10); - var currentContractTime = DateTime.UtcNow - contractStartUtc!.Value; - var timeout = (ContractDuration - currentContractTime) + gracePeriod; - WaitForStorageContractState(timeout, "finished"); - } - - /// - /// Wait for contract to start. Max timeout depends on contract filesize. Allows more time for larger files. - /// - public void WaitForStorageContractStarted(ByteSize contractFileSize) - { - var filesizeInMb = contractFileSize.SizeInBytes / (1024 * 1024); - var maxWaitTime = TimeSpan.FromSeconds(filesizeInMb * 10.0); - - WaitForStorageContractStarted(maxWaitTime); - } - - public void WaitForStorageContractStarted(TimeSpan timeout) - { - WaitForStorageContractState(timeout, "started"); - contractStartUtc = DateTime.UtcNow; - } - - private void WaitForStorageContractState(TimeSpan timeout, string desiredState) - { - var lastState = ""; - var waitStart = DateTime.UtcNow; - - log.Log($"Waiting for {Time.FormatDuration(timeout)} for contract '{PurchaseId}' to reach state '{desiredState}'."); - while (lastState != desiredState) - { - var purchaseStatus = codexAccess.GetPurchaseStatus(PurchaseId); - var statusJson = JsonConvert.SerializeObject(purchaseStatus); - if (purchaseStatus != null && purchaseStatus.state != lastState) - { - lastState = purchaseStatus.state; - log.Debug("Purchase status: " + statusJson); - } - - Thread.Sleep(1000); - - if (lastState == "errored") - { - Assert.Fail("Contract errored: " + statusJson); - } - - if (DateTime.UtcNow - waitStart > timeout) - { - Assert.Fail($"Contract did not reach '{desiredState}' within timeout. {statusJson}"); - } - } - log.Log($"Contract '{desiredState}'."); - } - - public CodexStoragePurchase GetPurchaseStatus(string purchaseId) - { - return codexAccess.GetPurchaseStatus(purchaseId); - } - } -} diff --git a/DistTestCore/Marketplace/MarketplaceAccessFactory.cs b/DistTestCore/Marketplace/MarketplaceAccessFactory.cs deleted file mode 100644 index ac59556..0000000 --- a/DistTestCore/Marketplace/MarketplaceAccessFactory.cs +++ /dev/null @@ -1,41 +0,0 @@ -using DistTestCore.Codex; - -namespace DistTestCore.Marketplace -{ - public interface IMarketplaceAccessFactory - { - IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access); - } - - public class MarketplaceUnavailableAccessFactory : IMarketplaceAccessFactory - { - public IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access) - { - return new MarketplaceUnavailable(); - } - } - - public class GethMarketplaceAccessFactory : IMarketplaceAccessFactory - { - private readonly TestLifecycle lifecycle; - private readonly MarketplaceNetwork marketplaceNetwork; - - public GethMarketplaceAccessFactory(TestLifecycle lifecycle, MarketplaceNetwork marketplaceNetwork) - { - this.lifecycle = lifecycle; - this.marketplaceNetwork = marketplaceNetwork; - } - - public IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access) - { - var companionNode = GetGethCompanionNode(access); - return new MarketplaceAccess(lifecycle, marketplaceNetwork, companionNode, access); - } - - private GethAccount GetGethCompanionNode(CodexAccess access) - { - var account = access.Container.Recipe.Additionals.Single(a => a is GethAccount); - return (GethAccount)account; - } - } -} diff --git a/DistTestCore/Marketplace/MarketplaceInitialConfig.cs b/DistTestCore/Marketplace/MarketplaceInitialConfig.cs deleted file mode 100644 index c51d79f..0000000 --- a/DistTestCore/Marketplace/MarketplaceInitialConfig.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace DistTestCore.Marketplace -{ - public class MarketplaceInitialConfig - { - public MarketplaceInitialConfig(Ether initialEth, TestToken initialTestTokens, bool isValidator) - { - InitialEth = initialEth; - InitialTestTokens = initialTestTokens; - IsValidator = isValidator; - } - - public Ether InitialEth { get; } - public TestToken InitialTestTokens { get; } - public bool IsValidator { get; } - public int? AccountIndexOverride { get; set; } - } -} diff --git a/DistTestCore/Marketplace/MarketplaceNetwork.cs b/DistTestCore/Marketplace/MarketplaceNetwork.cs deleted file mode 100644 index bba80a2..0000000 --- a/DistTestCore/Marketplace/MarketplaceNetwork.cs +++ /dev/null @@ -1,21 +0,0 @@ -using NethereumWorkflow; - -namespace DistTestCore.Marketplace -{ - public class MarketplaceNetwork - { - public MarketplaceNetwork(GethBootstrapNodeInfo bootstrap, MarketplaceInfo marketplace) - { - Bootstrap = bootstrap; - Marketplace = marketplace; - } - - public GethBootstrapNodeInfo Bootstrap { get; } - public MarketplaceInfo Marketplace { get; } - - public NethereumInteraction StartInteraction(TestLifecycle lifecycle) - { - return Bootstrap.StartInteraction(lifecycle); - } - } -} diff --git a/DistTestCore/Metrics/GrafanaContainerRecipe.cs b/DistTestCore/Metrics/GrafanaContainerRecipe.cs deleted file mode 100644 index 24ab599..0000000 --- a/DistTestCore/Metrics/GrafanaContainerRecipe.cs +++ /dev/null @@ -1,25 +0,0 @@ -using KubernetesWorkflow; - -namespace DistTestCore.Metrics -{ - public class GrafanaContainerRecipe : DefaultContainerRecipe - { - public override string AppName => "grafana"; - public override string Image => "grafana/grafana-oss:10.0.3"; - - public const string DefaultAdminUser = "adminium"; - public const string DefaultAdminPassword = "passwordium"; - - protected override void InitializeRecipe(StartupConfig startupConfig) - { - AddExposedPort(3000); - - AddEnvVar("GF_AUTH_ANONYMOUS_ENABLED", "true"); - AddEnvVar("GF_AUTH_ANONYMOUS_ORG_NAME", "Main Org."); - AddEnvVar("GF_AUTH_ANONYMOUS_ORG_ROLE", "Editor"); - - AddEnvVar("GF_SECURITY_ADMIN_USER", DefaultAdminUser); - AddEnvVar("GF_SECURITY_ADMIN_PASSWORD", DefaultAdminPassword); - } - } -} diff --git a/DistTestCore/Metrics/MetricsAccess.cs b/DistTestCore/Metrics/MetricsAccess.cs deleted file mode 100644 index 23b6522..0000000 --- a/DistTestCore/Metrics/MetricsAccess.cs +++ /dev/null @@ -1,81 +0,0 @@ -using DistTestCore.Helpers; -using KubernetesWorkflow; -using Logging; -using NUnit.Framework; -using NUnit.Framework.Constraints; -using Utils; - -namespace DistTestCore.Metrics -{ - public interface IMetricsAccess - { - void AssertThat(string metricName, IResolveConstraint constraint, string message = ""); - } - - public class MetricsAccess : IMetricsAccess - { - private readonly BaseLog log; - private readonly ITimeSet timeSet; - private readonly MetricsQuery query; - private readonly RunningContainer node; - - public MetricsAccess(BaseLog log, ITimeSet timeSet, MetricsQuery query, RunningContainer node) - { - this.log = log; - this.timeSet = timeSet; - this.query = query; - this.node = node; - } - - public void AssertThat(string metricName, IResolveConstraint constraint, string message = "") - { - AssertHelpers.RetryAssert(constraint, () => - { - var metricSet = GetMetricWithTimeout(metricName); - var metricValue = metricSet.Values[0].Value; - - log.Log($"{node.Name} metric '{metricName}' = {metricValue}"); - return metricValue; - }, message); - } - - public Metrics? GetAllMetrics() - { - return query.GetAllMetricsForNode(node); - } - - private MetricsSet GetMetricWithTimeout(string metricName) - { - var start = DateTime.UtcNow; - - while (true) - { - var mostRecent = GetMostRecent(metricName); - if (mostRecent != null) return mostRecent; - if (DateTime.UtcNow - start > timeSet.WaitForMetricTimeout()) - { - Assert.Fail($"Timeout: Unable to get metric '{metricName}'."); - throw new TimeoutException(); - } - - Time.Sleep(TimeSpan.FromSeconds(2)); - } - } - - private MetricsSet? GetMostRecent(string metricName) - { - var result = query.GetMostRecent(metricName, node); - if (result == null) return null; - return result.Sets.LastOrDefault(); - } - } - - public class MetricsUnavailable : IMetricsAccess - { - public void AssertThat(string metricName, IResolveConstraint constraint, string message = "") - { - Assert.Fail("Incorrect test setup: Metrics were not enabled for this group of Codex nodes. Add 'EnableMetrics()' after 'SetupCodexNodes()' to enable it."); - throw new InvalidOperationException(); - } - } -} diff --git a/DistTestCore/Metrics/MetricsAccessFactory.cs b/DistTestCore/Metrics/MetricsAccessFactory.cs deleted file mode 100644 index 18dae04..0000000 --- a/DistTestCore/Metrics/MetricsAccessFactory.cs +++ /dev/null @@ -1,35 +0,0 @@ -using KubernetesWorkflow; - -namespace DistTestCore.Metrics -{ - public interface IMetricsAccessFactory - { - IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer); - } - - public class MetricsUnavailableAccessFactory : IMetricsAccessFactory - { - public IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer) - { - return new MetricsUnavailable(); - } - } - - public class CodexNodeMetricsAccessFactory : IMetricsAccessFactory - { - private readonly TestLifecycle lifecycle; - private readonly RunningContainers prometheusContainer; - - public CodexNodeMetricsAccessFactory(TestLifecycle lifecycle, RunningContainers prometheusContainer) - { - this.lifecycle = lifecycle; - this.prometheusContainer = prometheusContainer; - } - - public IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer) - { - var query = new MetricsQuery(lifecycle, prometheusContainer); - return new MetricsAccess(lifecycle.Log, lifecycle.TimeSet, query, codexContainer); - } - } -} diff --git a/DistTestCore/Metrics/MetricsDownloader.cs b/DistTestCore/Metrics/MetricsDownloader.cs deleted file mode 100644 index d0d11cd..0000000 --- a/DistTestCore/Metrics/MetricsDownloader.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Logging; -using System.Globalization; - -namespace DistTestCore.Metrics -{ - public class MetricsDownloader - { - private readonly BaseLog log; - - public MetricsDownloader(BaseLog log) - { - this.log = log; - } - - public void DownloadAllMetricsForNode(string nodeName, MetricsAccess access) - { - var metrics = access.GetAllMetrics(); - if (metrics == null || metrics.Sets.Length == 0 || metrics.Sets.All(s => s.Values.Length == 0)) return; - - var headers = new[] { "timestamp" }.Concat(metrics.Sets.Select(s => s.Name)).ToArray(); - var map = CreateValueMap(metrics); - - WriteToFile(nodeName, headers, map); - } - - private void WriteToFile(string nodeName, string[] headers, Dictionary> map) - { - var file = log.CreateSubfile("csv"); - log.Log($"Downloading metrics for {nodeName} to file {file.FullFilename}"); - - file.WriteRaw(string.Join(",", headers)); - - foreach (var pair in map) - { - file.WriteRaw(string.Join(",", new[] { FormatTimestamp(pair.Key) }.Concat(pair.Value))); - } - } - - private Dictionary> CreateValueMap(Metrics metrics) - { - var map = CreateForAllTimestamps(metrics); - foreach (var metric in metrics.Sets) - { - AddToMap(map, metric); - } - return map; - - } - - private Dictionary> CreateForAllTimestamps(Metrics metrics) - { - var result = new Dictionary>(); - var timestamps = metrics.Sets.SelectMany(s => s.Values).Select(v => v.Timestamp).Distinct().ToArray(); - foreach (var timestamp in timestamps) result.Add(timestamp, new List()); - return result; - } - - private void AddToMap(Dictionary> map, MetricsSet metric) - { - foreach (var key in map.Keys) - { - map[key].Add(GetValueAtTimestamp(key, metric)); - } - } - - private string GetValueAtTimestamp(DateTime key, MetricsSet metric) - { - var value = metric.Values.SingleOrDefault(v => v.Timestamp == key); - if (value == null) return ""; - return value.Value.ToString(CultureInfo.InvariantCulture); - } - - private string FormatTimestamp(DateTime key) - { - var origin = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); - var diff = key - origin; - return Math.Floor(diff.TotalSeconds).ToString(CultureInfo.InvariantCulture); - } - } -} diff --git a/DistTestCore/Metrics/MetricsMode.cs b/DistTestCore/Metrics/MetricsMode.cs deleted file mode 100644 index 60b4f5e..0000000 --- a/DistTestCore/Metrics/MetricsMode.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DistTestCore.Metrics -{ - public enum MetricsMode - { - None, - Record, - Dashboard - } -} diff --git a/DistTestCore/Metrics/MetricsQuery.cs b/DistTestCore/Metrics/MetricsQuery.cs deleted file mode 100644 index baffe66..0000000 --- a/DistTestCore/Metrics/MetricsQuery.cs +++ /dev/null @@ -1,198 +0,0 @@ -using DistTestCore.Codex; -using KubernetesWorkflow; -using System.Globalization; - -namespace DistTestCore.Metrics -{ - public class MetricsQuery - { - private readonly Http http; - - public MetricsQuery(TestLifecycle lifecycle, RunningContainers runningContainers) - { - RunningContainers = runningContainers; - - var address = lifecycle.Configuration.GetAddress(runningContainers.Containers[0]); - - http = new Http( - lifecycle.Log, - lifecycle.TimeSet, - address, - "api/v1"); - } - - public RunningContainers RunningContainers { get; } - - public Metrics? GetMostRecent(string metricName, RunningContainer node) - { - var response = GetLastOverTime(metricName, GetInstanceStringForNode(node)); - if (response == null) return null; - - return new Metrics - { - Sets = response.data.result.Select(r => - { - return new MetricsSet - { - Instance = r.metric.instance, - Values = MapSingleValue(r.value) - }; - }).ToArray() - }; - } - - public Metrics? GetMetrics(string metricName) - { - var response = GetAll(metricName); - if (response == null) return null; - return MapResponseToMetrics(response); - } - - public Metrics? GetAllMetricsForNode(RunningContainer node) - { - var response = http.HttpGetJson($"query?query={GetInstanceStringForNode(node)}{GetQueryTimeRange()}"); - if (response.status != "success") return null; - return MapResponseToMetrics(response); - } - - private PrometheusQueryResponse? GetLastOverTime(string metricName, string instanceString) - { - var response = http.HttpGetJson($"query?query=last_over_time({metricName}{instanceString}{GetQueryTimeRange()})"); - if (response.status != "success") return null; - return response; - } - - private PrometheusQueryResponse? GetAll(string metricName) - { - var response = http.HttpGetJson($"query?query={metricName}{GetQueryTimeRange()}"); - if (response.status != "success") return null; - return response; - } - - private Metrics MapResponseToMetrics(PrometheusQueryResponse response) - { - return new Metrics - { - Sets = response.data.result.Select(r => - { - return new MetricsSet - { - Name = r.metric.__name__, - Instance = r.metric.instance, - Values = MapMultipleValues(r.values) - }; - }).ToArray() - }; - } - - private MetricsSetValue[] MapSingleValue(object[] value) - { - if (value != null && value.Length > 0) - { - return new[] - { - MapValue(value) - }; - } - return Array.Empty(); - } - - private MetricsSetValue[] MapMultipleValues(object[][] values) - { - if (values != null && values.Length > 0) - { - return values.Select(v => MapValue(v)).ToArray(); - } - return Array.Empty(); - } - - private MetricsSetValue MapValue(object[] value) - { - if (value.Length != 2) throw new InvalidOperationException("Expected value to be [double, string]."); - - return new MetricsSetValue - { - Timestamp = ToTimestamp(value[0]), - Value = ToValue(value[1]) - }; - } - - private string GetInstanceNameForNode(RunningContainer node) - { - var ip = node.Pod.PodInfo.Ip; - var port = node.Recipe.GetPortByTag(CodexContainerRecipe.MetricsPortTag).Number; - return $"{ip}:{port}"; - } - - private string GetInstanceStringForNode(RunningContainer node) - { - return "{instance=\"" + GetInstanceNameForNode(node) + "\"}"; - } - - private string GetQueryTimeRange() - { - return "[12h]"; - } - - private double ToValue(object v) - { - return Convert.ToDouble(v, CultureInfo.InvariantCulture); - } - - private DateTime ToTimestamp(object v) - { - var unixSeconds = ToValue(v); - return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(unixSeconds); - } - } - - public class Metrics - { - public MetricsSet[] Sets { get; set; } = Array.Empty(); - } - - public class MetricsSet - { - public string Name { get; set; } = string.Empty; - public string Instance { get; set; } = string.Empty; - public MetricsSetValue[] Values { get; set; } = Array.Empty(); - } - - public class MetricsSetValue - { - public DateTime Timestamp { get; set; } - public double Value { get; set; } - } - - public class PrometheusQueryResponse - { - public string status { get; set; } = string.Empty; - public PrometheusQueryResponseData data { get; set; } = new(); - } - - public class PrometheusQueryResponseData - { - public string resultType { get; set; } = string.Empty; - public PrometheusQueryResponseDataResultEntry[] result { get; set; } = Array.Empty(); - } - - public class PrometheusQueryResponseDataResultEntry - { - public ResultEntryMetric metric { get; set; } = new(); - public object[] value { get; set; } = Array.Empty(); - public object[][] values { get; set; } = Array.Empty(); - } - - public class ResultEntryMetric - { - public string __name__ { get; set; } = string.Empty; - public string instance { get; set; } = string.Empty; - public string job { get; set; } = string.Empty; - } - - public class PrometheusAllNamesResponse - { - public string status { get; set; } = string.Empty; - public string[] data { get; set; } = Array.Empty(); - } -} diff --git a/DistTestCore/Metrics/PrometheusContainerRecipe.cs b/DistTestCore/Metrics/PrometheusContainerRecipe.cs deleted file mode 100644 index 6228712..0000000 --- a/DistTestCore/Metrics/PrometheusContainerRecipe.cs +++ /dev/null @@ -1,18 +0,0 @@ -using KubernetesWorkflow; - -namespace DistTestCore.Metrics -{ - public class PrometheusContainerRecipe : DefaultContainerRecipe - { - public override string AppName => "prometheus"; - public override string Image => "codexstorage/dist-tests-prometheus:latest"; - - protected override void InitializeRecipe(StartupConfig startupConfig) - { - var config = startupConfig.Get(); - - AddExposedPortAndVar("PROM_PORT"); - AddEnvVar("PROM_CONFIG", config.PrometheusConfigBase64); - } - } -} diff --git a/DistTestCore/Metrics/PrometheusStartupConfig.cs b/DistTestCore/Metrics/PrometheusStartupConfig.cs deleted file mode 100644 index 7bf7fe6..0000000 --- a/DistTestCore/Metrics/PrometheusStartupConfig.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace DistTestCore.Metrics -{ - public class PrometheusStartupConfig - { - public PrometheusStartupConfig(string prometheusConfigBase64) - { - PrometheusConfigBase64 = prometheusConfigBase64; - } - - public string PrometheusConfigBase64 { get; } - } -} diff --git a/DistTestCore/PrometheusStarter.cs b/DistTestCore/PrometheusStarter.cs index f19978c..bfba12b 100644 --- a/DistTestCore/PrometheusStarter.cs +++ b/DistTestCore/PrometheusStarter.cs @@ -1,6 +1,4 @@ -using DistTestCore.Codex; -using DistTestCore.Metrics; -using KubernetesWorkflow; +using KubernetesWorkflow; using System.Text; namespace DistTestCore @@ -14,39 +12,40 @@ namespace DistTestCore public RunningContainers CollectMetricsFor(RunningContainers[] containers) { - LogStart($"Starting metrics server for {containers.Describe()}"); - var startupConfig = new StartupConfig(); - startupConfig.Add(new PrometheusStartupConfig(GeneratePrometheusConfig(containers.Containers()))); + //LogStart($"Starting metrics server for {containers.Describe()}"); + //var startupConfig = new StartupConfig(); + //startupConfig.Add(new PrometheusStartupConfig(GeneratePrometheusConfig(containers.Containers()))); - var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - var runningContainers = workflow.Start(1, Location.Unspecified, new PrometheusContainerRecipe(), startupConfig); - if (runningContainers.Containers.Length != 1) throw new InvalidOperationException("Expected only 1 Prometheus container to be created."); + //var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); + //var runningContainers = workflow.Start(1, Location.Unspecified, new PrometheusContainerRecipe(), startupConfig); + //if (runningContainers.Containers.Length != 1) throw new InvalidOperationException("Expected only 1 Prometheus container to be created."); - return runningContainers; + //return runningContainers; + return null!; } - private string GeneratePrometheusConfig(RunningContainer[] nodes) - { - var config = ""; - config += "global:\n"; - config += " scrape_interval: 10s\n"; - config += " scrape_timeout: 10s\n"; - config += "\n"; - config += "scrape_configs:\n"; - config += " - job_name: services\n"; - config += " metrics_path: /metrics\n"; - config += " static_configs:\n"; - config += " - targets:\n"; + //private string GeneratePrometheusConfig(RunningContainer[] nodes) + //{ + // var config = ""; + // config += "global:\n"; + // config += " scrape_interval: 10s\n"; + // config += " scrape_timeout: 10s\n"; + // config += "\n"; + // config += "scrape_configs:\n"; + // config += " - job_name: services\n"; + // config += " metrics_path: /metrics\n"; + // config += " static_configs:\n"; + // config += " - targets:\n"; - foreach (var node in nodes) - { - var ip = node.Pod.PodInfo.Ip; - var port = node.Recipe.GetPortByTag(CodexContainerRecipe.MetricsPortTag).Number; - config += $" - '{ip}:{port}'\n"; - } + // foreach (var node in nodes) + // { + // var ip = node.Pod.PodInfo.Ip; + // var port = node.Recipe.GetPortByTag(CodexContainerRecipe.MetricsPortTag).Number; + // config += $" - '{ip}:{port}'\n"; + // } - var bytes = Encoding.ASCII.GetBytes(config); - return Convert.ToBase64String(bytes); - } + // var bytes = Encoding.ASCII.GetBytes(config); + // return Convert.ToBase64String(bytes); + //} } } diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 0a364c8..eeb65c6 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -1,7 +1,4 @@ -using DistTestCore.Codex; -using DistTestCore.Logs; -using DistTestCore.Marketplace; -using DistTestCore.Metrics; +using DistTestCore.Logs; using FileUtils; using KubernetesWorkflow; using Logging; @@ -22,12 +19,12 @@ namespace DistTestCore WorkflowCreator = new WorkflowCreator(log, configuration.GetK8sConfiguration(timeSet), testNamespace); FileManager = new FileManager(Log, configuration.GetFileManagerFolder()); - CodexStarter = new CodexStarter(this); + //CodexStarter = new CodexStarter(this); PrometheusStarter = new PrometheusStarter(this); GrafanaStarter = new GrafanaStarter(this); - GethStarter = new GethStarter(this); + //GethStarter = new GethStarter(this); testStart = DateTime.UtcNow; - CodexVersion = null; + //CodexVersion = null; Log.WriteLogTag(); } @@ -37,15 +34,15 @@ namespace DistTestCore public ITimeSet TimeSet { get; } public WorkflowCreator WorkflowCreator { get; } public FileManager FileManager { get; } - public CodexStarter CodexStarter { get; } + //public CodexStarter CodexStarter { get; } public PrometheusStarter PrometheusStarter { get; } public GrafanaStarter GrafanaStarter { get; } - public GethStarter GethStarter { get; } - public CodexDebugVersionResponse? CodexVersion { get; private set; } + //public GethStarter GethStarter { get; } + //public CodexDebugVersionResponse? CodexVersion { get; private set; } public void DeleteAllResources() { - CodexStarter.DeleteAllResources(); + //CodexStarter.DeleteAllResources(); FileManager.DeleteAllTestFiles(); } @@ -56,7 +53,7 @@ namespace DistTestCore var handler = new LogDownloadHandler(container, description, subFile); Log.Log($"Downloading logs for {description} to file '{subFile.FullFilename}'"); - CodexStarter.DownloadLog(container, handler, tailLines); + //CodexStarter.DownloadLog(container, handler, tailLines); return new DownloadedLog(subFile, description); } @@ -67,28 +64,30 @@ namespace DistTestCore return Time.FormatDuration(testDuration); } - public void SetCodexVersion(CodexDebugVersionResponse version) - { - if (CodexVersion == null) CodexVersion = version; - } + //public void SetCodexVersion(CodexDebugVersionResponse version) + //{ + // if (CodexVersion == null) CodexVersion = version; + //} public ApplicationIds GetApplicationIds() { - return new ApplicationIds( - codexId: GetCodexId(), - gethId: new GethContainerRecipe().Image, - prometheusId: new PrometheusContainerRecipe().Image, - codexContractsId: new CodexContractsContainerRecipe().Image, - grafanaId: new GrafanaContainerRecipe().Image - ); + //return new ApplicationIds( + // codexId: GetCodexId(), + // gethId: new GethContainerRecipe().Image, + // prometheusId: new PrometheusContainerRecipe().Image, + // codexContractsId: new CodexContractsContainerRecipe().Image, + // grafanaId: new GrafanaContainerRecipe().Image + //); + return null!; } private string GetCodexId() { - var v = CodexVersion; - if (v == null) return new CodexContainerRecipe().Image; - if (v.version != "untagged build") return v.version; - return v.revision; + return ""; + //var v = CodexVersion; + //if (v == null) return new CodexContainerRecipe().Image; + //if (v.version != "untagged build") return v.version; + //return v.revision; } } } diff --git a/Tests/BasicTests/ContinuousSubstitute.cs b/Tests/BasicTests/ContinuousSubstitute.cs deleted file mode 100644 index 6f4ba16..0000000 --- a/Tests/BasicTests/ContinuousSubstitute.cs +++ /dev/null @@ -1,252 +0,0 @@ -using DistTestCore; -using NUnit.Framework; -using Utils; - -namespace Tests.BasicTests -{ - [Ignore("Used for debugging continuous tests")] - [TestFixture] - public class ContinuousSubstitute : AutoBootstrapDistTest - { - [Test] - public void ContinuousTestSubstitute() - { - var group = SetupCodexNodes(5, o => o - .EnableMetrics() - .EnableMarketplace(100000.TestTokens(), 0.Eth(), isValidator: true) - .WithBlockTTL(TimeSpan.FromMinutes(2)) - .WithBlockMaintenanceInterval(TimeSpan.FromMinutes(2)) - .WithBlockMaintenanceNumber(10000) - .WithBlockTTL(TimeSpan.FromMinutes(2)) - .WithStorageQuota(1.GB())); - - var nodes = group.Cast().ToArray(); - - foreach (var node in nodes) - { - node.Marketplace.MakeStorageAvailable( - size: 500.MB(), - minPricePerBytePerSecond: 1.TestTokens(), - maxCollateral: 1024.TestTokens(), - maxDuration: TimeSpan.FromMinutes(5)); - } - - var endTime = DateTime.UtcNow + TimeSpan.FromHours(10); - while (DateTime.UtcNow < endTime) - { - var allNodes = nodes.ToList(); - var primary = allNodes.PickOneRandom(); - var secondary = allNodes.PickOneRandom(); - - Log("Run Test"); - PerformTest(primary, secondary); - - Thread.Sleep(TimeSpan.FromSeconds(5)); - } - } - - [Test] - public void PeerTest() - { - var group = SetupCodexNodes(5, o => o - .EnableMetrics() - .EnableMarketplace(100000.TestTokens(), 0.Eth(), isValidator: true) - .WithBlockTTL(TimeSpan.FromMinutes(2)) - .WithBlockMaintenanceInterval(TimeSpan.FromMinutes(2)) - .WithBlockMaintenanceNumber(10000) - .WithBlockTTL(TimeSpan.FromMinutes(2)) - .WithStorageQuota(1.GB())); - - var nodes = group.Cast().ToArray(); - - var checkTime = DateTime.UtcNow + TimeSpan.FromMinutes(1); - var endTime = DateTime.UtcNow + TimeSpan.FromHours(10); - while (DateTime.UtcNow < endTime) - { - CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); - CheckRoutingTables(GetAllOnlineCodexNodes()); - - var node = RandomUtils.PickOneRandom(nodes.ToList()); - var file = GenerateTestFile(50.MB()); - node.UploadFile(file); - - Thread.Sleep(20000); - } - } - - private void CheckRoutingTables(IEnumerable nodes) - { - var all = nodes.ToArray(); - var allIds = all.Select(n => n.GetDebugInfo().table.localNode.nodeId).ToArray(); - - var errors = all.Select(n => AreAllPresent(n, allIds)).Where(s => !string.IsNullOrEmpty(s)).ToArray(); - - if (errors.Any()) - { - Assert.Fail(string.Join(Environment.NewLine, errors)); - } - } - - private string AreAllPresent(IOnlineCodexNode n, string[] allIds) - { - var info = n.GetDebugInfo(); - var known = info.table.nodes.Select(n => n.nodeId).ToArray(); - var expected = allIds.Where(i => i != info.table.localNode.nodeId).ToArray(); - - if (!expected.All(ex => known.Contains(ex))) - { - return $"Not all of '{string.Join(",", expected)}' were present in routing table: '{string.Join(",", known)}'"; - } - - return string.Empty; - } - - private ByteSize fileSize = 80.MB(); - - private void PerformTest(IOnlineCodexNode primary, IOnlineCodexNode secondary) - { - ScopedTestFiles(() => - { - var testFile = GenerateTestFile(fileSize); - - var contentId = primary.UploadFile(testFile); - - var downloadedFile = secondary.DownloadContent(contentId); - - testFile.AssertIsEqual(downloadedFile); - }); - } - - [Test] - public void HoldMyBeerTest() - { - var blockExpirationTime = TimeSpan.FromMinutes(3); - var group = SetupCodexNodes(3, o => o - .EnableMetrics() - .WithBlockTTL(blockExpirationTime) - .WithBlockMaintenanceInterval(TimeSpan.FromMinutes(2)) - .WithBlockMaintenanceNumber(10000) - .WithStorageQuota(2000.MB())); - - var nodes = group.Cast().ToArray(); - - var endTime = DateTime.UtcNow + TimeSpan.FromHours(24); - - var filesize = 80.MB(); - double codexDefaultBlockSize = 31 * 64 * 33; - var numberOfBlocks = Convert.ToInt64(Math.Ceiling(filesize.SizeInBytes / codexDefaultBlockSize)); - var sizeInBytes = filesize.SizeInBytes; - Assert.That(numberOfBlocks, Is.EqualTo(1282)); - - var startTime = DateTime.UtcNow; - var successfulUploads = 0; - var successfulDownloads = 0; - - while (DateTime.UtcNow < endTime) - { - foreach (var node in nodes) - { - try - { - Thread.Sleep(TimeSpan.FromSeconds(5)); - - ScopedTestFiles(() => - { - var uploadStartTime = DateTime.UtcNow; - var file = GenerateTestFile(filesize); - var cid = node.UploadFile(file); - - var cidTag = cid.Id.Substring(cid.Id.Length - 6); - Measure("upload-log-asserts", () => - { - var uploadLog = node.DownloadLog(tailLines: 50000); - - var storeLines = uploadLog.FindLinesThatContain("Stored data", "topics=\"codex node\""); - uploadLog.DeleteFile(); - - var storeLine = GetLineForCidTag(storeLines, cidTag); - AssertStoreLineContains(storeLine, numberOfBlocks, sizeInBytes); - }); - successfulUploads++; - - var uploadTimeTaken = DateTime.UtcNow - uploadStartTime; - if (uploadTimeTaken >= blockExpirationTime.Subtract(TimeSpan.FromSeconds(10))) - { - Assert.Fail("Upload took too long. Blocks already expired."); - } - - var dl = node.DownloadContent(cid); - file.AssertIsEqual(dl); - - Measure("download-log-asserts", () => - { - var downloadLog = node.DownloadLog(tailLines: 50000); - - var sentLines = downloadLog.FindLinesThatContain("Sent bytes", "topics=\"codex restapi\""); - downloadLog.DeleteFile(); - - var sentLine = GetLineForCidTag(sentLines, cidTag); - AssertSentLineContains(sentLine, sizeInBytes); - }); - successfulDownloads++; - }); - } - catch - { - var testDuration = DateTime.UtcNow - startTime; - Log("Test failed. Delaying shut-down by 30 seconds to collect metrics."); - Log($"Test failed after {Time.FormatDuration(testDuration)} and {successfulUploads} successful uploads and {successfulDownloads} successful downloads"); - Thread.Sleep(TimeSpan.FromSeconds(30)); - throw; - } - } - - Thread.Sleep(TimeSpan.FromSeconds(5)); - } - } - - private void AssertSentLineContains(string sentLine, long sizeInBytes) - { - var tag = "bytes="; - var token = sentLine.Substring(sentLine.IndexOf(tag) + tag.Length); - var bytes = Convert.ToInt64(token); - Assert.AreEqual(sizeInBytes, bytes, $"Sent bytes: Number of bytes incorrect. Line: '{sentLine}'"); - } - - private void AssertStoreLineContains(string storeLine, long numberOfBlocks, long sizeInBytes) - { - var tokens = storeLine.Split(" "); - - var blocksToken = GetToken(tokens, "blocks="); - var sizeToken = GetToken(tokens, "size="); - if (blocksToken == null) Assert.Fail("blockToken not found in " + storeLine); - if (sizeToken == null) Assert.Fail("sizeToken not found in " + storeLine); - - var blocks = Convert.ToInt64(blocksToken); - var size = Convert.ToInt64(sizeToken?.Replace("'NByte", "")); - - var lineLog = $" Line: '{storeLine}'"; - Assert.AreEqual(numberOfBlocks, blocks, "Stored data: Number of blocks incorrect." + lineLog); - Assert.AreEqual(sizeInBytes, size, "Stored data: Number of blocks incorrect." + lineLog); - } - - private string GetLineForCidTag(string[] lines, string cidTag) - { - var result = lines.SingleOrDefault(l => l.Contains(cidTag)); - if (result == null) - { - Assert.Fail($"Failed to find '{cidTag}' in lines: '{string.Join(",", lines)}'"); - throw new Exception(); - } - - return result; - } - - private string? GetToken(string[] tokens, string tag) - { - var token = tokens.SingleOrDefault(t => t.StartsWith(tag)); - if (token == null) return null; - return token.Substring(tag.Length); - } - } -} diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs deleted file mode 100644 index 76b37ab..0000000 --- a/Tests/BasicTests/ExampleTests.cs +++ /dev/null @@ -1,86 +0,0 @@ -using DistTestCore; -using NUnit.Framework; -using Utils; - -namespace Tests.BasicTests -{ - [TestFixture] - public class ExampleTests : DistTest - { - [Test] - public void CodexLogExample() - { - var primary = SetupCodexNodes(2)[0]; - - primary.UploadFile(GenerateTestFile(5.MB())); - - var log = primary.DownloadLog(); - - log.AssertLogContains("Uploaded file"); - } - - [Test] - public void TwoMetricsExample() - { - var group = SetupCodexNodes(2, s => s.EnableMetrics()); - var group2 = SetupCodexNodes(2, s => s.EnableMetrics()); - - var primary = group[0]; - var secondary = group[1]; - var primary2 = group2[0]; - var secondary2 = group2[1]; - - primary.ConnectToPeer(secondary); - primary2.ConnectToPeer(secondary2); - - Thread.Sleep(TimeSpan.FromMinutes(2)); - - primary.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1)); - primary2.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1)); - } - - [Test] - public void MarketplaceExample() - { - var sellerInitialBalance = 234.TestTokens(); - var buyerInitialBalance = 1000.TestTokens(); - var fileSize = 10.MB(); - - var seller = SetupCodexNode(s => s - .WithStorageQuota(11.GB()) - .EnableMarketplace(sellerInitialBalance)); - - seller.Marketplace.AssertThatBalance(Is.EqualTo(sellerInitialBalance)); - seller.Marketplace.MakeStorageAvailable( - size: 10.GB(), - minPricePerBytePerSecond: 1.TestTokens(), - maxCollateral: 20.TestTokens(), - maxDuration: TimeSpan.FromMinutes(3)); - - var testFile = GenerateTestFile(fileSize); - - var buyer = SetupCodexNode(s => s - .WithBootstrapNode(seller) - .EnableMarketplace(buyerInitialBalance)); - - buyer.Marketplace.AssertThatBalance(Is.EqualTo(buyerInitialBalance)); - - var contentId = buyer.UploadFile(testFile); - var purchaseContract = buyer.Marketplace.RequestStorage(contentId, - pricePerSlotPerSecond: 2.TestTokens(), - requiredCollateral: 10.TestTokens(), - minRequiredNumberOfNodes: 1, - proofProbability: 5, - duration: TimeSpan.FromMinutes(1)); - - purchaseContract.WaitForStorageContractStarted(fileSize); - - seller.Marketplace.AssertThatBalance(Is.LessThan(sellerInitialBalance), "Collateral was not placed."); - - purchaseContract.WaitForStorageContractFinished(); - - seller.Marketplace.AssertThatBalance(Is.GreaterThan(sellerInitialBalance), "Seller was not paid for storage."); - buyer.Marketplace.AssertThatBalance(Is.LessThan(buyerInitialBalance), "Buyer was not charged for storage."); - } - } -} diff --git a/Tests/BasicTests/NetworkIsolationTest.cs b/Tests/BasicTests/NetworkIsolationTest.cs deleted file mode 100644 index e1c6e7b..0000000 --- a/Tests/BasicTests/NetworkIsolationTest.cs +++ /dev/null @@ -1,46 +0,0 @@ -using DistTestCore; -using NUnit.Framework; -using Utils; - -namespace Tests.BasicTests -{ - // Warning! - // This is a test to check network-isolation in the test-infrastructure. - // It requires parallelism(2) or greater to run. - [TestFixture] - [Ignore("Disabled until a solution is implemented.")] - public class NetworkIsolationTest : DistTest - { - private IOnlineCodexNode? node = null; - - [Test] - public void SetUpANodeAndWait() - { - node = SetupCodexNode(); - - Time.WaitUntil(() => node == null, TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(5)); - } - - [Test] - public void ForeignNodeConnects() - { - var myNode = SetupCodexNode(); - - Time.WaitUntil(() => node != null, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); - - try - { - myNode.ConnectToPeer(node!); - } - catch - { - // Good! This connection should be prohibited by the network isolation policy. - node = null; - return; - } - - Assert.Fail("Connection could be established between two Codex nodes running in different namespaces. " + - "This may cause cross-test interference. Network isolation policy should be applied. Test infra failure."); - } - } -} diff --git a/Tests/BasicTests/OneClientTests.cs b/Tests/BasicTests/OneClientTests.cs deleted file mode 100644 index a31be3c..0000000 --- a/Tests/BasicTests/OneClientTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using DistTestCore; -using NUnit.Framework; -using Utils; - -namespace Tests.BasicTests -{ - [TestFixture] - public class OneClientTests : DistTest - { - [Test] - public void OneClientTest() - { - var primary = SetupCodexNode(); - - PerformOneClientTest(primary); - } - - [Test] - public void RestartTest() - { - var primary = SetupCodexNode(); - - var setup = primary.BringOffline(); - - primary = BringOnline(setup)[0]; - - PerformOneClientTest(primary); - } - - private void PerformOneClientTest(IOnlineCodexNode primary) - { - var testFile = GenerateTestFile(1.MB()); - - var contentId = primary.UploadFile(testFile); - - var downloadedFile = primary.DownloadContent(contentId); - - testFile.AssertIsEqual(downloadedFile); - } - } -} diff --git a/Tests/BasicTests/ThreeClientTest.cs b/Tests/BasicTests/ThreeClientTest.cs deleted file mode 100644 index c857e35..0000000 --- a/Tests/BasicTests/ThreeClientTest.cs +++ /dev/null @@ -1,25 +0,0 @@ -using DistTestCore; -using NUnit.Framework; -using Utils; - -namespace Tests.BasicTests -{ - [TestFixture] - public class ThreeClientTest : AutoBootstrapDistTest - { - [Test] - public void ThreeClient() - { - var primary = SetupCodexNode(); - var secondary = SetupCodexNode(); - - var testFile = GenerateTestFile(10.MB()); - - var contentId = primary.UploadFile(testFile); - - var downloadedFile = secondary.DownloadContent(contentId); - - testFile.AssertIsEqual(downloadedFile); - } - } -} diff --git a/Tests/BasicTests/TwoClientTests.cs b/Tests/BasicTests/TwoClientTests.cs index 4bebc20..ed16790 100644 --- a/Tests/BasicTests/TwoClientTests.cs +++ b/Tests/BasicTests/TwoClientTests.cs @@ -2,6 +2,7 @@ using KubernetesWorkflow; using NUnit.Framework; using Utils; +using CodexPlugin; namespace Tests.BasicTests { @@ -11,7 +12,7 @@ namespace Tests.BasicTests [Test] public void TwoClientTest() { - var group = SetupCodexNodes(2); + var group = this.SetupCodexNodes(2); var primary = group[0]; var secondary = group[1]; @@ -22,8 +23,8 @@ namespace Tests.BasicTests [Test] public void TwoClientsTwoLocationsTest() { - var primary = SetupCodexNode(s => s.At(Location.One)); - var secondary = SetupCodexNode(s => s.At(Location.Two)); + var primary = this.SetupCodexNode(s => s.At(Location.One)); + var secondary = this.SetupCodexNode(s => s.At(Location.Two)); PerformTwoClientTest(primary, secondary); } diff --git a/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs b/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs deleted file mode 100644 index 9fb5630..0000000 --- a/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -using DistTestCore; -using NUnit.Framework; -using Utils; - -namespace Tests.DownloadConnectivityTests -{ - [TestFixture] - public class FullyConnectedDownloadTests : AutoBootstrapDistTest - { - [Test] - public void MetricsDoesNotInterfereWithPeerDownload() - { - SetupCodexNodes(2, s => s.EnableMetrics()); - - AssertAllNodesConnected(); - } - - [Test] - public void MarketplaceDoesNotInterfereWithPeerDownload() - { - SetupCodexNodes(2, s => s.EnableMetrics().EnableMarketplace(1000.TestTokens())); - - AssertAllNodesConnected(); - } - - [Test] - [Combinatorial] - public void FullyConnectedDownloadTest( - [Values(3, 5)] int numberOfNodes, - [Values(10, 80)] int sizeMBs) - { - SetupCodexNodes(numberOfNodes); - - AssertAllNodesConnected(sizeMBs); - } - - private void AssertAllNodesConnected(int sizeMBs = 10) - { - CreatePeerDownloadTestHelpers().AssertFullDownloadInterconnectivity(GetAllOnlineCodexNodes(), sizeMBs.MB()); - } - } -} diff --git a/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs b/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs deleted file mode 100644 index 8cd32a2..0000000 --- a/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using DistTestCore; -using NUnit.Framework; - -namespace Tests.PeerDiscoveryTests -{ - [TestFixture] - public class LayeredDiscoveryTests : DistTest - { - [Test] - public void TwoLayersTest() - { - var root = SetupCodexNode(); - var l1Source = SetupCodexNode(s => s.WithBootstrapNode(root)); - var l1Node = SetupCodexNode(s => s.WithBootstrapNode(root)); - var l2Target = SetupCodexNode(s => s.WithBootstrapNode(l1Node)); - - AssertAllNodesConnected(); - } - - [Test] - public void ThreeLayersTest() - { - var root = SetupCodexNode(); - var l1Source = SetupCodexNode(s => s.WithBootstrapNode(root)); - var l1Node = SetupCodexNode(s => s.WithBootstrapNode(root)); - var l2Node = SetupCodexNode(s => s.WithBootstrapNode(l1Node)); - var l3Target = SetupCodexNode(s => s.WithBootstrapNode(l2Node)); - - AssertAllNodesConnected(); - } - - [TestCase(3)] - [TestCase(5)] - [TestCase(10)] - [TestCase(20)] - public void NodeChainTest(int chainLength) - { - var node = SetupCodexNode(); - for (var i = 1; i < chainLength; i++) - { - node = SetupCodexNode(s => s.WithBootstrapNode(node)); - } - - AssertAllNodesConnected(); - } - - private void AssertAllNodesConnected() - { - CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); - } - } -} diff --git a/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs b/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs deleted file mode 100644 index fcb44fd..0000000 --- a/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -using DistTestCore; -using NUnit.Framework; - -namespace Tests.PeerDiscoveryTests -{ - [TestFixture] - public class PeerDiscoveryTests : AutoBootstrapDistTest - { - [Test] - public void CanReportUnknownPeerId() - { - var unknownId = "16Uiu2HAkv2CHWpff3dj5iuVNERAp8AGKGNgpGjPexJZHSqUstfsK"; - var node = SetupCodexNode(); - - var result = node.GetDebugPeer(unknownId); - Assert.That(result.IsPeerFound, Is.False); - } - - [Test] - public void MetricsDoesNotInterfereWithPeerDiscovery() - { - SetupCodexNodes(2, s => s.EnableMetrics()); - - AssertAllNodesConnected(); - } - - [Test] - public void MarketplaceDoesNotInterfereWithPeerDiscovery() - { - SetupCodexNodes(2, s => s.EnableMarketplace(1000.TestTokens())); - - AssertAllNodesConnected(); - } - - [TestCase(2)] - [TestCase(3)] - [TestCase(10)] - [TestCase(20)] - public void VariableNodes(int number) - { - SetupCodexNodes(number); - - AssertAllNodesConnected(); - } - - private void AssertAllNodesConnected() - { - CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); - } - } -} diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index ab5558b..16dbb66 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index b080268..59b290c 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -5,8 +5,6 @@ VisualStudioVersion = 17.4.33213.308 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{57F57B85-A537-4D3A-B7AE-B72C66B74AAB}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestsLong", "LongTests\TestsLong.csproj", "{AFCE270E-F844-4A7C-9006-69AE622BB1F4}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistTestCore", "DistTestCore\DistTestCore.csproj", "{47F31305-6E68-4827-8E39-7B41DAA1CE7A}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KubernetesWorkflow", "KubernetesWorkflow\KubernetesWorkflow.csproj", "{359123AA-3D9B-4442-80F4-19E32E3EC9EA}" @@ -27,6 +25,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDownloader", "Codex EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileUtils", "FileUtils\FileUtils.csproj", "{ECC954DA-8D4E-49EE-83AD-80085A43DEEB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexPlugin", "CodexPlugin\CodexPlugin.csproj", "{DE4E802C-288C-45C4-84B6-8A5A6A96EF49}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -37,10 +37,6 @@ Global {57F57B85-A537-4D3A-B7AE-B72C66B74AAB}.Debug|Any CPU.Build.0 = Debug|Any CPU {57F57B85-A537-4D3A-B7AE-B72C66B74AAB}.Release|Any CPU.ActiveCfg = Release|Any CPU {57F57B85-A537-4D3A-B7AE-B72C66B74AAB}.Release|Any CPU.Build.0 = Release|Any CPU - {AFCE270E-F844-4A7C-9006-69AE622BB1F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AFCE270E-F844-4A7C-9006-69AE622BB1F4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AFCE270E-F844-4A7C-9006-69AE622BB1F4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AFCE270E-F844-4A7C-9006-69AE622BB1F4}.Release|Any CPU.Build.0 = Release|Any CPU {47F31305-6E68-4827-8E39-7B41DAA1CE7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {47F31305-6E68-4827-8E39-7B41DAA1CE7A}.Debug|Any CPU.Build.0 = Debug|Any CPU {47F31305-6E68-4827-8E39-7B41DAA1CE7A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -81,6 +77,10 @@ Global {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Debug|Any CPU.Build.0 = Debug|Any CPU {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Release|Any CPU.ActiveCfg = Release|Any CPU {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Release|Any CPU.Build.0 = Release|Any CPU + {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 48dda1735cc19b2446f5d4ce9859731b5aa43351 Mon Sep 17 00:00:00 2001 From: ThatBen Date: Mon, 11 Sep 2023 16:57:57 +0200 Subject: [PATCH 03/51] Good progress --- CodexPlugin/CodexAccess.cs | 4 +- CodexPlugin/CodexNodeFactory.cs | 14 +-- CodexPlugin/CodexNodeGroup.cs | 29 +++-- CodexPlugin/CodexStarter.cs | 129 ++++++++++++---------- CodexPlugin/DistTestExtensions.cs | 12 +- CodexPlugin/OnlineCodexNode.cs | 7 +- CodexPlugin/Plugin.cs | 40 +++++++ DistTestCore/Configuration.cs | 66 ----------- DistTestCore/Http.cs | 6 +- DistTestCore/PluginManager.cs | 64 +++++++++++ DistTestCore/TestLifecycle.cs | 8 ++ KubernetesWorkflow/RunnerLocationUtils.cs | 55 +++++++++ KubernetesWorkflow/RunningContainers.cs | 13 +++ KubernetesWorkflow/StartupConfig.cs | 1 + KubernetesWorkflow/StartupWorkflow.cs | 31 ++++-- KubernetesWorkflow/WorkflowCreator.cs | 2 +- Logging/BaseLog.cs | 13 ++- Logging/Stopwatch.cs | 16 +-- Tests/Tests.csproj | 1 - 19 files changed, 332 insertions(+), 179 deletions(-) create mode 100644 CodexPlugin/Plugin.cs create mode 100644 DistTestCore/PluginManager.cs create mode 100644 KubernetesWorkflow/RunnerLocationUtils.cs diff --git a/CodexPlugin/CodexAccess.cs b/CodexPlugin/CodexAccess.cs index 13d971c..75823c8 100644 --- a/CodexPlugin/CodexAccess.cs +++ b/CodexPlugin/CodexAccess.cs @@ -7,11 +7,11 @@ namespace CodexPlugin { public class CodexAccess : ILogHandler { - private readonly BaseLog log; + private readonly ILog log; private readonly ITimeSet timeSet; private bool hasContainerCrashed; - public CodexAccess(BaseLog log, RunningContainer container, ITimeSet timeSet, Address address) + public CodexAccess(ILog log, RunningContainer container, ITimeSet timeSet, Address address) { this.log = log; Container = container; diff --git a/CodexPlugin/CodexNodeFactory.cs b/CodexPlugin/CodexNodeFactory.cs index 5e3abbd..0394fac 100644 --- a/CodexPlugin/CodexNodeFactory.cs +++ b/CodexPlugin/CodexNodeFactory.cs @@ -3,7 +3,7 @@ namespace CodexPlugin { public interface ICodexNodeFactory { - //OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group); + OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group); } public class CodexNodeFactory : ICodexNodeFactory @@ -19,11 +19,11 @@ namespace CodexPlugin // this.marketplaceAccessFactory = marketplaceAccessFactory; //} - //public OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group) - //{ - // var metricsAccess = metricsAccessFactory.CreateMetricsAccess(access.Container); - // var marketplaceAccess = marketplaceAccessFactory.CreateMarketplaceAccess(access); - // return new OnlineCodexNode(lifecycle, access, group, metricsAccess, marketplaceAccess); - //} + public OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group) + { + //var metricsAccess = metricsAccessFactory.CreateMetricsAccess(access.Container); + //var marketplaceAccess = marketplaceAccessFactory.CreateMarketplaceAccess(access); + return new OnlineCodexNode(/*lifecycle,*/ access, group/*, metricsAccess, marketplaceAccess*/); + } } } diff --git a/CodexPlugin/CodexNodeGroup.cs b/CodexPlugin/CodexNodeGroup.cs index e783c20..bd9980b 100644 --- a/CodexPlugin/CodexNodeGroup.cs +++ b/CodexPlugin/CodexNodeGroup.cs @@ -1,11 +1,13 @@ -using KubernetesWorkflow; +using DistTestCore; +using KubernetesWorkflow; +using Logging; using System.Collections; namespace CodexPlugin { public interface ICodexNodeGroup : IEnumerable { - ICodexSetup BringOffline(); + void BringOffline(); IOnlineCodexNode this[int index] { get; } } @@ -13,12 +15,12 @@ namespace CodexPlugin { //private readonly TestLifecycle lifecycle; - public CodexNodeGroup(/*TestLifecycle lifecycle, */CodexSetup setup, RunningContainers[] containers, ICodexNodeFactory codexNodeFactory) + public CodexNodeGroup(/*TestLifecycle lifecycle, CodexSetup setup,*/ILog log, ITimeSet timeSet, RunningContainers[] containers, ICodexNodeFactory codexNodeFactory) { //this.lifecycle = lifecycle; - Setup = setup; + //Setup = setup; Containers = containers; - Nodes = containers.Containers().Select(c => CreateOnlineCodexNode(c, codexNodeFactory)).ToArray(); + Nodes = containers.Containers().Select(c => CreateOnlineCodexNode(c, log, timeSet, codexNodeFactory)).ToArray(); Version = new CodexDebugVersionResponse(); } @@ -30,20 +32,18 @@ namespace CodexPlugin } } - public ICodexSetup BringOffline() + public void BringOffline() { //lifecycle.CodexStarter.BringOffline(this); - var result = Setup; + //var result = Setup; // Clear everything. Prevent accidental use. - Setup = null!; + //Setup = null!; Nodes = Array.Empty(); Containers = null!; - - return result; } - public CodexSetup Setup { get; private set; } + //public CodexSetup Setup { get; private set; } public RunningContainers[] Containers { get; private set; } public OnlineCodexNode[] Nodes { get; private set; } public CodexDebugVersionResponse Version { get; private set; } @@ -78,11 +78,10 @@ namespace CodexPlugin Version = first; } - private OnlineCodexNode CreateOnlineCodexNode(RunningContainer c, ICodexNodeFactory factory) + private OnlineCodexNode CreateOnlineCodexNode(RunningContainer c, ILog log, ITimeSet timeSet, ICodexNodeFactory factory) { - //var access = new CodexAccess(lifecycle.Log, c, lifecycle.TimeSet, lifecycle.Configuration.GetAddress(c)); - //return factory.CreateOnlineCodexNode(access, this); - return null!; + var access = new CodexAccess(log, c, timeSet, c.Address); + return factory.CreateOnlineCodexNode(access, this); } } } diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index dcd9908..c3ccfb2 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -1,26 +1,32 @@ -using KubernetesWorkflow; +using DistTestCore; +using KubernetesWorkflow; using Logging; namespace CodexPlugin { - public class CodexStarter //: BaseStarter + public class CodexStarter { + private readonly IPluginActions pluginActions; + //public CodexStarter(TestLifecycle lifecycle) // : base(lifecycle) //{ //} - public List RunningGroups { get; } = new List(); + public CodexStarter(IPluginActions pluginActions) + { + this.pluginActions = pluginActions; + } - public ICodexNodeGroup BringOnline(CodexSetup codexSetup) + public RunningContainers[] BringOnline(CodexSetup codexSetup) { //LogSeparator(); //LogStart($"Starting {codexSetup.Describe()}..."); //var gethStartResult = lifecycle.GethStarter.BringOnlineMarketplaceFor(codexSetup); - //var startupConfig = CreateStartupConfig(gethStartResult, codexSetup); + var startupConfig = CreateStartupConfig(/*gethStartResult,*/ codexSetup); - //var containers = StartCodexContainers(startupConfig, codexSetup.NumberOfNodes, codexSetup.Location); + return StartCodexContainers(startupConfig, codexSetup.NumberOfNodes, codexSetup.Location); //var metricAccessFactory = CollectMetrics(codexSetup, containers); @@ -38,7 +44,26 @@ namespace CodexPlugin //LogSeparator(); //return group; - return null!; + } + + public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) + { + //var metricAccessFactory = CollectMetrics(codexSetup, containers); + + var codexNodeFactory = new CodexNodeFactory();// (lifecycle, metricAccessFactory, gethStartResult.MarketplaceAccessFactory); + + return CreateCodexGroup(/*codexSetup,*/ containers, codexNodeFactory); + //lifecycle.SetCodexVersion(group.Version); + + //var nl = Environment.NewLine; + //var podInfos = string.Join(nl, containers.Containers().Select(c => $"Container: '{c.Name}' runs at '{c.Pod.PodInfo.K8SNodeName}'={c.Pod.PodInfo.Ip}")); + //LogEnd($"Started {codexSetup.NumberOfNodes} nodes " + + // $"of image '{containers.Containers().First().Recipe.Image}' " + + // $"and version '{group.Version}'{nl}" + + // podInfos); + //LogSeparator(); + + //return group; } public void BringOffline(CodexNodeGroup group) @@ -50,7 +75,6 @@ namespace CodexPlugin // StopCrashWatcher(c); // workflow.Stop(c); //} - //RunningGroups.Remove(group); //LogEnd("Stopped."); } @@ -58,8 +82,6 @@ namespace CodexPlugin { //var workflow = CreateWorkflow(); //workflow.DeleteTestResources(); - - //RunningGroups.Clear(); } public void DownloadLog(RunningContainer container, ILogHandler logHandler, int? tailLines) @@ -82,52 +104,51 @@ namespace CodexPlugin // return new CodexNodeMetricsAccessFactory(lifecycle, runningContainers); //} - //private StartupConfig CreateStartupConfig(GethStartResult gethStartResult, CodexSetup codexSetup) - //{ - // var startupConfig = new StartupConfig(); - // startupConfig.NameOverride = codexSetup.NameOverride; - // startupConfig.Add(codexSetup); - // startupConfig.Add(gethStartResult); - // return startupConfig; - //} + private StartupConfig CreateStartupConfig(/*GethStartResult gethStartResult, */ CodexSetup codexSetup) + { + var startupConfig = new StartupConfig(); + startupConfig.NameOverride = codexSetup.NameOverride; + startupConfig.CreateCrashWatcher = true; + startupConfig.Add(codexSetup); + //startupConfig.Add(gethStartResult); + return startupConfig; + } - //private RunningContainers[] StartCodexContainers(StartupConfig startupConfig, int numberOfNodes, Location location) - //{ - // var result = new List(); - // var recipe = new CodexContainerRecipe(); - // for (var i = 0; i < numberOfNodes; i++) - // { - // var workflow = CreateWorkflow(); - // var rc = workflow.Start(1, location, recipe, startupConfig); - // CreateCrashWatcher(workflow, rc); - // result.Add(rc); - // } - // return result.ToArray(); - //} + private RunningContainers[] StartCodexContainers(StartupConfig startupConfig, int numberOfNodes, Location location) + { + var result = new List(); + var recipe = new CodexContainerRecipe(); + for (var i = 0; i < numberOfNodes; i++) + { + var workflow = pluginActions.CreateWorkflow(); + result.Add(workflow.Start(1, location, recipe, startupConfig)); + } + return result.ToArray(); + } - //private CodexNodeGroup CreateCodexGroup(CodexSetup codexSetup, RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) - //{ - // var group = new CodexNodeGroup(lifecycle, codexSetup, runningContainers, codexNodeFactory); - // RunningGroups.Add(group); + private CodexNodeGroup CreateCodexGroup(/*CodexSetup codexSetup, */RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) + { + var group = new CodexNodeGroup(pluginActions.GetLog(), pluginActions.GetTimeSet(), /*lifecycle, codexSetup,*/ runningContainers, codexNodeFactory); - // try - // { - // Stopwatch.Measure(lifecycle.Log, "EnsureOnline", group.EnsureOnline, debug: true); - // } - // catch - // { - // CodexNodesNotOnline(runningContainers); - // throw; - // } + try + { + Stopwatch.Measure(pluginActions.GetLog(), "EnsureOnline", group.EnsureOnline, debug: true); + } + catch + { + CodexNodesNotOnline(runningContainers); + throw; + } - // return group; - //} + return group; + } - //private void CodexNodesNotOnline(RunningContainers[] runningContainers) - //{ - // Log("Codex nodes failed to start"); - // foreach (var container in runningContainers.Containers()) lifecycle.DownloadLog(container); - //} + private void CodexNodesNotOnline(RunningContainers[] runningContainers) + { + pluginActions.GetLog().Log("Codex nodes failed to start"); + // todo: + //foreach (var container in runningContainers.Containers()) lifecycle.DownloadLog(container); + } //private StartupWorkflow CreateWorkflow() //{ @@ -139,12 +160,6 @@ namespace CodexPlugin // Log("----------------------------------------------------------------------------"); //} - //private void CreateCrashWatcher(StartupWorkflow workflow, RunningContainers rc) - //{ - // var c = rc.Containers.Single(); - // c.CrashWatcher = workflow.CreateCrashWatcher(c); - //} - //private void StopCrashWatcher(RunningContainers containers) //{ // foreach (var c in containers.Containers) diff --git a/CodexPlugin/DistTestExtensions.cs b/CodexPlugin/DistTestExtensions.cs index e699627..ebb1d81 100644 --- a/CodexPlugin/DistTestExtensions.cs +++ b/CodexPlugin/DistTestExtensions.cs @@ -5,24 +5,26 @@ namespace CodexPlugin { public static class DistTestExtensions { - public static RunningContainers StartCodexNodes(this DistTest distTest, int number, Action setup) + public static Plugin Plugin { get; internal set; } = null!; + + public static RunningContainers[] StartCodexNodes(this DistTest distTest, int number, Action setup) { - return null!; + return Plugin.StartCodexNodes(number, setup); } public static ICodexNodeGroup WrapCodexContainers(this DistTest distTest, RunningContainers containers) { - return null!; + return Plugin.WrapCodexContainers(containers); } public static IOnlineCodexNode SetupCodexNode(this DistTest distTest, Action setup) { - return null!; + return Plugin.SetupCodexNode(setup); } public static ICodexNodeGroup SetupCodexNodes(this DistTest distTest, int number) { - return null!; + return Plugin.SetupCodexNodes(number); } } } diff --git a/CodexPlugin/OnlineCodexNode.cs b/CodexPlugin/OnlineCodexNode.cs index 0a6a094..a6bc54b 100644 --- a/CodexPlugin/OnlineCodexNode.cs +++ b/CodexPlugin/OnlineCodexNode.cs @@ -1,6 +1,5 @@ using DistTestCore.Logs; using FileUtils; -using Logging; using NUnit.Framework; using Utils; @@ -18,7 +17,7 @@ namespace CodexPlugin //IMetricsAccess Metrics { get; } //IMarketplaceAccess Marketplace { get; } CodexDebugVersionResponse Version { get; } - ICodexSetup BringOffline(); + void BringOffline(); } public class OnlineCodexNode : IOnlineCodexNode @@ -108,13 +107,13 @@ namespace CodexPlugin return null!; // lifecycle.DownloadLog(CodexAccess.Container, tailLines); } - public ICodexSetup BringOffline() + public void BringOffline() { if (Group.Count() > 1) throw new InvalidOperationException("Codex-nodes that are part of a group cannot be " + "individually shut down. Use 'BringOffline()' on the group object to stop the group. This method is only " + "available for codex-nodes in groups of 1."); - return Group.BringOffline(); + Group.BringOffline(); } public void EnsureOnlineGetVersionResponse() diff --git a/CodexPlugin/Plugin.cs b/CodexPlugin/Plugin.cs new file mode 100644 index 0000000..a014f8e --- /dev/null +++ b/CodexPlugin/Plugin.cs @@ -0,0 +1,40 @@ +using DistTestCore; +using KubernetesWorkflow; + +namespace CodexPlugin +{ + public class Plugin : IProjectPlugin + { + private readonly CodexStarter codexStarter; + + public Plugin(IPluginActions actions) + { + codexStarter = new CodexStarter(actions); + + DistTestExtensions.Plugin = this; + } + + public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) + { + var codexSetup = new CodexSetup(numberOfNodes, CodexLogLevel.Trace); + setup(codexSetup); + return codexStarter.BringOnline(codexSetup); + } + + public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) + { + return codexStarter.WrapCodexContainers(containers); + } + + public IOnlineCodexNode SetupCodexNode(Action setup) + { + return null!; + } + + public ICodexNodeGroup SetupCodexNodes(int number) + { + var rc = StartCodexNodes(1, s => { }); + return WrapCodexContainers(rc); + } + } +} diff --git a/DistTestCore/Configuration.cs b/DistTestCore/Configuration.cs index 86dc238..e8a3856 100644 --- a/DistTestCore/Configuration.cs +++ b/DistTestCore/Configuration.cs @@ -12,7 +12,6 @@ namespace DistTestCore private readonly string dataFilesPath; //private readonly CodexLogLevel codexLogLevel; private readonly string k8sNamespacePrefix; - private static RunnerLocation? runnerLocation = null; public Configuration() { @@ -59,20 +58,6 @@ namespace DistTestCore // return codexLogLevel; //} - public Address GetAddress(RunningContainer container) - { - if (runnerLocation == null) - { - runnerLocation = RunnerLocationUtils.DetermineRunnerLocation(container); - } - - if (runnerLocation == RunnerLocation.InternalToCluster) - { - return container.ClusterInternalAddress; - } - return container.ClusterExternalAddress; - } - private static string GetEnvVarOrDefault(string varName, string defaultValue) { var v = Environment.GetEnvironmentVariable(varName); @@ -88,56 +73,5 @@ namespace DistTestCore } } - public enum RunnerLocation - { - ExternalToCluster, - InternalToCluster, - } - public static class RunnerLocationUtils - { - private static bool alreadyDidThat = false; - - public static RunnerLocation DetermineRunnerLocation(RunningContainer container) - { - // We want to be sure we don't ping more often than strictly necessary. - // If we have already determined the location during this application - // lifetime, don't do it again. - if (alreadyDidThat) throw new Exception("We already did that."); - alreadyDidThat = true; - - if (PingHost(container.Pod.PodInfo.Ip)) - { - return RunnerLocation.InternalToCluster; - } - if (PingHost(Format(container.ClusterExternalAddress))) - { - return RunnerLocation.ExternalToCluster; - } - - throw new Exception("Unable to determine runner location."); - } - - private static string Format(Address host) - { - return host.Host - .Replace("http://", "") - .Replace("https://", ""); - } - - private static bool PingHost(string host) - { - try - { - using var pinger = new Ping(); - PingReply reply = pinger.Send(host); - return reply.Status == IPStatus.Success; - } - catch (PingException) - { - } - - return false; - } - } } diff --git a/DistTestCore/Http.cs b/DistTestCore/Http.cs index 773f9a5..5df53bb 100644 --- a/DistTestCore/Http.cs +++ b/DistTestCore/Http.cs @@ -9,19 +9,19 @@ namespace DistTestCore { public class Http { - private readonly BaseLog log; + private readonly ILog log; private readonly ITimeSet timeSet; private readonly Address address; private readonly string baseUrl; private readonly Action onClientCreated; private readonly string? logAlias; - public Http(BaseLog log, ITimeSet timeSet, Address address, string baseUrl, string? logAlias = null) + public Http(ILog log, ITimeSet timeSet, Address address, string baseUrl, string? logAlias = null) : this(log, timeSet, address, baseUrl, DoNothing, logAlias) { } - public Http(BaseLog log, ITimeSet timeSet, Address address, string baseUrl, Action onClientCreated, string? logAlias = null) + public Http(ILog log, ITimeSet timeSet, Address address, string baseUrl, Action onClientCreated, string? logAlias = null) { this.log = log; this.timeSet = timeSet; diff --git a/DistTestCore/PluginManager.cs b/DistTestCore/PluginManager.cs new file mode 100644 index 0000000..ea21e6d --- /dev/null +++ b/DistTestCore/PluginManager.cs @@ -0,0 +1,64 @@ +using KubernetesWorkflow; +using Logging; + +namespace DistTestCore +{ + public class PluginManager : IPluginActions + { + private readonly BaseLog log; + private readonly Configuration configuration; + private readonly string testNamespace; + private readonly WorkflowCreator workflowCreator; + private readonly ITimeSet timeSet; + private readonly List projectPlugins = new List(); + + public PluginManager(BaseLog log, Configuration configuration, ITimeSet timeSet, string testNamespace) + { + this.log = log; + this.configuration = configuration; + this.timeSet = timeSet; + this.testNamespace = testNamespace; + workflowCreator = new WorkflowCreator(log, configuration.GetK8sConfiguration(timeSet), testNamespace); + } + + public IStartupWorkflow CreateWorkflow() + { + return workflowCreator.CreateWorkflow(); + } + + public ILog GetLog() + { + return log; + } + + public ITimeSet GetTimeSet() + { + return timeSet; + } + + public void InitializeAllPlugins() + { + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + var pluginTypes = assemblies.SelectMany(a => a.GetTypes().Where(t => typeof(IProjectPlugin).IsAssignableFrom(t))).ToArray(); + + foreach (var pluginType in pluginTypes) + { + IPluginActions actions = this; + var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType, args: actions)!; + projectPlugins.Add(plugin); + } + } + } + + public interface IProjectPlugin + { + } + + // probably seggregate this out. + public interface IPluginActions + { + IStartupWorkflow CreateWorkflow(); + ILog GetLog(); + ITimeSet GetTimeSet(); + } +} diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index eeb65c6..be3da02 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -26,6 +26,14 @@ namespace DistTestCore testStart = DateTime.UtcNow; //CodexVersion = null; + // the plugin manager is starting to look like the testlifecycle, that's bad because they are not supposed to be doing the same things: + // pluginmanager should be useful for disttest-deployer-continuoustest, everyone! + // but testlifecycle should be a disttest specific user of the plugin manager. + // disttest requires a hook by which it can keep track of containers created?? (does it?) /namespace used? for the purpose of cleaning up. + + //var pluginManager = new PluginManager(Log, configuration, timeSet, testNamespace); + //pluginManager.InitializeAllPlugins(); + Log.WriteLogTag(); } diff --git a/KubernetesWorkflow/RunnerLocationUtils.cs b/KubernetesWorkflow/RunnerLocationUtils.cs new file mode 100644 index 0000000..05d88c4 --- /dev/null +++ b/KubernetesWorkflow/RunnerLocationUtils.cs @@ -0,0 +1,55 @@ +using System.Net.NetworkInformation; +using Utils; + +namespace KubernetesWorkflow +{ + internal enum RunnerLocation + { + ExternalToCluster, + InternalToCluster, + } + + internal static class RunnerLocationUtils + { + private static RunnerLocation? knownLocation = null; + + internal static RunnerLocation DetermineRunnerLocation(RunningContainer container) + { + if (knownLocation != null) return knownLocation.Value; + + if (PingHost(container.Pod.PodInfo.Ip)) + { + knownLocation = RunnerLocation.InternalToCluster; + } + if (PingHost(Format(container.ClusterExternalAddress))) + { + knownLocation = RunnerLocation.ExternalToCluster; + } + + if (knownLocation == null) throw new Exception("Unable to determine location relative to kubernetes cluster."); + return knownLocation.Value; + } + + private static string Format(Address host) + { + return host.Host + .Replace("http://", "") + .Replace("https://", ""); + } + + private static bool PingHost(string host) + { + try + { + using var pinger = new Ping(); + PingReply reply = pinger.Send(host); + return reply.Status == IPStatus.Success; + } + catch (PingException) + { + } + + return false; + } + } +} diff --git a/KubernetesWorkflow/RunningContainers.cs b/KubernetesWorkflow/RunningContainers.cs index 3f43a37..4b36f95 100644 --- a/KubernetesWorkflow/RunningContainers.cs +++ b/KubernetesWorkflow/RunningContainers.cs @@ -43,6 +43,19 @@ namespace KubernetesWorkflow [JsonIgnore] public CrashWatcher? CrashWatcher { get; set; } + + [JsonIgnore] + public Address Address + { + get + { + if (RunnerLocationUtils.DetermineRunnerLocation(this) == RunnerLocation.InternalToCluster) + { + return ClusterInternalAddress; + } + return ClusterExternalAddress; + } + } } public static class RunningContainersExtensions diff --git a/KubernetesWorkflow/StartupConfig.cs b/KubernetesWorkflow/StartupConfig.cs index 76a96b6..6aa7268 100644 --- a/KubernetesWorkflow/StartupConfig.cs +++ b/KubernetesWorkflow/StartupConfig.cs @@ -5,6 +5,7 @@ private readonly List configs = new List(); public string? NameOverride { get; set; } + public bool CreateCrashWatcher { get; set; } public void Add(object config) { diff --git a/KubernetesWorkflow/StartupWorkflow.cs b/KubernetesWorkflow/StartupWorkflow.cs index 7595be2..5bf5b73 100644 --- a/KubernetesWorkflow/StartupWorkflow.cs +++ b/KubernetesWorkflow/StartupWorkflow.cs @@ -3,7 +3,17 @@ using Utils; namespace KubernetesWorkflow { - public class StartupWorkflow + public interface IStartupWorkflow + { + RunningContainers Start(int numberOfContainers, Location location, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig); + void Stop(RunningContainers runningContainers); + void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines); + string ExecuteCommand(RunningContainer container, string command, params string[] args); + void DeleteAllResources();// !!! delete namespace then!? + void DeleteTestResources(); // !!! do not mention tests. what are we deleting? + } + + public class StartupWorkflow : IStartupWorkflow { private readonly BaseLog log; private readonly WorkflowNumberSource numberSource; @@ -26,18 +36,15 @@ namespace KubernetesWorkflow return K8s(controller => { var recipes = CreateRecipes(numberOfContainers, recipeFactory, startupConfig); - var runningPod = controller.BringOnline(recipes, location); + var containers = CreateContainers(runningPod, recipes, startupConfig); - return new RunningContainers(startupConfig, runningPod, CreateContainers(runningPod, recipes, startupConfig)); + if (startupConfig.CreateCrashWatcher) CreateCrashWatchers(controller, containers); + + return new RunningContainers(startupConfig, runningPod, containers); }); } - public CrashWatcher CreateCrashWatcher(RunningContainer container) - { - return K8s(controller => controller.CreateCrashWatcher(container)); - } - public void Stop(RunningContainers runningContainers) { K8s(controller => @@ -78,6 +85,14 @@ namespace KubernetesWorkflow }); } + private void CreateCrashWatchers(K8sController controller, RunningContainer[] runningContainers) + { + foreach (var container in runningContainers) + { + container.CrashWatcher = controller.CreateCrashWatcher(container); + } + } + private RunningContainer[] CreateContainers(RunningPod runningPod, ContainerRecipe[] recipes, StartupConfig startupConfig) { log.Debug(); diff --git a/KubernetesWorkflow/WorkflowCreator.cs b/KubernetesWorkflow/WorkflowCreator.cs index 51f0177..ae8c85b 100644 --- a/KubernetesWorkflow/WorkflowCreator.cs +++ b/KubernetesWorkflow/WorkflowCreator.cs @@ -19,7 +19,7 @@ namespace KubernetesWorkflow this.testNamespace = testNamespace.ToLowerInvariant(); } - public StartupWorkflow CreateWorkflow() + public IStartupWorkflow CreateWorkflow() { var workflowNumberSource = new WorkflowNumberSource(numberSource.GetNextNumber(), containerNumberSource); diff --git a/Logging/BaseLog.cs b/Logging/BaseLog.cs index d11ecc1..b106b84 100644 --- a/Logging/BaseLog.cs +++ b/Logging/BaseLog.cs @@ -1,8 +1,17 @@ -using Utils; +using System.Diagnostics; +using Utils; namespace Logging { - public abstract class BaseLog + public interface ILog + { + void Log(string message); + void Debug(string message = "", int skipFrames = 0); + void Error(string message); + LogFile CreateSubfile(string ext = "log"); + } + + public abstract class BaseLog : ILog { private readonly NumberSource subfileNumberSource = new NumberSource(0); private readonly bool debug; diff --git a/Logging/Stopwatch.cs b/Logging/Stopwatch.cs index f62f7f6..830b49a 100644 --- a/Logging/Stopwatch.cs +++ b/Logging/Stopwatch.cs @@ -5,25 +5,25 @@ namespace Logging public class Stopwatch { private readonly DateTime start = DateTime.UtcNow; - private readonly BaseLog log; + private readonly ILog log; private readonly string name; private readonly bool debug; - private Stopwatch(BaseLog log, string name, bool debug) + private Stopwatch(ILog log, string name, bool debug) { this.log = log; this.name = name; this.debug = debug; } - public static void Measure(BaseLog log, string name, Action action, bool debug = false) + public static void Measure(ILog log, string name, Action action, bool debug = false) { var sw = Begin(log, name, debug); action(); sw.End(); } - public static T Measure(BaseLog log, string name, Func action, bool debug = false) + public static T Measure(ILog log, string name, Func action, bool debug = false) { var sw = Begin(log, name, debug); var result = action(); @@ -31,22 +31,22 @@ namespace Logging return result; } - public static Stopwatch Begin(BaseLog log) + public static Stopwatch Begin(ILog log) { return Begin(log, ""); } - public static Stopwatch Begin(BaseLog log, string name) + public static Stopwatch Begin(ILog log, string name) { return Begin(log, name, false); } - public static Stopwatch Begin(BaseLog log, bool debug) + public static Stopwatch Begin(ILog log, bool debug) { return Begin(log, "", debug); } - public static Stopwatch Begin(BaseLog log, string name, bool debug) + public static Stopwatch Begin(ILog log, string name, bool debug) { return new Stopwatch(log, name, debug); } diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 16dbb66..ecc4f58 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -14,7 +14,6 @@ - From 0f86642524d32eb85beba18c5322f6f3d15aa470 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 12 Sep 2023 10:31:55 +0200 Subject: [PATCH 04/51] simple test lined up --- CodexPlugin/CodexAccess.cs | 14 +-- CodexPlugin/CodexNodeGroup.cs | 13 +-- CodexPlugin/CodexStarter.cs | 16 +-- CodexPlugin/DistTestExtensions.cs | 2 +- CodexPlugin/Plugin.cs | 21 +++- ContinuousTests/ContinuousTestRunner.cs | 2 +- ContinuousTests/NodeRunner.cs | 2 +- DistTestCore/Configuration.cs | 1 - DistTestCore/DistTest.cs | 55 ++++++++-- DistTestCore/PluginManager.cs | 103 ++++++++++-------- DistTestCore/TestLifecycle.cs | 133 +++++++++++++----------- FileUtils/FileManager.cs | 4 +- FileUtils/TestFile.cs | 4 +- KubernetesWorkflow/Configuration.cs | 4 +- KubernetesWorkflow/CrashWatcher.cs | 4 +- KubernetesWorkflow/K8sController.cs | 56 +++++----- KubernetesWorkflow/StartupWorkflow.cs | 27 +++-- KubernetesWorkflow/WorkflowCreator.cs | 4 +- Logging/BaseLog.cs | 15 +-- Logging/FixtureLog.cs | 4 +- Logging/NullLog.cs | 4 - Logging/StatusLog.cs | 12 +-- Logging/TestLog.cs | 23 ++-- 23 files changed, 281 insertions(+), 242 deletions(-) diff --git a/CodexPlugin/CodexAccess.cs b/CodexPlugin/CodexAccess.cs index 75823c8..842beb7 100644 --- a/CodexPlugin/CodexAccess.cs +++ b/CodexPlugin/CodexAccess.cs @@ -1,29 +1,24 @@ using DistTestCore; using KubernetesWorkflow; -using Logging; using Utils; namespace CodexPlugin { public class CodexAccess : ILogHandler { - private readonly ILog log; - private readonly ITimeSet timeSet; + private readonly IPluginTools tools; private bool hasContainerCrashed; - public CodexAccess(ILog log, RunningContainer container, ITimeSet timeSet, Address address) + public CodexAccess(IPluginTools tools, RunningContainer container) { - this.log = log; + this.tools = tools; Container = container; - this.timeSet = timeSet; - Address = address; hasContainerCrashed = false; if (container.CrashWatcher != null) container.CrashWatcher.Start(this); } public RunningContainer Container { get; } - public Address Address { get; } public CodexDebugResponse GetDebugInfo() { @@ -90,7 +85,7 @@ namespace CodexPlugin private Http Http() { - return new Http(log, timeSet, Address, baseUrl: "/api/codex/v1", CheckContainerCrashed, Container.Name); + return tools.CreateHttp(Container.Address, baseUrl: "/api/codex/v1", CheckContainerCrashed, Container.Name); } private void CheckContainerCrashed(HttpClient client) @@ -100,6 +95,7 @@ namespace CodexPlugin public void Log(Stream crashLog) { + var log = tools.GetLog(); var file = log.CreateSubfile(); log.Log($"Container {Container.Name} has crashed. Downloading crash log to '{file.FullFilename}'..."); diff --git a/CodexPlugin/CodexNodeGroup.cs b/CodexPlugin/CodexNodeGroup.cs index bd9980b..a4c868e 100644 --- a/CodexPlugin/CodexNodeGroup.cs +++ b/CodexPlugin/CodexNodeGroup.cs @@ -13,14 +13,10 @@ namespace CodexPlugin public class CodexNodeGroup : ICodexNodeGroup { - //private readonly TestLifecycle lifecycle; - - public CodexNodeGroup(/*TestLifecycle lifecycle, CodexSetup setup,*/ILog log, ITimeSet timeSet, RunningContainers[] containers, ICodexNodeFactory codexNodeFactory) + public CodexNodeGroup(IPluginTools tools, RunningContainers[] containers, ICodexNodeFactory codexNodeFactory) { - //this.lifecycle = lifecycle; - //Setup = setup; Containers = containers; - Nodes = containers.Containers().Select(c => CreateOnlineCodexNode(c, log, timeSet, codexNodeFactory)).ToArray(); + Nodes = containers.Containers().Select(c => CreateOnlineCodexNode(c, tools, codexNodeFactory)).ToArray(); Version = new CodexDebugVersionResponse(); } @@ -43,7 +39,6 @@ namespace CodexPlugin Containers = null!; } - //public CodexSetup Setup { get; private set; } public RunningContainers[] Containers { get; private set; } public OnlineCodexNode[] Nodes { get; private set; } public CodexDebugVersionResponse Version { get; private set; } @@ -78,9 +73,9 @@ namespace CodexPlugin Version = first; } - private OnlineCodexNode CreateOnlineCodexNode(RunningContainer c, ILog log, ITimeSet timeSet, ICodexNodeFactory factory) + private OnlineCodexNode CreateOnlineCodexNode(RunningContainer c, IPluginTools tools, ICodexNodeFactory factory) { - var access = new CodexAccess(log, c, timeSet, c.Address); + var access = new CodexAccess(tools, c); return factory.CreateOnlineCodexNode(access, this); } } diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index c3ccfb2..bec93c7 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -6,16 +6,16 @@ namespace CodexPlugin { public class CodexStarter { - private readonly IPluginActions pluginActions; + private readonly IPluginTools pluginTools; //public CodexStarter(TestLifecycle lifecycle) // : base(lifecycle) //{ //} - public CodexStarter(IPluginActions pluginActions) + public CodexStarter(IPluginTools pluginActions) { - this.pluginActions = pluginActions; + this.pluginTools = pluginActions; } public RunningContainers[] BringOnline(CodexSetup codexSetup) @@ -120,19 +120,19 @@ namespace CodexPlugin var recipe = new CodexContainerRecipe(); for (var i = 0; i < numberOfNodes; i++) { - var workflow = pluginActions.CreateWorkflow(); + var workflow = pluginTools.CreateWorkflow(); result.Add(workflow.Start(1, location, recipe, startupConfig)); } return result.ToArray(); } - private CodexNodeGroup CreateCodexGroup(/*CodexSetup codexSetup, */RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) + private CodexNodeGroup CreateCodexGroup(RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) { - var group = new CodexNodeGroup(pluginActions.GetLog(), pluginActions.GetTimeSet(), /*lifecycle, codexSetup,*/ runningContainers, codexNodeFactory); + var group = new CodexNodeGroup(pluginTools, runningContainers, codexNodeFactory); try { - Stopwatch.Measure(pluginActions.GetLog(), "EnsureOnline", group.EnsureOnline, debug: true); + Stopwatch.Measure(pluginTools.GetLog(), "EnsureOnline", group.EnsureOnline, debug: true); } catch { @@ -145,7 +145,7 @@ namespace CodexPlugin private void CodexNodesNotOnline(RunningContainers[] runningContainers) { - pluginActions.GetLog().Log("Codex nodes failed to start"); + pluginTools.GetLog().Log("Codex nodes failed to start"); // todo: //foreach (var container in runningContainers.Containers()) lifecycle.DownloadLog(container); } diff --git a/CodexPlugin/DistTestExtensions.cs b/CodexPlugin/DistTestExtensions.cs index ebb1d81..de0362a 100644 --- a/CodexPlugin/DistTestExtensions.cs +++ b/CodexPlugin/DistTestExtensions.cs @@ -12,7 +12,7 @@ namespace CodexPlugin return Plugin.StartCodexNodes(number, setup); } - public static ICodexNodeGroup WrapCodexContainers(this DistTest distTest, RunningContainers containers) + public static ICodexNodeGroup WrapCodexContainers(this DistTest distTest, RunningContainers[] containers) { return Plugin.WrapCodexContainers(containers); } diff --git a/CodexPlugin/Plugin.cs b/CodexPlugin/Plugin.cs index a014f8e..e7c72a0 100644 --- a/CodexPlugin/Plugin.cs +++ b/CodexPlugin/Plugin.cs @@ -1,19 +1,33 @@ using DistTestCore; using KubernetesWorkflow; +using Logging; namespace CodexPlugin { public class Plugin : IProjectPlugin { - private readonly CodexStarter codexStarter; + private CodexStarter codexStarter = null!; - public Plugin(IPluginActions actions) + #region IProjectPlugin Implementation + + public void Announce(ILog log) { - codexStarter = new CodexStarter(actions); + log.Log("hello from codex plugin. codex container info here."); + } + + public void Initialize(IPluginTools tools) + { + codexStarter = new CodexStarter(tools); DistTestExtensions.Plugin = this; } + public void Finalize(ILog log) + { + } + + #endregion + public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) { var codexSetup = new CodexSetup(numberOfNodes, CodexLogLevel.Trace); @@ -36,5 +50,6 @@ namespace CodexPlugin var rc = StartCodexNodes(1, s => { }); return WrapCodexContainers(rc); } + } } diff --git a/ContinuousTests/ContinuousTestRunner.cs b/ContinuousTests/ContinuousTestRunner.cs index fb69e05..cf40a8f 100644 --- a/ContinuousTests/ContinuousTestRunner.cs +++ b/ContinuousTests/ContinuousTestRunner.cs @@ -59,7 +59,7 @@ namespace ContinuousTests log.Log($"Clearing namespace '{test.CustomK8sNamespace}'..."); var lifecycle = k8SFactory.CreateTestLifecycle(config.KubeConfigFile, config.LogPath, config.DataPath, test.CustomK8sNamespace, new DefaultTimeSet(), log); - lifecycle.WorkflowCreator.CreateWorkflow().DeleteTestResources(); + lifecycle.WorkflowCreator.CreateWorkflow().DeleteNamespacesStartingWith(); } } } diff --git a/ContinuousTests/NodeRunner.cs b/ContinuousTests/NodeRunner.cs index d599c4d..b8bb415 100644 --- a/ContinuousTests/NodeRunner.cs +++ b/ContinuousTests/NodeRunner.cs @@ -101,7 +101,7 @@ namespace ContinuousTests } finally { - flow.DeleteTestResources(); + flow.DeleteNamespacesStartingWith(); } } diff --git a/DistTestCore/Configuration.cs b/DistTestCore/Configuration.cs index e8a3856..91d64d3 100644 --- a/DistTestCore/Configuration.cs +++ b/DistTestCore/Configuration.cs @@ -36,7 +36,6 @@ namespace DistTestCore public KubernetesWorkflow.Configuration GetK8sConfiguration(ITimeSet timeSet) { return new KubernetesWorkflow.Configuration( - k8sNamespacePrefix: k8sNamespacePrefix, kubeConfigFile: kubeConfigFile, operationTimeout: timeSet.K8sOperationTimeout(), retryDelay: timeSet.WaitForK8sServiceDelay() diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index e965514..d445944 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -18,7 +18,8 @@ namespace DistTestCore private readonly StatusLog statusLog; private readonly object lifecycleLock = new object(); private readonly Dictionary lifecycles = new Dictionary(); - + private readonly PluginManager PluginManager = new PluginManager(); + public DistTest() { var assemblies = AppDomain.CurrentDomain.GetAssemblies(); @@ -33,11 +34,9 @@ namespace DistTestCore [OneTimeSetUp] public void GlobalSetup() { - fixtureLog.Log($"Codex Distributed Tests are starting..."); - //fixtureLog.Log($"Codex image: '{new CodexContainerRecipe().Image}'"); - //fixtureLog.Log($"CodexContracts image: '{new CodexContractsContainerRecipe().Image}'"); - //fixtureLog.Log($"Prometheus image: '{new PrometheusContainerRecipe().Image}'"); - //fixtureLog.Log($"Geth image: '{new GethContainerRecipe().Image}'"); + fixtureLog.Log($"Distributed Tests are starting..."); + PluginManager.DiscoverPlugins(); + AnnouncePlugins(fixtureLog); // Previous test run may have been interrupted. // Begin by cleaning everything up. @@ -46,7 +45,7 @@ namespace DistTestCore Stopwatch.Measure(fixtureLog, "Global setup", () => { var wc = new WorkflowCreator(fixtureLog, configuration.GetK8sConfiguration(GetTimeSet()), string.Empty); - wc.CreateWorkflow().DeleteAllResources(); + wc.CreateWorkflow().DeleteNamespace(); }); } catch (Exception ex) @@ -59,6 +58,12 @@ namespace DistTestCore fixtureLog.Log("Global setup cleanup successful"); } + [OneTimeTearDown] + public void GlobalTearDown() + { + FinalizePlugins(fixtureLog); + } + [SetUp] public void SetUpDistTest() { @@ -148,7 +153,17 @@ namespace DistTestCore // return Get().CodexStarter.RunningGroups.SelectMany(g => g.Nodes); //} - public BaseLog GetTestLog() + private void AnnouncePlugins(FixtureLog fixtureLog) + { + PluginManager.AnnouncePlugins(fixtureLog); + } + + private void FinalizePlugins(FixtureLog fixtureLog) + { + PluginManager.FinalizePlugins(fixtureLog); + } + + public ILog GetTestLog() { return Get().Log; } @@ -205,7 +220,7 @@ namespace DistTestCore var lifecycle = new TestLifecycle(fixtureLog.CreateTestLog(), configuration, GetTimeSet(), testNamespace); lifecycles.Add(testName, lifecycle); DefaultContainerRecipe.TestsType = TestsType; - DefaultContainerRecipe.ApplicationIds = lifecycle.GetApplicationIds(); + //DefaultContainerRecipe.ApplicationIds = lifecycle.GetApplicationIds(); } }); } @@ -216,16 +231,34 @@ namespace DistTestCore var testResult = GetTestResult(); var testDuration = lifecycle.GetTestDuration(); fixtureLog.Log($"{GetCurrentTestName()} = {testResult} ({testDuration})"); - statusLog.ConcludeTest(testResult, testDuration, lifecycle.GetApplicationIds()); + statusLog.ConcludeTest(testResult, testDuration);//, lifecycle.GetApplicationIds()); Stopwatch.Measure(fixtureLog, $"Teardown for {GetCurrentTestName()}", () => { - lifecycle.Log.EndTest(); + WriteEndTestLog(lifecycle.Log); + IncludeLogsAndMetricsOnTestFailure(lifecycle); lifecycle.DeleteAllResources(); lifecycle = null!; }); } + private void WriteEndTestLog(TestLog log) + { + var result = TestContext.CurrentContext.Result; + + Log($"*** Finished: {GetCurrentTestName()} = {result.Outcome.Status}"); + if (!string.IsNullOrEmpty(result.Message)) + { + Log(result.Message); + Log($"{result.StackTrace}"); + } + + if (result.Outcome.Status == NUnit.Framework.Interfaces.TestStatus.Failed) + { + log.MarkAsFailed(); + } + } + private ITimeSet GetTimeSet() { if (ShouldUseLongTimeouts()) return new LongTimeSet(); diff --git a/DistTestCore/PluginManager.cs b/DistTestCore/PluginManager.cs index ea21e6d..27f9bf7 100644 --- a/DistTestCore/PluginManager.cs +++ b/DistTestCore/PluginManager.cs @@ -1,64 +1,85 @@ -using KubernetesWorkflow; +using FileUtils; +using KubernetesWorkflow; using Logging; +using Utils; namespace DistTestCore { - public class PluginManager : IPluginActions + public class PluginManager { - private readonly BaseLog log; - private readonly Configuration configuration; - private readonly string testNamespace; - private readonly WorkflowCreator workflowCreator; - private readonly ITimeSet timeSet; private readonly List projectPlugins = new List(); - public PluginManager(BaseLog log, Configuration configuration, ITimeSet timeSet, string testNamespace) + public void DiscoverPlugins() { - this.log = log; - this.configuration = configuration; - this.timeSet = timeSet; - this.testNamespace = testNamespace; - workflowCreator = new WorkflowCreator(log, configuration.GetK8sConfiguration(timeSet), testNamespace); - } - - public IStartupWorkflow CreateWorkflow() - { - return workflowCreator.CreateWorkflow(); - } - - public ILog GetLog() - { - return log; - } - - public ITimeSet GetTimeSet() - { - return timeSet; - } - - public void InitializeAllPlugins() - { - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - var pluginTypes = assemblies.SelectMany(a => a.GetTypes().Where(t => typeof(IProjectPlugin).IsAssignableFrom(t))).ToArray(); - + projectPlugins.Clear(); + var pluginTypes = PluginFinder.GetPluginTypes(); foreach (var pluginType in pluginTypes) { - IPluginActions actions = this; - var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType, args: actions)!; + var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType)!; projectPlugins.Add(plugin); } } + + public void AnnouncePlugins(ILog log) + { + foreach (var plugin in projectPlugins) plugin.Announce(log); + } + + public void InitializePlugins(IPluginTools tools) + { + foreach (var plugin in projectPlugins) plugin.Initialize(tools); + } + + public void FinalizePlugins(ILog log) + { + foreach (var plugin in projectPlugins) plugin.Finalize(log); + } + } + + public static class PluginFinder + { + private static Type[]? pluginTypes = null; + + public static Type[] GetPluginTypes() + { + if (pluginTypes != null) return pluginTypes; + + // Reflection can be costly. Do this only once. + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + pluginTypes = assemblies.SelectMany(a => a.GetTypes().Where(t => typeof(IProjectPlugin).IsAssignableFrom(t))).ToArray(); + return pluginTypes; + } } public interface IProjectPlugin { + void Announce(ILog log); + void Initialize(IPluginTools tools); + void Finalize(ILog log); } - // probably seggregate this out. - public interface IPluginActions + public interface IPluginTools : IWorkflowTool, ILogTool, IHttpFactoryTool, IFileTool + { + } + + public interface IWorkflowTool + { + IStartupWorkflow CreateWorkflow(string? namespaceOverride = null); + } + + public interface ILogTool { - IStartupWorkflow CreateWorkflow(); ILog GetLog(); - ITimeSet GetTimeSet(); + } + + public interface IHttpFactoryTool + { + Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null); + Http CreateHttp(Address address, string baseUrl, string? logAlias = null); + } + + public interface IFileTool + { + IFileManager GetFileManager(); } } diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index be3da02..851e1f9 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -1,70 +1,81 @@ -using DistTestCore.Logs; -using FileUtils; +using FileUtils; using KubernetesWorkflow; using Logging; using Utils; namespace DistTestCore { - public class TestLifecycle + public class TestLifecycle : IPluginTools { + private readonly PluginManager pluginManager; private readonly DateTime testStart; - public TestLifecycle(BaseLog log, Configuration configuration, ITimeSet timeSet, string testNamespace) + public TestLifecycle(TestLog log, Configuration configuration, ITimeSet timeSet, string testNamespace) { Log = log; Configuration = configuration; TimeSet = timeSet; - - WorkflowCreator = new WorkflowCreator(log, configuration.GetK8sConfiguration(timeSet), testNamespace); - - FileManager = new FileManager(Log, configuration.GetFileManagerFolder()); - //CodexStarter = new CodexStarter(this); - PrometheusStarter = new PrometheusStarter(this); - GrafanaStarter = new GrafanaStarter(this); - //GethStarter = new GethStarter(this); + TestNamespace = testNamespace; testStart = DateTime.UtcNow; - //CodexVersion = null; + FileManager = new FileManager(Log, Configuration.GetFileManagerFolder()); - // the plugin manager is starting to look like the testlifecycle, that's bad because they are not supposed to be doing the same things: - // pluginmanager should be useful for disttest-deployer-continuoustest, everyone! - // but testlifecycle should be a disttest specific user of the plugin manager. - // disttest requires a hook by which it can keep track of containers created?? (does it?) /namespace used? for the purpose of cleaning up. + pluginManager = new PluginManager(); + pluginManager.DiscoverPlugins(); + pluginManager.InitializePlugins(this); - //var pluginManager = new PluginManager(Log, configuration, timeSet, testNamespace); - //pluginManager.InitializeAllPlugins(); - - Log.WriteLogTag(); + log.WriteLogTag(); } - public BaseLog Log { get; } + public TestLog Log { get; } public Configuration Configuration { get; } public ITimeSet TimeSet { get; } - public WorkflowCreator WorkflowCreator { get; } - public FileManager FileManager { get; } - //public CodexStarter CodexStarter { get; } - public PrometheusStarter PrometheusStarter { get; } - public GrafanaStarter GrafanaStarter { get; } - //public GethStarter GethStarter { get; } - //public CodexDebugVersionResponse? CodexVersion { get; private set; } + public string TestNamespace { get; } + public IFileManager FileManager { get; } + + public Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) + { + return new Http(Log, TimeSet, address, baseUrl, onClientCreated, logAlias); + } + + public Http CreateHttp(Address address, string baseUrl, string? logAlias = null) + { + return new Http(Log, TimeSet, address, baseUrl, logAlias); + } + + public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null) + { + if (namespaceOverride != null) throw new Exception("Namespace override is not supported in the DistTest environment. (It would mess up automatic resource cleanup.)"); + var wc = new WorkflowCreator(Log, Configuration.GetK8sConfiguration(TimeSet), TestNamespace); + return wc.CreateWorkflow(); + } + + public IFileManager GetFileManager() + { + return FileManager; + } + + public ILog GetLog() + { + return Log; + } public void DeleteAllResources() { - //CodexStarter.DeleteAllResources(); + CreateWorkflow().DeleteNamespace(); FileManager.DeleteAllTestFiles(); } - public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null) - { - var subFile = Log.CreateSubfile(); - var description = container.Name; - var handler = new LogDownloadHandler(container, description, subFile); + //public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null) + //{ + // var subFile = Log.CreateSubfile(); + // var description = container.Name; + // var handler = new LogDownloadHandler(container, description, subFile); - Log.Log($"Downloading logs for {description} to file '{subFile.FullFilename}'"); - //CodexStarter.DownloadLog(container, handler, tailLines); + // Log.Log($"Downloading logs for {description} to file '{subFile.FullFilename}'"); + // //CodexStarter.DownloadLog(container, handler, tailLines); - return new DownloadedLog(subFile, description); - } + // return new DownloadedLog(subFile, description); + //} public string GetTestDuration() { @@ -72,30 +83,30 @@ namespace DistTestCore return Time.FormatDuration(testDuration); } - //public void SetCodexVersion(CodexDebugVersionResponse version) + ////public void SetCodexVersion(CodexDebugVersionResponse version) + ////{ + //// if (CodexVersion == null) CodexVersion = version; + ////} + + //public ApplicationIds GetApplicationIds() //{ - // if (CodexVersion == null) CodexVersion = version; + // //return new ApplicationIds( + // // codexId: GetCodexId(), + // // gethId: new GethContainerRecipe().Image, + // // prometheusId: new PrometheusContainerRecipe().Image, + // // codexContractsId: new CodexContractsContainerRecipe().Image, + // // grafanaId: new GrafanaContainerRecipe().Image + // //); + // return null!; //} - public ApplicationIds GetApplicationIds() - { - //return new ApplicationIds( - // codexId: GetCodexId(), - // gethId: new GethContainerRecipe().Image, - // prometheusId: new PrometheusContainerRecipe().Image, - // codexContractsId: new CodexContractsContainerRecipe().Image, - // grafanaId: new GrafanaContainerRecipe().Image - //); - return null!; - } - - private string GetCodexId() - { - return ""; - //var v = CodexVersion; - //if (v == null) return new CodexContainerRecipe().Image; - //if (v.version != "untagged build") return v.version; - //return v.revision; - } + //private string GetCodexId() + //{ + // return ""; + // //var v = CodexVersion; + // //if (v == null) return new CodexContainerRecipe().Image; + // //if (v.version != "untagged build") return v.version; + // //return v.revision; + //} } } diff --git a/FileUtils/FileManager.cs b/FileUtils/FileManager.cs index 58bb0a8..4fc7b52 100644 --- a/FileUtils/FileManager.cs +++ b/FileUtils/FileManager.cs @@ -18,11 +18,11 @@ namespace FileUtils public const int ChunkSize = 1024 * 1024 * 100; private static NumberSource folderNumberSource = new NumberSource(0); private readonly Random random = new Random(); - private readonly BaseLog log; + private readonly ILog log; private readonly string folder; private readonly List> fileSetStack = new List>(); - public FileManager(BaseLog log, string rootFolder) + public FileManager(ILog log, string rootFolder) { folder = Path.Combine(rootFolder, folderNumberSource.GetNextNumber().ToString("D5")); diff --git a/FileUtils/TestFile.cs b/FileUtils/TestFile.cs index 3f22926..cb51544 100644 --- a/FileUtils/TestFile.cs +++ b/FileUtils/TestFile.cs @@ -6,9 +6,9 @@ namespace FileUtils { public class TestFile { - private readonly BaseLog log; + private readonly ILog log; - public TestFile(BaseLog log, string filename, string label) + public TestFile(ILog log, string filename, string label) { this.log = log; Filename = filename; diff --git a/KubernetesWorkflow/Configuration.cs b/KubernetesWorkflow/Configuration.cs index 53fee79..c594a10 100644 --- a/KubernetesWorkflow/Configuration.cs +++ b/KubernetesWorkflow/Configuration.cs @@ -2,15 +2,13 @@ { public class Configuration { - public Configuration(string k8sNamespacePrefix, string? kubeConfigFile, TimeSpan operationTimeout, TimeSpan retryDelay) + public Configuration(string? kubeConfigFile, TimeSpan operationTimeout, TimeSpan retryDelay) { - K8sNamespacePrefix = k8sNamespacePrefix; KubeConfigFile = kubeConfigFile; OperationTimeout = operationTimeout; RetryDelay = retryDelay; } - public string K8sNamespacePrefix { get; } public string? KubeConfigFile { get; } public TimeSpan OperationTimeout { get; } public TimeSpan RetryDelay { get; } diff --git a/KubernetesWorkflow/CrashWatcher.cs b/KubernetesWorkflow/CrashWatcher.cs index 7f8bc71..8a27da7 100644 --- a/KubernetesWorkflow/CrashWatcher.cs +++ b/KubernetesWorkflow/CrashWatcher.cs @@ -5,7 +5,7 @@ namespace KubernetesWorkflow { public class CrashWatcher { - private readonly BaseLog log; + private readonly ILog log; private readonly KubernetesClientConfiguration config; private readonly string k8sNamespace; private readonly RunningContainer container; @@ -14,7 +14,7 @@ namespace KubernetesWorkflow private Task? worker; private Exception? workerException; - public CrashWatcher(BaseLog log, KubernetesClientConfiguration config, string k8sNamespace, RunningContainer container) + public CrashWatcher(ILog log, KubernetesClientConfiguration config, string k8sNamespace, RunningContainer container) { this.log = log; this.config = config; diff --git a/KubernetesWorkflow/K8sController.cs b/KubernetesWorkflow/K8sController.cs index c162bcf..ff53f66 100644 --- a/KubernetesWorkflow/K8sController.cs +++ b/KubernetesWorkflow/K8sController.cs @@ -7,13 +7,13 @@ namespace KubernetesWorkflow { public class K8sController { - private readonly BaseLog log; + private readonly ILog log; private readonly K8sCluster cluster; private readonly KnownK8sPods knownPods; private readonly WorkflowNumberSource workflowNumberSource; private readonly K8sClient client; - public K8sController(BaseLog log, K8sCluster cluster, KnownK8sPods knownPods, WorkflowNumberSource workflowNumberSource, string testNamespace) + public K8sController(ILog log, K8sCluster cluster, KnownK8sPods knownPods, WorkflowNumberSource workflowNumberSource, string k8sNamespace) { this.log = log; this.cluster = cluster; @@ -21,7 +21,7 @@ namespace KubernetesWorkflow this.workflowNumberSource = workflowNumberSource; client = new K8sClient(cluster.GetK8sClientConfig()); - K8sTestNamespace = cluster.Configuration.K8sNamespacePrefix + testNamespace; + K8sNamespace = k8sNamespace; } public void Dispose() @@ -54,7 +54,7 @@ namespace KubernetesWorkflow public void DownloadPodLog(RunningPod pod, ContainerRecipe recipe, ILogHandler logHandler, int? tailLines) { log.Debug(); - using var stream = client.Run(c => c.ReadNamespacedPodLog(pod.PodInfo.Name, K8sTestNamespace, recipe.Name, tailLines: tailLines)); + using var stream = client.Run(c => c.ReadNamespacedPodLog(pod.PodInfo.Name, K8sNamespace, recipe.Name, tailLines: tailLines)); logHandler.Log(stream); } @@ -63,7 +63,7 @@ namespace KubernetesWorkflow var cmdAndArgs = $"{containerName}: {command} ({string.Join(",", args)})"; log.Debug(cmdAndArgs); - var runner = new CommandRunner(client, K8sTestNamespace, pod, containerName, command, args); + var runner = new CommandRunner(client, K8sNamespace, pod, containerName, command, args); runner.Run(); var result = runner.GetStdOut(); @@ -71,12 +71,12 @@ namespace KubernetesWorkflow return result; } - public void DeleteAllResources() + public void DeleteAllNamespacesStartingWith(string prefix) { log.Debug(); var all = client.Run(c => c.ListNamespace().Items); - var namespaces = all.Select(n => n.Name()).Where(n => n.StartsWith(cluster.Configuration.K8sNamespacePrefix)); + var namespaces = all.Select(n => n.Name()).Where(n => n.StartsWith(prefix)); foreach (var ns in namespaces) { @@ -88,12 +88,12 @@ namespace KubernetesWorkflow } } - public void DeleteTestNamespace() + public void DeleteNamespace() { log.Debug(); if (IsTestNamespaceOnline()) { - client.Run(c => c.DeleteNamespace(K8sTestNamespace, null, null, gracePeriodSeconds: 0)); + client.Run(c => c.DeleteNamespace(K8sNamespace, null, null, gracePeriodSeconds: 0)); } WaitUntilNamespaceDeleted(); } @@ -145,7 +145,7 @@ namespace KubernetesWorkflow #region Namespace management - private string K8sTestNamespace { get; } + private string K8sNamespace { get; } private void EnsureTestNamespace() { @@ -156,8 +156,8 @@ namespace KubernetesWorkflow ApiVersion = "v1", Metadata = new V1ObjectMeta { - Name = K8sTestNamespace, - Labels = new Dictionary { { "name", K8sTestNamespace } } + Name = K8sNamespace, + Labels = new Dictionary { { "name", K8sNamespace } } } }; client.Run(c => c.CreateNamespace(namespaceSpec)); @@ -168,7 +168,7 @@ namespace KubernetesWorkflow private bool IsTestNamespaceOnline() { - return IsNamespaceOnline(K8sTestNamespace); + return IsNamespaceOnline(K8sNamespace); } private bool IsNamespaceOnline(string name) @@ -185,7 +185,7 @@ namespace KubernetesWorkflow Metadata = new V1ObjectMeta { Name = "isolate-policy", - NamespaceProperty = K8sTestNamespace + NamespaceProperty = K8sNamespace }, Spec = new V1NetworkPolicySpec { @@ -314,7 +314,7 @@ namespace KubernetesWorkflow } }; - c.CreateNamespacedNetworkPolicy(body, K8sTestNamespace); + c.CreateNamespacedNetworkPolicy(body, K8sNamespace); }); } @@ -352,7 +352,7 @@ namespace KubernetesWorkflow } }; - client.Run(c => c.CreateNamespacedDeployment(deploymentSpec, K8sTestNamespace)); + client.Run(c => c.CreateNamespacedDeployment(deploymentSpec, K8sNamespace)); WaitUntilDeploymentOnline(deploymentSpec.Metadata.Name); return deploymentSpec.Metadata.Name; @@ -360,7 +360,7 @@ namespace KubernetesWorkflow private void DeleteDeployment(string deploymentName) { - client.Run(c => c.DeleteNamespacedDeployment(deploymentName, K8sTestNamespace)); + client.Run(c => c.DeleteNamespacedDeployment(deploymentName, K8sNamespace)); WaitUntilDeploymentOffline(deploymentName); } @@ -400,7 +400,7 @@ namespace KubernetesWorkflow return new V1ObjectMeta { Name = "deploy-" + workflowNumberSource.WorkflowNumber, - NamespaceProperty = K8sTestNamespace, + NamespaceProperty = K8sNamespace, Labels = GetSelector(containerRecipes), Annotations = GetAnnotations(containerRecipes) }; @@ -495,7 +495,7 @@ namespace KubernetesWorkflow } } } - }, K8sTestNamespace)); + }, K8sNamespace)); return new V1Volume { @@ -571,7 +571,7 @@ namespace KubernetesWorkflow } }; - client.Run(c => c.CreateNamespacedService(serviceSpec, K8sTestNamespace)); + client.Run(c => c.CreateNamespacedService(serviceSpec, K8sNamespace)); ReadBackServiceAndMapPorts(serviceSpec, containerRecipes, result); @@ -581,7 +581,7 @@ namespace KubernetesWorkflow private void ReadBackServiceAndMapPorts(V1Service serviceSpec, ContainerRecipe[] containerRecipes, List result) { // For each container-recipe, we need to figure out which service-ports it was assigned by K8s. - var readback = client.Run(c => c.ReadNamespacedService(serviceSpec.Metadata.Name, K8sTestNamespace)); + var readback = client.Run(c => c.ReadNamespacedService(serviceSpec.Metadata.Name, K8sNamespace)); foreach (var r in containerRecipes) { if (r.ExposedPorts.Any()) @@ -610,7 +610,7 @@ namespace KubernetesWorkflow private void DeleteService(string serviceName) { - client.Run(c => c.DeleteNamespacedService(serviceName, K8sTestNamespace)); + client.Run(c => c.DeleteNamespacedService(serviceName, K8sNamespace)); } private V1ObjectMeta CreateServiceMetadata() @@ -618,7 +618,7 @@ namespace KubernetesWorkflow return new V1ObjectMeta { Name = "service-" + workflowNumberSource.WorkflowNumber, - NamespaceProperty = K8sTestNamespace + NamespaceProperty = K8sNamespace }; } @@ -672,7 +672,7 @@ namespace KubernetesWorkflow { WaitUntil(() => { - var deployment = client.Run(c => c.ReadNamespacedDeployment(deploymentName, K8sTestNamespace)); + var deployment = client.Run(c => c.ReadNamespacedDeployment(deploymentName, K8sNamespace)); return deployment?.Status.AvailableReplicas != null && deployment.Status.AvailableReplicas > 0; }); } @@ -681,7 +681,7 @@ namespace KubernetesWorkflow { WaitUntil(() => { - var deployments = client.Run(c => c.ListNamespacedDeployment(K8sTestNamespace)); + var deployments = client.Run(c => c.ListNamespacedDeployment(K8sNamespace)); var deployment = deployments.Items.SingleOrDefault(d => d.Metadata.Name == deploymentName); return deployment == null || deployment.Status.AvailableReplicas == 0; }); @@ -691,7 +691,7 @@ namespace KubernetesWorkflow { WaitUntil(() => { - var pods = client.Run(c => c.ListNamespacedPod(K8sTestNamespace)).Items; + var pods = client.Run(c => c.ListNamespacedPod(K8sNamespace)).Items; var pod = pods.SingleOrDefault(p => p.Metadata.Name == podName); return pod == null; }); @@ -714,12 +714,12 @@ namespace KubernetesWorkflow public CrashWatcher CreateCrashWatcher(RunningContainer container) { - return new CrashWatcher(log, cluster.GetK8sClientConfig(), K8sTestNamespace, container); + return new CrashWatcher(log, cluster.GetK8sClientConfig(), K8sNamespace, container); } private PodInfo FetchNewPod() { - var pods = client.Run(c => c.ListNamespacedPod(K8sTestNamespace)).Items; + var pods = client.Run(c => c.ListNamespacedPod(K8sNamespace)).Items; var newPods = pods.Where(p => !knownPods.Contains(p.Name())).ToArray(); if (newPods.Length != 1) throw new InvalidOperationException("Expected only 1 pod to be created. Test infra failure."); diff --git a/KubernetesWorkflow/StartupWorkflow.cs b/KubernetesWorkflow/StartupWorkflow.cs index 5bf5b73..97b66ed 100644 --- a/KubernetesWorkflow/StartupWorkflow.cs +++ b/KubernetesWorkflow/StartupWorkflow.cs @@ -9,26 +9,26 @@ namespace KubernetesWorkflow void Stop(RunningContainers runningContainers); void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines); string ExecuteCommand(RunningContainer container, string command, params string[] args); - void DeleteAllResources();// !!! delete namespace then!? - void DeleteTestResources(); // !!! do not mention tests. what are we deleting? + void DeleteNamespace(); + void DeleteNamespacesStartingWith(string namespacePrefix); } public class StartupWorkflow : IStartupWorkflow { - private readonly BaseLog log; + private readonly ILog log; private readonly WorkflowNumberSource numberSource; private readonly K8sCluster cluster; private readonly KnownK8sPods knownK8SPods; - private readonly string testNamespace; + private readonly string k8sNamespace; private readonly RecipeComponentFactory componentFactory = new RecipeComponentFactory(); - internal StartupWorkflow(BaseLog log, WorkflowNumberSource numberSource, K8sCluster cluster, KnownK8sPods knownK8SPods, string testNamespace) + internal StartupWorkflow(ILog log, WorkflowNumberSource numberSource, K8sCluster cluster, KnownK8sPods knownK8SPods, string k8sNamespace) { this.log = log; this.numberSource = numberSource; this.cluster = cluster; this.knownK8SPods = knownK8SPods; - this.testNamespace = testNamespace; + this.k8sNamespace = k8sNamespace; } public RunningContainers Start(int numberOfContainers, Location location, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig) @@ -69,19 +69,19 @@ namespace KubernetesWorkflow }); } - public void DeleteAllResources() + public void DeleteNamespace() { K8s(controller => { - controller.DeleteAllResources(); + controller.DeleteNamespace(); }); } - public void DeleteTestResources() + public void DeleteNamespacesStartingWith(string namespacePrefix) { K8s(controller => { - controller.DeleteTestNamespace(); + controller.DeleteAllNamespacesStartingWith(namespacePrefix); }); } @@ -133,11 +133,10 @@ namespace KubernetesWorkflow private Address GetContainerInternalAddress(ContainerRecipe recipe) { var serviceName = "service-" + numberSource.WorkflowNumber; - var namespaceName = cluster.Configuration.K8sNamespacePrefix + testNamespace; var port = GetInternalPort(recipe); return new Address( - $"http://{serviceName}.{namespaceName}.svc.cluster.local", + $"http://{serviceName}.{k8sNamespace}.svc.cluster.local", port); } @@ -167,14 +166,14 @@ namespace KubernetesWorkflow private void K8s(Action action) { - var controller = new K8sController(log, cluster, knownK8SPods, numberSource, testNamespace); + var controller = new K8sController(log, cluster, knownK8SPods, numberSource, k8sNamespace); action(controller); controller.Dispose(); } private T K8s(Func action) { - var controller = new K8sController(log, cluster, knownK8SPods, numberSource, testNamespace); + var controller = new K8sController(log, cluster, knownK8SPods, numberSource, k8sNamespace); var result = action(controller); controller.Dispose(); return result; diff --git a/KubernetesWorkflow/WorkflowCreator.cs b/KubernetesWorkflow/WorkflowCreator.cs index ae8c85b..3a8d385 100644 --- a/KubernetesWorkflow/WorkflowCreator.cs +++ b/KubernetesWorkflow/WorkflowCreator.cs @@ -9,10 +9,10 @@ namespace KubernetesWorkflow private readonly NumberSource containerNumberSource = new NumberSource(0); private readonly KnownK8sPods knownPods = new KnownK8sPods(); private readonly K8sCluster cluster; - private readonly BaseLog log; + private readonly ILog log; private readonly string testNamespace; - public WorkflowCreator(BaseLog log, Configuration configuration, string testNamespace) + public WorkflowCreator(ILog log, Configuration configuration, string testNamespace) { cluster = new K8sCluster(configuration); this.log = log; diff --git a/Logging/BaseLog.cs b/Logging/BaseLog.cs index b106b84..e8a94a4 100644 --- a/Logging/BaseLog.cs +++ b/Logging/BaseLog.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using Utils; +using Utils; namespace Logging { @@ -16,7 +15,6 @@ namespace Logging private readonly NumberSource subfileNumberSource = new NumberSource(0); private readonly bool debug; private readonly List replacements = new List(); - private bool hasFailed; private LogFile? logFile; protected BaseLog(bool debug) @@ -35,10 +33,6 @@ namespace Logging } } - public virtual void EndTest() - { - } - public virtual void Log(string message) { LogFile.Write(ApplyReplacements(message)); @@ -59,13 +53,6 @@ namespace Logging Log($"[ERROR] {message}"); } - public virtual void MarkAsFailed() - { - if (hasFailed) return; - hasFailed = true; - LogFile.ConcatToFilename("_FAILED"); - } - public virtual void AddStringReplace(string from, string to) { if (string.IsNullOrWhiteSpace(from)) return; diff --git a/Logging/FixtureLog.cs b/Logging/FixtureLog.cs index 809ff38..2a11fbd 100644 --- a/Logging/FixtureLog.cs +++ b/Logging/FixtureLog.cs @@ -1,12 +1,12 @@ namespace Logging { - public class FixtureLog : BaseLog + public class FixtureLog : TestLog { private readonly string fullName; private readonly LogConfig config; public FixtureLog(LogConfig config, DateTime start, string name = "") - : base(config.DebugEnabled) + : base(config.LogRoot, config.DebugEnabled) { fullName = NameUtils.GetFixtureFullName(config, start, name); this.config = config; diff --git a/Logging/NullLog.cs b/Logging/NullLog.cs index 75d43ff..c969b17 100644 --- a/Logging/NullLog.cs +++ b/Logging/NullLog.cs @@ -26,10 +26,6 @@ Console.WriteLine("Error: " + message); } - public override void MarkAsFailed() - { - } - public override void AddStringReplace(string from, string to) { } diff --git a/Logging/StatusLog.cs b/Logging/StatusLog.cs index f5d5831..3fe3a7d 100644 --- a/Logging/StatusLog.cs +++ b/Logging/StatusLog.cs @@ -14,7 +14,7 @@ namespace Logging fixtureName = NameUtils.GetRawFixtureName(); } - public void ConcludeTest(string resultStatus, string testDuration, ApplicationIds applicationIds) + public void ConcludeTest(string resultStatus, string testDuration/*, ApplicationIds applicationIds*/) { Write(new StatusLogJson { @@ -22,11 +22,11 @@ namespace Logging runid = NameUtils.GetRunId(), status = resultStatus, testid = NameUtils.GetTestId(), - codexid = applicationIds.CodexId, - gethid = applicationIds.GethId, - prometheusid = applicationIds.PrometheusId, - codexcontractsid = applicationIds.CodexContractsId, - grafanaid = applicationIds.GrafanaId, + //codexid = applicationIds.CodexId, + //gethid = applicationIds.GethId, + //prometheusid = applicationIds.PrometheusId, + //codexcontractsid = applicationIds.CodexContractsId, + //grafanaid = applicationIds.GrafanaId, category = NameUtils.GetCategoryName(), fixturename = fixtureName, testname = NameUtils.GetTestMethodName(), diff --git a/Logging/TestLog.cs b/Logging/TestLog.cs index a8d3249..4279d10 100644 --- a/Logging/TestLog.cs +++ b/Logging/TestLog.cs @@ -1,11 +1,10 @@ -using NUnit.Framework; - -namespace Logging +namespace Logging { public class TestLog : BaseLog { private readonly string methodName; private readonly string fullName; + private bool hasFailed; public TestLog(string folder, bool debug, string name = "") : base(debug) @@ -16,21 +15,11 @@ namespace Logging Log($"*** Begin: {methodName}"); } - public override void EndTest() + public void MarkAsFailed() { - var result = TestContext.CurrentContext.Result; - - Log($"*** Finished: {methodName} = {result.Outcome.Status}"); - if (!string.IsNullOrEmpty(result.Message)) - { - Log(result.Message); - Log($"{result.StackTrace}"); - } - - if (result.Outcome.Status == NUnit.Framework.Interfaces.TestStatus.Failed) - { - MarkAsFailed(); - } + if (hasFailed) return; + hasFailed = true; + LogFile.ConcatToFilename("_FAILED"); } protected override string GetFullName() From c995732f352be6d7f79d1c3f14131bc52a61e654 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 12 Sep 2023 11:25:04 +0200 Subject: [PATCH 05/51] Fun with assembly loading --- CodexPlugin/{Plugin.cs => CodexPlugin.cs} | 4 +-- CodexPlugin/DistTestExtensions.cs | 30 ------------------- CodexPlugin/PluginInterfaceExtensions.cs | 33 +++++++++++++++++++++ DistTestCore/DistTest.cs | 7 ++++- DistTestCore/PluginInterface.cs | 7 +++++ DistTestCore/PluginManager.cs | 35 ++++++++++++++++++++++- DistTestCore/TestLifecycle.cs | 5 ++++ Logging/BaseTestLog.cs | 19 ++++++++++++ Logging/FixtureLog.cs | 4 +-- Logging/TestLog.cs | 10 +------ 10 files changed, 108 insertions(+), 46 deletions(-) rename CodexPlugin/{Plugin.cs => CodexPlugin.cs} (93%) delete mode 100644 CodexPlugin/DistTestExtensions.cs create mode 100644 CodexPlugin/PluginInterfaceExtensions.cs create mode 100644 DistTestCore/PluginInterface.cs create mode 100644 Logging/BaseTestLog.cs diff --git a/CodexPlugin/Plugin.cs b/CodexPlugin/CodexPlugin.cs similarity index 93% rename from CodexPlugin/Plugin.cs rename to CodexPlugin/CodexPlugin.cs index e7c72a0..a47f1a5 100644 --- a/CodexPlugin/Plugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -4,7 +4,7 @@ using Logging; namespace CodexPlugin { - public class Plugin : IProjectPlugin + public class CodexPlugin : IProjectPlugin { private CodexStarter codexStarter = null!; @@ -18,8 +18,6 @@ namespace CodexPlugin public void Initialize(IPluginTools tools) { codexStarter = new CodexStarter(tools); - - DistTestExtensions.Plugin = this; } public void Finalize(ILog log) diff --git a/CodexPlugin/DistTestExtensions.cs b/CodexPlugin/DistTestExtensions.cs deleted file mode 100644 index de0362a..0000000 --- a/CodexPlugin/DistTestExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using DistTestCore; -using KubernetesWorkflow; - -namespace CodexPlugin -{ - public static class DistTestExtensions - { - public static Plugin Plugin { get; internal set; } = null!; - - public static RunningContainers[] StartCodexNodes(this DistTest distTest, int number, Action setup) - { - return Plugin.StartCodexNodes(number, setup); - } - - public static ICodexNodeGroup WrapCodexContainers(this DistTest distTest, RunningContainers[] containers) - { - return Plugin.WrapCodexContainers(containers); - } - - public static IOnlineCodexNode SetupCodexNode(this DistTest distTest, Action setup) - { - return Plugin.SetupCodexNode(setup); - } - - public static ICodexNodeGroup SetupCodexNodes(this DistTest distTest, int number) - { - return Plugin.SetupCodexNodes(number); - } - } -} diff --git a/CodexPlugin/PluginInterfaceExtensions.cs b/CodexPlugin/PluginInterfaceExtensions.cs new file mode 100644 index 0000000..8fa21f2 --- /dev/null +++ b/CodexPlugin/PluginInterfaceExtensions.cs @@ -0,0 +1,33 @@ +using DistTestCore; +using KubernetesWorkflow; + +namespace CodexPlugin +{ + public static class PluginInterfaceExtensions + { + public static RunningContainers[] StartCodexNodes(this PluginInterface pluginInterface, int number, Action setup) + { + return Plugin(pluginInterface).StartCodexNodes(number, setup); + } + + public static ICodexNodeGroup WrapCodexContainers(this PluginInterface pluginInterface, RunningContainers[] containers) + { + return Plugin(pluginInterface).WrapCodexContainers(containers); + } + + public static IOnlineCodexNode SetupCodexNode(this PluginInterface pluginInterface, Action setup) + { + return Plugin(pluginInterface).SetupCodexNode(setup); + } + + public static ICodexNodeGroup SetupCodexNodes(this PluginInterface pluginInterface, int number) + { + return Plugin(pluginInterface).SetupCodexNodes(number); + } + + private static CodexPlugin Plugin(PluginInterface pluginInterface) + { + return pluginInterface.GetPlugin(); + } + } +} diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index d445944..d5020e2 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -9,7 +9,7 @@ using Utils; namespace DistTestCore { [Parallelizable(ParallelScope.All)] - public abstract class DistTest + public abstract class DistTest : PluginInterface { private const string TestsType = "dist-tests"; private readonly Configuration configuration = new Configuration(); @@ -153,6 +153,11 @@ namespace DistTestCore // return Get().CodexStarter.RunningGroups.SelectMany(g => g.Nodes); //} + public override T GetPlugin() + { + return Get().GetPlugin(); + } + private void AnnouncePlugins(FixtureLog fixtureLog) { PluginManager.AnnouncePlugins(fixtureLog); diff --git a/DistTestCore/PluginInterface.cs b/DistTestCore/PluginInterface.cs new file mode 100644 index 0000000..72b0da3 --- /dev/null +++ b/DistTestCore/PluginInterface.cs @@ -0,0 +1,7 @@ +namespace DistTestCore +{ + public abstract class PluginInterface + { + public abstract T GetPlugin() where T : IProjectPlugin; + } +} diff --git a/DistTestCore/PluginManager.cs b/DistTestCore/PluginManager.cs index 27f9bf7..b4bb804 100644 --- a/DistTestCore/PluginManager.cs +++ b/DistTestCore/PluginManager.cs @@ -1,6 +1,7 @@ using FileUtils; using KubernetesWorkflow; using Logging; +using System.Reflection; using Utils; namespace DistTestCore @@ -34,6 +35,11 @@ namespace DistTestCore { foreach (var plugin in projectPlugins) plugin.Finalize(log); } + + public T GetPlugin() where T : IProjectPlugin + { + return (T)projectPlugins.Single(p => p.GetType() == typeof(T)); + } } public static class PluginFinder @@ -45,10 +51,37 @@ namespace DistTestCore if (pluginTypes != null) return pluginTypes; // Reflection can be costly. Do this only once. + FindAndLoadPluginAssemblies(); + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - pluginTypes = assemblies.SelectMany(a => a.GetTypes().Where(t => typeof(IProjectPlugin).IsAssignableFrom(t))).ToArray(); + pluginTypes = assemblies.SelectMany(a => a.GetTypes().Where(t => + typeof(IProjectPlugin).IsAssignableFrom(t) && + !t.IsAbstract) + ).ToArray(); + return pluginTypes; } + + private static void FindAndLoadPluginAssemblies() + { + var files = Directory.GetFiles("."); + foreach (var file in files) + { + var f = file.ToLowerInvariant(); + if (f.Contains("plugin") && f.EndsWith("dll")) + { + var name = Path.GetFileNameWithoutExtension(file); + try + { + Assembly.Load(name); + } + catch (Exception ex) + { + throw new Exception($"Failed to load plugin from file '{name}'.", ex); + } + } + } + } } public interface IProjectPlugin diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 851e1f9..a13dee2 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -32,6 +32,11 @@ namespace DistTestCore public string TestNamespace { get; } public IFileManager FileManager { get; } + public T GetPlugin() where T : IProjectPlugin + { + return pluginManager.GetPlugin(); + } + public Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) { return new Http(Log, TimeSet, address, baseUrl, onClientCreated, logAlias); diff --git a/Logging/BaseTestLog.cs b/Logging/BaseTestLog.cs new file mode 100644 index 0000000..99f1eb1 --- /dev/null +++ b/Logging/BaseTestLog.cs @@ -0,0 +1,19 @@ +namespace Logging +{ + public abstract class BaseTestLog : BaseLog + { + private bool hasFailed; + + public BaseTestLog(bool debug) + : base(debug) + { + } + + public void MarkAsFailed() + { + if (hasFailed) return; + hasFailed = true; + LogFile.ConcatToFilename("_FAILED"); + } + } +} diff --git a/Logging/FixtureLog.cs b/Logging/FixtureLog.cs index 2a11fbd..306b40a 100644 --- a/Logging/FixtureLog.cs +++ b/Logging/FixtureLog.cs @@ -1,12 +1,12 @@ namespace Logging { - public class FixtureLog : TestLog + public class FixtureLog : BaseTestLog { private readonly string fullName; private readonly LogConfig config; public FixtureLog(LogConfig config, DateTime start, string name = "") - : base(config.LogRoot, config.DebugEnabled) + : base(config.DebugEnabled) { fullName = NameUtils.GetFixtureFullName(config, start, name); this.config = config; diff --git a/Logging/TestLog.cs b/Logging/TestLog.cs index 4279d10..217a755 100644 --- a/Logging/TestLog.cs +++ b/Logging/TestLog.cs @@ -1,10 +1,9 @@ namespace Logging { - public class TestLog : BaseLog + public class TestLog : BaseTestLog { private readonly string methodName; private readonly string fullName; - private bool hasFailed; public TestLog(string folder, bool debug, string name = "") : base(debug) @@ -15,13 +14,6 @@ Log($"*** Begin: {methodName}"); } - public void MarkAsFailed() - { - if (hasFailed) return; - hasFailed = true; - LogFile.ConcatToFilename("_FAILED"); - } - protected override string GetFullName() { return fullName; From 4cbd9ac8285f9bbe869722c5b20f34dd1ddd81e8 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 12 Sep 2023 11:37:20 +0200 Subject: [PATCH 06/51] workflow creator lifecycle --- CodexPlugin/CodexPlugin.cs | 2 +- DistTestCore/DistTest.cs | 5 +++-- DistTestCore/TestLifecycle.cs | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index a47f1a5..2f9c531 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -45,7 +45,7 @@ namespace CodexPlugin public ICodexNodeGroup SetupCodexNodes(int number) { - var rc = StartCodexNodes(1, s => { }); + var rc = StartCodexNodes(number, s => { }); return WrapCodexContainers(rc); } diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index d5020e2..fb56023 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -12,6 +12,7 @@ namespace DistTestCore public abstract class DistTest : PluginInterface { private const string TestsType = "dist-tests"; + private const string TestNamespacePrefix = "ct-"; private readonly Configuration configuration = new Configuration(); private readonly Assembly[] testAssemblies; private readonly FixtureLog fixtureLog; @@ -45,7 +46,7 @@ namespace DistTestCore Stopwatch.Measure(fixtureLog, "Global setup", () => { var wc = new WorkflowCreator(fixtureLog, configuration.GetK8sConfiguration(GetTimeSet()), string.Empty); - wc.CreateWorkflow().DeleteNamespace(); + wc.CreateWorkflow().DeleteNamespacesStartingWith(TestNamespacePrefix); }); } catch (Exception ex) @@ -221,7 +222,7 @@ namespace DistTestCore { lock (lifecycleLock) { - var testNamespace = Guid.NewGuid().ToString(); + var testNamespace = TestNamespacePrefix + Guid.NewGuid().ToString(); var lifecycle = new TestLifecycle(fixtureLog.CreateTestLog(), configuration, GetTimeSet(), testNamespace); lifecycles.Add(testName, lifecycle); DefaultContainerRecipe.TestsType = TestsType; diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index a13dee2..a075fe5 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -9,6 +9,7 @@ namespace DistTestCore { private readonly PluginManager pluginManager; private readonly DateTime testStart; + private readonly WorkflowCreator workflowCreator; public TestLifecycle(TestLog log, Configuration configuration, ITimeSet timeSet, string testNamespace) { @@ -19,6 +20,8 @@ namespace DistTestCore testStart = DateTime.UtcNow; FileManager = new FileManager(Log, Configuration.GetFileManagerFolder()); + workflowCreator = new WorkflowCreator(Log, Configuration.GetK8sConfiguration(TimeSet), TestNamespace); + pluginManager = new PluginManager(); pluginManager.DiscoverPlugins(); pluginManager.InitializePlugins(this); @@ -50,8 +53,7 @@ namespace DistTestCore public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null) { if (namespaceOverride != null) throw new Exception("Namespace override is not supported in the DistTest environment. (It would mess up automatic resource cleanup.)"); - var wc = new WorkflowCreator(Log, Configuration.GetK8sConfiguration(TimeSet), TestNamespace); - return wc.CreateWorkflow(); + return workflowCreator.CreateWorkflow(); } public IFileManager GetFileManager() From 32ad778a91fd2a5c1bd34fef7baf6a7699f78327 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 12 Sep 2023 11:43:46 +0200 Subject: [PATCH 07/51] twoclient test passed. --- CodexPlugin/CodexNodeFactory.cs | 12 +++++++-- CodexPlugin/CodexStarter.cs | 2 +- CodexPlugin/OnlineCodexNode.cs | 48 ++++++++++++++++----------------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/CodexPlugin/CodexNodeFactory.cs b/CodexPlugin/CodexNodeFactory.cs index 0394fac..90a5bfb 100644 --- a/CodexPlugin/CodexNodeFactory.cs +++ b/CodexPlugin/CodexNodeFactory.cs @@ -1,4 +1,5 @@ - +using DistTestCore; + namespace CodexPlugin { public interface ICodexNodeFactory @@ -8,6 +9,13 @@ namespace CodexPlugin public class CodexNodeFactory : ICodexNodeFactory { + private readonly IPluginTools tools; + + public CodexNodeFactory(IPluginTools tools) + { + this.tools = tools; + } + //private readonly TestLifecycle lifecycle; //private readonly IMetricsAccessFactory metricsAccessFactory; //private readonly IMarketplaceAccessFactory marketplaceAccessFactory; @@ -23,7 +31,7 @@ namespace CodexPlugin { //var metricsAccess = metricsAccessFactory.CreateMetricsAccess(access.Container); //var marketplaceAccess = marketplaceAccessFactory.CreateMarketplaceAccess(access); - return new OnlineCodexNode(/*lifecycle,*/ access, group/*, metricsAccess, marketplaceAccess*/); + return new OnlineCodexNode(tools, access, group/*, metricsAccess, marketplaceAccess*/); } } } diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index bec93c7..1507878 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -50,7 +50,7 @@ namespace CodexPlugin { //var metricAccessFactory = CollectMetrics(codexSetup, containers); - var codexNodeFactory = new CodexNodeFactory();// (lifecycle, metricAccessFactory, gethStartResult.MarketplaceAccessFactory); + var codexNodeFactory = new CodexNodeFactory(pluginTools);// (lifecycle, metricAccessFactory, gethStartResult.MarketplaceAccessFactory); return CreateCodexGroup(/*codexSetup,*/ containers, codexNodeFactory); //lifecycle.SetCodexVersion(group.Version); diff --git a/CodexPlugin/OnlineCodexNode.cs b/CodexPlugin/OnlineCodexNode.cs index a6bc54b..b3120f3 100644 --- a/CodexPlugin/OnlineCodexNode.cs +++ b/CodexPlugin/OnlineCodexNode.cs @@ -1,5 +1,7 @@ -using DistTestCore.Logs; +using DistTestCore; +using DistTestCore.Logs; using FileUtils; +using Logging; using NUnit.Framework; using Utils; @@ -24,11 +26,11 @@ namespace CodexPlugin { private const string SuccessfullyConnectedMessage = "Successfully connected to peer"; private const string UploadFailedMessage = "Unable to store block"; - //private readonly TestLifecycle lifecycle; + private readonly IPluginTools tools; - public OnlineCodexNode(/*TestLifecycle lifecycle, */CodexAccess codexAccess, CodexNodeGroup group/*, IMetricsAccess metricsAccess, IMarketplaceAccess marketplaceAccess*/) + public OnlineCodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group/*, IMetricsAccess metricsAccess, IMarketplaceAccess marketplaceAccess*/) { - //this.lifecycle = lifecycle; + this.tools = tools; CodexAccess = codexAccess; Group = group; //Metrics = metricsAccess; @@ -62,32 +64,30 @@ namespace CodexPlugin public ContentId UploadFile(TestFile file) { - //using var fileStream = File.OpenRead(file.Filename); + using var fileStream = File.OpenRead(file.Filename); - //var logMessage = $"Uploading file {file.Describe()}..."; - //Log(logMessage); - //var response = Stopwatch.Measure(lifecycle.Log, logMessage, () => - //{ - // return CodexAccess.UploadFile(fileStream); - //}); + var logMessage = $"Uploading file {file.Describe()}..."; + Log(logMessage); + var response = Stopwatch.Measure(tools.GetLog(), logMessage, () => + { + return CodexAccess.UploadFile(fileStream); + }); - //if (string.IsNullOrEmpty(response)) Assert.Fail("Received empty response."); - //if (response.StartsWith(UploadFailedMessage)) Assert.Fail("Node failed to store block."); + if (string.IsNullOrEmpty(response)) Assert.Fail("Received empty response."); + if (response.StartsWith(UploadFailedMessage)) Assert.Fail("Node failed to store block."); - //Log($"Uploaded file. Received contentId: '{response}'."); - //return new ContentId(response); - return null!; + Log($"Uploaded file. Received contentId: '{response}'."); + return new ContentId(response); } public TestFile? DownloadContent(ContentId contentId, string fileLabel = "") { - //var logMessage = $"Downloading for contentId: '{contentId.Id}'..."; - //Log(logMessage); - //var file = lifecycle.FileManager.CreateEmptyTestFile(fileLabel); - //Stopwatch.Measure(lifecycle.Log, logMessage, () => DownloadToFile(contentId.Id, file)); - //Log($"Downloaded file {file.Describe()} to '{file.Filename}'."); - //return file; - return null!; + var logMessage = $"Downloading for contentId: '{contentId.Id}'..."; + Log(logMessage); + var file = tools.GetFileManager().CreateEmptyTestFile(fileLabel); + Stopwatch.Measure(tools.GetLog(), logMessage, () => DownloadToFile(contentId.Id, file)); + Log($"Downloaded file {file.Describe()} to '{file.Filename}'."); + return file; } public void ConnectToPeer(IOnlineCodexNode node) @@ -159,7 +159,7 @@ namespace CodexPlugin private void Log(string msg) { - //lifecycle.Log.Log($"{GetName()}: {msg}"); + tools.GetLog().Log($"{GetName()}: {msg}"); } } From dc1bed6861703984fc456679a9ace0c0508ccc03 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 12 Sep 2023 13:32:06 +0200 Subject: [PATCH 08/51] Extracts core from disttest core. --- CodexPlugin/CodexAccess.cs | 2 +- CodexPlugin/CodexContainerRecipe.cs | 2 +- CodexPlugin/CodexNodeFactory.cs | 2 +- CodexPlugin/CodexNodeGroup.cs | 3 +- CodexPlugin/CodexPlugin.cs | 11 +++-- CodexPlugin/CodexPlugin.csproj | 2 +- CodexPlugin/CodexStarter.cs | 2 +- CodexPlugin/OnlineCodexNode.cs | 19 +++++---- CodexPlugin/PluginInterfaceExtensions.cs | 7 +++- Core/Core.csproj | 14 +++++++ .../DefaultContainerRecipe.cs | 2 +- {DistTestCore/Logs => Core}/DownloadedLog.cs | 16 ++++--- {DistTestCore => Core}/Http.cs | 2 +- .../Logs => Core}/LogDownloadHandler.cs | 10 ++--- {DistTestCore => Core}/PluginInterface.cs | 2 +- {DistTestCore => Core}/PluginManager.cs | 2 +- DistTestCore/Timing.cs => Core/TimeSet.cs | 42 +------------------ DistTestCore/Configuration.cs | 4 +- DistTestCore/DistTest.cs | 4 +- DistTestCore/DistTestCore.csproj | 1 + ...ownloadLogsAndMetricsOnFailureAttribute.cs | 2 +- DistTestCore/LongTimeSet.cs | 37 ++++++++++++++++ DistTestCore/LongTimeoutsTestAttribute.cs | 9 ++++ DistTestCore/TestLifecycle.cs | 3 +- FileUtils/FileManager.cs | 22 +++++----- FileUtils/{TestFile.cs => TrackedFile.cs} | 10 ++--- KubernetesWorkflow/StartupWorkflow.cs | 4 +- cs-codex-dist-testing.sln | 10 ++++- 28 files changed, 140 insertions(+), 106 deletions(-) create mode 100644 Core/Core.csproj rename {DistTestCore => Core}/DefaultContainerRecipe.cs (98%) rename {DistTestCore/Logs => Core}/DownloadedLog.cs (73%) rename {DistTestCore => Core}/Http.cs (99%) rename {DistTestCore/Logs => Core}/LogDownloadHandler.cs (58%) rename {DistTestCore => Core}/PluginInterface.cs (82%) rename {DistTestCore => Core}/PluginManager.cs (99%) rename DistTestCore/Timing.cs => Core/TimeSet.cs (52%) rename DistTestCore/{Logs => }/DontDownloadLogsAndMetricsOnFailureAttribute.cs (93%) create mode 100644 DistTestCore/LongTimeSet.cs create mode 100644 DistTestCore/LongTimeoutsTestAttribute.cs rename FileUtils/{TestFile.cs => TrackedFile.cs} (89%) diff --git a/CodexPlugin/CodexAccess.cs b/CodexPlugin/CodexAccess.cs index 842beb7..40433cf 100644 --- a/CodexPlugin/CodexAccess.cs +++ b/CodexPlugin/CodexAccess.cs @@ -1,4 +1,4 @@ -using DistTestCore; +using Core; using KubernetesWorkflow; using Utils; diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index 1f7f689..f16a88b 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -1,5 +1,5 @@ //using DistTestCore.Marketplace; -using DistTestCore; +using Core; using KubernetesWorkflow; using Utils; diff --git a/CodexPlugin/CodexNodeFactory.cs b/CodexPlugin/CodexNodeFactory.cs index 90a5bfb..89b69eb 100644 --- a/CodexPlugin/CodexNodeFactory.cs +++ b/CodexPlugin/CodexNodeFactory.cs @@ -1,4 +1,4 @@ -using DistTestCore; +using Core; namespace CodexPlugin { diff --git a/CodexPlugin/CodexNodeGroup.cs b/CodexPlugin/CodexNodeGroup.cs index a4c868e..c2e619e 100644 --- a/CodexPlugin/CodexNodeGroup.cs +++ b/CodexPlugin/CodexNodeGroup.cs @@ -1,6 +1,5 @@ -using DistTestCore; +using Core; using KubernetesWorkflow; -using Logging; using System.Collections; namespace CodexPlugin diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index 2f9c531..dbc18b1 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -1,4 +1,4 @@ -using DistTestCore; +using Core; using KubernetesWorkflow; using Logging; @@ -40,7 +40,13 @@ namespace CodexPlugin public IOnlineCodexNode SetupCodexNode(Action setup) { - return null!; + return SetupCodexNodes(1, setup)[0]; + } + + public ICodexNodeGroup SetupCodexNodes(int number, Action setup) + { + var rc = StartCodexNodes(number, setup); + return WrapCodexContainers(rc); } public ICodexNodeGroup SetupCodexNodes(int number) @@ -48,6 +54,5 @@ namespace CodexPlugin var rc = StartCodexNodes(number, s => { }); return WrapCodexContainers(rc); } - } } diff --git a/CodexPlugin/CodexPlugin.csproj b/CodexPlugin/CodexPlugin.csproj index dc720d5..e17d31e 100644 --- a/CodexPlugin/CodexPlugin.csproj +++ b/CodexPlugin/CodexPlugin.csproj @@ -21,7 +21,7 @@ - + diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index 1507878..7480090 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -1,4 +1,4 @@ -using DistTestCore; +using Core; using KubernetesWorkflow; using Logging; diff --git a/CodexPlugin/OnlineCodexNode.cs b/CodexPlugin/OnlineCodexNode.cs index b3120f3..7895076 100644 --- a/CodexPlugin/OnlineCodexNode.cs +++ b/CodexPlugin/OnlineCodexNode.cs @@ -1,5 +1,4 @@ -using DistTestCore; -using DistTestCore.Logs; +using Core; using FileUtils; using Logging; using NUnit.Framework; @@ -12,8 +11,8 @@ namespace CodexPlugin string GetName(); CodexDebugResponse GetDebugInfo(); CodexDebugPeerResponse GetDebugPeer(string peerId); - ContentId UploadFile(TestFile file); - TestFile? DownloadContent(ContentId contentId, string fileLabel = ""); + ContentId UploadFile(TrackedFile file); + TrackedFile? DownloadContent(ContentId contentId, string fileLabel = ""); void ConnectToPeer(IOnlineCodexNode node); IDownloadedLog DownloadLog(int? tailLines = null); //IMetricsAccess Metrics { get; } @@ -62,7 +61,7 @@ namespace CodexPlugin return CodexAccess.GetDebugPeer(peerId); } - public ContentId UploadFile(TestFile file) + public ContentId UploadFile(TrackedFile file) { using var fileStream = File.OpenRead(file.Filename); @@ -80,7 +79,7 @@ namespace CodexPlugin return new ContentId(response); } - public TestFile? DownloadContent(ContentId contentId, string fileLabel = "") + public TrackedFile? DownloadContent(ContentId contentId, string fileLabel = "") { var logMessage = $"Downloading for contentId: '{contentId.Id}'..."; Log(logMessage); @@ -104,7 +103,11 @@ namespace CodexPlugin public IDownloadedLog DownloadLog(int? tailLines = null) { - return null!; // lifecycle.DownloadLog(CodexAccess.Container, tailLines); + var workflow = tools.CreateWorkflow(); + var file = tools.GetLog().CreateSubfile(); + var logHandler = new LogDownloadHandler(CodexAccess.GetName(), file); + workflow.DownloadContainerLog(CodexAccess.Container, logHandler); + return logHandler.DownloadLog(); } public void BringOffline() @@ -142,7 +145,7 @@ namespace CodexPlugin return multiAddress.Replace("0.0.0.0", peer.CodexAccess.Container.Pod.PodInfo.Ip); } - private void DownloadToFile(string contentId, TestFile file) + private void DownloadToFile(string contentId, TrackedFile file) { using var fileStream = File.OpenWrite(file.Filename); try diff --git a/CodexPlugin/PluginInterfaceExtensions.cs b/CodexPlugin/PluginInterfaceExtensions.cs index 8fa21f2..3300afe 100644 --- a/CodexPlugin/PluginInterfaceExtensions.cs +++ b/CodexPlugin/PluginInterfaceExtensions.cs @@ -1,4 +1,4 @@ -using DistTestCore; +using Core; using KubernetesWorkflow; namespace CodexPlugin @@ -20,6 +20,11 @@ namespace CodexPlugin return Plugin(pluginInterface).SetupCodexNode(setup); } + public static ICodexNodeGroup SetupCodexNodes(this PluginInterface pluginInterface, int number, Action setup) + { + return Plugin(pluginInterface).SetupCodexNodes(number, setup); + } + public static ICodexNodeGroup SetupCodexNodes(this PluginInterface pluginInterface, int number) { return Plugin(pluginInterface).SetupCodexNodes(number); diff --git a/Core/Core.csproj b/Core/Core.csproj new file mode 100644 index 0000000..58c7951 --- /dev/null +++ b/Core/Core.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + + + + + + + + diff --git a/DistTestCore/DefaultContainerRecipe.cs b/Core/DefaultContainerRecipe.cs similarity index 98% rename from DistTestCore/DefaultContainerRecipe.cs rename to Core/DefaultContainerRecipe.cs index cfb8e57..1a34e71 100644 --- a/DistTestCore/DefaultContainerRecipe.cs +++ b/Core/DefaultContainerRecipe.cs @@ -1,7 +1,7 @@ using KubernetesWorkflow; using Logging; -namespace DistTestCore +namespace Core { public abstract class DefaultContainerRecipe : ContainerRecipeFactory { diff --git a/DistTestCore/Logs/DownloadedLog.cs b/Core/DownloadedLog.cs similarity index 73% rename from DistTestCore/Logs/DownloadedLog.cs rename to Core/DownloadedLog.cs index 606c411..ee31ed7 100644 --- a/DistTestCore/Logs/DownloadedLog.cs +++ b/Core/DownloadedLog.cs @@ -1,11 +1,10 @@ using Logging; -using NUnit.Framework; -namespace DistTestCore.Logs +namespace Core { public interface IDownloadedLog { - void AssertLogContains(string expectedString); + bool DoesLogContain(string expectedString); string[] FindLinesThatContain(params string[] tags); void DeleteFile(); } @@ -13,15 +12,13 @@ namespace DistTestCore.Logs public class DownloadedLog : IDownloadedLog { private readonly LogFile logFile; - private readonly string owner; - public DownloadedLog(LogFile logFile, string owner) + public DownloadedLog(LogFile logFile) { this.logFile = logFile; - this.owner = owner; } - public void AssertLogContains(string expectedString) + public bool DoesLogContain(string expectedString) { using var file = File.OpenRead(logFile.FullFilename); using var streamReader = new StreamReader(file); @@ -29,11 +26,12 @@ namespace DistTestCore.Logs var line = streamReader.ReadLine(); while (line != null) { - if (line.Contains(expectedString)) return; + if (line.Contains(expectedString)) return true; line = streamReader.ReadLine(); } - Assert.Fail($"{owner} Unable to find string '{expectedString}' in CodexNode log file {logFile.FullFilename}"); + //Assert.Fail($"{owner} Unable to find string '{expectedString}' in CodexNode log file {logFile.FullFilename}"); + return false; } public string[] FindLinesThatContain(params string[] tags) diff --git a/DistTestCore/Http.cs b/Core/Http.cs similarity index 99% rename from DistTestCore/Http.cs rename to Core/Http.cs index 5df53bb..dbb2f99 100644 --- a/DistTestCore/Http.cs +++ b/Core/Http.cs @@ -5,7 +5,7 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using Utils; -namespace DistTestCore +namespace Core { public class Http { diff --git a/DistTestCore/Logs/LogDownloadHandler.cs b/Core/LogDownloadHandler.cs similarity index 58% rename from DistTestCore/Logs/LogDownloadHandler.cs rename to Core/LogDownloadHandler.cs index 483e46b..ae6a1da 100644 --- a/DistTestCore/Logs/LogDownloadHandler.cs +++ b/Core/LogDownloadHandler.cs @@ -1,25 +1,23 @@ using KubernetesWorkflow; using Logging; -namespace DistTestCore.Logs +namespace Core { public class LogDownloadHandler : LogHandler, ILogHandler { - private readonly RunningContainer container; private readonly LogFile log; - public LogDownloadHandler(RunningContainer container, string description, LogFile log) + public LogDownloadHandler(string description, LogFile log) { - this.container = container; this.log = log; log.Write($"{description} -->> {log.FullFilename}"); log.WriteRaw(description); } - public DownloadedLog DownloadLog() + public IDownloadedLog DownloadLog() { - return new DownloadedLog(log, container.Name); + return new DownloadedLog(log); } protected override void ProcessLine(string line) diff --git a/DistTestCore/PluginInterface.cs b/Core/PluginInterface.cs similarity index 82% rename from DistTestCore/PluginInterface.cs rename to Core/PluginInterface.cs index 72b0da3..619d0b5 100644 --- a/DistTestCore/PluginInterface.cs +++ b/Core/PluginInterface.cs @@ -1,4 +1,4 @@ -namespace DistTestCore +namespace Core { public abstract class PluginInterface { diff --git a/DistTestCore/PluginManager.cs b/Core/PluginManager.cs similarity index 99% rename from DistTestCore/PluginManager.cs rename to Core/PluginManager.cs index b4bb804..27783de 100644 --- a/DistTestCore/PluginManager.cs +++ b/Core/PluginManager.cs @@ -4,7 +4,7 @@ using Logging; using System.Reflection; using Utils; -namespace DistTestCore +namespace Core { public class PluginManager { diff --git a/DistTestCore/Timing.cs b/Core/TimeSet.cs similarity index 52% rename from DistTestCore/Timing.cs rename to Core/TimeSet.cs index 38df6d8..db78fa5 100644 --- a/DistTestCore/Timing.cs +++ b/Core/TimeSet.cs @@ -1,12 +1,5 @@ -using NUnit.Framework; - -namespace DistTestCore +namespace Core { - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class UseLongTimeoutsAttribute : PropertyAttribute - { - } - public interface ITimeSet { TimeSpan HttpCallTimeout(); @@ -49,37 +42,4 @@ namespace DistTestCore return TimeSpan.FromSeconds(30); } } - - public class LongTimeSet : ITimeSet - { - public TimeSpan HttpCallTimeout() - { - return TimeSpan.FromHours(2); - } - - public TimeSpan HttpCallRetryTime() - { - return TimeSpan.FromHours(5); - } - - public TimeSpan HttpCallRetryDelay() - { - return TimeSpan.FromSeconds(2); - } - - public TimeSpan WaitForK8sServiceDelay() - { - return TimeSpan.FromSeconds(10); - } - - public TimeSpan K8sOperationTimeout() - { - return TimeSpan.FromMinutes(15); - } - - public TimeSpan WaitForMetricTimeout() - { - return TimeSpan.FromMinutes(5); - } - } } diff --git a/DistTestCore/Configuration.cs b/DistTestCore/Configuration.cs index 91d64d3..2eefcf0 100644 --- a/DistTestCore/Configuration.cs +++ b/DistTestCore/Configuration.cs @@ -1,6 +1,4 @@ -using KubernetesWorkflow; -using System.Net.NetworkInformation; -using Utils; +using Core; namespace DistTestCore { diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index fb56023..257332e 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -1,4 +1,4 @@ -using DistTestCore.Logs; +using Core; using FileUtils; using KubernetesWorkflow; using Logging; @@ -92,7 +92,7 @@ namespace DistTestCore } } - public TestFile GenerateTestFile(ByteSize size, string label = "") + public TrackedFile GenerateTestFile(ByteSize size, string label = "") { return Get().FileManager.GenerateTestFile(size, label); } diff --git a/DistTestCore/DistTestCore.csproj b/DistTestCore/DistTestCore.csproj index 6005043..69da090 100644 --- a/DistTestCore/DistTestCore.csproj +++ b/DistTestCore/DistTestCore.csproj @@ -19,6 +19,7 @@ + diff --git a/DistTestCore/Logs/DontDownloadLogsAndMetricsOnFailureAttribute.cs b/DistTestCore/DontDownloadLogsAndMetricsOnFailureAttribute.cs similarity index 93% rename from DistTestCore/Logs/DontDownloadLogsAndMetricsOnFailureAttribute.cs rename to DistTestCore/DontDownloadLogsAndMetricsOnFailureAttribute.cs index b95d875..335bbe3 100644 --- a/DistTestCore/Logs/DontDownloadLogsAndMetricsOnFailureAttribute.cs +++ b/DistTestCore/DontDownloadLogsAndMetricsOnFailureAttribute.cs @@ -1,6 +1,6 @@ using NUnit.Framework; -namespace DistTestCore.Logs +namespace DistTestCore { [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class DontDownloadLogsAndMetricsOnFailureAttribute : PropertyAttribute diff --git a/DistTestCore/LongTimeSet.cs b/DistTestCore/LongTimeSet.cs new file mode 100644 index 0000000..8d0d5e8 --- /dev/null +++ b/DistTestCore/LongTimeSet.cs @@ -0,0 +1,37 @@ +using Core; + +namespace DistTestCore +{ + public class LongTimeSet : ITimeSet + { + public TimeSpan HttpCallTimeout() + { + return TimeSpan.FromHours(2); + } + + public TimeSpan HttpCallRetryTime() + { + return TimeSpan.FromHours(5); + } + + public TimeSpan HttpCallRetryDelay() + { + return TimeSpan.FromSeconds(2); + } + + public TimeSpan WaitForK8sServiceDelay() + { + return TimeSpan.FromSeconds(10); + } + + public TimeSpan K8sOperationTimeout() + { + return TimeSpan.FromMinutes(15); + } + + public TimeSpan WaitForMetricTimeout() + { + return TimeSpan.FromMinutes(5); + } + } +} diff --git a/DistTestCore/LongTimeoutsTestAttribute.cs b/DistTestCore/LongTimeoutsTestAttribute.cs new file mode 100644 index 0000000..5bbc612 --- /dev/null +++ b/DistTestCore/LongTimeoutsTestAttribute.cs @@ -0,0 +1,9 @@ +using NUnit.Framework; + +namespace DistTestCore +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class UseLongTimeoutsAttribute : PropertyAttribute + { + } +} diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index a075fe5..864f6ca 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -1,4 +1,5 @@ -using FileUtils; +using Core; +using FileUtils; using KubernetesWorkflow; using Logging; using Utils; diff --git a/FileUtils/FileManager.cs b/FileUtils/FileManager.cs index 4fc7b52..6d389ef 100644 --- a/FileUtils/FileManager.cs +++ b/FileUtils/FileManager.cs @@ -6,8 +6,8 @@ namespace FileUtils { public interface IFileManager { - TestFile CreateEmptyTestFile(string label = ""); - TestFile GenerateTestFile(ByteSize size, string label = ""); + TrackedFile CreateEmptyTestFile(string label = ""); + TrackedFile GenerateTestFile(ByteSize size, string label = ""); void DeleteAllTestFiles(); void ScopedFiles(Action action); T ScopedFiles(Func action); @@ -20,7 +20,7 @@ namespace FileUtils private readonly Random random = new Random(); private readonly ILog log; private readonly string folder; - private readonly List> fileSetStack = new List>(); + private readonly List> fileSetStack = new List>(); public FileManager(ILog log, string rootFolder) { @@ -30,16 +30,16 @@ namespace FileUtils this.log = log; } - public TestFile CreateEmptyTestFile(string label = "") + public TrackedFile CreateEmptyTestFile(string label = "") { var path = Path.Combine(folder, Guid.NewGuid().ToString() + "_test.bin"); - var result = new TestFile(log, path, label); + var result = new TrackedFile(log, path, label); File.Create(result.Filename).Close(); if (fileSetStack.Any()) fileSetStack.Last().Add(result); return result; } - public TestFile GenerateTestFile(ByteSize size, string label) + public TrackedFile GenerateTestFile(ByteSize size, string label) { var sw = Stopwatch.Begin(log); var result = GenerateFile(size, label); @@ -69,7 +69,7 @@ namespace FileUtils private void PushFileSet() { - fileSetStack.Add(new List()); + fileSetStack.Add(new List()); } private void PopFileSet() @@ -88,7 +88,7 @@ namespace FileUtils } } - private TestFile GenerateFile(ByteSize size, string label) + private TrackedFile GenerateFile(ByteSize size, string label) { var result = CreateEmptyTestFile(label); CheckSpaceAvailable(result, size); @@ -97,7 +97,7 @@ namespace FileUtils return result; } - private void CheckSpaceAvailable(TestFile testFile, ByteSize size) + private void CheckSpaceAvailable(TrackedFile testFile, ByteSize size) { var file = new FileInfo(testFile.Filename); var drive = new DriveInfo(file.Directory!.Root.FullName); @@ -115,7 +115,7 @@ namespace FileUtils } } - private void GenerateFileBytes(TestFile result, ByteSize size) + private void GenerateFileBytes(TrackedFile result, ByteSize size) { long bytesLeft = size.SizeInBytes; int chunkSize = ChunkSize; @@ -135,7 +135,7 @@ namespace FileUtils } } - private void AppendRandomBytesToFile(TestFile result, long length) + private void AppendRandomBytesToFile(TrackedFile result, long length) { var bytes = new byte[length]; random.NextBytes(bytes); diff --git a/FileUtils/TestFile.cs b/FileUtils/TrackedFile.cs similarity index 89% rename from FileUtils/TestFile.cs rename to FileUtils/TrackedFile.cs index cb51544..3b6ccfb 100644 --- a/FileUtils/TestFile.cs +++ b/FileUtils/TrackedFile.cs @@ -4,11 +4,11 @@ using Utils; namespace FileUtils { - public class TestFile + public class TrackedFile { private readonly ILog log; - public TestFile(ILog log, string filename, string label) + public TrackedFile(ILog log, string filename, string label) { this.log = log; Filename = filename; @@ -18,7 +18,7 @@ namespace FileUtils public string Filename { get; } public string Label { get; } - public void AssertIsEqual(TestFile? actual) + public void AssertIsEqual(TrackedFile? actual) { var sw = Stopwatch.Begin(log); try @@ -27,7 +27,7 @@ namespace FileUtils } finally { - sw.End($"{nameof(TestFile)}.{nameof(AssertIsEqual)}"); + sw.End($"{nameof(TrackedFile)}.{nameof(AssertIsEqual)}"); } } @@ -38,7 +38,7 @@ namespace FileUtils return $"'{Filename}'{sizePostfix}"; } - private void AssertEqual(TestFile? actual) + private void AssertEqual(TrackedFile? actual) { if (actual == null) Assert.Fail("TestFile is null."); if (actual == this || actual!.Filename == Filename) Assert.Fail("TestFile is compared to itself."); diff --git a/KubernetesWorkflow/StartupWorkflow.cs b/KubernetesWorkflow/StartupWorkflow.cs index 97b66ed..5cc8f1e 100644 --- a/KubernetesWorkflow/StartupWorkflow.cs +++ b/KubernetesWorkflow/StartupWorkflow.cs @@ -7,7 +7,7 @@ namespace KubernetesWorkflow { RunningContainers Start(int numberOfContainers, Location location, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig); void Stop(RunningContainers runningContainers); - void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines); + void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines = null); string ExecuteCommand(RunningContainer container, string command, params string[] args); void DeleteNamespace(); void DeleteNamespacesStartingWith(string namespacePrefix); @@ -53,7 +53,7 @@ namespace KubernetesWorkflow }); } - public void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines) + public void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines = null) { K8s(controller => { diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index 59b290c..9cd1c7e 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -23,9 +23,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArgsUniform", "ArgsUniform\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDownloader", "CodexNetDownloader\CodexNetDownloader.csproj", "{6CDF35D2-906A-4285-8E1F-4794588B948B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileUtils", "FileUtils\FileUtils.csproj", "{ECC954DA-8D4E-49EE-83AD-80085A43DEEB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileUtils", "FileUtils\FileUtils.csproj", "{ECC954DA-8D4E-49EE-83AD-80085A43DEEB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexPlugin", "CodexPlugin\CodexPlugin.csproj", "{DE4E802C-288C-45C4-84B6-8A5A6A96EF49}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexPlugin", "CodexPlugin\CodexPlugin.csproj", "{DE4E802C-288C-45C4-84B6-8A5A6A96EF49}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{F2BF34B3-C660-43EF-BD42-BC5C60237FC4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -81,6 +83,10 @@ Global {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Debug|Any CPU.Build.0 = Debug|Any CPU {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Release|Any CPU.ActiveCfg = Release|Any CPU {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Release|Any CPU.Build.0 = Release|Any CPU + {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 8849c4dfa75edfba1046e993713cf3586d3f23ff Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 12 Sep 2023 14:50:18 +0200 Subject: [PATCH 09/51] Extracts plugin mapping to core assembly. --- CodexPlugin/CoreInterfaceExtensions.cs | 38 ++++++++++ CodexPlugin/PluginInterfaceExtensions.cs | 38 ---------- Core/CoreInterface.cs | 23 ++++++ Core/EntryPoint.cs | 90 ++++++++++++++++++++++++ Core/PluginFinder.cs | 46 ++++++++++++ Core/PluginInterface.cs | 7 -- Core/PluginManager.cs | 45 +----------- DistTestCore/Configuration.cs | 5 +- DistTestCore/DistTest.cs | 39 ++++------ DistTestCore/TestLifecycle.cs | 53 ++------------ KubernetesWorkflow/Configuration.cs | 4 +- KubernetesWorkflow/WorkflowCreator.cs | 11 +-- 12 files changed, 231 insertions(+), 168 deletions(-) create mode 100644 CodexPlugin/CoreInterfaceExtensions.cs delete mode 100644 CodexPlugin/PluginInterfaceExtensions.cs create mode 100644 Core/CoreInterface.cs create mode 100644 Core/EntryPoint.cs create mode 100644 Core/PluginFinder.cs delete mode 100644 Core/PluginInterface.cs diff --git a/CodexPlugin/CoreInterfaceExtensions.cs b/CodexPlugin/CoreInterfaceExtensions.cs new file mode 100644 index 0000000..dce2bcc --- /dev/null +++ b/CodexPlugin/CoreInterfaceExtensions.cs @@ -0,0 +1,38 @@ +using Core; +using KubernetesWorkflow; + +namespace CodexPlugin +{ + public static class CoreInterfaceExtensions + { + public static RunningContainers[] StartCodexNodes(this CoreInterface ci, int number, Action setup) + { + return Plugin(ci).StartCodexNodes(number, setup); + } + + public static ICodexNodeGroup WrapCodexContainers(this CoreInterface ci, RunningContainers[] containers) + { + return Plugin(ci).WrapCodexContainers(containers); + } + + public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci, Action setup) + { + return Plugin(ci).SetupCodexNode(setup); + } + + public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number, Action setup) + { + return Plugin(ci).SetupCodexNodes(number, setup); + } + + public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number) + { + return Plugin(ci).SetupCodexNodes(number); + } + + private static CodexPlugin Plugin(CoreInterface ci) + { + return ci.GetPlugin(); + } + } +} diff --git a/CodexPlugin/PluginInterfaceExtensions.cs b/CodexPlugin/PluginInterfaceExtensions.cs deleted file mode 100644 index 3300afe..0000000 --- a/CodexPlugin/PluginInterfaceExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Core; -using KubernetesWorkflow; - -namespace CodexPlugin -{ - public static class PluginInterfaceExtensions - { - public static RunningContainers[] StartCodexNodes(this PluginInterface pluginInterface, int number, Action setup) - { - return Plugin(pluginInterface).StartCodexNodes(number, setup); - } - - public static ICodexNodeGroup WrapCodexContainers(this PluginInterface pluginInterface, RunningContainers[] containers) - { - return Plugin(pluginInterface).WrapCodexContainers(containers); - } - - public static IOnlineCodexNode SetupCodexNode(this PluginInterface pluginInterface, Action setup) - { - return Plugin(pluginInterface).SetupCodexNode(setup); - } - - public static ICodexNodeGroup SetupCodexNodes(this PluginInterface pluginInterface, int number, Action setup) - { - return Plugin(pluginInterface).SetupCodexNodes(number, setup); - } - - public static ICodexNodeGroup SetupCodexNodes(this PluginInterface pluginInterface, int number) - { - return Plugin(pluginInterface).SetupCodexNodes(number); - } - - private static CodexPlugin Plugin(PluginInterface pluginInterface) - { - return pluginInterface.GetPlugin(); - } - } -} diff --git a/Core/CoreInterface.cs b/Core/CoreInterface.cs new file mode 100644 index 0000000..351b7ad --- /dev/null +++ b/Core/CoreInterface.cs @@ -0,0 +1,23 @@ +namespace Core +{ + public class CoreInterface + { + private static readonly Dictionary coreAssociations = new Dictionary(); + + public T GetPlugin() where T : IProjectPlugin + { + return coreAssociations[this].GetPlugin(); + } + + internal static void Associate(CoreInterface coreInterface, EntryPoint entryPoint) + { + coreAssociations.Add(coreInterface, entryPoint); + } + + internal static void Desociate(EntryPoint entryPoint) + { + var key = coreAssociations.Single(p => p.Value == entryPoint).Key; + coreAssociations.Remove(key); + } + } +} diff --git a/Core/EntryPoint.cs b/Core/EntryPoint.cs new file mode 100644 index 0000000..96fdd19 --- /dev/null +++ b/Core/EntryPoint.cs @@ -0,0 +1,90 @@ +using FileUtils; +using KubernetesWorkflow; +using Logging; +using Utils; + +namespace Core +{ + public class EntryPoint : IPluginTools + { + private readonly PluginManager manager = new PluginManager(); + private readonly ILog log; + private readonly ITimeSet timeSet; + private readonly FileManager fileManager; + private readonly WorkflowCreator workflowCreator; + + public EntryPoint(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) + { + this.log = log; + this.timeSet = timeSet; + fileManager = new FileManager(log, fileManagerRootFolder); + workflowCreator = new WorkflowCreator(log, configuration); + + manager.InstantiatePlugins(PluginFinder.GetPluginTypes()); + } + + public EntryPoint(ILog log, Configuration configuration, string fileManagerRootFolder) + : this(log, configuration, fileManagerRootFolder, new DefaultTimeSet()) + { + } + + public void Announce() + { + manager.AnnouncePlugins(log); + } + + public void Initialize() + { + manager.InitializePlugins(this); + } + + public void ManuallyAssociateCoreInterface(CoreInterface ci) + { + CoreInterface.Associate(ci, this); + } + + public CoreInterface CreateInterface() + { + var ci = new CoreInterface(); + CoreInterface.Associate(ci, this); + return ci; + } + + public void Decommission() + { + CoreInterface.Desociate(this); + manager.FinalizePlugins(log); + } + + internal T GetPlugin() where T : IProjectPlugin + { + return manager.GetPlugin(); + } + + public Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) + { + return new Http(log, timeSet, address, baseUrl, onClientCreated, logAlias); + } + + public Http CreateHttp(Address address, string baseUrl, string? logAlias = null) + { + return new Http(log, timeSet, address, baseUrl, logAlias); + } + + public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null) + { + if (namespaceOverride != null) throw new Exception("Namespace override is not supported in the DistTest environment. (It would mess up automatic resource cleanup.)"); + return workflowCreator.CreateWorkflow(); + } + + public IFileManager GetFileManager() + { + return fileManager; + } + + public ILog GetLog() + { + return log; + } + } +} diff --git a/Core/PluginFinder.cs b/Core/PluginFinder.cs new file mode 100644 index 0000000..693db86 --- /dev/null +++ b/Core/PluginFinder.cs @@ -0,0 +1,46 @@ +using System.Reflection; + +namespace Core +{ + public static class PluginFinder + { + private static Type[]? pluginTypes = null; + + public static Type[] GetPluginTypes() + { + if (pluginTypes != null) return pluginTypes; + + // Reflection can be costly. Do this only once. + FindAndLoadPluginAssemblies(); + + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + pluginTypes = assemblies.SelectMany(a => a.GetTypes().Where(t => + typeof(IProjectPlugin).IsAssignableFrom(t) && + !t.IsAbstract) + ).ToArray(); + + return pluginTypes; + } + + private static void FindAndLoadPluginAssemblies() + { + var files = Directory.GetFiles("."); + foreach (var file in files) + { + var f = file.ToLowerInvariant(); + if (f.Contains("plugin") && f.EndsWith("dll")) + { + var name = Path.GetFileNameWithoutExtension(file); + try + { + Assembly.Load(name); + } + catch (Exception ex) + { + throw new Exception($"Failed to load plugin from file '{name}'.", ex); + } + } + } + } + } +} diff --git a/Core/PluginInterface.cs b/Core/PluginInterface.cs deleted file mode 100644 index 619d0b5..0000000 --- a/Core/PluginInterface.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Core -{ - public abstract class PluginInterface - { - public abstract T GetPlugin() where T : IProjectPlugin; - } -} diff --git a/Core/PluginManager.cs b/Core/PluginManager.cs index 27783de..c8cf8a4 100644 --- a/Core/PluginManager.cs +++ b/Core/PluginManager.cs @@ -10,10 +10,9 @@ namespace Core { private readonly List projectPlugins = new List(); - public void DiscoverPlugins() + public void InstantiatePlugins(Type[] pluginTypes) { projectPlugins.Clear(); - var pluginTypes = PluginFinder.GetPluginTypes(); foreach (var pluginType in pluginTypes) { var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType)!; @@ -42,48 +41,6 @@ namespace Core } } - public static class PluginFinder - { - private static Type[]? pluginTypes = null; - - public static Type[] GetPluginTypes() - { - if (pluginTypes != null) return pluginTypes; - - // Reflection can be costly. Do this only once. - FindAndLoadPluginAssemblies(); - - var assemblies = AppDomain.CurrentDomain.GetAssemblies(); - pluginTypes = assemblies.SelectMany(a => a.GetTypes().Where(t => - typeof(IProjectPlugin).IsAssignableFrom(t) && - !t.IsAbstract) - ).ToArray(); - - return pluginTypes; - } - - private static void FindAndLoadPluginAssemblies() - { - var files = Directory.GetFiles("."); - foreach (var file in files) - { - var f = file.ToLowerInvariant(); - if (f.Contains("plugin") && f.EndsWith("dll")) - { - var name = Path.GetFileNameWithoutExtension(file); - try - { - Assembly.Load(name); - } - catch (Exception ex) - { - throw new Exception($"Failed to load plugin from file '{name}'.", ex); - } - } - } - } - } - public interface IProjectPlugin { void Announce(ILog log); diff --git a/DistTestCore/Configuration.cs b/DistTestCore/Configuration.cs index 2eefcf0..b76a380 100644 --- a/DistTestCore/Configuration.cs +++ b/DistTestCore/Configuration.cs @@ -31,12 +31,13 @@ namespace DistTestCore this.k8sNamespacePrefix = k8sNamespacePrefix; } - public KubernetesWorkflow.Configuration GetK8sConfiguration(ITimeSet timeSet) + public KubernetesWorkflow.Configuration GetK8sConfiguration(ITimeSet timeSet, string k8sNamespace) { return new KubernetesWorkflow.Configuration( kubeConfigFile: kubeConfigFile, operationTimeout: timeSet.K8sOperationTimeout(), - retryDelay: timeSet.WaitForK8sServiceDelay() + retryDelay: timeSet.WaitForK8sServiceDelay(), + kubernetesNamespace: k8sNamespace ); } diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index 257332e..2d7c5e6 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -1,6 +1,5 @@ using Core; using FileUtils; -using KubernetesWorkflow; using Logging; using NUnit.Framework; using System.Reflection; @@ -9,7 +8,7 @@ using Utils; namespace DistTestCore { [Parallelizable(ParallelScope.All)] - public abstract class DistTest : PluginInterface + public abstract class DistTest : CoreInterface { private const string TestsType = "dist-tests"; private const string TestNamespacePrefix = "ct-"; @@ -18,8 +17,8 @@ namespace DistTestCore private readonly FixtureLog fixtureLog; private readonly StatusLog statusLog; private readonly object lifecycleLock = new object(); + private readonly EntryPoint globalEntryPoint; private readonly Dictionary lifecycles = new Dictionary(); - private readonly PluginManager PluginManager = new PluginManager(); public DistTest() { @@ -30,14 +29,15 @@ namespace DistTestCore var startTime = DateTime.UtcNow; fixtureLog = new FixtureLog(logConfig, startTime); statusLog = new StatusLog(logConfig, startTime); + + globalEntryPoint = new EntryPoint(fixtureLog, configuration.GetK8sConfiguration(new DefaultTimeSet(), TestNamespacePrefix), configuration.GetFileManagerFolder()); } [OneTimeSetUp] public void GlobalSetup() { fixtureLog.Log($"Distributed Tests are starting..."); - PluginManager.DiscoverPlugins(); - AnnouncePlugins(fixtureLog); + globalEntryPoint.Announce(); // Previous test run may have been interrupted. // Begin by cleaning everything up. @@ -45,8 +45,7 @@ namespace DistTestCore { Stopwatch.Measure(fixtureLog, "Global setup", () => { - var wc = new WorkflowCreator(fixtureLog, configuration.GetK8sConfiguration(GetTimeSet()), string.Empty); - wc.CreateWorkflow().DeleteNamespacesStartingWith(TestNamespacePrefix); + globalEntryPoint.CreateWorkflow().DeleteNamespacesStartingWith(TestNamespacePrefix); }); } catch (Exception ex) @@ -62,7 +61,7 @@ namespace DistTestCore [OneTimeTearDown] public void GlobalTearDown() { - FinalizePlugins(fixtureLog); + globalEntryPoint.Decommission(); } [SetUp] @@ -94,7 +93,7 @@ namespace DistTestCore public TrackedFile GenerateTestFile(ByteSize size, string label = "") { - return Get().FileManager.GenerateTestFile(size, label); + return Get().EntryPoint.GetFileManager().GenerateTestFile(size, label); } /// @@ -103,7 +102,7 @@ namespace DistTestCore /// public void ScopedTestFiles(Action action) { - Get().FileManager.ScopedFiles(action); + Get().EntryPoint.GetFileManager().ScopedFiles(action); } //public IOnlineCodexNode SetupCodexBootstrapNode() @@ -154,20 +153,10 @@ namespace DistTestCore // return Get().CodexStarter.RunningGroups.SelectMany(g => g.Nodes); //} - public override T GetPlugin() - { - return Get().GetPlugin(); - } - - private void AnnouncePlugins(FixtureLog fixtureLog) - { - PluginManager.AnnouncePlugins(fixtureLog); - } - - private void FinalizePlugins(FixtureLog fixtureLog) - { - PluginManager.FinalizePlugins(fixtureLog); - } + //public override T GetPlugin() + //{ + // return Get().GetPlugin(); + //} public ILog GetTestLog() { @@ -224,6 +213,7 @@ namespace DistTestCore { var testNamespace = TestNamespacePrefix + Guid.NewGuid().ToString(); var lifecycle = new TestLifecycle(fixtureLog.CreateTestLog(), configuration, GetTimeSet(), testNamespace); + lifecycle.EntryPoint.ManuallyAssociateCoreInterface(this); lifecycles.Add(testName, lifecycle); DefaultContainerRecipe.TestsType = TestsType; //DefaultContainerRecipe.ApplicationIds = lifecycle.GetApplicationIds(); @@ -243,6 +233,7 @@ namespace DistTestCore WriteEndTestLog(lifecycle.Log); IncludeLogsAndMetricsOnTestFailure(lifecycle); + lifecycle.EntryPoint.Decommission(); lifecycle.DeleteAllResources(); lifecycle = null!; }); diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 864f6ca..8f9f600 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -1,31 +1,22 @@ using Core; -using FileUtils; -using KubernetesWorkflow; using Logging; using Utils; namespace DistTestCore { - public class TestLifecycle : IPluginTools + public class TestLifecycle { - private readonly PluginManager pluginManager; private readonly DateTime testStart; - private readonly WorkflowCreator workflowCreator; public TestLifecycle(TestLog log, Configuration configuration, ITimeSet timeSet, string testNamespace) { Log = log; Configuration = configuration; TimeSet = timeSet; - TestNamespace = testNamespace; testStart = DateTime.UtcNow; - FileManager = new FileManager(Log, Configuration.GetFileManagerFolder()); - workflowCreator = new WorkflowCreator(Log, Configuration.GetK8sConfiguration(TimeSet), TestNamespace); - - pluginManager = new PluginManager(); - pluginManager.DiscoverPlugins(); - pluginManager.InitializePlugins(this); + EntryPoint = new EntryPoint(log, configuration.GetK8sConfiguration(timeSet, testNamespace), configuration.GetFileManagerFolder(), timeSet); + EntryPoint.Initialize(); log.WriteLogTag(); } @@ -33,44 +24,12 @@ namespace DistTestCore public TestLog Log { get; } public Configuration Configuration { get; } public ITimeSet TimeSet { get; } - public string TestNamespace { get; } - public IFileManager FileManager { get; } - - public T GetPlugin() where T : IProjectPlugin - { - return pluginManager.GetPlugin(); - } - - public Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) - { - return new Http(Log, TimeSet, address, baseUrl, onClientCreated, logAlias); - } - - public Http CreateHttp(Address address, string baseUrl, string? logAlias = null) - { - return new Http(Log, TimeSet, address, baseUrl, logAlias); - } - - public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null) - { - if (namespaceOverride != null) throw new Exception("Namespace override is not supported in the DistTest environment. (It would mess up automatic resource cleanup.)"); - return workflowCreator.CreateWorkflow(); - } - - public IFileManager GetFileManager() - { - return FileManager; - } - - public ILog GetLog() - { - return Log; - } + public EntryPoint EntryPoint { get; } public void DeleteAllResources() { - CreateWorkflow().DeleteNamespace(); - FileManager.DeleteAllTestFiles(); + EntryPoint.CreateWorkflow().DeleteNamespace(); + EntryPoint.GetFileManager().DeleteAllTestFiles(); } //public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null) diff --git a/KubernetesWorkflow/Configuration.cs b/KubernetesWorkflow/Configuration.cs index c594a10..80505bf 100644 --- a/KubernetesWorkflow/Configuration.cs +++ b/KubernetesWorkflow/Configuration.cs @@ -2,15 +2,17 @@ { public class Configuration { - public Configuration(string? kubeConfigFile, TimeSpan operationTimeout, TimeSpan retryDelay) + public Configuration(string? kubeConfigFile, TimeSpan operationTimeout, TimeSpan retryDelay, string kubernetesNamespace) { KubeConfigFile = kubeConfigFile; OperationTimeout = operationTimeout; RetryDelay = retryDelay; + KubernetesNamespace = kubernetesNamespace; } public string? KubeConfigFile { get; } public TimeSpan OperationTimeout { get; } public TimeSpan RetryDelay { get; } + public string KubernetesNamespace { get; } } } diff --git a/KubernetesWorkflow/WorkflowCreator.cs b/KubernetesWorkflow/WorkflowCreator.cs index 3a8d385..bc0edab 100644 --- a/KubernetesWorkflow/WorkflowCreator.cs +++ b/KubernetesWorkflow/WorkflowCreator.cs @@ -10,13 +10,14 @@ namespace KubernetesWorkflow private readonly KnownK8sPods knownPods = new KnownK8sPods(); private readonly K8sCluster cluster; private readonly ILog log; - private readonly string testNamespace; + private readonly string k8sNamespace; - public WorkflowCreator(ILog log, Configuration configuration, string testNamespace) + public WorkflowCreator(ILog log, Configuration configuration) { - cluster = new K8sCluster(configuration); this.log = log; - this.testNamespace = testNamespace.ToLowerInvariant(); + + cluster = new K8sCluster(configuration); + k8sNamespace = configuration.KubernetesNamespace.ToLowerInvariant(); } public IStartupWorkflow CreateWorkflow() @@ -24,7 +25,7 @@ namespace KubernetesWorkflow var workflowNumberSource = new WorkflowNumberSource(numberSource.GetNextNumber(), containerNumberSource); - return new StartupWorkflow(log, workflowNumberSource, cluster, knownPods, testNamespace); + return new StartupWorkflow(log, workflowNumberSource, cluster, knownPods, k8sNamespace); } } } From 140dd37c6e97b72e6046d424d14cb4ff839db830 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 12 Sep 2023 15:18:36 +0200 Subject: [PATCH 10/51] fixes issue with unassociated coreinterfaces. --- CodexPlugin/CodexStarter.cs | 57 ++++++++++++++++--------------------- Core/CoreInterface.cs | 9 ++++-- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index 7480090..18ebc28 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -8,25 +8,20 @@ namespace CodexPlugin { private readonly IPluginTools pluginTools; - //public CodexStarter(TestLifecycle lifecycle) - // : base(lifecycle) - //{ - //} - - public CodexStarter(IPluginTools pluginActions) + public CodexStarter(IPluginTools pluginTools) { - this.pluginTools = pluginActions; + this.pluginTools = pluginTools; } public RunningContainers[] BringOnline(CodexSetup codexSetup) { - //LogSeparator(); - //LogStart($"Starting {codexSetup.Describe()}..."); + LogSeparator(); + Log($"Starting {codexSetup.Describe()}..."); //var gethStartResult = lifecycle.GethStarter.BringOnlineMarketplaceFor(codexSetup); var startupConfig = CreateStartupConfig(/*gethStartResult,*/ codexSetup); - return StartCodexContainers(startupConfig, codexSetup.NumberOfNodes, codexSetup.Location); + var containers = StartCodexContainers(startupConfig, codexSetup.NumberOfNodes, codexSetup.Location); //var metricAccessFactory = CollectMetrics(codexSetup, containers); @@ -35,15 +30,11 @@ namespace CodexPlugin //var group = CreateCodexGroup(codexSetup, containers, codexNodeFactory); //lifecycle.SetCodexVersion(group.Version); - //var nl = Environment.NewLine; - //var podInfos = string.Join(nl, containers.Containers().Select(c => $"Container: '{c.Name}' runs at '{c.Pod.PodInfo.K8SNodeName}'={c.Pod.PodInfo.Ip}")); - //LogEnd($"Started {codexSetup.NumberOfNodes} nodes " + - // $"of image '{containers.Containers().First().Recipe.Image}' " + - // $"and version '{group.Version}'{nl}" + - // podInfos); - //LogSeparator(); + var podInfos = string.Join(", ", containers.Containers().Select(c => $"Container: '{c.Name}' runs at '{c.Pod.PodInfo.K8SNodeName}'={c.Pod.PodInfo.Ip}")); + Log($"Started {codexSetup.NumberOfNodes} nodes of image '{containers.Containers().First().Recipe.Image}'. ({podInfos})"); + LogSeparator(); - //return group; + return containers; } public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) @@ -52,18 +43,13 @@ namespace CodexPlugin var codexNodeFactory = new CodexNodeFactory(pluginTools);// (lifecycle, metricAccessFactory, gethStartResult.MarketplaceAccessFactory); - return CreateCodexGroup(/*codexSetup,*/ containers, codexNodeFactory); + var group = CreateCodexGroup(containers, codexNodeFactory); + + Log($"Codex version: {group.Version}"); + //lifecycle.SetCodexVersion(group.Version); - //var nl = Environment.NewLine; - //var podInfos = string.Join(nl, containers.Containers().Select(c => $"Container: '{c.Name}' runs at '{c.Pod.PodInfo.K8SNodeName}'={c.Pod.PodInfo.Ip}")); - //LogEnd($"Started {codexSetup.NumberOfNodes} nodes " + - // $"of image '{containers.Containers().First().Recipe.Image}' " + - // $"and version '{group.Version}'{nl}" + - // podInfos); - //LogSeparator(); - - //return group; + return group; } public void BringOffline(CodexNodeGroup group) @@ -132,7 +118,7 @@ namespace CodexPlugin try { - Stopwatch.Measure(pluginTools.GetLog(), "EnsureOnline", group.EnsureOnline, debug: true); + Stopwatch.Measure(pluginTools.GetLog(), "(CodexStarter) EnsureOnline", group.EnsureOnline); // log prefixer plz } catch { @@ -155,10 +141,15 @@ namespace CodexPlugin // return lifecycle.WorkflowCreator.CreateWorkflow(); //} - //private void LogSeparator() - //{ - // Log("----------------------------------------------------------------------------"); - //} + private void LogSeparator() + { + Log("----------------------------------------------------------------------------"); + } + + private void Log(string message) + { + pluginTools.GetLog().Log($"(CodexStarter) {message}"); + } //private void StopCrashWatcher(RunningContainers containers) //{ diff --git a/Core/CoreInterface.cs b/Core/CoreInterface.cs index 351b7ad..31bed2d 100644 --- a/Core/CoreInterface.cs +++ b/Core/CoreInterface.cs @@ -16,8 +16,13 @@ internal static void Desociate(EntryPoint entryPoint) { - var key = coreAssociations.Single(p => p.Value == entryPoint).Key; - coreAssociations.Remove(key); + var keys = coreAssociations.Where(p => p.Value == entryPoint).ToArray(); + if (keys.Length == 0) return; + + foreach (var key in keys) + { + coreAssociations.Remove(key.Key); + } } } } From dd6b99c670ad8a202ca476d4c8bbf581d377571a Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 12 Sep 2023 15:43:30 +0200 Subject: [PATCH 11/51] Brings back all the tests. --- CodexPlugin/CoreInterfaceExtensions.cs | 5 + DistTestCore/AutoBootstrapDistTest.cs | 30 --- DistTestCore/DownloadedLogExtensions.cs | 13 + Tests/AutoBootstrapDistTest.cs | 45 ++++ Tests/BasicTests/ContinuousSubstitute.cs | 253 ++++++++++++++++++ Tests/BasicTests/ExampleTests.cs | 87 ++++++ Tests/BasicTests/NetworkIsolationTest.cs | 47 ++++ Tests/BasicTests/OneClientTests.cs | 42 +++ Tests/BasicTests/ThreeClientTest.cs | 25 ++ Tests/BasicTests/TwoClientTests.cs | 4 +- .../FullyConnectedDownloadTests.cs | 43 +++ .../LayeredDiscoveryTests.cs | 53 ++++ .../PeerDiscoveryTests/PeerDiscoveryTests.cs | 51 ++++ 13 files changed, 666 insertions(+), 32 deletions(-) delete mode 100644 DistTestCore/AutoBootstrapDistTest.cs create mode 100644 DistTestCore/DownloadedLogExtensions.cs create mode 100644 Tests/AutoBootstrapDistTest.cs create mode 100644 Tests/BasicTests/ContinuousSubstitute.cs create mode 100644 Tests/BasicTests/ExampleTests.cs create mode 100644 Tests/BasicTests/NetworkIsolationTest.cs create mode 100644 Tests/BasicTests/OneClientTests.cs create mode 100644 Tests/BasicTests/ThreeClientTest.cs create mode 100644 Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs create mode 100644 Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs create mode 100644 Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs diff --git a/CodexPlugin/CoreInterfaceExtensions.cs b/CodexPlugin/CoreInterfaceExtensions.cs index dce2bcc..58b1920 100644 --- a/CodexPlugin/CoreInterfaceExtensions.cs +++ b/CodexPlugin/CoreInterfaceExtensions.cs @@ -15,6 +15,11 @@ namespace CodexPlugin return Plugin(ci).WrapCodexContainers(containers); } + public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci) + { + return Plugin(ci).SetupCodexNode(s => { }); // do more unification here. Keep plugin simpler. + } + public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci, Action setup) { return Plugin(ci).SetupCodexNode(setup); diff --git a/DistTestCore/AutoBootstrapDistTest.cs b/DistTestCore/AutoBootstrapDistTest.cs deleted file mode 100644 index 6fadf9b..0000000 --- a/DistTestCore/AutoBootstrapDistTest.cs +++ /dev/null @@ -1,30 +0,0 @@ -using NUnit.Framework; - -namespace DistTestCore -{ - public class AutoBootstrapDistTest : DistTest - { - //public override IOnlineCodexNode SetupCodexBootstrapNode(Action setup) - //{ - // throw new Exception("AutoBootstrapDistTest creates and attaches a single bootstrap node for you. " + - // "If you want to control the bootstrap node from your test, please use DistTest instead."); - //} - - //public override ICodexNodeGroup SetupCodexNodes(int numberOfNodes, Action setup) - //{ - // var codexSetup = CreateCodexSetup(numberOfNodes); - // setup(codexSetup); - // codexSetup.WithBootstrapNode(BootstrapNode); - // return BringOnline(codexSetup); - //} - - //[SetUp] - //public void SetUpBootstrapNode() - //{ - // var setup = CreateCodexSetup(1).WithName("BOOTSTRAP"); - // BootstrapNode = BringOnline(setup)[0]; - //} - - //protected IOnlineCodexNode BootstrapNode { get; private set; } = null!; - } -} diff --git a/DistTestCore/DownloadedLogExtensions.cs b/DistTestCore/DownloadedLogExtensions.cs new file mode 100644 index 0000000..59b020a --- /dev/null +++ b/DistTestCore/DownloadedLogExtensions.cs @@ -0,0 +1,13 @@ +using Core; +using NUnit.Framework; + +namespace DistTestCore +{ + public static class DownloadedLogExtensions + { + public static void AssertLogContains(this IDownloadedLog log, string expectedString) + { + Assert.That(log.DoesLogContain(expectedString), $"Did not find '{expectedString}' in log."); + } + } +} diff --git a/Tests/AutoBootstrapDistTest.cs b/Tests/AutoBootstrapDistTest.cs new file mode 100644 index 0000000..950b082 --- /dev/null +++ b/Tests/AutoBootstrapDistTest.cs @@ -0,0 +1,45 @@ +using CodexPlugin; +using DistTestCore; +using NUnit.Framework; + +namespace Tests +{ + public class AutoBootstrapDistTest : DistTest + { + public IOnlineCodexNode AddCodex() + { + return AddCodex(s => { }); + } + + public IOnlineCodexNode AddCodex(Action setup) + { + return this.SetupCodexNode(s => + { + setup(s); + s.WithBootstrapNode(BootstrapNode); + }); + } + + public ICodexNodeGroup AddCodex(int numberOfNodes) + { + return this.SetupCodexNodes(numberOfNodes, s => s.WithBootstrapNode(BootstrapNode)); + } + + public ICodexNodeGroup AddCodex(int numberOfNodes, Action setup) + { + return this.SetupCodexNodes(numberOfNodes, s => + { + setup(s); + s.WithBootstrapNode(BootstrapNode); + }); + } + + [SetUp] + public void SetUpBootstrapNode() + { + BootstrapNode = this.SetupCodexNode(s => s.WithName("BOOTSTRAP")); + } + + protected IOnlineCodexNode BootstrapNode { get; private set; } = null!; + } +} diff --git a/Tests/BasicTests/ContinuousSubstitute.cs b/Tests/BasicTests/ContinuousSubstitute.cs new file mode 100644 index 0000000..41b8da6 --- /dev/null +++ b/Tests/BasicTests/ContinuousSubstitute.cs @@ -0,0 +1,253 @@ +using CodexPlugin; +using DistTestCore; +using NUnit.Framework; +using Utils; + +namespace Tests.BasicTests +{ + [Ignore("Used for debugging continuous tests")] + [TestFixture] + public class ContinuousSubstitute : AutoBootstrapDistTest + { + [Test] + public void ContinuousTestSubstitute() + { + var group = AddCodex(5, o => o + //.EnableMetrics() + //.EnableMarketplace(100000.TestTokens(), 0.Eth(), isValidator: true) + .WithBlockTTL(TimeSpan.FromMinutes(2)) + .WithBlockMaintenanceInterval(TimeSpan.FromMinutes(2)) + .WithBlockMaintenanceNumber(10000) + .WithBlockTTL(TimeSpan.FromMinutes(2)) + .WithStorageQuota(1.GB())); + + var nodes = group.Cast().ToArray(); + + foreach (var node in nodes) + { + //node.Marketplace.MakeStorageAvailable( + //size: 500.MB(), + //minPricePerBytePerSecond: 1.TestTokens(), + //maxCollateral: 1024.TestTokens(), + //maxDuration: TimeSpan.FromMinutes(5)); + } + + var endTime = DateTime.UtcNow + TimeSpan.FromHours(10); + while (DateTime.UtcNow < endTime) + { + var allNodes = nodes.ToList(); + var primary = allNodes.PickOneRandom(); + var secondary = allNodes.PickOneRandom(); + + Log("Run Test"); + PerformTest(primary, secondary); + + Thread.Sleep(TimeSpan.FromSeconds(5)); + } + } + + [Test] + public void PeerTest() + { + var group = AddCodex(5, o => o + //.EnableMetrics() + //.EnableMarketplace(100000.TestTokens(), 0.Eth(), isValidator: true) + .WithBlockTTL(TimeSpan.FromMinutes(2)) + .WithBlockMaintenanceInterval(TimeSpan.FromMinutes(2)) + .WithBlockMaintenanceNumber(10000) + .WithBlockTTL(TimeSpan.FromMinutes(2)) + .WithStorageQuota(1.GB())); + + var nodes = group.Cast().ToArray(); + + var checkTime = DateTime.UtcNow + TimeSpan.FromMinutes(1); + var endTime = DateTime.UtcNow + TimeSpan.FromHours(10); + while (DateTime.UtcNow < endTime) + { + //CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); + //CheckRoutingTables(GetAllOnlineCodexNodes()); + + var node = RandomUtils.PickOneRandom(nodes.ToList()); + var file = GenerateTestFile(50.MB()); + node.UploadFile(file); + + Thread.Sleep(20000); + } + } + + private void CheckRoutingTables(IEnumerable nodes) + { + var all = nodes.ToArray(); + var allIds = all.Select(n => n.GetDebugInfo().table.localNode.nodeId).ToArray(); + + var errors = all.Select(n => AreAllPresent(n, allIds)).Where(s => !string.IsNullOrEmpty(s)).ToArray(); + + if (errors.Any()) + { + Assert.Fail(string.Join(Environment.NewLine, errors)); + } + } + + private string AreAllPresent(IOnlineCodexNode n, string[] allIds) + { + var info = n.GetDebugInfo(); + var known = info.table.nodes.Select(n => n.nodeId).ToArray(); + var expected = allIds.Where(i => i != info.table.localNode.nodeId).ToArray(); + + if (!expected.All(ex => known.Contains(ex))) + { + return $"Not all of '{string.Join(",", expected)}' were present in routing table: '{string.Join(",", known)}'"; + } + + return string.Empty; + } + + private ByteSize fileSize = 80.MB(); + + private void PerformTest(IOnlineCodexNode primary, IOnlineCodexNode secondary) + { + ScopedTestFiles(() => + { + var testFile = GenerateTestFile(fileSize); + + var contentId = primary.UploadFile(testFile); + + var downloadedFile = secondary.DownloadContent(contentId); + + testFile.AssertIsEqual(downloadedFile); + }); + } + + [Test] + public void HoldMyBeerTest() + { + var blockExpirationTime = TimeSpan.FromMinutes(3); + var group = AddCodex(3, o => o + //.EnableMetrics() + .WithBlockTTL(blockExpirationTime) + .WithBlockMaintenanceInterval(TimeSpan.FromMinutes(2)) + .WithBlockMaintenanceNumber(10000) + .WithStorageQuota(2000.MB())); + + var nodes = group.Cast().ToArray(); + + var endTime = DateTime.UtcNow + TimeSpan.FromHours(24); + + var filesize = 80.MB(); + double codexDefaultBlockSize = 31 * 64 * 33; + var numberOfBlocks = Convert.ToInt64(Math.Ceiling(filesize.SizeInBytes / codexDefaultBlockSize)); + var sizeInBytes = filesize.SizeInBytes; + Assert.That(numberOfBlocks, Is.EqualTo(1282)); + + var startTime = DateTime.UtcNow; + var successfulUploads = 0; + var successfulDownloads = 0; + + while (DateTime.UtcNow < endTime) + { + foreach (var node in nodes) + { + try + { + Thread.Sleep(TimeSpan.FromSeconds(5)); + + ScopedTestFiles(() => + { + var uploadStartTime = DateTime.UtcNow; + var file = GenerateTestFile(filesize); + var cid = node.UploadFile(file); + + var cidTag = cid.Id.Substring(cid.Id.Length - 6); + Measure("upload-log-asserts", () => + { + var uploadLog = node.DownloadLog(tailLines: 50000); + + var storeLines = uploadLog.FindLinesThatContain("Stored data", "topics=\"codex node\""); + uploadLog.DeleteFile(); + + var storeLine = GetLineForCidTag(storeLines, cidTag); + AssertStoreLineContains(storeLine, numberOfBlocks, sizeInBytes); + }); + successfulUploads++; + + var uploadTimeTaken = DateTime.UtcNow - uploadStartTime; + if (uploadTimeTaken >= blockExpirationTime.Subtract(TimeSpan.FromSeconds(10))) + { + Assert.Fail("Upload took too long. Blocks already expired."); + } + + var dl = node.DownloadContent(cid); + file.AssertIsEqual(dl); + + Measure("download-log-asserts", () => + { + var downloadLog = node.DownloadLog(tailLines: 50000); + + var sentLines = downloadLog.FindLinesThatContain("Sent bytes", "topics=\"codex restapi\""); + downloadLog.DeleteFile(); + + var sentLine = GetLineForCidTag(sentLines, cidTag); + AssertSentLineContains(sentLine, sizeInBytes); + }); + successfulDownloads++; + }); + } + catch + { + var testDuration = DateTime.UtcNow - startTime; + Log("Test failed. Delaying shut-down by 30 seconds to collect metrics."); + Log($"Test failed after {Time.FormatDuration(testDuration)} and {successfulUploads} successful uploads and {successfulDownloads} successful downloads"); + Thread.Sleep(TimeSpan.FromSeconds(30)); + throw; + } + } + + Thread.Sleep(TimeSpan.FromSeconds(5)); + } + } + + private void AssertSentLineContains(string sentLine, long sizeInBytes) + { + var tag = "bytes="; + var token = sentLine.Substring(sentLine.IndexOf(tag) + tag.Length); + var bytes = Convert.ToInt64(token); + Assert.AreEqual(sizeInBytes, bytes, $"Sent bytes: Number of bytes incorrect. Line: '{sentLine}'"); + } + + private void AssertStoreLineContains(string storeLine, long numberOfBlocks, long sizeInBytes) + { + var tokens = storeLine.Split(" "); + + var blocksToken = GetToken(tokens, "blocks="); + var sizeToken = GetToken(tokens, "size="); + if (blocksToken == null) Assert.Fail("blockToken not found in " + storeLine); + if (sizeToken == null) Assert.Fail("sizeToken not found in " + storeLine); + + var blocks = Convert.ToInt64(blocksToken); + var size = Convert.ToInt64(sizeToken?.Replace("'NByte", "")); + + var lineLog = $" Line: '{storeLine}'"; + Assert.AreEqual(numberOfBlocks, blocks, "Stored data: Number of blocks incorrect." + lineLog); + Assert.AreEqual(sizeInBytes, size, "Stored data: Number of blocks incorrect." + lineLog); + } + + private string GetLineForCidTag(string[] lines, string cidTag) + { + var result = lines.SingleOrDefault(l => l.Contains(cidTag)); + if (result == null) + { + Assert.Fail($"Failed to find '{cidTag}' in lines: '{string.Join(",", lines)}'"); + throw new Exception(); + } + + return result; + } + + private string? GetToken(string[] tokens, string tag) + { + var token = tokens.SingleOrDefault(t => t.StartsWith(tag)); + if (token == null) return null; + return token.Substring(tag.Length); + } + } +} diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs new file mode 100644 index 0000000..8934386 --- /dev/null +++ b/Tests/BasicTests/ExampleTests.cs @@ -0,0 +1,87 @@ +using CodexPlugin; +using DistTestCore; +using NUnit.Framework; +using Utils; + +namespace Tests.BasicTests +{ + [TestFixture] + public class ExampleTests : DistTest + { + [Test] + public void CodexLogExample() + { + var primary = this.SetupCodexNode(); + + primary.UploadFile(GenerateTestFile(5.MB())); + + var log = primary.DownloadLog(); + + log.AssertLogContains("Uploaded file"); + } + + [Test] + public void TwoMetricsExample() + { + //var group = this.SetupCodexNodes(2, s => s.EnableMetrics()); + //var group2 = this.SetupCodexNodes(2, s => s.EnableMetrics()); + + //var primary = group[0]; + //var secondary = group[1]; + //var primary2 = group2[0]; + //var secondary2 = group2[1]; + + //primary.ConnectToPeer(secondary); + //primary2.ConnectToPeer(secondary2); + + //Thread.Sleep(TimeSpan.FromMinutes(2)); + + //primary.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1)); + //primary2.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1)); + } + + [Test] + public void MarketplaceExample() + { + //var sellerInitialBalance = 234.TestTokens(); + //var buyerInitialBalance = 1000.TestTokens(); + //var fileSize = 10.MB(); + + //var seller = this.SetupCodexNode(s => s + // .WithStorageQuota(11.GB()) + // .EnableMarketplace(sellerInitialBalance)); + + //seller.Marketplace.AssertThatBalance(Is.EqualTo(sellerInitialBalance)); + //seller.Marketplace.MakeStorageAvailable( + // size: 10.GB(), + // minPricePerBytePerSecond: 1.TestTokens(), + // maxCollateral: 20.TestTokens(), + // maxDuration: TimeSpan.FromMinutes(3)); + + //var testFile = GenerateTestFile(fileSize); + + //var buyer = this.SetupCodexNode(s => s + // .WithBootstrapNode(seller) + // .EnableMarketplace(buyerInitialBalance)); + + //buyer.Marketplace.AssertThatBalance(Is.EqualTo(buyerInitialBalance)); + + //var contentId = buyer.UploadFile(testFile); + //var purchaseContract = buyer.Marketplace.RequestStorage(contentId, + // pricePerSlotPerSecond: 2.TestTokens(), + // requiredCollateral: 10.TestTokens(), + // minRequiredNumberOfNodes: 1, + // proofProbability: 5, + // duration: TimeSpan.FromMinutes(1)); + + //purchaseContract.WaitForStorageContractStarted(fileSize); + + //seller.Marketplace.AssertThatBalance(Is.LessThan(sellerInitialBalance), "Collateral was not placed."); + + //purchaseContract.WaitForStorageContractFinished(); + + //seller.Marketplace.AssertThatBalance(Is.GreaterThan(sellerInitialBalance), "Seller was not paid for storage."); + //buyer.Marketplace.AssertThatBalance(Is.LessThan(buyerInitialBalance), "Buyer was not charged for storage."); + } + } +} diff --git a/Tests/BasicTests/NetworkIsolationTest.cs b/Tests/BasicTests/NetworkIsolationTest.cs new file mode 100644 index 0000000..da1be63 --- /dev/null +++ b/Tests/BasicTests/NetworkIsolationTest.cs @@ -0,0 +1,47 @@ +using CodexPlugin; +using DistTestCore; +using NUnit.Framework; +using Utils; + +namespace Tests.BasicTests +{ + // Warning! + // This is a test to check network-isolation in the test-infrastructure. + // It requires parallelism(2) or greater to run. + [TestFixture] + [Ignore("Disabled until a solution is implemented.")] + public class NetworkIsolationTest : DistTest + { + private IOnlineCodexNode? node = null; + + [Test] + public void SetUpANodeAndWait() + { + node = this.SetupCodexNode(); + + Time.WaitUntil(() => node == null, TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(5)); + } + + [Test] + public void ForeignNodeConnects() + { + var myNode = this.SetupCodexNode(); + + Time.WaitUntil(() => node != null, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); + + try + { + myNode.ConnectToPeer(node!); + } + catch + { + // Good! This connection should be prohibited by the network isolation policy. + node = null; + return; + } + + Assert.Fail("Connection could be established between two Codex nodes running in different namespaces. " + + "This may cause cross-test interference. Network isolation policy should be applied. Test infra failure."); + } + } +} diff --git a/Tests/BasicTests/OneClientTests.cs b/Tests/BasicTests/OneClientTests.cs new file mode 100644 index 0000000..5fa2636 --- /dev/null +++ b/Tests/BasicTests/OneClientTests.cs @@ -0,0 +1,42 @@ +using CodexPlugin; +using DistTestCore; +using NUnit.Framework; +using Utils; + +namespace Tests.BasicTests +{ + [TestFixture] + public class OneClientTests : DistTest + { + [Test] + public void OneClientTest() + { + var primary = this.SetupCodexNode(); + + PerformOneClientTest(primary); + } + + [Test] + public void RestartTest() + { + var primary = this.SetupCodexNode(); + + //var setup = primary.BringOffline(); + + //primary = BringOnline(setup)[0]; + + PerformOneClientTest(primary); + } + + private void PerformOneClientTest(IOnlineCodexNode primary) + { + var testFile = GenerateTestFile(1.MB()); + + var contentId = primary.UploadFile(testFile); + + var downloadedFile = primary.DownloadContent(contentId); + + testFile.AssertIsEqual(downloadedFile); + } + } +} diff --git a/Tests/BasicTests/ThreeClientTest.cs b/Tests/BasicTests/ThreeClientTest.cs new file mode 100644 index 0000000..5e44c97 --- /dev/null +++ b/Tests/BasicTests/ThreeClientTest.cs @@ -0,0 +1,25 @@ +using DistTestCore; +using NUnit.Framework; +using Utils; + +namespace Tests.BasicTests +{ + [TestFixture] + public class ThreeClientTest : AutoBootstrapDistTest + { + [Test] + public void ThreeClient() + { + var primary = AddCodex(); + var secondary = AddCodex(); + + var testFile = GenerateTestFile(10.MB()); + + var contentId = primary.UploadFile(testFile); + + var downloadedFile = secondary.DownloadContent(contentId); + + testFile.AssertIsEqual(downloadedFile); + } + } +} diff --git a/Tests/BasicTests/TwoClientTests.cs b/Tests/BasicTests/TwoClientTests.cs index ed16790..909f29c 100644 --- a/Tests/BasicTests/TwoClientTests.cs +++ b/Tests/BasicTests/TwoClientTests.cs @@ -1,8 +1,8 @@ -using DistTestCore; +using CodexPlugin; +using DistTestCore; using KubernetesWorkflow; using NUnit.Framework; using Utils; -using CodexPlugin; namespace Tests.BasicTests { diff --git a/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs b/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs new file mode 100644 index 0000000..4728389 --- /dev/null +++ b/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs @@ -0,0 +1,43 @@ +using CodexPlugin; +using DistTestCore; +using NUnit.Framework; +using Utils; + +namespace Tests.DownloadConnectivityTests +{ + [TestFixture] + public class FullyConnectedDownloadTests : AutoBootstrapDistTest + { + [Test] + public void MetricsDoesNotInterfereWithPeerDownload() + { + //AddCodex(2, s => s.EnableMetrics()); + + AssertAllNodesConnected(); + } + + [Test] + public void MarketplaceDoesNotInterfereWithPeerDownload() + { + //AddCodex(2, s => s.EnableMetrics().EnableMarketplace(1000.TestTokens())); + + AssertAllNodesConnected(); + } + + [Test] + [Combinatorial] + public void FullyConnectedDownloadTest( + [Values(3, 5)] int numberOfNodes, + [Values(10, 80)] int sizeMBs) + { + AddCodex(numberOfNodes); + + AssertAllNodesConnected(sizeMBs); + } + + private void AssertAllNodesConnected(int sizeMBs = 10) + { + //CreatePeerDownloadTestHelpers().AssertFullDownloadInterconnectivity(GetAllOnlineCodexNodes(), sizeMBs.MB()); + } + } +} diff --git a/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs b/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs new file mode 100644 index 0000000..aee601b --- /dev/null +++ b/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs @@ -0,0 +1,53 @@ +using CodexPlugin; +using DistTestCore; +using NUnit.Framework; + +namespace Tests.PeerDiscoveryTests +{ + [TestFixture] + public class LayeredDiscoveryTests : DistTest + { + [Test] + public void TwoLayersTest() + { + var root = this.SetupCodexNode(); + var l1Source = this.SetupCodexNode(s => s.WithBootstrapNode(root)); + var l1Node = this.SetupCodexNode(s => s.WithBootstrapNode(root)); + var l2Target = this.SetupCodexNode(s => s.WithBootstrapNode(l1Node)); + + AssertAllNodesConnected(); + } + + [Test] + public void ThreeLayersTest() + { + var root = this.SetupCodexNode(); + var l1Source = this.SetupCodexNode(s => s.WithBootstrapNode(root)); + var l1Node = this.SetupCodexNode(s => s.WithBootstrapNode(root)); + var l2Node = this.SetupCodexNode(s => s.WithBootstrapNode(l1Node)); + var l3Target = this.SetupCodexNode(s => s.WithBootstrapNode(l2Node)); + + AssertAllNodesConnected(); + } + + [TestCase(3)] + [TestCase(5)] + [TestCase(10)] + [TestCase(20)] + public void NodeChainTest(int chainLength) + { + var node = this.SetupCodexNode(); + for (var i = 1; i < chainLength; i++) + { + node = this.SetupCodexNode(s => s.WithBootstrapNode(node)); + } + + AssertAllNodesConnected(); + } + + private void AssertAllNodesConnected() + { + //CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); + } + } +} diff --git a/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs b/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs new file mode 100644 index 0000000..350afc7 --- /dev/null +++ b/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs @@ -0,0 +1,51 @@ +using DistTestCore; +using NUnit.Framework; + +namespace Tests.PeerDiscoveryTests +{ + [TestFixture] + public class PeerDiscoveryTests : AutoBootstrapDistTest + { + [Test] + public void CanReportUnknownPeerId() + { + var unknownId = "16Uiu2HAkv2CHWpff3dj5iuVNERAp8AGKGNgpGjPexJZHSqUstfsK"; + var node = AddCodex(); + + var result = node.GetDebugPeer(unknownId); + Assert.That(result.IsPeerFound, Is.False); + } + + [Test] + public void MetricsDoesNotInterfereWithPeerDiscovery() + { + //AddCodex(2, s => s.EnableMetrics()); + + AssertAllNodesConnected(); + } + + [Test] + public void MarketplaceDoesNotInterfereWithPeerDiscovery() + { + //AddCodex(2, s => s.EnableMarketplace(1000.TestTokens())); + + AssertAllNodesConnected(); + } + + [TestCase(2)] + [TestCase(3)] + [TestCase(10)] + [TestCase(20)] + public void VariableNodes(int number) + { + AddCodex(number); + + AssertAllNodesConnected(); + } + + private void AssertAllNodesConnected() + { + //CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); + } + } +} From 7e7414d4913d017cc673ede91805bd57c125aaa0 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 08:55:04 +0200 Subject: [PATCH 12/51] Removes core-interface associate methods. --- CodexPlugin/CodexPlugin.cs | 17 ------------ CodexPlugin/CoreInterfaceExtensions.cs | 9 ++++--- Core/CoreInterface.cs | 27 ++++++------------- Core/EntryPoint.cs | 10 +------ DistTestCore/DistTest.cs | 11 ++++++-- DistTestCore/TestLifecycle.cs | 2 ++ Tests/AutoBootstrapDistTest.cs | 8 +++--- Tests/BasicTests/ExampleTests.cs | 10 +++---- Tests/BasicTests/NetworkIsolationTest.cs | 4 +-- Tests/BasicTests/OneClientTests.cs | 4 +-- Tests/BasicTests/TwoClientTests.cs | 6 ++--- .../LayeredDiscoveryTests.cs | 22 +++++++-------- 12 files changed, 52 insertions(+), 78 deletions(-) diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index dbc18b1..7f20f6f 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -37,22 +37,5 @@ namespace CodexPlugin { return codexStarter.WrapCodexContainers(containers); } - - public IOnlineCodexNode SetupCodexNode(Action setup) - { - return SetupCodexNodes(1, setup)[0]; - } - - public ICodexNodeGroup SetupCodexNodes(int number, Action setup) - { - var rc = StartCodexNodes(number, setup); - return WrapCodexContainers(rc); - } - - public ICodexNodeGroup SetupCodexNodes(int number) - { - var rc = StartCodexNodes(number, s => { }); - return WrapCodexContainers(rc); - } } } diff --git a/CodexPlugin/CoreInterfaceExtensions.cs b/CodexPlugin/CoreInterfaceExtensions.cs index 58b1920..75b779f 100644 --- a/CodexPlugin/CoreInterfaceExtensions.cs +++ b/CodexPlugin/CoreInterfaceExtensions.cs @@ -17,22 +17,23 @@ namespace CodexPlugin public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci) { - return Plugin(ci).SetupCodexNode(s => { }); // do more unification here. Keep plugin simpler. + return ci.SetupCodexNodes(1)[0]; } public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci, Action setup) { - return Plugin(ci).SetupCodexNode(setup); + return ci.SetupCodexNodes(1, setup)[0]; } public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number, Action setup) { - return Plugin(ci).SetupCodexNodes(number, setup); + var rc = ci.StartCodexNodes(number, setup); + return ci.WrapCodexContainers(rc); } public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number) { - return Plugin(ci).SetupCodexNodes(number); + return ci.SetupCodexNodes(number, s => { }); } private static CodexPlugin Plugin(CoreInterface ci) diff --git a/Core/CoreInterface.cs b/Core/CoreInterface.cs index 31bed2d..cad0eea 100644 --- a/Core/CoreInterface.cs +++ b/Core/CoreInterface.cs @@ -1,28 +1,17 @@ namespace Core { - public class CoreInterface + public sealed class CoreInterface { - private static readonly Dictionary coreAssociations = new Dictionary(); + private readonly EntryPoint entryPoint; + + internal CoreInterface(EntryPoint entryPoint) + { + this.entryPoint = entryPoint; + } public T GetPlugin() where T : IProjectPlugin { - return coreAssociations[this].GetPlugin(); - } - - internal static void Associate(CoreInterface coreInterface, EntryPoint entryPoint) - { - coreAssociations.Add(coreInterface, entryPoint); - } - - internal static void Desociate(EntryPoint entryPoint) - { - var keys = coreAssociations.Where(p => p.Value == entryPoint).ToArray(); - if (keys.Length == 0) return; - - foreach (var key in keys) - { - coreAssociations.Remove(key.Key); - } + return entryPoint.GetPlugin(); } } } diff --git a/Core/EntryPoint.cs b/Core/EntryPoint.cs index 96fdd19..aa061ad 100644 --- a/Core/EntryPoint.cs +++ b/Core/EntryPoint.cs @@ -38,21 +38,13 @@ namespace Core manager.InitializePlugins(this); } - public void ManuallyAssociateCoreInterface(CoreInterface ci) - { - CoreInterface.Associate(ci, this); - } - public CoreInterface CreateInterface() { - var ci = new CoreInterface(); - CoreInterface.Associate(ci, this); - return ci; + return new CoreInterface(this); } public void Decommission() { - CoreInterface.Desociate(this); manager.FinalizePlugins(log); } diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index 2d7c5e6..594b8d3 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -8,7 +8,7 @@ using Utils; namespace DistTestCore { [Parallelizable(ParallelScope.All)] - public abstract class DistTest : CoreInterface + public abstract class DistTest { private const string TestsType = "dist-tests"; private const string TestNamespacePrefix = "ct-"; @@ -91,6 +91,14 @@ namespace DistTestCore } } + public CoreInterface Ci + { + get + { + return Get().CoreInterface; + } + } + public TrackedFile GenerateTestFile(ByteSize size, string label = "") { return Get().EntryPoint.GetFileManager().GenerateTestFile(size, label); @@ -213,7 +221,6 @@ namespace DistTestCore { var testNamespace = TestNamespacePrefix + Guid.NewGuid().ToString(); var lifecycle = new TestLifecycle(fixtureLog.CreateTestLog(), configuration, GetTimeSet(), testNamespace); - lifecycle.EntryPoint.ManuallyAssociateCoreInterface(this); lifecycles.Add(testName, lifecycle); DefaultContainerRecipe.TestsType = TestsType; //DefaultContainerRecipe.ApplicationIds = lifecycle.GetApplicationIds(); diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 8f9f600..66be2e1 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -17,6 +17,7 @@ namespace DistTestCore EntryPoint = new EntryPoint(log, configuration.GetK8sConfiguration(timeSet, testNamespace), configuration.GetFileManagerFolder(), timeSet); EntryPoint.Initialize(); + CoreInterface = EntryPoint.CreateInterface(); log.WriteLogTag(); } @@ -25,6 +26,7 @@ namespace DistTestCore public Configuration Configuration { get; } public ITimeSet TimeSet { get; } public EntryPoint EntryPoint { get; } + public CoreInterface CoreInterface { get; } public void DeleteAllResources() { diff --git a/Tests/AutoBootstrapDistTest.cs b/Tests/AutoBootstrapDistTest.cs index 950b082..b22b439 100644 --- a/Tests/AutoBootstrapDistTest.cs +++ b/Tests/AutoBootstrapDistTest.cs @@ -13,7 +13,7 @@ namespace Tests public IOnlineCodexNode AddCodex(Action setup) { - return this.SetupCodexNode(s => + return Ci.SetupCodexNode(s => { setup(s); s.WithBootstrapNode(BootstrapNode); @@ -22,12 +22,12 @@ namespace Tests public ICodexNodeGroup AddCodex(int numberOfNodes) { - return this.SetupCodexNodes(numberOfNodes, s => s.WithBootstrapNode(BootstrapNode)); + return Ci.SetupCodexNodes(numberOfNodes, s => s.WithBootstrapNode(BootstrapNode)); } public ICodexNodeGroup AddCodex(int numberOfNodes, Action setup) { - return this.SetupCodexNodes(numberOfNodes, s => + return Ci.SetupCodexNodes(numberOfNodes, s => { setup(s); s.WithBootstrapNode(BootstrapNode); @@ -37,7 +37,7 @@ namespace Tests [SetUp] public void SetUpBootstrapNode() { - BootstrapNode = this.SetupCodexNode(s => s.WithName("BOOTSTRAP")); + BootstrapNode = Ci.SetupCodexNode(s => s.WithName("BOOTSTRAP")); } protected IOnlineCodexNode BootstrapNode { get; private set; } = null!; diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 8934386..15c82f5 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -11,7 +11,7 @@ namespace Tests.BasicTests [Test] public void CodexLogExample() { - var primary = this.SetupCodexNode(); + var primary = Ci.SetupCodexNode(); primary.UploadFile(GenerateTestFile(5.MB())); @@ -23,8 +23,8 @@ namespace Tests.BasicTests [Test] public void TwoMetricsExample() { - //var group = this.SetupCodexNodes(2, s => s.EnableMetrics()); - //var group2 = this.SetupCodexNodes(2, s => s.EnableMetrics()); + //var group = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); + //var group2 = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); //var primary = group[0]; //var secondary = group[1]; @@ -47,7 +47,7 @@ namespace Tests.BasicTests //var buyerInitialBalance = 1000.TestTokens(); //var fileSize = 10.MB(); - //var seller = this.SetupCodexNode(s => s + //var seller = Ci.SetupCodexNode(s => s // .WithStorageQuota(11.GB()) // .EnableMarketplace(sellerInitialBalance)); @@ -60,7 +60,7 @@ namespace Tests.BasicTests //var testFile = GenerateTestFile(fileSize); - //var buyer = this.SetupCodexNode(s => s + //var buyer = Ci.SetupCodexNode(s => s // .WithBootstrapNode(seller) // .EnableMarketplace(buyerInitialBalance)); diff --git a/Tests/BasicTests/NetworkIsolationTest.cs b/Tests/BasicTests/NetworkIsolationTest.cs index da1be63..38e9a49 100644 --- a/Tests/BasicTests/NetworkIsolationTest.cs +++ b/Tests/BasicTests/NetworkIsolationTest.cs @@ -17,7 +17,7 @@ namespace Tests.BasicTests [Test] public void SetUpANodeAndWait() { - node = this.SetupCodexNode(); + node = Ci.SetupCodexNode(); Time.WaitUntil(() => node == null, TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(5)); } @@ -25,7 +25,7 @@ namespace Tests.BasicTests [Test] public void ForeignNodeConnects() { - var myNode = this.SetupCodexNode(); + var myNode = Ci.SetupCodexNode(); Time.WaitUntil(() => node != null, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); diff --git a/Tests/BasicTests/OneClientTests.cs b/Tests/BasicTests/OneClientTests.cs index 5fa2636..dd74924 100644 --- a/Tests/BasicTests/OneClientTests.cs +++ b/Tests/BasicTests/OneClientTests.cs @@ -11,7 +11,7 @@ namespace Tests.BasicTests [Test] public void OneClientTest() { - var primary = this.SetupCodexNode(); + var primary = Ci.SetupCodexNode(); PerformOneClientTest(primary); } @@ -19,7 +19,7 @@ namespace Tests.BasicTests [Test] public void RestartTest() { - var primary = this.SetupCodexNode(); + var primary = Ci.SetupCodexNode(); //var setup = primary.BringOffline(); diff --git a/Tests/BasicTests/TwoClientTests.cs b/Tests/BasicTests/TwoClientTests.cs index 909f29c..7c34053 100644 --- a/Tests/BasicTests/TwoClientTests.cs +++ b/Tests/BasicTests/TwoClientTests.cs @@ -12,7 +12,7 @@ namespace Tests.BasicTests [Test] public void TwoClientTest() { - var group = this.SetupCodexNodes(2); + var group = Ci.SetupCodexNodes(2); var primary = group[0]; var secondary = group[1]; @@ -23,8 +23,8 @@ namespace Tests.BasicTests [Test] public void TwoClientsTwoLocationsTest() { - var primary = this.SetupCodexNode(s => s.At(Location.One)); - var secondary = this.SetupCodexNode(s => s.At(Location.Two)); + var primary = Ci.SetupCodexNode(s => s.At(Location.One)); + var secondary = Ci.SetupCodexNode(s => s.At(Location.Two)); PerformTwoClientTest(primary, secondary); } diff --git a/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs b/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs index aee601b..b5e7edf 100644 --- a/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs +++ b/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs @@ -10,10 +10,10 @@ namespace Tests.PeerDiscoveryTests [Test] public void TwoLayersTest() { - var root = this.SetupCodexNode(); - var l1Source = this.SetupCodexNode(s => s.WithBootstrapNode(root)); - var l1Node = this.SetupCodexNode(s => s.WithBootstrapNode(root)); - var l2Target = this.SetupCodexNode(s => s.WithBootstrapNode(l1Node)); + var root = Ci.SetupCodexNode(); + var l1Source = Ci.SetupCodexNode(s => s.WithBootstrapNode(root)); + var l1Node = Ci.SetupCodexNode(s => s.WithBootstrapNode(root)); + var l2Target = Ci.SetupCodexNode(s => s.WithBootstrapNode(l1Node)); AssertAllNodesConnected(); } @@ -21,11 +21,11 @@ namespace Tests.PeerDiscoveryTests [Test] public void ThreeLayersTest() { - var root = this.SetupCodexNode(); - var l1Source = this.SetupCodexNode(s => s.WithBootstrapNode(root)); - var l1Node = this.SetupCodexNode(s => s.WithBootstrapNode(root)); - var l2Node = this.SetupCodexNode(s => s.WithBootstrapNode(l1Node)); - var l3Target = this.SetupCodexNode(s => s.WithBootstrapNode(l2Node)); + var root = Ci.SetupCodexNode(); + var l1Source = Ci.SetupCodexNode(s => s.WithBootstrapNode(root)); + var l1Node = Ci.SetupCodexNode(s => s.WithBootstrapNode(root)); + var l2Node = Ci.SetupCodexNode(s => s.WithBootstrapNode(l1Node)); + var l3Target = Ci.SetupCodexNode(s => s.WithBootstrapNode(l2Node)); AssertAllNodesConnected(); } @@ -36,10 +36,10 @@ namespace Tests.PeerDiscoveryTests [TestCase(20)] public void NodeChainTest(int chainLength) { - var node = this.SetupCodexNode(); + var node = Ci.SetupCodexNode(); for (var i = 1; i < chainLength; i++) { - node = this.SetupCodexNode(s => s.WithBootstrapNode(node)); + node = Ci.SetupCodexNode(s => s.WithBootstrapNode(node)); } AssertAllNodesConnected(); From d900416d7c63a6814185495ff08b18b3a55ba6bc Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 09:12:18 +0200 Subject: [PATCH 13/51] setting up for metrics --- CodexPlugin/CodexContainerRecipe.cs | 4 +- CodexPlugin/CodexStarter.cs | 8 ++-- DistTestCore/BaseStarter.cs | 42 ------------------- Logging/LogPrefixer.cs | 34 +++++++++++++++ MetricsPlugin/CoreInterfaceExtensions.cs | 18 ++++++++ .../GrafanaContainerRecipe.cs | 0 .../GrafanaStarter.cs | 2 +- .../MetricsAccess.cs | 0 .../MetricsAccessFactory.cs | 0 .../MetricsDownloader.cs | 0 .../Metrics => MetricsPlugin}/MetricsMode.cs | 0 MetricsPlugin/MetricsPlugin.cs | 32 ++++++++++++++ MetricsPlugin/MetricsPlugin.csproj | 9 ++++ .../Metrics => MetricsPlugin}/MetricsQuery.cs | 0 .../PrometheusContainerRecipe.cs | 0 .../PrometheusStarter.cs | 13 +++--- .../PrometheusStartupConfig.cs | 0 .../Metrics => MetricsPlugin}/dashboard.json | 0 18 files changed, 109 insertions(+), 53 deletions(-) delete mode 100644 DistTestCore/BaseStarter.cs create mode 100644 Logging/LogPrefixer.cs create mode 100644 MetricsPlugin/CoreInterfaceExtensions.cs rename {CodexPlugin/Metrics => MetricsPlugin}/GrafanaContainerRecipe.cs (100%) rename {DistTestCore => MetricsPlugin}/GrafanaStarter.cs (99%) rename {CodexPlugin/Metrics => MetricsPlugin}/MetricsAccess.cs (100%) rename {CodexPlugin/Metrics => MetricsPlugin}/MetricsAccessFactory.cs (100%) rename {CodexPlugin/Metrics => MetricsPlugin}/MetricsDownloader.cs (100%) rename {CodexPlugin/Metrics => MetricsPlugin}/MetricsMode.cs (100%) create mode 100644 MetricsPlugin/MetricsPlugin.cs create mode 100644 MetricsPlugin/MetricsPlugin.csproj rename {CodexPlugin/Metrics => MetricsPlugin}/MetricsQuery.cs (100%) rename {CodexPlugin/Metrics => MetricsPlugin}/PrometheusContainerRecipe.cs (100%) rename {DistTestCore => MetricsPlugin}/PrometheusStarter.cs (88%) rename {CodexPlugin/Metrics => MetricsPlugin}/PrometheusStartupConfig.cs (100%) rename {CodexPlugin/Metrics => MetricsPlugin}/dashboard.json (100%) diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index f16a88b..1eb72f7 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -23,8 +23,8 @@ namespace CodexPlugin { Image = GetDockerImage(); - Resources.Requests = new ContainerResourceSet(milliCPUs: 1000, memory: 6.GB()); - Resources.Limits = new ContainerResourceSet(milliCPUs: 4000, memory: 12.GB()); + //Resources.Requests = new ContainerResourceSet(milliCPUs: 1000, memory: 6.GB()); + //Resources.Limits = new ContainerResourceSet(milliCPUs: 4000, memory: 12.GB()); } protected override void InitializeRecipe(StartupConfig startupConfig) diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index 18ebc28..7558a88 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -7,10 +7,12 @@ namespace CodexPlugin public class CodexStarter { private readonly IPluginTools pluginTools; + private readonly ILog log; public CodexStarter(IPluginTools pluginTools) { this.pluginTools = pluginTools; + log = new LogPrefixer(pluginTools.GetLog(), "(CodexStarter) "); } public RunningContainers[] BringOnline(CodexSetup codexSetup) @@ -118,7 +120,7 @@ namespace CodexPlugin try { - Stopwatch.Measure(pluginTools.GetLog(), "(CodexStarter) EnsureOnline", group.EnsureOnline); // log prefixer plz + Stopwatch.Measure(log, "EnsureOnline", group.EnsureOnline); } catch { @@ -131,7 +133,7 @@ namespace CodexPlugin private void CodexNodesNotOnline(RunningContainers[] runningContainers) { - pluginTools.GetLog().Log("Codex nodes failed to start"); + Log("Codex nodes failed to start"); // todo: //foreach (var container in runningContainers.Containers()) lifecycle.DownloadLog(container); } @@ -148,7 +150,7 @@ namespace CodexPlugin private void Log(string message) { - pluginTools.GetLog().Log($"(CodexStarter) {message}"); + log.Log(message); } //private void StopCrashWatcher(RunningContainers containers) diff --git a/DistTestCore/BaseStarter.cs b/DistTestCore/BaseStarter.cs deleted file mode 100644 index 4d10643..0000000 --- a/DistTestCore/BaseStarter.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Logging; - -namespace DistTestCore -{ - public class BaseStarter - { - protected readonly TestLifecycle lifecycle; - private Stopwatch? stopwatch; - - public BaseStarter(TestLifecycle lifecycle) - { - this.lifecycle = lifecycle; - } - - protected void LogStart(string msg) - { - Log(msg); - stopwatch = Stopwatch.Begin(lifecycle.Log, GetClassName()); - } - - protected void LogEnd(string msg) - { - stopwatch!.End(msg); - stopwatch = null; - } - - protected void Log(string msg) - { - lifecycle.Log.Log($"{GetClassName()} {msg}"); - } - - protected void Debug(string msg) - { - lifecycle.Log.Debug($"{GetClassName()} {msg}", 1); - } - - private string GetClassName() - { - return $"({GetType().Name})"; - } - } -} diff --git a/Logging/LogPrefixer.cs b/Logging/LogPrefixer.cs new file mode 100644 index 0000000..de05730 --- /dev/null +++ b/Logging/LogPrefixer.cs @@ -0,0 +1,34 @@ +namespace Logging +{ + public class LogPrefixer : ILog + { + private readonly ILog backingLog; + private readonly string prefix; + + public LogPrefixer(ILog backingLog, string prefix) + { + this.backingLog = backingLog; + this.prefix = prefix; + } + + public LogFile CreateSubfile(string ext = "log") + { + return backingLog.CreateSubfile(ext); + } + + public void Debug(string message = "", int skipFrames = 0) + { + backingLog.Debug(prefix + message, skipFrames); + } + + public void Error(string message) + { + backingLog.Error(prefix + message); + } + + public void Log(string message) + { + backingLog.Log(prefix + message); + } + } +} diff --git a/MetricsPlugin/CoreInterfaceExtensions.cs b/MetricsPlugin/CoreInterfaceExtensions.cs new file mode 100644 index 0000000..df62c06 --- /dev/null +++ b/MetricsPlugin/CoreInterfaceExtensions.cs @@ -0,0 +1,18 @@ +using Core; +using KubernetesWorkflow; + +namespace MetricsPlugin +{ + public static class CoreInterfaceExtensions + { + public static RunningContainer StartMetricsCollector(this CoreInterface ci, RunningContainers[] scrapeTargets) + { + return Plugin(ci).StartMetricsCollector(scrapeTargets); + } + + private static MetricsPlugin Plugin(CoreInterface ci) + { + return ci.GetPlugin(); + } + } +} diff --git a/CodexPlugin/Metrics/GrafanaContainerRecipe.cs b/MetricsPlugin/GrafanaContainerRecipe.cs similarity index 100% rename from CodexPlugin/Metrics/GrafanaContainerRecipe.cs rename to MetricsPlugin/GrafanaContainerRecipe.cs diff --git a/DistTestCore/GrafanaStarter.cs b/MetricsPlugin/GrafanaStarter.cs similarity index 99% rename from DistTestCore/GrafanaStarter.cs rename to MetricsPlugin/GrafanaStarter.cs index f6dd43f..b2f14f7 100644 --- a/DistTestCore/GrafanaStarter.cs +++ b/MetricsPlugin/GrafanaStarter.cs @@ -1,6 +1,6 @@ using KubernetesWorkflow; -namespace DistTestCore +namespace MetricsPlugin { public class GrafanaStarter : BaseStarter { diff --git a/CodexPlugin/Metrics/MetricsAccess.cs b/MetricsPlugin/MetricsAccess.cs similarity index 100% rename from CodexPlugin/Metrics/MetricsAccess.cs rename to MetricsPlugin/MetricsAccess.cs diff --git a/CodexPlugin/Metrics/MetricsAccessFactory.cs b/MetricsPlugin/MetricsAccessFactory.cs similarity index 100% rename from CodexPlugin/Metrics/MetricsAccessFactory.cs rename to MetricsPlugin/MetricsAccessFactory.cs diff --git a/CodexPlugin/Metrics/MetricsDownloader.cs b/MetricsPlugin/MetricsDownloader.cs similarity index 100% rename from CodexPlugin/Metrics/MetricsDownloader.cs rename to MetricsPlugin/MetricsDownloader.cs diff --git a/CodexPlugin/Metrics/MetricsMode.cs b/MetricsPlugin/MetricsMode.cs similarity index 100% rename from CodexPlugin/Metrics/MetricsMode.cs rename to MetricsPlugin/MetricsMode.cs diff --git a/MetricsPlugin/MetricsPlugin.cs b/MetricsPlugin/MetricsPlugin.cs new file mode 100644 index 0000000..8d6fc26 --- /dev/null +++ b/MetricsPlugin/MetricsPlugin.cs @@ -0,0 +1,32 @@ +using Core; +using KubernetesWorkflow; +using Logging; + +namespace MetricsPlugin +{ + public class MetricsPlugin : IProjectPlugin + { + + #region IProjectPlugin Implementation + + public void Announce(ILog log) + { + log.Log("Hi from the metrics plugin."); + } + + public void Initialize(IPluginTools tools) + { + } + + public void Finalize(ILog log) + { + } + + #endregion + + public RunningContainer StartMetricsCollector(RunningContainers[] scrapeTargets) + { + return null!; + } + } +} diff --git a/MetricsPlugin/MetricsPlugin.csproj b/MetricsPlugin/MetricsPlugin.csproj new file mode 100644 index 0000000..cfadb03 --- /dev/null +++ b/MetricsPlugin/MetricsPlugin.csproj @@ -0,0 +1,9 @@ + + + + net7.0 + enable + enable + + + diff --git a/CodexPlugin/Metrics/MetricsQuery.cs b/MetricsPlugin/MetricsQuery.cs similarity index 100% rename from CodexPlugin/Metrics/MetricsQuery.cs rename to MetricsPlugin/MetricsQuery.cs diff --git a/CodexPlugin/Metrics/PrometheusContainerRecipe.cs b/MetricsPlugin/PrometheusContainerRecipe.cs similarity index 100% rename from CodexPlugin/Metrics/PrometheusContainerRecipe.cs rename to MetricsPlugin/PrometheusContainerRecipe.cs diff --git a/DistTestCore/PrometheusStarter.cs b/MetricsPlugin/PrometheusStarter.cs similarity index 88% rename from DistTestCore/PrometheusStarter.cs rename to MetricsPlugin/PrometheusStarter.cs index bfba12b..fbf9a3a 100644 --- a/DistTestCore/PrometheusStarter.cs +++ b/MetricsPlugin/PrometheusStarter.cs @@ -1,13 +1,16 @@ -using KubernetesWorkflow; +using Core; +using KubernetesWorkflow; using System.Text; -namespace DistTestCore +namespace MetricsPlugin { - public class PrometheusStarter : BaseStarter + public class PrometheusStarter { - public PrometheusStarter(TestLifecycle lifecycle) - : base(lifecycle) + private readonly IPluginTools tools; + + public PrometheusStarter(IPluginTools tools) { + this.tools = tools; } public RunningContainers CollectMetricsFor(RunningContainers[] containers) diff --git a/CodexPlugin/Metrics/PrometheusStartupConfig.cs b/MetricsPlugin/PrometheusStartupConfig.cs similarity index 100% rename from CodexPlugin/Metrics/PrometheusStartupConfig.cs rename to MetricsPlugin/PrometheusStartupConfig.cs diff --git a/CodexPlugin/Metrics/dashboard.json b/MetricsPlugin/dashboard.json similarity index 100% rename from CodexPlugin/Metrics/dashboard.json rename to MetricsPlugin/dashboard.json From 3c724f62065f9e1db74b6b116f9d8cc6a183d46b Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 10:03:11 +0200 Subject: [PATCH 14/51] initialize plugins with constructor --- CodexPlugin/CodexPlugin.cs | 21 +++++------ CodexPlugin/CodexPlugin.csproj | 10 ------ CodexPlugin/OnlineCodexNode.cs | 2 +- Core/EntryPoint.cs | 57 +++++------------------------- Core/PluginManager.cs | 25 +++++-------- Core/ToolsProvider.cs | 49 +++++++++++++++++++++++++ DistTestCore/DistTest.cs | 7 ++-- DistTestCore/TestLifecycle.cs | 23 ++++++++---- FileUtils/FileManager.cs | 18 +++++----- MetricsPlugin/GrafanaStarter.cs | 5 ++- MetricsPlugin/MetricsPlugin.cs | 11 ++---- MetricsPlugin/MetricsPlugin.csproj | 14 ++++++++ cs-codex-dist-testing.sln | 8 ++++- 13 files changed, 134 insertions(+), 116 deletions(-) create mode 100644 Core/ToolsProvider.cs diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index 7f20f6f..5602678 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -1,26 +1,27 @@ using Core; using KubernetesWorkflow; -using Logging; namespace CodexPlugin { public class CodexPlugin : IProjectPlugin { - private CodexStarter codexStarter = null!; + private readonly CodexStarter codexStarter; + private readonly IPluginTools tools; + + public CodexPlugin(IPluginTools tools) + { + codexStarter = new CodexStarter(tools); + this.tools = tools; + } #region IProjectPlugin Implementation - public void Announce(ILog log) + public void Announce() { - log.Log("hello from codex plugin. codex container info here."); + tools.GetLog().Log("hello from codex plugin. codex container info here."); } - public void Initialize(IPluginTools tools) - { - codexStarter = new CodexStarter(tools); - } - - public void Finalize(ILog log) + public void Decommission() { } diff --git a/CodexPlugin/CodexPlugin.csproj b/CodexPlugin/CodexPlugin.csproj index e17d31e..1f910c4 100644 --- a/CodexPlugin/CodexPlugin.csproj +++ b/CodexPlugin/CodexPlugin.csproj @@ -6,16 +6,6 @@ enable - - - - - - - Never - - - diff --git a/CodexPlugin/OnlineCodexNode.cs b/CodexPlugin/OnlineCodexNode.cs index 7895076..cbd0dcd 100644 --- a/CodexPlugin/OnlineCodexNode.cs +++ b/CodexPlugin/OnlineCodexNode.cs @@ -83,7 +83,7 @@ namespace CodexPlugin { var logMessage = $"Downloading for contentId: '{contentId.Id}'..."; Log(logMessage); - var file = tools.GetFileManager().CreateEmptyTestFile(fileLabel); + var file = tools.GetFileManager().CreateEmptyFile(fileLabel); Stopwatch.Measure(tools.GetLog(), logMessage, () => DownloadToFile(contentId.Id, file)); Log($"Downloaded file {file.Describe()} to '{file.Filename}'."); return file; diff --git a/Core/EntryPoint.cs b/Core/EntryPoint.cs index aa061ad..a9d6d5b 100644 --- a/Core/EntryPoint.cs +++ b/Core/EntryPoint.cs @@ -1,26 +1,16 @@ -using FileUtils; -using KubernetesWorkflow; +using KubernetesWorkflow; using Logging; -using Utils; namespace Core { - public class EntryPoint : IPluginTools + public class EntryPoint { private readonly PluginManager manager = new PluginManager(); - private readonly ILog log; - private readonly ITimeSet timeSet; - private readonly FileManager fileManager; - private readonly WorkflowCreator workflowCreator; - + public EntryPoint(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) { - this.log = log; - this.timeSet = timeSet; - fileManager = new FileManager(log, fileManagerRootFolder); - workflowCreator = new WorkflowCreator(log, configuration); - - manager.InstantiatePlugins(PluginFinder.GetPluginTypes()); + Tools = new ToolsProvider(log, configuration, fileManagerRootFolder, timeSet); + manager.InstantiatePlugins(PluginFinder.GetPluginTypes(), Tools); } public EntryPoint(ILog log, Configuration configuration, string fileManagerRootFolder) @@ -28,14 +18,11 @@ namespace Core { } + public IPluginTools Tools { get; } + public void Announce() { - manager.AnnouncePlugins(log); - } - - public void Initialize() - { - manager.InitializePlugins(this); + manager.AnnouncePlugins(); } public CoreInterface CreateInterface() @@ -45,38 +32,12 @@ namespace Core public void Decommission() { - manager.FinalizePlugins(log); + manager.DecommissionPlugins(); } internal T GetPlugin() where T : IProjectPlugin { return manager.GetPlugin(); } - - public Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) - { - return new Http(log, timeSet, address, baseUrl, onClientCreated, logAlias); - } - - public Http CreateHttp(Address address, string baseUrl, string? logAlias = null) - { - return new Http(log, timeSet, address, baseUrl, logAlias); - } - - public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null) - { - if (namespaceOverride != null) throw new Exception("Namespace override is not supported in the DistTest environment. (It would mess up automatic resource cleanup.)"); - return workflowCreator.CreateWorkflow(); - } - - public IFileManager GetFileManager() - { - return fileManager; - } - - public ILog GetLog() - { - return log; - } } } diff --git a/Core/PluginManager.cs b/Core/PluginManager.cs index c8cf8a4..81dbcee 100644 --- a/Core/PluginManager.cs +++ b/Core/PluginManager.cs @@ -1,7 +1,6 @@ using FileUtils; using KubernetesWorkflow; using Logging; -using System.Reflection; using Utils; namespace Core @@ -10,29 +9,24 @@ namespace Core { private readonly List projectPlugins = new List(); - public void InstantiatePlugins(Type[] pluginTypes) + public void InstantiatePlugins(Type[] pluginTypes, IPluginTools tools) { projectPlugins.Clear(); foreach (var pluginType in pluginTypes) { - var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType)!; + var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType, args: tools)!; projectPlugins.Add(plugin); } } - public void AnnouncePlugins(ILog log) + public void AnnouncePlugins() { - foreach (var plugin in projectPlugins) plugin.Announce(log); + foreach (var plugin in projectPlugins) plugin.Announce(); } - public void InitializePlugins(IPluginTools tools) + public void DecommissionPlugins() { - foreach (var plugin in projectPlugins) plugin.Initialize(tools); - } - - public void FinalizePlugins(ILog log) - { - foreach (var plugin in projectPlugins) plugin.Finalize(log); + foreach (var plugin in projectPlugins) plugin.Decommission(); } public T GetPlugin() where T : IProjectPlugin @@ -43,13 +37,12 @@ namespace Core public interface IProjectPlugin { - void Announce(ILog log); - void Initialize(IPluginTools tools); - void Finalize(ILog log); + void Announce(); + void Decommission(); } public interface IPluginTools : IWorkflowTool, ILogTool, IHttpFactoryTool, IFileTool - { + { } public interface IWorkflowTool diff --git a/Core/ToolsProvider.cs b/Core/ToolsProvider.cs new file mode 100644 index 0000000..81a5010 --- /dev/null +++ b/Core/ToolsProvider.cs @@ -0,0 +1,49 @@ +using FileUtils; +using KubernetesWorkflow; +using Logging; +using Utils; + +namespace Core +{ + public class ToolsProvider : IPluginTools + { + private readonly ILog log; + private readonly ITimeSet timeSet; + private readonly WorkflowCreator workflowCreator; + private readonly IFileManager fileManager; + + public ToolsProvider(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) + { + this.log = log; + this.timeSet = timeSet; + fileManager = new FileManager(log, fileManagerRootFolder); + workflowCreator = new WorkflowCreator(log, configuration); + } + + public Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) + { + return new Http(log, timeSet, address, baseUrl, onClientCreated, logAlias); + } + + public Http CreateHttp(Address address, string baseUrl, string? logAlias = null) + { + return new Http(log, timeSet, address, baseUrl, logAlias); + } + + public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null) + { + if (namespaceOverride != null) throw new Exception("Namespace override is not supported in the DistTest environment. (It would mess up automatic resource cleanup.)"); + return workflowCreator.CreateWorkflow(); + } + + public IFileManager GetFileManager() + { + return fileManager; + } + + public ILog GetLog() + { + return log; + } + } +} diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index 594b8d3..e5b202f 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -45,7 +45,7 @@ namespace DistTestCore { Stopwatch.Measure(fixtureLog, "Global setup", () => { - globalEntryPoint.CreateWorkflow().DeleteNamespacesStartingWith(TestNamespacePrefix); + globalEntryPoint.Tools.CreateWorkflow().DeleteNamespacesStartingWith(TestNamespacePrefix); }); } catch (Exception ex) @@ -101,7 +101,7 @@ namespace DistTestCore public TrackedFile GenerateTestFile(ByteSize size, string label = "") { - return Get().EntryPoint.GetFileManager().GenerateTestFile(size, label); + return Get().GenerateTestFile(size, label); } /// @@ -110,7 +110,7 @@ namespace DistTestCore /// public void ScopedTestFiles(Action action) { - Get().EntryPoint.GetFileManager().ScopedFiles(action); + Get().ScopedTestFiles(action); } //public IOnlineCodexNode SetupCodexBootstrapNode() @@ -240,7 +240,6 @@ namespace DistTestCore WriteEndTestLog(lifecycle.Log); IncludeLogsAndMetricsOnTestFailure(lifecycle); - lifecycle.EntryPoint.Decommission(); lifecycle.DeleteAllResources(); lifecycle = null!; }); diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 66be2e1..53b95e9 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -1,4 +1,5 @@ using Core; +using FileUtils; using Logging; using Utils; @@ -7,6 +8,7 @@ namespace DistTestCore public class TestLifecycle { private readonly DateTime testStart; + private readonly EntryPoint entryPoint; public TestLifecycle(TestLog log, Configuration configuration, ITimeSet timeSet, string testNamespace) { @@ -15,9 +17,8 @@ namespace DistTestCore TimeSet = timeSet; testStart = DateTime.UtcNow; - EntryPoint = new EntryPoint(log, configuration.GetK8sConfiguration(timeSet, testNamespace), configuration.GetFileManagerFolder(), timeSet); - EntryPoint.Initialize(); - CoreInterface = EntryPoint.CreateInterface(); + entryPoint = new EntryPoint(log, configuration.GetK8sConfiguration(timeSet, testNamespace), configuration.GetFileManagerFolder(), timeSet); + CoreInterface = entryPoint.CreateInterface(); log.WriteLogTag(); } @@ -25,13 +26,23 @@ namespace DistTestCore public TestLog Log { get; } public Configuration Configuration { get; } public ITimeSet TimeSet { get; } - public EntryPoint EntryPoint { get; } public CoreInterface CoreInterface { get; } public void DeleteAllResources() { - EntryPoint.CreateWorkflow().DeleteNamespace(); - EntryPoint.GetFileManager().DeleteAllTestFiles(); + entryPoint.Tools.CreateWorkflow().DeleteNamespace(); + entryPoint.Tools.GetFileManager().DeleteAllFiles(); + entryPoint.Decommission(); + } + + public TrackedFile GenerateTestFile(ByteSize size, string label = "") + { + return entryPoint.Tools.GetFileManager().GenerateFile(size, label); + } + + public void ScopedTestFiles(Action action) + { + entryPoint.Tools.GetFileManager().ScopedFiles(action); } //public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null) diff --git a/FileUtils/FileManager.cs b/FileUtils/FileManager.cs index 6d389ef..33641ab 100644 --- a/FileUtils/FileManager.cs +++ b/FileUtils/FileManager.cs @@ -6,9 +6,9 @@ namespace FileUtils { public interface IFileManager { - TrackedFile CreateEmptyTestFile(string label = ""); - TrackedFile GenerateTestFile(ByteSize size, string label = ""); - void DeleteAllTestFiles(); + TrackedFile CreateEmptyFile(string label = ""); + TrackedFile GenerateFile(ByteSize size, string label = ""); + void DeleteAllFiles(); void ScopedFiles(Action action); T ScopedFiles(Func action); } @@ -30,7 +30,7 @@ namespace FileUtils this.log = log; } - public TrackedFile CreateEmptyTestFile(string label = "") + public TrackedFile CreateEmptyFile(string label = "") { var path = Path.Combine(folder, Guid.NewGuid().ToString() + "_test.bin"); var result = new TrackedFile(log, path, label); @@ -39,15 +39,15 @@ namespace FileUtils return result; } - public TrackedFile GenerateTestFile(ByteSize size, string label) + public TrackedFile GenerateFile(ByteSize size, string label) { var sw = Stopwatch.Begin(log); - var result = GenerateFile(size, label); + var result = GenerateRandomFile(size, label); sw.End($"Generated file '{result.Describe()}'."); return result; } - public void DeleteAllTestFiles() + public void DeleteAllFiles() { DeleteDirectory(); } @@ -88,9 +88,9 @@ namespace FileUtils } } - private TrackedFile GenerateFile(ByteSize size, string label) + private TrackedFile GenerateRandomFile(ByteSize size, string label) { - var result = CreateEmptyTestFile(label); + var result = CreateEmptyFile(label); CheckSpaceAvailable(result, size); GenerateFileBytes(result, size); diff --git a/MetricsPlugin/GrafanaStarter.cs b/MetricsPlugin/GrafanaStarter.cs index b2f14f7..7a30a65 100644 --- a/MetricsPlugin/GrafanaStarter.cs +++ b/MetricsPlugin/GrafanaStarter.cs @@ -2,13 +2,12 @@ namespace MetricsPlugin { - public class GrafanaStarter : BaseStarter + public class GrafanaStarter { private const string StorageQuotaThresholdReplaceToken = "\"\""; private const string BytesUsedGraphAxisSoftMaxReplaceToken = "\"\""; - public GrafanaStarter(TestLifecycle lifecycle) - : base(lifecycle) + public GrafanaStarter() { } diff --git a/MetricsPlugin/MetricsPlugin.cs b/MetricsPlugin/MetricsPlugin.cs index 8d6fc26..8566a2e 100644 --- a/MetricsPlugin/MetricsPlugin.cs +++ b/MetricsPlugin/MetricsPlugin.cs @@ -1,6 +1,5 @@ using Core; using KubernetesWorkflow; -using Logging; namespace MetricsPlugin { @@ -9,16 +8,12 @@ namespace MetricsPlugin #region IProjectPlugin Implementation - public void Announce(ILog log) + public void Announce() { - log.Log("Hi from the metrics plugin."); + //log.Log("Hi from the metrics plugin."); } - public void Initialize(IPluginTools tools) - { - } - - public void Finalize(ILog log) + public void Decommission() { } diff --git a/MetricsPlugin/MetricsPlugin.csproj b/MetricsPlugin/MetricsPlugin.csproj index cfadb03..ec0e3ef 100644 --- a/MetricsPlugin/MetricsPlugin.csproj +++ b/MetricsPlugin/MetricsPlugin.csproj @@ -6,4 +6,18 @@ enable + + + + + + + Never + + + + + + + diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index 9cd1c7e..4065079 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -27,7 +27,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileUtils", "FileUtils\File EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexPlugin", "CodexPlugin\CodexPlugin.csproj", "{DE4E802C-288C-45C4-84B6-8A5A6A96EF49}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{F2BF34B3-C660-43EF-BD42-BC5C60237FC4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "Core\Core.csproj", "{F2BF34B3-C660-43EF-BD42-BC5C60237FC4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetricsPlugin", "MetricsPlugin\MetricsPlugin.csproj", "{FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -87,6 +89,10 @@ Global {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Release|Any CPU.ActiveCfg = Release|Any CPU {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Release|Any CPU.Build.0 = Release|Any CPU + {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From a05a82c0309977e7061ecce8d529b5a40115e68b Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 10:23:05 +0200 Subject: [PATCH 15/51] cleanup of tools creation, adds automatic log prefixing. --- CodexPlugin/CodexPlugin.cs | 6 +-- CodexPlugin/CodexStarter.cs | 6 +-- Core/EntryPoint.cs | 9 ++-- Core/PluginManager.cs | 54 ++++++++++------------- Core/{ToolsProvider.cs => PluginTools.cs} | 36 +++++++++++++-- Core/ToolsFactory.cs | 31 +++++++++++++ MetricsPlugin/CoreInterfaceExtensions.cs | 2 +- MetricsPlugin/MetricsPlugin.cs | 15 ++++--- MetricsPlugin/PrometheusStarter.cs | 1 - 9 files changed, 108 insertions(+), 52 deletions(-) rename Core/{ToolsProvider.cs => PluginTools.cs} (60%) create mode 100644 Core/ToolsFactory.cs diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index 5602678..b82821d 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -3,7 +3,7 @@ using KubernetesWorkflow; namespace CodexPlugin { - public class CodexPlugin : IProjectPlugin + public class CodexPlugin : IProjectPlugin, IHasLogPrefix { private readonly CodexStarter codexStarter; private readonly IPluginTools tools; @@ -14,7 +14,7 @@ namespace CodexPlugin this.tools = tools; } - #region IProjectPlugin Implementation + public string LogPrefix => "(Codex) "; public void Announce() { @@ -25,8 +25,6 @@ namespace CodexPlugin { } - #endregion - public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) { var codexSetup = new CodexSetup(numberOfNodes, CodexLogLevel.Trace); diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index 7558a88..4b6be37 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -7,12 +7,10 @@ namespace CodexPlugin public class CodexStarter { private readonly IPluginTools pluginTools; - private readonly ILog log; public CodexStarter(IPluginTools pluginTools) { this.pluginTools = pluginTools; - log = new LogPrefixer(pluginTools.GetLog(), "(CodexStarter) "); } public RunningContainers[] BringOnline(CodexSetup codexSetup) @@ -120,7 +118,7 @@ namespace CodexPlugin try { - Stopwatch.Measure(log, "EnsureOnline", group.EnsureOnline); + Stopwatch.Measure(pluginTools.GetLog(), "EnsureOnline", group.EnsureOnline); } catch { @@ -150,7 +148,7 @@ namespace CodexPlugin private void Log(string message) { - log.Log(message); + pluginTools.GetLog().Log(message); } //private void StopCrashWatcher(RunningContainers containers) diff --git a/Core/EntryPoint.cs b/Core/EntryPoint.cs index a9d6d5b..788c15a 100644 --- a/Core/EntryPoint.cs +++ b/Core/EntryPoint.cs @@ -5,12 +5,15 @@ namespace Core { public class EntryPoint { + private readonly IToolsFactory toolsFactory; private readonly PluginManager manager = new PluginManager(); - + public EntryPoint(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) { - Tools = new ToolsProvider(log, configuration, fileManagerRootFolder, timeSet); - manager.InstantiatePlugins(PluginFinder.GetPluginTypes(), Tools); + toolsFactory = new ToolsFactory(log, configuration, fileManagerRootFolder, timeSet); + + Tools = toolsFactory.CreateTools(); + manager.InstantiatePlugins(PluginFinder.GetPluginTypes(), toolsFactory); } public EntryPoint(ILog log, Configuration configuration, string fileManagerRootFolder) diff --git a/Core/PluginManager.cs b/Core/PluginManager.cs index 81dbcee..4edccb8 100644 --- a/Core/PluginManager.cs +++ b/Core/PluginManager.cs @@ -1,21 +1,18 @@ -using FileUtils; -using KubernetesWorkflow; -using Logging; -using Utils; - -namespace Core +namespace Core { public class PluginManager { private readonly List projectPlugins = new List(); - public void InstantiatePlugins(Type[] pluginTypes, IPluginTools tools) + internal void InstantiatePlugins(Type[] pluginTypes, IToolsFactory provider) { projectPlugins.Clear(); foreach (var pluginType in pluginTypes) { - var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType, args: tools)!; - projectPlugins.Add(plugin); + var tools = provider.CreateTools(); + var plugin = InstantiatePlugins(pluginType, tools); + + ApplyLogPrefix(plugin, tools); } } @@ -33,6 +30,21 @@ namespace Core { return (T)projectPlugins.Single(p => p.GetType() == typeof(T)); } + + private IProjectPlugin InstantiatePlugins(Type pluginType, PluginTools tools) + { + var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType, args: tools)!; + projectPlugins.Add(plugin); + return plugin; + } + + private void ApplyLogPrefix(IProjectPlugin plugin, PluginTools tools) + { + if (plugin is IHasLogPrefix hasLogPrefix) + { + tools.ApplyLogPrefix(hasLogPrefix.LogPrefix); + } + } } public interface IProjectPlugin @@ -41,28 +53,8 @@ namespace Core void Decommission(); } - public interface IPluginTools : IWorkflowTool, ILogTool, IHttpFactoryTool, IFileTool + public interface IHasLogPrefix { - } - - public interface IWorkflowTool - { - IStartupWorkflow CreateWorkflow(string? namespaceOverride = null); - } - - public interface ILogTool - { - ILog GetLog(); - } - - public interface IHttpFactoryTool - { - Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null); - Http CreateHttp(Address address, string baseUrl, string? logAlias = null); - } - - public interface IFileTool - { - IFileManager GetFileManager(); + string LogPrefix { get; } } } diff --git a/Core/ToolsProvider.cs b/Core/PluginTools.cs similarity index 60% rename from Core/ToolsProvider.cs rename to Core/PluginTools.cs index 81a5010..f25b970 100644 --- a/Core/ToolsProvider.cs +++ b/Core/PluginTools.cs @@ -5,14 +5,39 @@ using Utils; namespace Core { - public class ToolsProvider : IPluginTools + public interface IPluginTools : IWorkflowTool, ILogTool, IHttpFactoryTool, IFileTool + { + } + + public interface IWorkflowTool + { + IStartupWorkflow CreateWorkflow(string? namespaceOverride = null); + } + + public interface ILogTool + { + ILog GetLog(); + } + + public interface IHttpFactoryTool + { + Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null); + Http CreateHttp(Address address, string baseUrl, string? logAlias = null); + } + + public interface IFileTool + { + IFileManager GetFileManager(); + } + + internal class PluginTools : IPluginTools { - private readonly ILog log; private readonly ITimeSet timeSet; private readonly WorkflowCreator workflowCreator; private readonly IFileManager fileManager; + private ILog log; - public ToolsProvider(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) + public PluginTools(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) { this.log = log; this.timeSet = timeSet; @@ -20,6 +45,11 @@ namespace Core workflowCreator = new WorkflowCreator(log, configuration); } + public void ApplyLogPrefix(string prefix) + { + log = new LogPrefixer(log, prefix); + } + public Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) { return new Http(log, timeSet, address, baseUrl, onClientCreated, logAlias); diff --git a/Core/ToolsFactory.cs b/Core/ToolsFactory.cs new file mode 100644 index 0000000..581c0d3 --- /dev/null +++ b/Core/ToolsFactory.cs @@ -0,0 +1,31 @@ +using KubernetesWorkflow; +using Logging; + +namespace Core +{ + internal interface IToolsFactory + { + PluginTools CreateTools(); + } + + internal class ToolsFactory : IToolsFactory + { + private readonly ILog log; + private readonly Configuration configuration; + private readonly string fileManagerRootFolder; + private readonly ITimeSet timeSet; + + public ToolsFactory(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) + { + this.log = log; + this.configuration = configuration; + this.fileManagerRootFolder = fileManagerRootFolder; + this.timeSet = timeSet; + } + + public PluginTools CreateTools() + { + return new PluginTools(log, configuration, fileManagerRootFolder, timeSet); + } + } +} diff --git a/MetricsPlugin/CoreInterfaceExtensions.cs b/MetricsPlugin/CoreInterfaceExtensions.cs index df62c06..1f6cecd 100644 --- a/MetricsPlugin/CoreInterfaceExtensions.cs +++ b/MetricsPlugin/CoreInterfaceExtensions.cs @@ -7,7 +7,7 @@ namespace MetricsPlugin { public static RunningContainer StartMetricsCollector(this CoreInterface ci, RunningContainers[] scrapeTargets) { - return Plugin(ci).StartMetricsCollector(scrapeTargets); + return null!;// Plugin(ci).StartMetricsCollector(scrapeTargets); } private static MetricsPlugin Plugin(CoreInterface ci) diff --git a/MetricsPlugin/MetricsPlugin.cs b/MetricsPlugin/MetricsPlugin.cs index 8566a2e..fc79541 100644 --- a/MetricsPlugin/MetricsPlugin.cs +++ b/MetricsPlugin/MetricsPlugin.cs @@ -5,8 +5,15 @@ namespace MetricsPlugin { public class MetricsPlugin : IProjectPlugin { + private readonly IPluginTools tools; + private readonly PrometheusStarter starter; + + public MetricsPlugin(IPluginTools tools) + { + this.tools = tools; + starter = new PrometheusStarter(tools); + } - #region IProjectPlugin Implementation public void Announce() { @@ -17,11 +24,9 @@ namespace MetricsPlugin { } - #endregion - - public RunningContainer StartMetricsCollector(RunningContainers[] scrapeTargets) + public RunningContainers StartMetricsCollector(RunningContainers[] scrapeTargets) { - return null!; + return starter.CollectMetricsFor(scrapeTargets); } } } diff --git a/MetricsPlugin/PrometheusStarter.cs b/MetricsPlugin/PrometheusStarter.cs index fbf9a3a..1edc8fe 100644 --- a/MetricsPlugin/PrometheusStarter.cs +++ b/MetricsPlugin/PrometheusStarter.cs @@ -1,6 +1,5 @@ using Core; using KubernetesWorkflow; -using System.Text; namespace MetricsPlugin { From 1ca3ddc67e95623e02fc69de5f7e4b169d4c4154 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 11:25:08 +0200 Subject: [PATCH 16/51] Implements metrics plugin --- Core/TimeSet.cs | 6 - DistTestCore/LongTimeSet.cs | 5 - MetricsPlugin/CoreInterfaceExtensions.cs | 21 +- MetricsPlugin/GrafanaContainerRecipe.cs | 25 -- MetricsPlugin/GrafanaStarter.cs | 188 ------------ MetricsPlugin/MetricsAccess.cs | 128 ++++---- MetricsPlugin/MetricsAccessFactory.cs | 35 --- MetricsPlugin/MetricsDownloader.cs | 134 +++++---- MetricsPlugin/MetricsMode.cs | 9 - MetricsPlugin/MetricsPlugin.cs | 17 +- MetricsPlugin/MetricsQuery.cs | 333 ++++++++++----------- MetricsPlugin/MetricsScrapeTarget.cs | 32 ++ MetricsPlugin/PrometheusContainerRecipe.cs | 31 +- MetricsPlugin/PrometheusStarter.cs | 70 +++-- MetricsPlugin/PrometheusStartupConfig.cs | 22 +- Tests/BasicTests/ExampleTests.cs | 3 + Tests/Tests.csproj | 1 + 17 files changed, 424 insertions(+), 636 deletions(-) delete mode 100644 MetricsPlugin/GrafanaContainerRecipe.cs delete mode 100644 MetricsPlugin/GrafanaStarter.cs delete mode 100644 MetricsPlugin/MetricsAccessFactory.cs delete mode 100644 MetricsPlugin/MetricsMode.cs create mode 100644 MetricsPlugin/MetricsScrapeTarget.cs diff --git a/Core/TimeSet.cs b/Core/TimeSet.cs index db78fa5..ff7a19e 100644 --- a/Core/TimeSet.cs +++ b/Core/TimeSet.cs @@ -7,7 +7,6 @@ TimeSpan HttpCallRetryDelay(); TimeSpan WaitForK8sServiceDelay(); TimeSpan K8sOperationTimeout(); - TimeSpan WaitForMetricTimeout(); } public class DefaultTimeSet : ITimeSet @@ -36,10 +35,5 @@ { return TimeSpan.FromMinutes(30); } - - public TimeSpan WaitForMetricTimeout() - { - return TimeSpan.FromSeconds(30); - } } } diff --git a/DistTestCore/LongTimeSet.cs b/DistTestCore/LongTimeSet.cs index 8d0d5e8..cc441cc 100644 --- a/DistTestCore/LongTimeSet.cs +++ b/DistTestCore/LongTimeSet.cs @@ -28,10 +28,5 @@ namespace DistTestCore { return TimeSpan.FromMinutes(15); } - - public TimeSpan WaitForMetricTimeout() - { - return TimeSpan.FromMinutes(5); - } } } diff --git a/MetricsPlugin/CoreInterfaceExtensions.cs b/MetricsPlugin/CoreInterfaceExtensions.cs index 1f6cecd..0346857 100644 --- a/MetricsPlugin/CoreInterfaceExtensions.cs +++ b/MetricsPlugin/CoreInterfaceExtensions.cs @@ -1,13 +1,30 @@ using Core; using KubernetesWorkflow; +using Logging; namespace MetricsPlugin { public static class CoreInterfaceExtensions { - public static RunningContainer StartMetricsCollector(this CoreInterface ci, RunningContainers[] scrapeTargets) + public static RunningContainers StartMetricsCollector(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) { - return null!;// Plugin(ci).StartMetricsCollector(scrapeTargets); + return Plugin(ci).StartMetricsCollector(scrapeTargets); + } + + public static IMetricsAccess GetMetricsFor(this CoreInterface ci, RunningContainers metricsContainer, IMetricsScrapeTarget scrapeTarget) + { + return Plugin(ci).CreateAccessForTarget(metricsContainer, scrapeTarget); + } + + public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) + { + var rc = ci.StartMetricsCollector(scrapeTargets); + return scrapeTargets.Select(t => ci.GetMetricsFor(rc, t)).ToArray(); + } + + public static LogFile? DownloadAllMetrics(this CoreInterface ci, IMetricsAccess metricsAccess, string targetName) + { + return Plugin(ci).DownloadAllMetrics(metricsAccess, targetName); } private static MetricsPlugin Plugin(CoreInterface ci) diff --git a/MetricsPlugin/GrafanaContainerRecipe.cs b/MetricsPlugin/GrafanaContainerRecipe.cs deleted file mode 100644 index db38461..0000000 --- a/MetricsPlugin/GrafanaContainerRecipe.cs +++ /dev/null @@ -1,25 +0,0 @@ -//using KubernetesWorkflow; - -//namespace DistTestCore.Metrics -//{ -// public class GrafanaContainerRecipe : DefaultContainerRecipe -// { -// public override string AppName => "grafana"; -// public override string Image => "grafana/grafana-oss:10.0.3"; - -// public const string DefaultAdminUser = "adminium"; -// public const string DefaultAdminPassword = "passwordium"; - -// protected override void InitializeRecipe(StartupConfig startupConfig) -// { -// AddExposedPort(3000); - -// AddEnvVar("GF_AUTH_ANONYMOUS_ENABLED", "true"); -// AddEnvVar("GF_AUTH_ANONYMOUS_ORG_NAME", "Main Org."); -// AddEnvVar("GF_AUTH_ANONYMOUS_ORG_ROLE", "Editor"); - -// AddEnvVar("GF_SECURITY_ADMIN_USER", DefaultAdminUser); -// AddEnvVar("GF_SECURITY_ADMIN_PASSWORD", DefaultAdminPassword); -// } -// } -//} diff --git a/MetricsPlugin/GrafanaStarter.cs b/MetricsPlugin/GrafanaStarter.cs deleted file mode 100644 index 7a30a65..0000000 --- a/MetricsPlugin/GrafanaStarter.cs +++ /dev/null @@ -1,188 +0,0 @@ -using KubernetesWorkflow; - -namespace MetricsPlugin -{ - public class GrafanaStarter - { - private const string StorageQuotaThresholdReplaceToken = "\"\""; - private const string BytesUsedGraphAxisSoftMaxReplaceToken = "\"\""; - - public GrafanaStarter() - { - } - - public GrafanaStartInfo StartDashboard(RunningContainer prometheusContainer)//, CodexSetup codexSetup) - { - return null!; - //LogStart($"Starting dashboard server"); - - //var grafanaContainer = StartGrafanaContainer(); - //var grafanaAddress = lifecycle.Configuration.GetAddress(grafanaContainer); - - //var http = new Http(lifecycle.Log, new DefaultTimeSet(), grafanaAddress, "api/", AddBasicAuth); - - //Log("Connecting datasource..."); - //AddDataSource(http, prometheusContainer); - - //Log("Uploading dashboard configurations..."); - //var jsons = ReadEachDashboardJsonFile(codexSetup); - //var dashboardUrls = jsons.Select(j => UploadDashboard(http, grafanaContainer, j)).ToArray(); - - //LogEnd("Dashboard server started."); - - //return new GrafanaStartInfo(dashboardUrls, grafanaContainer); - } - - //private RunningContainer StartGrafanaContainer() - //{ - // var startupConfig = new StartupConfig(); - - // var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - // var grafanaContainers = workflow.Start(1, Location.Unspecified, new GrafanaContainerRecipe(), startupConfig); - // if (grafanaContainers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 dashboard container to be created."); - - // return grafanaContainers.Containers.First(); - //} - - //private void AddBasicAuth(HttpClient client) - //{ - // client.SetBasicAuthentication( - // GrafanaContainerRecipe.DefaultAdminUser, - // GrafanaContainerRecipe.DefaultAdminPassword); - //} - - //private static void AddDataSource(Http http, RunningContainer prometheusContainer) - //{ - // var prometheusAddress = prometheusContainer.ClusterExternalAddress; - // var prometheusUrl = prometheusAddress.Host + ":" + prometheusAddress.Port; - // var response = http.HttpPostJson("datasources", new GrafanaDataSourceRequest - // { - // uid = "c89eaad3-9184-429f-ac94-8ba0b1824dbb", - // name = "CodexPrometheus", - // type = "prometheus", - // url = prometheusUrl, - // access = "proxy", - // basicAuth = false, - // jsonData = new GrafanaDataSourceJsonData - // { - // httpMethod = "POST" - // } - // }); - - // if (response.message != "Datasource added") - // { - // throw new Exception("Test infra failure: Failed to add datasource to dashboard: " + response.message); - // } - //} - - //public static string UploadDashboard(Http http, RunningContainer grafanaContainer, string dashboardJson) - //{ - // var request = GetDashboardCreateRequest(dashboardJson); - // var response = http.HttpPostString("dashboards/db", request); - // var jsonResponse = JsonConvert.DeserializeObject(response); - // if (jsonResponse == null || string.IsNullOrEmpty(jsonResponse.url)) throw new Exception("Failed to upload dashboard."); - - // var grafanaAddress = grafanaContainer.ClusterExternalAddress; - // return grafanaAddress.Host + ":" + grafanaAddress.Port + jsonResponse.url; - //} - - //private static string[] ReadEachDashboardJsonFile(CodexSetup codexSetup) - //{ - // var assembly = Assembly.GetExecutingAssembly(); - // var resourceNames = new[] - // { - // "DistTestCore.Metrics.dashboard.json" - // }; - - // return resourceNames.Select(r => GetManifestResource(assembly, r, codexSetup)).ToArray(); - //} - - //private static string GetManifestResource(Assembly assembly, string resourceName, CodexSetup codexSetup) - //{ - // using var stream = assembly.GetManifestResourceStream(resourceName); - // if (stream == null) throw new Exception("Unable to find resource " + resourceName); - // using var reader = new StreamReader(stream); - // return ApplyReplacements(reader.ReadToEnd(), codexSetup); - //} - - //private static string ApplyReplacements(string input, CodexSetup codexSetup) - //{ - // var quotaString = GetQuotaString(codexSetup); - // var softMaxString = GetSoftMaxString(codexSetup); - - // return input - // .Replace(StorageQuotaThresholdReplaceToken, quotaString) - // .Replace(BytesUsedGraphAxisSoftMaxReplaceToken, softMaxString); - //} - - //private static string GetQuotaString(CodexSetup codexSetup) - //{ - // return GetCodexStorageQuotaInBytes(codexSetup).ToString(); - //} - - //private static string GetSoftMaxString(CodexSetup codexSetup) - //{ - // var quota = GetCodexStorageQuotaInBytes(codexSetup); - // var softMax = Convert.ToInt64(quota * 1.1); // + 10%, for nice viewing. - // return softMax.ToString(); - //} - - //private static long GetCodexStorageQuotaInBytes(CodexSetup codexSetup) - //{ - // if (codexSetup.StorageQuota != null) return codexSetup.StorageQuota.SizeInBytes; - - // // Codex default: 8GB - // return 8.GB().SizeInBytes; - //} - - //private static string GetDashboardCreateRequest(string dashboardJson) - //{ - // return $"{{\"dashboard\": {dashboardJson} ,\"message\": \"Default Codex Dashboard\",\"overwrite\": false}}"; - //} - } - - public class GrafanaStartInfo - { - public GrafanaStartInfo(string[] dashboardUrls, RunningContainer container) - { - DashboardUrls = dashboardUrls; - Container = container; - } - - public string[] DashboardUrls { get; } - public RunningContainer Container { get; } - } - - public class GrafanaDataSourceRequest - { - public string uid { get; set; } = string.Empty; - public string name { get; set; } = string.Empty; - public string type { get; set; } = string.Empty; - public string url { get; set; } = string.Empty; - public string access { get; set; } = string.Empty; - public bool basicAuth { get; set; } - public GrafanaDataSourceJsonData jsonData { get; set; } = new(); - } - - public class GrafanaDataSourceResponse - { - public int id { get; set; } - public string message { get; set; } = string.Empty; - public string name { get; set; } = string.Empty; - } - - public class GrafanaDataSourceJsonData - { - public string httpMethod { get; set; } = string.Empty; - } - - public class GrafanaPostDashboardResponse - { - public int id { get; set; } - public string slug { get; set; } = string.Empty; - public string status { get; set; } = string.Empty; - public string uid { get; set; } = string.Empty; - public string url { get; set; } = string.Empty; - public int version { get; set; } - } -} diff --git a/MetricsPlugin/MetricsAccess.cs b/MetricsPlugin/MetricsAccess.cs index 3641d4f..beb5ed1 100644 --- a/MetricsPlugin/MetricsAccess.cs +++ b/MetricsPlugin/MetricsAccess.cs @@ -1,81 +1,69 @@ -//using DistTestCore.Helpers; -//using KubernetesWorkflow; -//using Logging; -//using NUnit.Framework; -//using NUnit.Framework.Constraints; -//using Utils; +using Utils; -//namespace DistTestCore.Metrics -//{ -// public interface IMetricsAccess -// { -// void AssertThat(string metricName, IResolveConstraint constraint, string message = ""); -// } +namespace MetricsPlugin +{ + public interface IMetricsAccess + { + Metrics? GetAllMetrics(); + MetricsSet GetMetric(string metricName); + MetricsSet GetMetric(string metricName, TimeSpan timeout); + } -// public class MetricsAccess : IMetricsAccess -// { -// private readonly BaseLog log; -// private readonly ITimeSet timeSet; -// private readonly MetricsQuery query; -// private readonly RunningContainer node; + public class MetricsAccess : IMetricsAccess + { + private readonly MetricsQuery query; + private readonly IMetricsScrapeTarget target; -// public MetricsAccess(BaseLog log, ITimeSet timeSet, MetricsQuery query, RunningContainer node) -// { -// this.log = log; -// this.timeSet = timeSet; -// this.query = query; -// this.node = node; -// } + public MetricsAccess(MetricsQuery query, IMetricsScrapeTarget target) + { + this.query = query; + this.target = target; + } -// public void AssertThat(string metricName, IResolveConstraint constraint, string message = "") -// { -// AssertHelpers.RetryAssert(constraint, () => -// { -// var metricSet = GetMetricWithTimeout(metricName); -// var metricValue = metricSet.Values[0].Value; + //public void AssertThat(string metricName, IResolveConstraint constraint, string message = "") + //{ + // AssertHelpers.RetryAssert(constraint, () => + // { + // var metricSet = GetMetricWithTimeout(metricName); + // var metricValue = metricSet.Values[0].Value; -// log.Log($"{node.Name} metric '{metricName}' = {metricValue}"); -// return metricValue; -// }, message); -// } + // log.Log($"{node.Name} metric '{metricName}' = {metricValue}"); + // return metricValue; + // }, message); + //} -// public Metrics? GetAllMetrics() -// { -// return query.GetAllMetricsForNode(node); -// } + public Metrics? GetAllMetrics() + { + return query.GetAllMetricsForNode(target); + } -// private MetricsSet GetMetricWithTimeout(string metricName) -// { -// var start = DateTime.UtcNow; + public MetricsSet GetMetric(string metricName) + { + return GetMetric(metricName, TimeSpan.FromSeconds(10)); + } -// while (true) -// { -// var mostRecent = GetMostRecent(metricName); -// if (mostRecent != null) return mostRecent; -// if (DateTime.UtcNow - start > timeSet.WaitForMetricTimeout()) -// { -// Assert.Fail($"Timeout: Unable to get metric '{metricName}'."); -// throw new TimeoutException(); -// } + public MetricsSet GetMetric(string metricName, TimeSpan timeout) + { + var start = DateTime.UtcNow; -// Time.Sleep(TimeSpan.FromSeconds(2)); -// } -// } + while (true) + { + var mostRecent = GetMostRecent(metricName); + if (mostRecent != null) return mostRecent; + if (DateTime.UtcNow - start > timeout) + { + throw new TimeoutException(); + } -// private MetricsSet? GetMostRecent(string metricName) -// { -// var result = query.GetMostRecent(metricName, node); -// if (result == null) return null; -// return result.Sets.LastOrDefault(); -// } -// } + Time.Sleep(TimeSpan.FromSeconds(2)); + } + } -// public class MetricsUnavailable : IMetricsAccess -// { -// public void AssertThat(string metricName, IResolveConstraint constraint, string message = "") -// { -// Assert.Fail("Incorrect test setup: Metrics were not enabled for this group of Codex nodes. Add 'EnableMetrics()' after 'SetupCodexNodes()' to enable it."); -// throw new InvalidOperationException(); -// } -// } -//} + private MetricsSet? GetMostRecent(string metricName) + { + var result = query.GetMostRecent(metricName, target); + if (result == null) return null; + return result.Sets.LastOrDefault(); + } + } +} diff --git a/MetricsPlugin/MetricsAccessFactory.cs b/MetricsPlugin/MetricsAccessFactory.cs deleted file mode 100644 index c185fef..0000000 --- a/MetricsPlugin/MetricsAccessFactory.cs +++ /dev/null @@ -1,35 +0,0 @@ -//using KubernetesWorkflow; - -//namespace DistTestCore.Metrics -//{ -// public interface IMetricsAccessFactory -// { -// IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer); -// } - -// public class MetricsUnavailableAccessFactory : IMetricsAccessFactory -// { -// public IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer) -// { -// return new MetricsUnavailable(); -// } -// } - -// public class CodexNodeMetricsAccessFactory : IMetricsAccessFactory -// { -// private readonly TestLifecycle lifecycle; -// private readonly RunningContainers prometheusContainer; - -// public CodexNodeMetricsAccessFactory(TestLifecycle lifecycle, RunningContainers prometheusContainer) -// { -// this.lifecycle = lifecycle; -// this.prometheusContainer = prometheusContainer; -// } - -// public IMetricsAccess CreateMetricsAccess(RunningContainer codexContainer) -// { -// var query = new MetricsQuery(lifecycle, prometheusContainer); -// return new MetricsAccess(lifecycle.Log, lifecycle.TimeSet, query, codexContainer); -// } -// } -//} diff --git a/MetricsPlugin/MetricsDownloader.cs b/MetricsPlugin/MetricsDownloader.cs index 5efa085..1c642a0 100644 --- a/MetricsPlugin/MetricsDownloader.cs +++ b/MetricsPlugin/MetricsDownloader.cs @@ -1,80 +1,82 @@ -//using Logging; -//using System.Globalization; +using Logging; +using System.Globalization; -//namespace DistTestCore.Metrics -//{ -// public class MetricsDownloader -// { -// private readonly BaseLog log; +namespace MetricsPlugin +{ + public class MetricsDownloader + { + private readonly ILog log; -// public MetricsDownloader(BaseLog log) -// { -// this.log = log; -// } + public MetricsDownloader(ILog log) + { + this.log = log; + } -// public void DownloadAllMetricsForNode(string nodeName, MetricsAccess access) -// { -// var metrics = access.GetAllMetrics(); -// if (metrics == null || metrics.Sets.Length == 0 || metrics.Sets.All(s => s.Values.Length == 0)) return; + public LogFile? DownloadAllMetrics(string targetName, IMetricsAccess access) + { + var metrics = access.GetAllMetrics(); + if (metrics == null || metrics.Sets.Length == 0 || metrics.Sets.All(s => s.Values.Length == 0)) return null; -// var headers = new[] { "timestamp" }.Concat(metrics.Sets.Select(s => s.Name)).ToArray(); -// var map = CreateValueMap(metrics); + var headers = new[] { "timestamp" }.Concat(metrics.Sets.Select(s => s.Name)).ToArray(); + var map = CreateValueMap(metrics); -// WriteToFile(nodeName, headers, map); -// } + return WriteToFile(targetName, headers, map); + } -// private void WriteToFile(string nodeName, string[] headers, Dictionary> map) -// { -// var file = log.CreateSubfile("csv"); -// log.Log($"Downloading metrics for {nodeName} to file {file.FullFilename}"); + private LogFile WriteToFile(string nodeName, string[] headers, Dictionary> map) + { + var file = log.CreateSubfile("csv"); + log.Log($"Downloading metrics for {nodeName} to file {file.FullFilename}"); -// file.WriteRaw(string.Join(",", headers)); + file.WriteRaw(string.Join(",", headers)); -// foreach (var pair in map) -// { -// file.WriteRaw(string.Join(",", new[] { FormatTimestamp(pair.Key) }.Concat(pair.Value))); -// } -// } + foreach (var pair in map) + { + file.WriteRaw(string.Join(",", new[] { FormatTimestamp(pair.Key) }.Concat(pair.Value))); + } -// private Dictionary> CreateValueMap(Metrics metrics) -// { -// var map = CreateForAllTimestamps(metrics); -// foreach (var metric in metrics.Sets) -// { -// AddToMap(map, metric); -// } -// return map; + return file; + } -// } + private Dictionary> CreateValueMap(Metrics metrics) + { + var map = CreateForAllTimestamps(metrics); + foreach (var metric in metrics.Sets) + { + AddToMap(map, metric); + } + return map; -// private Dictionary> CreateForAllTimestamps(Metrics metrics) -// { -// var result = new Dictionary>(); -// var timestamps = metrics.Sets.SelectMany(s => s.Values).Select(v => v.Timestamp).Distinct().ToArray(); -// foreach (var timestamp in timestamps) result.Add(timestamp, new List()); -// return result; -// } + } -// private void AddToMap(Dictionary> map, MetricsSet metric) -// { -// foreach (var key in map.Keys) -// { -// map[key].Add(GetValueAtTimestamp(key, metric)); -// } -// } + private Dictionary> CreateForAllTimestamps(Metrics metrics) + { + var result = new Dictionary>(); + var timestamps = metrics.Sets.SelectMany(s => s.Values).Select(v => v.Timestamp).Distinct().ToArray(); + foreach (var timestamp in timestamps) result.Add(timestamp, new List()); + return result; + } -// private string GetValueAtTimestamp(DateTime key, MetricsSet metric) -// { -// var value = metric.Values.SingleOrDefault(v => v.Timestamp == key); -// if (value == null) return ""; -// return value.Value.ToString(CultureInfo.InvariantCulture); -// } + private void AddToMap(Dictionary> map, MetricsSet metric) + { + foreach (var key in map.Keys) + { + map[key].Add(GetValueAtTimestamp(key, metric)); + } + } -// private string FormatTimestamp(DateTime key) -// { -// var origin = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); -// var diff = key - origin; -// return Math.Floor(diff.TotalSeconds).ToString(CultureInfo.InvariantCulture); -// } -// } -//} + private string GetValueAtTimestamp(DateTime key, MetricsSet metric) + { + var value = metric.Values.SingleOrDefault(v => v.Timestamp == key); + if (value == null) return ""; + return value.Value.ToString(CultureInfo.InvariantCulture); + } + + private string FormatTimestamp(DateTime key) + { + var origin = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); + var diff = key - origin; + return Math.Floor(diff.TotalSeconds).ToString(CultureInfo.InvariantCulture); + } + } +} diff --git a/MetricsPlugin/MetricsMode.cs b/MetricsPlugin/MetricsMode.cs deleted file mode 100644 index 44a99e9..0000000 --- a/MetricsPlugin/MetricsMode.cs +++ /dev/null @@ -1,9 +0,0 @@ -//namespace DistTestCore.Metrics -//{ -// public enum MetricsMode -// { -// None, -// Record, -// Dashboard -// } -//} diff --git a/MetricsPlugin/MetricsPlugin.cs b/MetricsPlugin/MetricsPlugin.cs index fc79541..c2ed11c 100644 --- a/MetricsPlugin/MetricsPlugin.cs +++ b/MetricsPlugin/MetricsPlugin.cs @@ -1,5 +1,6 @@ using Core; using KubernetesWorkflow; +using Logging; namespace MetricsPlugin { @@ -14,19 +15,29 @@ namespace MetricsPlugin starter = new PrometheusStarter(tools); } - public void Announce() { - //log.Log("Hi from the metrics plugin."); + tools.GetLog().Log("Hi from the metrics plugin."); } public void Decommission() { } - public RunningContainers StartMetricsCollector(RunningContainers[] scrapeTargets) + public RunningContainers StartMetricsCollector(IMetricsScrapeTarget[] scrapeTargets) { return starter.CollectMetricsFor(scrapeTargets); } + + public MetricsAccess CreateAccessForTarget(RunningContainers runningContainers, IMetricsScrapeTarget target) + { + return starter.CreateAccessForTarget(runningContainers, target); + } + + public LogFile? DownloadAllMetrics(IMetricsAccess metricsAccess, string targetName) + { + var downloader = new MetricsDownloader(tools.GetLog()); + return downloader.DownloadAllMetrics(targetName, metricsAccess); + } } } diff --git a/MetricsPlugin/MetricsQuery.cs b/MetricsPlugin/MetricsQuery.cs index 668ee9f..8d75f20 100644 --- a/MetricsPlugin/MetricsQuery.cs +++ b/MetricsPlugin/MetricsQuery.cs @@ -1,198 +1,189 @@ -//using DistTestCore.Codex; -//using KubernetesWorkflow; -//using System.Globalization; +using Core; +using KubernetesWorkflow; +using System.Globalization; -//namespace DistTestCore.Metrics -//{ -// public class MetricsQuery -// { -// private readonly Http http; +namespace MetricsPlugin +{ + public class MetricsQuery + { + private readonly Http http; -// public MetricsQuery(TestLifecycle lifecycle, RunningContainers runningContainers) -// { -// RunningContainers = runningContainers; + public MetricsQuery(IPluginTools tools, RunningContainers runningContainers) + { + RunningContainers = runningContainers; + http = tools.CreateHttp(runningContainers.Containers[0].Address, "api/v1"); + } -// var address = lifecycle.Configuration.GetAddress(runningContainers.Containers[0]); + public RunningContainers RunningContainers { get; } -// http = new Http( -// lifecycle.Log, -// lifecycle.TimeSet, -// address, -// "api/v1"); -// } + public Metrics? GetMostRecent(string metricName, IMetricsScrapeTarget target) + { + var response = GetLastOverTime(metricName, GetInstanceStringForNode(target)); + if (response == null) return null; -// public RunningContainers RunningContainers { get; } + return new Metrics + { + Sets = response.data.result.Select(r => + { + return new MetricsSet + { + Instance = r.metric.instance, + Values = MapSingleValue(r.value) + }; + }).ToArray() + }; + } -// public Metrics? GetMostRecent(string metricName, RunningContainer node) -// { -// var response = GetLastOverTime(metricName, GetInstanceStringForNode(node)); -// if (response == null) return null; + public Metrics? GetMetrics(string metricName) + { + var response = GetAll(metricName); + if (response == null) return null; + return MapResponseToMetrics(response); + } -// return new Metrics -// { -// Sets = response.data.result.Select(r => -// { -// return new MetricsSet -// { -// Instance = r.metric.instance, -// Values = MapSingleValue(r.value) -// }; -// }).ToArray() -// }; -// } + public Metrics? GetAllMetricsForNode(IMetricsScrapeTarget target) + { + var response = http.HttpGetJson($"query?query={GetInstanceStringForNode(target)}{GetQueryTimeRange()}"); + if (response.status != "success") return null; + return MapResponseToMetrics(response); + } -// public Metrics? GetMetrics(string metricName) -// { -// var response = GetAll(metricName); -// if (response == null) return null; -// return MapResponseToMetrics(response); -// } + private PrometheusQueryResponse? GetLastOverTime(string metricName, string instanceString) + { + var response = http.HttpGetJson($"query?query=last_over_time({metricName}{instanceString}{GetQueryTimeRange()})"); + if (response.status != "success") return null; + return response; + } -// public Metrics? GetAllMetricsForNode(RunningContainer node) -// { -// var response = http.HttpGetJson($"query?query={GetInstanceStringForNode(node)}{GetQueryTimeRange()}"); -// if (response.status != "success") return null; -// return MapResponseToMetrics(response); -// } + private PrometheusQueryResponse? GetAll(string metricName) + { + var response = http.HttpGetJson($"query?query={metricName}{GetQueryTimeRange()}"); + if (response.status != "success") return null; + return response; + } -// private PrometheusQueryResponse? GetLastOverTime(string metricName, string instanceString) -// { -// var response = http.HttpGetJson($"query?query=last_over_time({metricName}{instanceString}{GetQueryTimeRange()})"); -// if (response.status != "success") return null; -// return response; -// } + private Metrics MapResponseToMetrics(PrometheusQueryResponse response) + { + return new Metrics + { + Sets = response.data.result.Select(r => + { + return new MetricsSet + { + Name = r.metric.__name__, + Instance = r.metric.instance, + Values = MapMultipleValues(r.values) + }; + }).ToArray() + }; + } -// private PrometheusQueryResponse? GetAll(string metricName) -// { -// var response = http.HttpGetJson($"query?query={metricName}{GetQueryTimeRange()}"); -// if (response.status != "success") return null; -// return response; -// } + private MetricsSetValue[] MapSingleValue(object[] value) + { + if (value != null && value.Length > 0) + { + return new[] + { + MapValue(value) + }; + } + return Array.Empty(); + } -// private Metrics MapResponseToMetrics(PrometheusQueryResponse response) -// { -// return new Metrics -// { -// Sets = response.data.result.Select(r => -// { -// return new MetricsSet -// { -// Name = r.metric.__name__, -// Instance = r.metric.instance, -// Values = MapMultipleValues(r.values) -// }; -// }).ToArray() -// }; -// } + private MetricsSetValue[] MapMultipleValues(object[][] values) + { + if (values != null && values.Length > 0) + { + return values.Select(v => MapValue(v)).ToArray(); + } + return Array.Empty(); + } -// private MetricsSetValue[] MapSingleValue(object[] value) -// { -// if (value != null && value.Length > 0) -// { -// return new[] -// { -// MapValue(value) -// }; -// } -// return Array.Empty(); -// } + private MetricsSetValue MapValue(object[] value) + { + if (value.Length != 2) throw new InvalidOperationException("Expected value to be [double, string]."); -// private MetricsSetValue[] MapMultipleValues(object[][] values) -// { -// if (values != null && values.Length > 0) -// { -// return values.Select(v => MapValue(v)).ToArray(); -// } -// return Array.Empty(); -// } + return new MetricsSetValue + { + Timestamp = ToTimestamp(value[0]), + Value = ToValue(value[1]) + }; + } -// private MetricsSetValue MapValue(object[] value) -// { -// if (value.Length != 2) throw new InvalidOperationException("Expected value to be [double, string]."); + private string GetInstanceNameForNode(IMetricsScrapeTarget target) + { + return $"{target.Ip}:{target.Port}"; + } -// return new MetricsSetValue -// { -// Timestamp = ToTimestamp(value[0]), -// Value = ToValue(value[1]) -// }; -// } + private string GetInstanceStringForNode(IMetricsScrapeTarget target) + { + return "{instance=\"" + GetInstanceNameForNode(target) + "\"}"; + } -// private string GetInstanceNameForNode(RunningContainer node) -// { -// var ip = node.Pod.PodInfo.Ip; -// var port = node.Recipe.GetPortByTag(CodexContainerRecipe.MetricsPortTag).Number; -// return $"{ip}:{port}"; -// } + private string GetQueryTimeRange() + { + return "[12h]"; + } -// private string GetInstanceStringForNode(RunningContainer node) -// { -// return "{instance=\"" + GetInstanceNameForNode(node) + "\"}"; -// } + private double ToValue(object v) + { + return Convert.ToDouble(v, CultureInfo.InvariantCulture); + } -// private string GetQueryTimeRange() -// { -// return "[12h]"; -// } + private DateTime ToTimestamp(object v) + { + var unixSeconds = ToValue(v); + return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(unixSeconds); + } + } -// private double ToValue(object v) -// { -// return Convert.ToDouble(v, CultureInfo.InvariantCulture); -// } + public class Metrics + { + public MetricsSet[] Sets { get; set; } = Array.Empty(); + } -// private DateTime ToTimestamp(object v) -// { -// var unixSeconds = ToValue(v); -// return new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(unixSeconds); -// } -// } + public class MetricsSet + { + public string Name { get; set; } = string.Empty; + public string Instance { get; set; } = string.Empty; + public MetricsSetValue[] Values { get; set; } = Array.Empty(); + } -// public class Metrics -// { -// public MetricsSet[] Sets { get; set; } = Array.Empty(); -// } + public class MetricsSetValue + { + public DateTime Timestamp { get; set; } + public double Value { get; set; } + } -// public class MetricsSet -// { -// public string Name { get; set; } = string.Empty; -// public string Instance { get; set; } = string.Empty; -// public MetricsSetValue[] Values { get; set; } = Array.Empty(); -// } + public class PrometheusQueryResponse + { + public string status { get; set; } = string.Empty; + public PrometheusQueryResponseData data { get; set; } = new(); + } -// public class MetricsSetValue -// { -// public DateTime Timestamp { get; set; } -// public double Value { get; set; } -// } + public class PrometheusQueryResponseData + { + public string resultType { get; set; } = string.Empty; + public PrometheusQueryResponseDataResultEntry[] result { get; set; } = Array.Empty(); + } -// public class PrometheusQueryResponse -// { -// public string status { get; set; } = string.Empty; -// public PrometheusQueryResponseData data { get; set; } = new(); -// } + public class PrometheusQueryResponseDataResultEntry + { + public ResultEntryMetric metric { get; set; } = new(); + public object[] value { get; set; } = Array.Empty(); + public object[][] values { get; set; } = Array.Empty(); + } -// public class PrometheusQueryResponseData -// { -// public string resultType { get; set; } = string.Empty; -// public PrometheusQueryResponseDataResultEntry[] result { get; set; } = Array.Empty(); -// } + public class ResultEntryMetric + { + public string __name__ { get; set; } = string.Empty; + public string instance { get; set; } = string.Empty; + public string job { get; set; } = string.Empty; + } -// public class PrometheusQueryResponseDataResultEntry -// { -// public ResultEntryMetric metric { get; set; } = new(); -// public object[] value { get; set; } = Array.Empty(); -// public object[][] values { get; set; } = Array.Empty(); -// } - -// public class ResultEntryMetric -// { -// public string __name__ { get; set; } = string.Empty; -// public string instance { get; set; } = string.Empty; -// public string job { get; set; } = string.Empty; -// } - -// public class PrometheusAllNamesResponse -// { -// public string status { get; set; } = string.Empty; -// public string[] data { get; set; } = Array.Empty(); -// } -//} + public class PrometheusAllNamesResponse + { + public string status { get; set; } = string.Empty; + public string[] data { get; set; } = Array.Empty(); + } +} diff --git a/MetricsPlugin/MetricsScrapeTarget.cs b/MetricsPlugin/MetricsScrapeTarget.cs new file mode 100644 index 0000000..6dff1be --- /dev/null +++ b/MetricsPlugin/MetricsScrapeTarget.cs @@ -0,0 +1,32 @@ +using KubernetesWorkflow; + +namespace MetricsPlugin +{ + public interface IMetricsScrapeTarget + { + string Ip { get; } + int Port { get; } + } + + public class MetricsScrapeTarget : IMetricsScrapeTarget + { + public MetricsScrapeTarget(string ip, int port) + { + Ip = ip; + Port = port; + } + + public MetricsScrapeTarget(RunningContainer container, int port) + : this(container.Pod.PodInfo.Ip, port) + { + } + + public MetricsScrapeTarget(RunningContainer container, Port port) + : this(container, port.Number) + { + } + + public string Ip { get; } + public int Port { get; } + } +} diff --git a/MetricsPlugin/PrometheusContainerRecipe.cs b/MetricsPlugin/PrometheusContainerRecipe.cs index c3b5706..584ad82 100644 --- a/MetricsPlugin/PrometheusContainerRecipe.cs +++ b/MetricsPlugin/PrometheusContainerRecipe.cs @@ -1,18 +1,19 @@ -//using KubernetesWorkflow; +using Core; +using KubernetesWorkflow; -//namespace DistTestCore.Metrics -//{ -// public class PrometheusContainerRecipe : DefaultContainerRecipe -// { -// public override string AppName => "prometheus"; -// public override string Image => "codexstorage/dist-tests-prometheus:latest"; +namespace MetricsPlugin +{ + public class PrometheusContainerRecipe : DefaultContainerRecipe + { + public override string AppName => "prometheus"; + public override string Image => "codexstorage/dist-tests-prometheus:latest"; -// protected override void InitializeRecipe(StartupConfig startupConfig) -// { -// var config = startupConfig.Get(); + protected override void InitializeRecipe(StartupConfig startupConfig) + { + var config = startupConfig.Get(); -// AddExposedPortAndVar("PROM_PORT"); -// AddEnvVar("PROM_CONFIG", config.PrometheusConfigBase64); -// } -// } -//} + AddExposedPortAndVar("PROM_PORT"); + AddEnvVar("PROM_CONFIG", config.PrometheusConfigBase64); + } + } +} diff --git a/MetricsPlugin/PrometheusStarter.cs b/MetricsPlugin/PrometheusStarter.cs index 1edc8fe..37814ea 100644 --- a/MetricsPlugin/PrometheusStarter.cs +++ b/MetricsPlugin/PrometheusStarter.cs @@ -1,5 +1,6 @@ using Core; using KubernetesWorkflow; +using System.Text; namespace MetricsPlugin { @@ -12,42 +13,51 @@ namespace MetricsPlugin this.tools = tools; } - public RunningContainers CollectMetricsFor(RunningContainers[] containers) + public RunningContainers CollectMetricsFor(IMetricsScrapeTarget[] targets) { - //LogStart($"Starting metrics server for {containers.Describe()}"); - //var startupConfig = new StartupConfig(); - //startupConfig.Add(new PrometheusStartupConfig(GeneratePrometheusConfig(containers.Containers()))); + Log($"Starting metrics server for {targets.Length} targets..."); + var startupConfig = new StartupConfig(); + startupConfig.Add(new PrometheusStartupConfig(GeneratePrometheusConfig(targets))); - //var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - //var runningContainers = workflow.Start(1, Location.Unspecified, new PrometheusContainerRecipe(), startupConfig); - //if (runningContainers.Containers.Length != 1) throw new InvalidOperationException("Expected only 1 Prometheus container to be created."); + var workflow = tools.CreateWorkflow(); + var runningContainers = workflow.Start(1, Location.Unspecified, new PrometheusContainerRecipe(), startupConfig); + if (runningContainers.Containers.Length != 1) throw new InvalidOperationException("Expected only 1 Prometheus container to be created."); - //return runningContainers; - return null!; + Log("Metrics server started."); + return runningContainers; } - //private string GeneratePrometheusConfig(RunningContainer[] nodes) - //{ - // var config = ""; - // config += "global:\n"; - // config += " scrape_interval: 10s\n"; - // config += " scrape_timeout: 10s\n"; - // config += "\n"; - // config += "scrape_configs:\n"; - // config += " - job_name: services\n"; - // config += " metrics_path: /metrics\n"; - // config += " static_configs:\n"; - // config += " - targets:\n"; + public MetricsAccess CreateAccessForTarget(RunningContainers metricsContainer, IMetricsScrapeTarget target) + { + var metricsQuery = new MetricsQuery(tools, metricsContainer); + return new MetricsAccess(metricsQuery, target); + } - // foreach (var node in nodes) - // { - // var ip = node.Pod.PodInfo.Ip; - // var port = node.Recipe.GetPortByTag(CodexContainerRecipe.MetricsPortTag).Number; - // config += $" - '{ip}:{port}'\n"; - // } + private void Log(string msg) + { + tools.GetLog().Log(msg); + } - // var bytes = Encoding.ASCII.GetBytes(config); - // return Convert.ToBase64String(bytes); - //} + private static string GeneratePrometheusConfig(IMetricsScrapeTarget[] targets) + { + var config = ""; + config += "global:\n"; + config += " scrape_interval: 10s\n"; + config += " scrape_timeout: 10s\n"; + config += "\n"; + config += "scrape_configs:\n"; + config += " - job_name: services\n"; + config += " metrics_path: /metrics\n"; + config += " static_configs:\n"; + config += " - targets:\n"; + + foreach (var target in targets) + { + config += $" - '{target.Ip}:{target.Port}'\n"; + } + + var bytes = Encoding.ASCII.GetBytes(config); + return Convert.ToBase64String(bytes); + } } } diff --git a/MetricsPlugin/PrometheusStartupConfig.cs b/MetricsPlugin/PrometheusStartupConfig.cs index 57434eb..a490420 100644 --- a/MetricsPlugin/PrometheusStartupConfig.cs +++ b/MetricsPlugin/PrometheusStartupConfig.cs @@ -1,12 +1,12 @@ -//namespace DistTestCore.Metrics -//{ -// public class PrometheusStartupConfig -// { -// public PrometheusStartupConfig(string prometheusConfigBase64) -// { -// PrometheusConfigBase64 = prometheusConfigBase64; -// } +namespace MetricsPlugin +{ + public class PrometheusStartupConfig + { + public PrometheusStartupConfig(string prometheusConfigBase64) + { + PrometheusConfigBase64 = prometheusConfigBase64; + } -// public string PrometheusConfigBase64 { get; } -// } -//} + public string PrometheusConfigBase64 { get; } + } +} diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 15c82f5..0b5156d 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -1,5 +1,6 @@ using CodexPlugin; using DistTestCore; +using MetricsPlugin; using NUnit.Framework; using Utils; @@ -23,6 +24,8 @@ namespace Tests.BasicTests [Test] public void TwoMetricsExample() { + var rc = Ci.StartMetricsCollector(); + //var group = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); //var group2 = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index ecc4f58..27552d7 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -15,6 +15,7 @@ + From 53bb9968ffe761da91df8f1810bd84c930977596 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 11:59:21 +0200 Subject: [PATCH 17/51] Metrics test passed --- CodexPlugin/CodexContainerRecipe.cs | 18 +++++++++--------- CodexPlugin/CodexNodeGroup.cs | 4 +++- CodexPlugin/CodexPlugin.csproj | 1 + CodexPlugin/CodexSetup.cs | 12 ++++++------ CodexPlugin/CodexStartupConfig.cs | 2 +- CodexPlugin/OnlineCodexNode.cs | 19 ++++++++++++------- Core/PluginTools.cs | 4 ++-- Core/ToolsFactory.cs | 6 +++--- KubernetesWorkflow/ContainerRecipe.cs | 4 ++-- MetricsPlugin/CoreInterfaceExtensions.cs | 5 +++++ MetricsPlugin/MetricsAccess.cs | 14 +++----------- MetricsPlugin/MetricsScrapeTarget.cs | 12 ++++++++++-- Tests/BasicTests/ExampleTests.cs | 24 ++++++++++++------------ Tests/MetricsAccessExtensions.cs | 22 ++++++++++++++++++++++ 14 files changed, 91 insertions(+), 56 deletions(-) create mode 100644 Tests/MetricsAccessExtensions.cs diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index 1eb72f7..08f5eae 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -67,15 +67,15 @@ namespace CodexPlugin { AddEnvVar("CODEX_BLOCK_MN", config.BlockMaintenanceNumber.ToString()!); } - //if (config.MetricsMode != Metrics.MetricsMode.None) - //{ - // var metricsPort = AddInternalPort(MetricsPortTag); - // AddEnvVar("CODEX_METRICS", "true"); - // AddEnvVar("CODEX_METRICS_ADDRESS", "0.0.0.0"); - // AddEnvVar("CODEX_METRICS_PORT", metricsPort); - // AddPodAnnotation("prometheus.io/scrape", "true"); - // AddPodAnnotation("prometheus.io/port", metricsPort.Number.ToString()); - //} + if (config.MetricsEnabled) + { + var metricsPort = AddInternalPort(MetricsPortTag); + AddEnvVar("CODEX_METRICS", "true"); + AddEnvVar("CODEX_METRICS_ADDRESS", "0.0.0.0"); + AddEnvVar("CODEX_METRICS_PORT", metricsPort); + AddPodAnnotation("prometheus.io/scrape", "true"); + AddPodAnnotation("prometheus.io/port", metricsPort.Number.ToString()); + } //if (config.MarketplaceConfig != null) //{ diff --git a/CodexPlugin/CodexNodeGroup.cs b/CodexPlugin/CodexNodeGroup.cs index c2e619e..e3522dd 100644 --- a/CodexPlugin/CodexNodeGroup.cs +++ b/CodexPlugin/CodexNodeGroup.cs @@ -1,10 +1,11 @@ using Core; using KubernetesWorkflow; +using MetricsPlugin; using System.Collections; namespace CodexPlugin { - public interface ICodexNodeGroup : IEnumerable + public interface ICodexNodeGroup : IEnumerable, IManyMetricScrapeTargets { void BringOffline(); IOnlineCodexNode this[int index] { get; } @@ -41,6 +42,7 @@ namespace CodexPlugin public RunningContainers[] Containers { get; private set; } public OnlineCodexNode[] Nodes { get; private set; } public CodexDebugVersionResponse Version { get; private set; } + public IMetricsScrapeTarget[] ScrapeTargets => Nodes.Select(n => n.MetricsScrapeTarget).ToArray(); public IEnumerator GetEnumerator() { diff --git a/CodexPlugin/CodexPlugin.csproj b/CodexPlugin/CodexPlugin.csproj index 1f910c4..4619e26 100644 --- a/CodexPlugin/CodexPlugin.csproj +++ b/CodexPlugin/CodexPlugin.csproj @@ -13,6 +13,7 @@ + diff --git a/CodexPlugin/CodexSetup.cs b/CodexPlugin/CodexSetup.cs index 7602aca..ff777ff 100644 --- a/CodexPlugin/CodexSetup.cs +++ b/CodexPlugin/CodexSetup.cs @@ -12,7 +12,7 @@ namespace CodexPlugin ICodexSetup WithBlockTTL(TimeSpan duration); ICodexSetup WithBlockMaintenanceInterval(TimeSpan duration); ICodexSetup WithBlockMaintenanceNumber(int numberOfBlocks); - //ICodexSetup EnableMetrics(); + ICodexSetup EnableMetrics(); //ICodexSetup EnableMarketplace(TestToken initialBalance); //ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther); //ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther, bool isValidator); @@ -70,11 +70,11 @@ namespace CodexPlugin return this; } - //public ICodexSetup EnableMetrics() - //{ - // MetricsMode = Metrics.MetricsMode.Record; - // return this; - //} + public ICodexSetup EnableMetrics() + { + MetricsEnabled = true; + return this; + } //public ICodexSetup EnableMarketplace(TestToken initialBalance) //{ diff --git a/CodexPlugin/CodexStartupConfig.cs b/CodexPlugin/CodexStartupConfig.cs index 8563959..e2ccb1f 100644 --- a/CodexPlugin/CodexStartupConfig.cs +++ b/CodexPlugin/CodexStartupConfig.cs @@ -14,7 +14,7 @@ namespace CodexPlugin public Location Location { get; set; } public CodexLogLevel LogLevel { get; } public ByteSize? StorageQuota { get; set; } - //public MetricsMode MetricsMode { get; set; } + public bool MetricsEnabled { get; set; } //public MarketplaceInitialConfig? MarketplaceConfig { get; set; } public string? BootstrapSpr { get; set; } public int? BlockTTL { get; set; } diff --git a/CodexPlugin/OnlineCodexNode.cs b/CodexPlugin/OnlineCodexNode.cs index cbd0dcd..f3fbaa9 100644 --- a/CodexPlugin/OnlineCodexNode.cs +++ b/CodexPlugin/OnlineCodexNode.cs @@ -1,6 +1,7 @@ using Core; using FileUtils; using Logging; +using MetricsPlugin; using NUnit.Framework; using Utils; @@ -15,10 +16,9 @@ namespace CodexPlugin TrackedFile? DownloadContent(ContentId contentId, string fileLabel = ""); void ConnectToPeer(IOnlineCodexNode node); IDownloadedLog DownloadLog(int? tailLines = null); - //IMetricsAccess Metrics { get; } - //IMarketplaceAccess Marketplace { get; } CodexDebugVersionResponse Version { get; } void BringOffline(); + IMetricsScrapeTarget MetricsScrapeTarget { get; } } public class OnlineCodexNode : IOnlineCodexNode @@ -27,21 +27,26 @@ namespace CodexPlugin private const string UploadFailedMessage = "Unable to store block"; private readonly IPluginTools tools; - public OnlineCodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group/*, IMetricsAccess metricsAccess, IMarketplaceAccess marketplaceAccess*/) + public OnlineCodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group) { this.tools = tools; CodexAccess = codexAccess; Group = group; - //Metrics = metricsAccess; - //Marketplace = marketplaceAccess; Version = new CodexDebugVersionResponse(); } public CodexAccess CodexAccess { get; } public CodexNodeGroup Group { get; } - //public IMetricsAccess Metrics { get; } - //public IMarketplaceAccess Marketplace { get; } public CodexDebugVersionResponse Version { get; private set; } + public IMetricsScrapeTarget MetricsScrapeTarget + { + get + { + var port = CodexAccess.Container.Recipe.GetPortByTag(CodexContainerRecipe.MetricsPortTag); + if (port == null) throw new Exception("Metrics is not available for this Codex node. Please start it with the option '.EnableMetrics()' to enable it."); + return new MetricsScrapeTarget(CodexAccess.Container, port); + } + } public string GetName() { diff --git a/Core/PluginTools.cs b/Core/PluginTools.cs index f25b970..522e481 100644 --- a/Core/PluginTools.cs +++ b/Core/PluginTools.cs @@ -37,12 +37,12 @@ namespace Core private readonly IFileManager fileManager; private ILog log; - public PluginTools(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) + public PluginTools(ILog log, WorkflowCreator workflowCreator, string fileManagerRootFolder, ITimeSet timeSet) { this.log = log; + this.workflowCreator = workflowCreator; this.timeSet = timeSet; fileManager = new FileManager(log, fileManagerRootFolder); - workflowCreator = new WorkflowCreator(log, configuration); } public void ApplyLogPrefix(string prefix) diff --git a/Core/ToolsFactory.cs b/Core/ToolsFactory.cs index 581c0d3..96752b6 100644 --- a/Core/ToolsFactory.cs +++ b/Core/ToolsFactory.cs @@ -11,21 +11,21 @@ namespace Core internal class ToolsFactory : IToolsFactory { private readonly ILog log; - private readonly Configuration configuration; + private readonly WorkflowCreator workflowCreator; private readonly string fileManagerRootFolder; private readonly ITimeSet timeSet; public ToolsFactory(ILog log, Configuration configuration, string fileManagerRootFolder, ITimeSet timeSet) { this.log = log; - this.configuration = configuration; + workflowCreator = new WorkflowCreator(log, configuration); this.fileManagerRootFolder = fileManagerRootFolder; this.timeSet = timeSet; } public PluginTools CreateTools() { - return new PluginTools(log, configuration, fileManagerRootFolder, timeSet); + return new PluginTools(log, workflowCreator, fileManagerRootFolder, timeSet); } } } diff --git a/KubernetesWorkflow/ContainerRecipe.cs b/KubernetesWorkflow/ContainerRecipe.cs index 51ea2e1..318d3ce 100644 --- a/KubernetesWorkflow/ContainerRecipe.cs +++ b/KubernetesWorkflow/ContainerRecipe.cs @@ -28,9 +28,9 @@ public VolumeMount[] Volumes { get; } public object[] Additionals { get; } - public Port GetPortByTag(string tag) + public Port? GetPortByTag(string tag) { - return ExposedPorts.Concat(InternalPorts).Single(p => p.Tag == tag); + return ExposedPorts.Concat(InternalPorts).SingleOrDefault(p => p.Tag == tag); } public override string ToString() diff --git a/MetricsPlugin/CoreInterfaceExtensions.cs b/MetricsPlugin/CoreInterfaceExtensions.cs index 0346857..03ea973 100644 --- a/MetricsPlugin/CoreInterfaceExtensions.cs +++ b/MetricsPlugin/CoreInterfaceExtensions.cs @@ -16,6 +16,11 @@ namespace MetricsPlugin return Plugin(ci).CreateAccessForTarget(metricsContainer, scrapeTarget); } + public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IManyMetricScrapeTargets[] manyScrapeTargets) + { + return ci.GetMetricsFor(manyScrapeTargets.SelectMany(t => t.ScrapeTargets).ToArray()); + } + public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) { var rc = ci.StartMetricsCollector(scrapeTargets); diff --git a/MetricsPlugin/MetricsAccess.cs b/MetricsPlugin/MetricsAccess.cs index beb5ed1..aa172f9 100644 --- a/MetricsPlugin/MetricsAccess.cs +++ b/MetricsPlugin/MetricsAccess.cs @@ -4,6 +4,7 @@ namespace MetricsPlugin { public interface IMetricsAccess { + string TargetName { get; } Metrics? GetAllMetrics(); MetricsSet GetMetric(string metricName); MetricsSet GetMetric(string metricName, TimeSpan timeout); @@ -18,19 +19,10 @@ namespace MetricsPlugin { this.query = query; this.target = target; + TargetName = target.Name; } - //public void AssertThat(string metricName, IResolveConstraint constraint, string message = "") - //{ - // AssertHelpers.RetryAssert(constraint, () => - // { - // var metricSet = GetMetricWithTimeout(metricName); - // var metricValue = metricSet.Values[0].Value; - - // log.Log($"{node.Name} metric '{metricName}' = {metricValue}"); - // return metricValue; - // }, message); - //} + public string TargetName { get; } public Metrics? GetAllMetrics() { diff --git a/MetricsPlugin/MetricsScrapeTarget.cs b/MetricsPlugin/MetricsScrapeTarget.cs index 6dff1be..e2e1979 100644 --- a/MetricsPlugin/MetricsScrapeTarget.cs +++ b/MetricsPlugin/MetricsScrapeTarget.cs @@ -4,20 +4,27 @@ namespace MetricsPlugin { public interface IMetricsScrapeTarget { + string Name { get; } string Ip { get; } int Port { get; } } + public interface IManyMetricScrapeTargets + { + IMetricsScrapeTarget[] ScrapeTargets { get; } + } + public class MetricsScrapeTarget : IMetricsScrapeTarget { - public MetricsScrapeTarget(string ip, int port) + public MetricsScrapeTarget(string ip, int port, string name) { Ip = ip; Port = port; + Name = name; } public MetricsScrapeTarget(RunningContainer container, int port) - : this(container.Pod.PodInfo.Ip, port) + : this(container.Pod.PodInfo.Ip, port, container.Name) { } @@ -26,6 +33,7 @@ namespace MetricsPlugin { } + public string Name { get; } public string Ip { get; } public int Port { get; } } diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 0b5156d..f9e76de 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -24,23 +24,23 @@ namespace Tests.BasicTests [Test] public void TwoMetricsExample() { - var rc = Ci.StartMetricsCollector(); + var group = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); + var group2 = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); - //var group = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); - //var group2 = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); + var primary = group[0]; + var secondary = group[1]; + var primary2 = group2[0]; + var secondary2 = group2[1]; - //var primary = group[0]; - //var secondary = group[1]; - //var primary2 = group2[0]; - //var secondary2 = group2[1]; + var metrics = Ci.GetMetricsFor(primary.MetricsScrapeTarget, primary2.MetricsScrapeTarget); - //primary.ConnectToPeer(secondary); - //primary2.ConnectToPeer(secondary2); + primary.ConnectToPeer(secondary); + primary2.ConnectToPeer(secondary2); - //Thread.Sleep(TimeSpan.FromMinutes(2)); + Thread.Sleep(TimeSpan.FromMinutes(2)); - //primary.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1)); - //primary2.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1)); + metrics[0].AssertThat("libp2p_peers", Is.EqualTo(1)); + metrics[1].AssertThat("libp2p_peers", Is.EqualTo(1)); } [Test] diff --git a/Tests/MetricsAccessExtensions.cs b/Tests/MetricsAccessExtensions.cs new file mode 100644 index 0000000..aa5da83 --- /dev/null +++ b/Tests/MetricsAccessExtensions.cs @@ -0,0 +1,22 @@ +using DistTestCore.Helpers; +using Logging; +using MetricsPlugin; +using NUnit.Framework.Constraints; + +namespace Tests +{ + public static class MetricsAccessExtensions + { + public static void AssertThat(this IMetricsAccess access, string metricName, IResolveConstraint constraint, ILog? log = null, string message = "") + { + AssertHelpers.RetryAssert(constraint, () => + { + var metricSet = access.GetMetric(metricName); + var metricValue = metricSet.Values[0].Value; + + if (log != null) log.Log($"{access.TargetName} metric '{metricName}' = {metricValue}"); + return metricValue; + }, message); + } + } +} From d1895bab02eda8239380e906b72112f9c4c58c2e Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 12:09:44 +0200 Subject: [PATCH 18/51] Implements bringOffline for codex nodes --- CodexPlugin/CodexNodeGroup.cs | 10 +++--- CodexPlugin/CodexStarter.cs | 52 +++++++++++++++--------------- Tests/BasicTests/OneClientTests.cs | 4 +-- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/CodexPlugin/CodexNodeGroup.cs b/CodexPlugin/CodexNodeGroup.cs index e3522dd..62158d7 100644 --- a/CodexPlugin/CodexNodeGroup.cs +++ b/CodexPlugin/CodexNodeGroup.cs @@ -13,8 +13,11 @@ namespace CodexPlugin public class CodexNodeGroup : ICodexNodeGroup { - public CodexNodeGroup(IPluginTools tools, RunningContainers[] containers, ICodexNodeFactory codexNodeFactory) + private readonly CodexStarter starter; + + public CodexNodeGroup(CodexStarter starter, IPluginTools tools, RunningContainers[] containers, ICodexNodeFactory codexNodeFactory) { + this.starter = starter; Containers = containers; Nodes = containers.Containers().Select(c => CreateOnlineCodexNode(c, tools, codexNodeFactory)).ToArray(); Version = new CodexDebugVersionResponse(); @@ -30,11 +33,8 @@ namespace CodexPlugin public void BringOffline() { - //lifecycle.CodexStarter.BringOffline(this); - - //var result = Setup; + starter.BringOffline(this); // Clear everything. Prevent accidental use. - //Setup = null!; Nodes = Array.Empty(); Containers = null!; } diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index 4b6be37..fc12369 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -54,27 +54,27 @@ namespace CodexPlugin public void BringOffline(CodexNodeGroup group) { - //LogStart($"Stopping {group.Describe()}..."); - //var workflow = CreateWorkflow(); - //foreach (var c in group.Containers) - //{ - // StopCrashWatcher(c); - // workflow.Stop(c); - //} - //LogEnd("Stopped."); + Log($"Stopping {group.Describe()}..."); + var workflow = pluginTools.CreateWorkflow(); + foreach (var c in group.Containers) + { + StopCrashWatcher(c); + workflow.Stop(c); + } + Log("Stopped."); } - public void DeleteAllResources() - { - //var workflow = CreateWorkflow(); - //workflow.DeleteTestResources(); - } + //public void DeleteAllResources() + //{ + // //var workflow = CreateWorkflow(); + // //workflow.DeleteTestResources(); + //} - public void DownloadLog(RunningContainer container, ILogHandler logHandler, int? tailLines) - { - //var workflow = CreateWorkflow(); - //workflow.DownloadContainerLog(container, logHandler, tailLines); - } + //public void DownloadLog(RunningContainer container, ILogHandler logHandler, int? tailLines) + //{ + // //var workflow = CreateWorkflow(); + // //workflow.DownloadContainerLog(container, logHandler, tailLines); + //} //private IMetricsAccessFactory CollectMetrics(CodexSetup codexSetup, RunningContainers[] containers) //{ @@ -114,7 +114,7 @@ namespace CodexPlugin private CodexNodeGroup CreateCodexGroup(RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) { - var group = new CodexNodeGroup(pluginTools, runningContainers, codexNodeFactory); + var group = new CodexNodeGroup(this, pluginTools, runningContainers, codexNodeFactory); try { @@ -151,12 +151,12 @@ namespace CodexPlugin pluginTools.GetLog().Log(message); } - //private void StopCrashWatcher(RunningContainers containers) - //{ - // foreach (var c in containers.Containers) - // { - // c.CrashWatcher?.Stop(); - // } - //} + private void StopCrashWatcher(RunningContainers containers) + { + foreach (var c in containers.Containers) + { + c.CrashWatcher?.Stop(); + } + } } } diff --git a/Tests/BasicTests/OneClientTests.cs b/Tests/BasicTests/OneClientTests.cs index dd74924..728d718 100644 --- a/Tests/BasicTests/OneClientTests.cs +++ b/Tests/BasicTests/OneClientTests.cs @@ -21,9 +21,9 @@ namespace Tests.BasicTests { var primary = Ci.SetupCodexNode(); - //var setup = primary.BringOffline(); + primary.BringOffline(); - //primary = BringOnline(setup)[0]; + primary = Ci.SetupCodexNode(); PerformOneClientTest(primary); } From 84dd51451744e2c628e417068e94891aeb4a1aa3 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 12:24:46 +0200 Subject: [PATCH 19/51] Restores connectivity test helpers --- CodexPlugin/OnlineCodexNode.cs | 3 + .../Helpers/FullConnectivityHelper.cs | 199 ------------------ .../Helpers/PeerConnectionTestHelpers.cs | 71 ------- .../Helpers/PeerDownloadTestHelpers.cs | 89 -------- Tests/Helpers/FullConnectivityHelper.cs | 199 ++++++++++++++++++ Tests/Helpers/PeerConnectionTestHelpers.cs | 67 ++++++ Tests/Helpers/PeerDownloadTestHelpers.cs | 78 +++++++ .../PeerDiscoveryTests/PeerDiscoveryTests.cs | 2 +- 8 files changed, 348 insertions(+), 360 deletions(-) delete mode 100644 DistTestCore/Helpers/FullConnectivityHelper.cs delete mode 100644 DistTestCore/Helpers/PeerConnectionTestHelpers.cs delete mode 100644 DistTestCore/Helpers/PeerDownloadTestHelpers.cs create mode 100644 Tests/Helpers/FullConnectivityHelper.cs create mode 100644 Tests/Helpers/PeerConnectionTestHelpers.cs create mode 100644 Tests/Helpers/PeerDownloadTestHelpers.cs diff --git a/CodexPlugin/OnlineCodexNode.cs b/CodexPlugin/OnlineCodexNode.cs index f3fbaa9..6a1d376 100644 --- a/CodexPlugin/OnlineCodexNode.cs +++ b/CodexPlugin/OnlineCodexNode.cs @@ -1,5 +1,6 @@ using Core; using FileUtils; +using KubernetesWorkflow; using Logging; using MetricsPlugin; using NUnit.Framework; @@ -10,6 +11,7 @@ namespace CodexPlugin public interface IOnlineCodexNode { string GetName(); + RunningContainer Container { get; } CodexDebugResponse GetDebugInfo(); CodexDebugPeerResponse GetDebugPeer(string peerId); ContentId UploadFile(TrackedFile file); @@ -35,6 +37,7 @@ namespace CodexPlugin Version = new CodexDebugVersionResponse(); } + public RunningContainer Container { get { return CodexAccess.Container; } } public CodexAccess CodexAccess { get; } public CodexNodeGroup Group { get; } public CodexDebugVersionResponse Version { get; private set; } diff --git a/DistTestCore/Helpers/FullConnectivityHelper.cs b/DistTestCore/Helpers/FullConnectivityHelper.cs deleted file mode 100644 index bad45f0..0000000 --- a/DistTestCore/Helpers/FullConnectivityHelper.cs +++ /dev/null @@ -1,199 +0,0 @@ -//using DistTestCore.Codex; -//using Logging; -//using NUnit.Framework; - -//namespace DistTestCore.Helpers -//{ -// public interface IFullConnectivityImplementation -// { -// string Description(); -// string ValidateEntry(FullConnectivityHelper.Entry entry, FullConnectivityHelper.Entry[] allEntries); -// FullConnectivityHelper.PeerConnectionState Check(FullConnectivityHelper.Entry from, FullConnectivityHelper.Entry to); -// } - -// public class FullConnectivityHelper -// { -// private static string Nl = Environment.NewLine; -// private readonly BaseLog log; -// private readonly IFullConnectivityImplementation implementation; - -// public FullConnectivityHelper(BaseLog log, IFullConnectivityImplementation implementation) -// { -// this.log = log; -// this.implementation = implementation; -// } - -// public void AssertFullyConnected(IEnumerable nodes) -// { -// AssertFullyConnected(nodes.ToArray()); -// } - -// private void AssertFullyConnected(CodexAccess[] nodes) -// { -// Log($"Asserting '{implementation.Description()}' for nodes: '{string.Join(",", nodes.Select(n => n.GetName()))}'..."); -// var entries = CreateEntries(nodes); -// var pairs = CreatePairs(entries); - -// // Each pair gets two chances. -// CheckAndRemoveSuccessful(pairs); -// CheckAndRemoveSuccessful(pairs); - -// if (pairs.Any()) -// { -// var pairDetails = string.Join(Nl, pairs.SelectMany(p => p.GetResultMessages())); - -// Log($"Connections failed:{Nl}{pairDetails}"); - -// Assert.Fail(string.Join(Nl, pairs.SelectMany(p => p.GetResultMessages()))); -// } -// else -// { -// Log($"'{implementation.Description()}' = Success! for nodes: {string.Join(",", nodes.Select(n => n.GetName()))}"); -// } -// } - -// private void CheckAndRemoveSuccessful(List pairs) -// { -// var results = new List(); -// foreach (var pair in pairs.ToArray()) -// { -// pair.Check(); -// if (pair.Success) -// { -// results.AddRange(pair.GetResultMessages()); -// pairs.Remove(pair); -// } -// } -// Log($"Connections successful:{Nl}{string.Join(Nl, results)}"); -// } - -// private Entry[] CreateEntries(CodexAccess[] nodes) -// { -// var entries = nodes.Select(n => new Entry(n)).ToArray(); - -// var errors = entries -// .Select(e => implementation.ValidateEntry(e, entries)) -// .Where(s => !string.IsNullOrEmpty(s)) -// .ToArray(); - -// if (errors.Any()) -// { -// Assert.Fail("Some node entries failed to validate: " + string.Join(Nl, errors)); -// } - -// return entries; -// } - -// private List CreatePairs(Entry[] entries) -// { -// return CreatePairsIterator(entries).ToList(); -// } - -// private IEnumerable CreatePairsIterator(Entry[] entries) -// { -// for (var x = 0; x < entries.Length; x++) -// { -// for (var y = x + 1; y < entries.Length; y++) -// { -// yield return new Pair(implementation, entries[x], entries[y]); -// } -// } -// } - -// private void Log(string msg) -// { -// log.Log(msg); -// } - -// public class Entry -// { -// public Entry(CodexAccess node) -// { -// Node = node; -// Response = node.GetDebugInfo(); -// } - -// public CodexAccess Node { get; } -// public CodexDebugResponse Response { get; } - -// public override string ToString() -// { -// if (Response == null || string.IsNullOrEmpty(Response.id)) return "UNKNOWN"; -// return Response.id; -// } -// } - -// public enum PeerConnectionState -// { -// Unknown, -// Connection, -// NoConnection, -// } - -// public class Pair -// { -// private TimeSpan aToBTime = TimeSpan.FromSeconds(0); -// private TimeSpan bToATime = TimeSpan.FromSeconds(0); -// private readonly IFullConnectivityImplementation implementation; - -// public Pair(IFullConnectivityImplementation implementation, Entry a, Entry b) -// { -// this.implementation = implementation; -// A = a; -// B = b; -// } - -// public Entry A { get; } -// public Entry B { get; } -// public PeerConnectionState AKnowsB { get; private set; } -// public PeerConnectionState BKnowsA { get; private set; } -// public bool Success { get { return AKnowsB == PeerConnectionState.Connection && BKnowsA == PeerConnectionState.Connection; } } -// public bool Inconclusive { get { return AKnowsB == PeerConnectionState.Unknown || BKnowsA == PeerConnectionState.Unknown; } } - -// public void Check() -// { -// aToBTime = Measure(() => AKnowsB = Check(A, B)); -// bToATime = Measure(() => BKnowsA = Check(B, A)); -// } - -// public override string ToString() -// { -// return $"[{string.Join(",", GetResultMessages())}]"; -// } - -// public string[] GetResultMessages() -// { -// var aName = A.ToString(); -// var bName = B.ToString(); - -// return new[] -// { -// $"[{aName} --> {bName}] = {AKnowsB} ({aToBTime.TotalSeconds} seconds)", -// $"[{aName} <-- {bName}] = {BKnowsA} ({bToATime.TotalSeconds} seconds)" -// }; -// } - -// private static TimeSpan Measure(Action action) -// { -// var start = DateTime.UtcNow; -// action(); -// return DateTime.UtcNow - start; -// } - -// private PeerConnectionState Check(Entry from, Entry to) -// { -// Thread.Sleep(10); - -// try -// { -// return implementation.Check(from, to); -// } -// catch -// { -// // Didn't get a conclusive answer. Try again later. -// return PeerConnectionState.Unknown; -// } -// } -// } -// } -//} diff --git a/DistTestCore/Helpers/PeerConnectionTestHelpers.cs b/DistTestCore/Helpers/PeerConnectionTestHelpers.cs deleted file mode 100644 index 39b7004..0000000 --- a/DistTestCore/Helpers/PeerConnectionTestHelpers.cs +++ /dev/null @@ -1,71 +0,0 @@ -//using DistTestCore.Codex; -//using Logging; -//using static DistTestCore.Helpers.FullConnectivityHelper; - -//namespace DistTestCore.Helpers -//{ -// public class PeerConnectionTestHelpers : IFullConnectivityImplementation -// { -// private readonly FullConnectivityHelper helper; - -// public PeerConnectionTestHelpers(BaseLog log) -// { -// helper = new FullConnectivityHelper(log, this); -// } - -// public void AssertFullyConnected(IEnumerable nodes) -// { -// AssertFullyConnected(nodes.Select(n => ((OnlineCodexNode)n).CodexAccess)); -// } - -// public void AssertFullyConnected(IEnumerable nodes) -// { -// helper.AssertFullyConnected(nodes); -// } - -// public string Description() -// { -// return "Peer Discovery"; -// } - -// public string ValidateEntry(Entry entry, Entry[] allEntries) -// { -// var result = string.Empty; -// foreach (var peer in entry.Response.table.nodes) -// { -// var expected = GetExpectedDiscoveryEndpoint(allEntries, peer); -// if (expected != peer.address) -// { -// result += $"Node:{entry.Node.GetName()} has incorrect peer table entry. Was: '{peer.address}', expected: '{expected}'. "; -// } -// } -// return result; -// } - -// public PeerConnectionState Check(Entry from, Entry to) -// { -// var peerId = to.Response.id; - -// var response = from.Node.GetDebugPeer(peerId); -// if (!response.IsPeerFound) -// { -// return PeerConnectionState.NoConnection; -// } -// if (!string.IsNullOrEmpty(response.peerId) && response.addresses.Any()) -// { -// return PeerConnectionState.Connection; -// } -// return PeerConnectionState.Unknown; -// } - -// private static string GetExpectedDiscoveryEndpoint(Entry[] allEntries, CodexDebugTableNodeResponse node) -// { -// var peer = allEntries.SingleOrDefault(e => e.Response.table.localNode.peerId == node.peerId); -// if (peer == null) return $"peerId: {node.peerId} is not known."; - -// var ip = peer.Node.Container.Pod.PodInfo.Ip; -// var discPort = peer.Node.Container.Recipe.GetPortByTag(CodexContainerRecipe.DiscoveryPortTag); -// return $"{ip}:{discPort.Number}"; -// } -// } -//} diff --git a/DistTestCore/Helpers/PeerDownloadTestHelpers.cs b/DistTestCore/Helpers/PeerDownloadTestHelpers.cs deleted file mode 100644 index 6c535cc..0000000 --- a/DistTestCore/Helpers/PeerDownloadTestHelpers.cs +++ /dev/null @@ -1,89 +0,0 @@ -//using DistTestCore.Codex; -//using FileUtils; -//using Logging; -//using Utils; -//using static DistTestCore.Helpers.FullConnectivityHelper; - -//namespace DistTestCore.Helpers -//{ -// public class PeerDownloadTestHelpers : IFullConnectivityImplementation -// { -// private readonly FullConnectivityHelper helper; -// private readonly BaseLog log; -// private readonly FileManager fileManager; -// private ByteSize testFileSize; - -// public PeerDownloadTestHelpers(BaseLog log, FileManager fileManager) -// { -// helper = new FullConnectivityHelper(log, this); -// testFileSize = 1.MB(); -// this.log = log; -// this.fileManager = fileManager; -// } - -// public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) -// { -// AssertFullDownloadInterconnectivity(nodes.Select(n => ((OnlineCodexNode)n).CodexAccess), testFileSize); -// } - -// public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) -// { -// this.testFileSize = testFileSize; -// helper.AssertFullyConnected(nodes); -// } - -// public string Description() -// { -// return "Download Connectivity"; -// } - -// public string ValidateEntry(Entry entry, Entry[] allEntries) -// { -// return string.Empty; -// } - -// public PeerConnectionState Check(Entry from, Entry to) -// { -// return fileManager.ScopedFiles(() => CheckConnectivity(from, to)); -// } - -// private PeerConnectionState CheckConnectivity(Entry from, Entry to) -// { -// var expectedFile = GenerateTestFile(from.Node, to.Node); - -// using var uploadStream = File.OpenRead(expectedFile.Filename); -// var contentId = Stopwatch.Measure(log, "Upload", () => from.Node.UploadFile(uploadStream)); - -// try -// { -// var downloadedFile = Stopwatch.Measure(log, "Download", () => DownloadFile(to.Node, contentId, expectedFile.Label + "_downloaded")); -// expectedFile.AssertIsEqual(downloadedFile); -// return PeerConnectionState.Connection; -// } -// catch -// { -// // Should an exception occur during the download or file-content assertion, -// // We consider that as no-connection for the purpose of this test. -// return PeerConnectionState.NoConnection; -// } -// // Should an exception occur during upload, then this try is inconclusive and we try again next loop. -// } - -// private TestFile DownloadFile(CodexAccess node, string contentId, string label) -// { -// var downloadedFile = fileManager.CreateEmptyTestFile(label); -// using var downloadStream = File.OpenWrite(downloadedFile.Filename); -// using var stream = node.DownloadFile(contentId); -// stream.CopyTo(downloadStream); -// return downloadedFile; -// } - -// private TestFile GenerateTestFile(CodexAccess uploader, CodexAccess downloader) -// { -// var up = uploader.GetName().Replace("<", "").Replace(">", ""); -// var down = downloader.GetName().Replace("<", "").Replace(">", ""); -// var label = $"~from:{up}-to:{down}~"; -// return fileManager.GenerateTestFile(testFileSize, label); -// } -// } -//} diff --git a/Tests/Helpers/FullConnectivityHelper.cs b/Tests/Helpers/FullConnectivityHelper.cs new file mode 100644 index 0000000..419f54d --- /dev/null +++ b/Tests/Helpers/FullConnectivityHelper.cs @@ -0,0 +1,199 @@ +using CodexPlugin; +using Logging; +using NUnit.Framework; + +namespace DistTestCore.Helpers +{ + public interface IFullConnectivityImplementation + { + string Description(); + string ValidateEntry(FullConnectivityHelper.Entry entry, FullConnectivityHelper.Entry[] allEntries); + FullConnectivityHelper.PeerConnectionState Check(FullConnectivityHelper.Entry from, FullConnectivityHelper.Entry to); + } + + public class FullConnectivityHelper + { + private static string Nl = Environment.NewLine; + private readonly ILog log; + private readonly IFullConnectivityImplementation implementation; + + public FullConnectivityHelper(ILog log, IFullConnectivityImplementation implementation) + { + this.log = log; + this.implementation = implementation; + } + + public void AssertFullyConnected(IEnumerable nodes) + { + AssertFullyConnected(nodes.ToArray()); + } + + private void AssertFullyConnected(IOnlineCodexNode[] nodes) + { + Log($"Asserting '{implementation.Description()}' for nodes: '{string.Join(",", nodes.Select(n => n.GetName()))}'..."); + var entries = CreateEntries(nodes); + var pairs = CreatePairs(entries); + + // Each pair gets two chances. + CheckAndRemoveSuccessful(pairs); + CheckAndRemoveSuccessful(pairs); + + if (pairs.Any()) + { + var pairDetails = string.Join(Nl, pairs.SelectMany(p => p.GetResultMessages())); + + Log($"Connections failed:{Nl}{pairDetails}"); + + Assert.Fail(string.Join(Nl, pairs.SelectMany(p => p.GetResultMessages()))); + } + else + { + Log($"'{implementation.Description()}' = Success! for nodes: {string.Join(",", nodes.Select(n => n.GetName()))}"); + } + } + + private void CheckAndRemoveSuccessful(List pairs) + { + var results = new List(); + foreach (var pair in pairs.ToArray()) + { + pair.Check(); + if (pair.Success) + { + results.AddRange(pair.GetResultMessages()); + pairs.Remove(pair); + } + } + Log($"Connections successful:{Nl}{string.Join(Nl, results)}"); + } + + private Entry[] CreateEntries(IOnlineCodexNode[] nodes) + { + var entries = nodes.Select(n => new Entry(n)).ToArray(); + + var errors = entries + .Select(e => implementation.ValidateEntry(e, entries)) + .Where(s => !string.IsNullOrEmpty(s)) + .ToArray(); + + if (errors.Any()) + { + Assert.Fail("Some node entries failed to validate: " + string.Join(Nl, errors)); + } + + return entries; + } + + private List CreatePairs(Entry[] entries) + { + return CreatePairsIterator(entries).ToList(); + } + + private IEnumerable CreatePairsIterator(Entry[] entries) + { + for (var x = 0; x < entries.Length; x++) + { + for (var y = x + 1; y < entries.Length; y++) + { + yield return new Pair(implementation, entries[x], entries[y]); + } + } + } + + private void Log(string msg) + { + log.Log(msg); + } + + public class Entry + { + public Entry(IOnlineCodexNode node) + { + Node = node; + Response = node.GetDebugInfo(); + } + + public IOnlineCodexNode Node { get; } + public CodexDebugResponse Response { get; } + + public override string ToString() + { + if (Response == null || string.IsNullOrEmpty(Response.id)) return "UNKNOWN"; + return Response.id; + } + } + + public enum PeerConnectionState + { + Unknown, + Connection, + NoConnection, + } + + public class Pair + { + private TimeSpan aToBTime = TimeSpan.FromSeconds(0); + private TimeSpan bToATime = TimeSpan.FromSeconds(0); + private readonly IFullConnectivityImplementation implementation; + + public Pair(IFullConnectivityImplementation implementation, Entry a, Entry b) + { + this.implementation = implementation; + A = a; + B = b; + } + + public Entry A { get; } + public Entry B { get; } + public PeerConnectionState AKnowsB { get; private set; } + public PeerConnectionState BKnowsA { get; private set; } + public bool Success { get { return AKnowsB == PeerConnectionState.Connection && BKnowsA == PeerConnectionState.Connection; } } + public bool Inconclusive { get { return AKnowsB == PeerConnectionState.Unknown || BKnowsA == PeerConnectionState.Unknown; } } + + public void Check() + { + aToBTime = Measure(() => AKnowsB = Check(A, B)); + bToATime = Measure(() => BKnowsA = Check(B, A)); + } + + public override string ToString() + { + return $"[{string.Join(",", GetResultMessages())}]"; + } + + public string[] GetResultMessages() + { + var aName = A.ToString(); + var bName = B.ToString(); + + return new[] + { + $"[{aName} --> {bName}] = {AKnowsB} ({aToBTime.TotalSeconds} seconds)", + $"[{aName} <-- {bName}] = {BKnowsA} ({bToATime.TotalSeconds} seconds)" + }; + } + + private static TimeSpan Measure(Action action) + { + var start = DateTime.UtcNow; + action(); + return DateTime.UtcNow - start; + } + + private PeerConnectionState Check(Entry from, Entry to) + { + Thread.Sleep(10); + + try + { + return implementation.Check(from, to); + } + catch + { + // Didn't get a conclusive answer. Try again later. + return PeerConnectionState.Unknown; + } + } + } + } +} diff --git a/Tests/Helpers/PeerConnectionTestHelpers.cs b/Tests/Helpers/PeerConnectionTestHelpers.cs new file mode 100644 index 0000000..e5f4d83 --- /dev/null +++ b/Tests/Helpers/PeerConnectionTestHelpers.cs @@ -0,0 +1,67 @@ +using CodexPlugin; +using Logging; +using static DistTestCore.Helpers.FullConnectivityHelper; + +namespace DistTestCore.Helpers +{ + public class PeerConnectionTestHelpers : IFullConnectivityImplementation + { + private readonly FullConnectivityHelper helper; + + public PeerConnectionTestHelpers(ILog log) + { + helper = new FullConnectivityHelper(log, this); + } + + public void AssertFullyConnected(IEnumerable nodes) + { + helper.AssertFullyConnected(nodes); + } + + public string Description() + { + return "Peer Discovery"; + } + + public string ValidateEntry(Entry entry, Entry[] allEntries) + { + var result = string.Empty; + foreach (var peer in entry.Response.table.nodes) + { + var expected = GetExpectedDiscoveryEndpoint(allEntries, peer); + if (expected != peer.address) + { + result += $"Node:{entry.Node.GetName()} has incorrect peer table entry. Was: '{peer.address}', expected: '{expected}'. "; + } + } + return result; + } + + public PeerConnectionState Check(Entry from, Entry to) + { + var peerId = to.Response.id; + + var response = from.Node.GetDebugPeer(peerId); + if (!response.IsPeerFound) + { + return PeerConnectionState.NoConnection; + } + if (!string.IsNullOrEmpty(response.peerId) && response.addresses.Any()) + { + return PeerConnectionState.Connection; + } + return PeerConnectionState.Unknown; + } + + private static string GetExpectedDiscoveryEndpoint(Entry[] allEntries, CodexDebugTableNodeResponse node) + { + var peer = allEntries.SingleOrDefault(e => e.Response.table.localNode.peerId == node.peerId); + if (peer == null) return $"peerId: {node.peerId} is not known."; + + var container = peer.Node.Container; + var ip = container.Pod.PodInfo.Ip; + var discPort = container.Recipe.GetPortByTag(CodexContainerRecipe.DiscoveryPortTag)!; + return $"{ip}:{discPort.Number}"; + } + } +} diff --git a/Tests/Helpers/PeerDownloadTestHelpers.cs b/Tests/Helpers/PeerDownloadTestHelpers.cs new file mode 100644 index 0000000..a3e75b3 --- /dev/null +++ b/Tests/Helpers/PeerDownloadTestHelpers.cs @@ -0,0 +1,78 @@ +using CodexPlugin; +using FileUtils; +using Logging; +using Utils; +using static DistTestCore.Helpers.FullConnectivityHelper; + +namespace DistTestCore.Helpers +{ + public class PeerDownloadTestHelpers : IFullConnectivityImplementation + { + private readonly FullConnectivityHelper helper; + private readonly ILog log; + private readonly FileManager fileManager; + private ByteSize testFileSize; + + public PeerDownloadTestHelpers(ILog log, FileManager fileManager) + { + helper = new FullConnectivityHelper(log, this); + testFileSize = 1.MB(); + this.log = log; + this.fileManager = fileManager; + } + + public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) + { + this.testFileSize = testFileSize; + helper.AssertFullyConnected(nodes); + } + + public string Description() + { + return "Download Connectivity"; + } + + public string ValidateEntry(Entry entry, Entry[] allEntries) + { + return string.Empty; + } + + public PeerConnectionState Check(Entry from, Entry to) + { + return fileManager.ScopedFiles(() => CheckConnectivity(from, to)); + } + + private PeerConnectionState CheckConnectivity(Entry from, Entry to) + { + var expectedFile = GenerateTestFile(from.Node, to.Node); + var contentId = Stopwatch.Measure(log, "Upload", () => from.Node.UploadFile(expectedFile)); + + try + { + var downloadedFile = Stopwatch.Measure(log, "Download", () => DownloadFile(to.Node, contentId, expectedFile.Label + "_downloaded")); + expectedFile.AssertIsEqual(downloadedFile); + return PeerConnectionState.Connection; + } + catch + { + // Should an exception occur during the download or file-content assertion, + // We consider that as no-connection for the purpose of this test. + return PeerConnectionState.NoConnection; + } + // Should an exception occur during upload, then this try is inconclusive and we try again next loop. + } + + private TrackedFile? DownloadFile(IOnlineCodexNode node, ContentId contentId, string label) + { + return node.DownloadContent(contentId, label); + } + + private TrackedFile GenerateTestFile(IOnlineCodexNode uploader, IOnlineCodexNode downloader) + { + var up = uploader.GetName().Replace("<", "").Replace(">", ""); + var down = downloader.GetName().Replace("<", "").Replace(">", ""); + var label = $"~from:{up}-to:{down}~"; + return fileManager.GenerateFile(testFileSize, label); + } + } +} diff --git a/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs b/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs index 350afc7..9a55cf4 100644 --- a/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs +++ b/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs @@ -19,7 +19,7 @@ namespace Tests.PeerDiscoveryTests [Test] public void MetricsDoesNotInterfereWithPeerDiscovery() { - //AddCodex(2, s => s.EnableMetrics()); + AddCodex(2, s => s.EnableMetrics()); AssertAllNodesConnected(); } From ec5aebb47b6b7ba114f849a8b5610e1d2e2d1c90 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 14:24:43 +0200 Subject: [PATCH 20/51] Restores full-connectivity tests. --- DistTestCore/DistTest.cs | 14 +---- DistTestCore/TestLifecycle.cs | 4 +- Tests/AutoBootstrapDistTest.cs | 41 ++++---------- Tests/CodexDistTest.cs | 56 +++++++++++++++++++ .../FullyConnectedDownloadTests.cs | 8 +-- Tests/Helpers/PeerDownloadTestHelpers.cs | 4 +- .../LayeredDiscoveryTests.cs | 5 +- .../PeerDiscoveryTests/PeerDiscoveryTests.cs | 5 +- 8 files changed, 80 insertions(+), 57 deletions(-) create mode 100644 Tests/CodexDistTest.cs diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index e5b202f..ac7063d 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -110,7 +110,7 @@ namespace DistTestCore /// public void ScopedTestFiles(Action action) { - Get().ScopedTestFiles(action); + Get().GetFileManager().ScopedFiles(action); } //public IOnlineCodexNode SetupCodexBootstrapNode() @@ -183,16 +183,6 @@ namespace DistTestCore GetTestLog().Debug(msg); } - //public PeerConnectionTestHelpers CreatePeerConnectionTestHelpers() - //{ - // return new PeerConnectionTestHelpers(GetTestLog()); - //} - - //public PeerDownloadTestHelpers CreatePeerDownloadTestHelpers() - //{ - // return new PeerDownloadTestHelpers(GetTestLog(), Get().FileManager); - //} - public void Measure(string name, Action action) { Stopwatch.Measure(Get().Log, name, action); @@ -203,7 +193,7 @@ namespace DistTestCore // return new CodexSetup(numberOfNodes, configuration.GetCodexLogLevel()); //} - private TestLifecycle Get() + protected TestLifecycle Get() { lock (lifecycleLock) { diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 53b95e9..4af6bc5 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -40,9 +40,9 @@ namespace DistTestCore return entryPoint.Tools.GetFileManager().GenerateFile(size, label); } - public void ScopedTestFiles(Action action) + public IFileManager GetFileManager() { - entryPoint.Tools.GetFileManager().ScopedFiles(action); + return entryPoint.Tools.GetFileManager(); } //public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null) diff --git a/Tests/AutoBootstrapDistTest.cs b/Tests/AutoBootstrapDistTest.cs index b22b439..fce68b8 100644 --- a/Tests/AutoBootstrapDistTest.cs +++ b/Tests/AutoBootstrapDistTest.cs @@ -1,45 +1,26 @@ using CodexPlugin; using DistTestCore; +using DistTestCore.Helpers; using NUnit.Framework; namespace Tests { - public class AutoBootstrapDistTest : DistTest + public class AutoBootstrapDistTest : CodexDistTest { - public IOnlineCodexNode AddCodex() - { - return AddCodex(s => { }); - } - - public IOnlineCodexNode AddCodex(Action setup) - { - return Ci.SetupCodexNode(s => - { - setup(s); - s.WithBootstrapNode(BootstrapNode); - }); - } - - public ICodexNodeGroup AddCodex(int numberOfNodes) - { - return Ci.SetupCodexNodes(numberOfNodes, s => s.WithBootstrapNode(BootstrapNode)); - } - - public ICodexNodeGroup AddCodex(int numberOfNodes, Action setup) - { - return Ci.SetupCodexNodes(numberOfNodes, s => - { - setup(s); - s.WithBootstrapNode(BootstrapNode); - }); - } + private readonly List onlineCodexNodes = new List(); [SetUp] public void SetUpBootstrapNode() { - BootstrapNode = Ci.SetupCodexNode(s => s.WithName("BOOTSTRAP")); + BootstrapNode = AddCodex(s => s.WithName("BOOTSTRAP")); + onlineCodexNodes.Add(BootstrapNode); } - protected IOnlineCodexNode BootstrapNode { get; private set; } = null!; + protected override void OnCodexSetup(ICodexSetup setup) + { + if (BootstrapNode != null) setup.WithBootstrapNode(BootstrapNode); + } + + protected IOnlineCodexNode? BootstrapNode { get; private set; } } } diff --git a/Tests/CodexDistTest.cs b/Tests/CodexDistTest.cs new file mode 100644 index 0000000..6ecd203 --- /dev/null +++ b/Tests/CodexDistTest.cs @@ -0,0 +1,56 @@ +using CodexPlugin; +using DistTestCore; +using DistTestCore.Helpers; + +namespace Tests +{ + public class CodexDistTest : DistTest + { + private readonly List onlineCodexNodes = new List(); + + public IOnlineCodexNode AddCodex() + { + return AddCodex(s => { }); + } + + public IOnlineCodexNode AddCodex(Action setup) + { + return AddCodex(1, setup)[0]; + } + + public ICodexNodeGroup AddCodex(int numberOfNodes) + { + return AddCodex(numberOfNodes, s => { }); + } + + public ICodexNodeGroup AddCodex(int numberOfNodes, Action setup) + { + var group = Ci.SetupCodexNodes(numberOfNodes, s => + { + setup(s); + OnCodexSetup(s); + }); + onlineCodexNodes.AddRange(group); + return group; + } + + public PeerConnectionTestHelpers CreatePeerConnectionTestHelpers() + { + return new PeerConnectionTestHelpers(GetTestLog()); + } + + public PeerDownloadTestHelpers CreatePeerDownloadTestHelpers() + { + return new PeerDownloadTestHelpers(GetTestLog(), Get().GetFileManager()); + } + + public IEnumerable GetAllOnlineCodexNodes() + { + return onlineCodexNodes; + } + + protected virtual void OnCodexSetup(ICodexSetup setup) + { + } + } +} diff --git a/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs b/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs index 4728389..ed66240 100644 --- a/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs +++ b/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs @@ -1,6 +1,4 @@ -using CodexPlugin; -using DistTestCore; -using NUnit.Framework; +using NUnit.Framework; using Utils; namespace Tests.DownloadConnectivityTests @@ -11,7 +9,7 @@ namespace Tests.DownloadConnectivityTests [Test] public void MetricsDoesNotInterfereWithPeerDownload() { - //AddCodex(2, s => s.EnableMetrics()); + AddCodex(2, s => s.EnableMetrics()); AssertAllNodesConnected(); } @@ -37,7 +35,7 @@ namespace Tests.DownloadConnectivityTests private void AssertAllNodesConnected(int sizeMBs = 10) { - //CreatePeerDownloadTestHelpers().AssertFullDownloadInterconnectivity(GetAllOnlineCodexNodes(), sizeMBs.MB()); + CreatePeerDownloadTestHelpers().AssertFullDownloadInterconnectivity(GetAllOnlineCodexNodes(), sizeMBs.MB()); } } } diff --git a/Tests/Helpers/PeerDownloadTestHelpers.cs b/Tests/Helpers/PeerDownloadTestHelpers.cs index a3e75b3..3af375d 100644 --- a/Tests/Helpers/PeerDownloadTestHelpers.cs +++ b/Tests/Helpers/PeerDownloadTestHelpers.cs @@ -10,10 +10,10 @@ namespace DistTestCore.Helpers { private readonly FullConnectivityHelper helper; private readonly ILog log; - private readonly FileManager fileManager; + private readonly IFileManager fileManager; private ByteSize testFileSize; - public PeerDownloadTestHelpers(ILog log, FileManager fileManager) + public PeerDownloadTestHelpers(ILog log, IFileManager fileManager) { helper = new FullConnectivityHelper(log, this); testFileSize = 1.MB(); diff --git a/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs b/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs index b5e7edf..088fbcb 100644 --- a/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs +++ b/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs @@ -1,11 +1,10 @@ using CodexPlugin; -using DistTestCore; using NUnit.Framework; namespace Tests.PeerDiscoveryTests { [TestFixture] - public class LayeredDiscoveryTests : DistTest + public class LayeredDiscoveryTests : CodexDistTest { [Test] public void TwoLayersTest() @@ -47,7 +46,7 @@ namespace Tests.PeerDiscoveryTests private void AssertAllNodesConnected() { - //CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); + CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); } } } diff --git a/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs b/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs index 9a55cf4..4141925 100644 --- a/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs +++ b/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs @@ -1,5 +1,4 @@ -using DistTestCore; -using NUnit.Framework; +using NUnit.Framework; namespace Tests.PeerDiscoveryTests { @@ -45,7 +44,7 @@ namespace Tests.PeerDiscoveryTests private void AssertAllNodesConnected() { - //CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); + CreatePeerConnectionTestHelpers().AssertFullyConnected(GetAllOnlineCodexNodes()); } } } From ca5981e8524eca72916b3f7e7965840eba2603a4 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 14:37:53 +0200 Subject: [PATCH 21/51] Cleanup codex log level and namespace override --- CodexPlugin/CodexPlugin.cs | 4 +- CodexPlugin/CodexSetup.cs | 10 ++- CodexPlugin/CodexStartupConfig.cs | 7 +-- Core/PluginTools.cs | 3 +- DistTestCore/Configuration.cs | 21 ++----- DistTestCore/DistTest.cs | 89 --------------------------- DistTestCore/TestLifecycle.cs | 17 ----- KubernetesWorkflow/Configuration.cs | 1 + KubernetesWorkflow/WorkflowCreator.cs | 17 ++++- 9 files changed, 34 insertions(+), 135 deletions(-) diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index b82821d..e862f78 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -7,6 +7,7 @@ namespace CodexPlugin { private readonly CodexStarter codexStarter; private readonly IPluginTools tools; + private readonly CodexLogLevel defaultLogLevel = CodexLogLevel.Trace; public CodexPlugin(IPluginTools tools) { @@ -27,7 +28,8 @@ namespace CodexPlugin public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) { - var codexSetup = new CodexSetup(numberOfNodes, CodexLogLevel.Trace); + var codexSetup = new CodexSetup(numberOfNodes); + codexSetup.LogLevel = defaultLogLevel; setup(codexSetup); return codexStarter.BringOnline(codexSetup); } diff --git a/CodexPlugin/CodexSetup.cs b/CodexPlugin/CodexSetup.cs index ff777ff..d6356e1 100644 --- a/CodexPlugin/CodexSetup.cs +++ b/CodexPlugin/CodexSetup.cs @@ -5,6 +5,7 @@ namespace CodexPlugin { public interface ICodexSetup { + ICodexSetup WithLogLevel(CodexLogLevel logLevel); ICodexSetup WithName(string name); ICodexSetup At(Location location); ICodexSetup WithBootstrapNode(IOnlineCodexNode node); @@ -22,12 +23,17 @@ namespace CodexPlugin { public int NumberOfNodes { get; } - public CodexSetup(int numberOfNodes, CodexLogLevel logLevel) - : base(logLevel) + public CodexSetup(int numberOfNodes) { NumberOfNodes = numberOfNodes; } + public ICodexSetup WithLogLevel(CodexLogLevel logLevel) + { + LogLevel = logLevel; + return this; + } + public ICodexSetup WithName(string name) { NameOverride = name; diff --git a/CodexPlugin/CodexStartupConfig.cs b/CodexPlugin/CodexStartupConfig.cs index e2ccb1f..56b41d9 100644 --- a/CodexPlugin/CodexStartupConfig.cs +++ b/CodexPlugin/CodexStartupConfig.cs @@ -5,14 +5,9 @@ namespace CodexPlugin { public class CodexStartupConfig { - public CodexStartupConfig(CodexLogLevel logLevel) - { - LogLevel = logLevel; - } - public string? NameOverride { get; set; } public Location Location { get; set; } - public CodexLogLevel LogLevel { get; } + public CodexLogLevel LogLevel { get; set; } public ByteSize? StorageQuota { get; set; } public bool MetricsEnabled { get; set; } //public MarketplaceInitialConfig? MarketplaceConfig { get; set; } diff --git a/Core/PluginTools.cs b/Core/PluginTools.cs index 522e481..704588a 100644 --- a/Core/PluginTools.cs +++ b/Core/PluginTools.cs @@ -62,8 +62,7 @@ namespace Core public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null) { - if (namespaceOverride != null) throw new Exception("Namespace override is not supported in the DistTest environment. (It would mess up automatic resource cleanup.)"); - return workflowCreator.CreateWorkflow(); + return workflowCreator.CreateWorkflow(namespaceOverride); } public IFileManager GetFileManager() diff --git a/DistTestCore/Configuration.cs b/DistTestCore/Configuration.cs index b76a380..b22415d 100644 --- a/DistTestCore/Configuration.cs +++ b/DistTestCore/Configuration.cs @@ -8,8 +8,6 @@ namespace DistTestCore private readonly string logPath; private readonly bool logDebug; private readonly string dataFilesPath; - //private readonly CodexLogLevel codexLogLevel; - private readonly string k8sNamespacePrefix; public Configuration() { @@ -17,28 +15,28 @@ namespace DistTestCore logPath = GetEnvVarOrDefault("LOGPATH", "CodexTestLogs"); logDebug = GetEnvVarOrDefault("LOGDEBUG", "false").ToLowerInvariant() == "true"; dataFilesPath = GetEnvVarOrDefault("DATAFILEPATH", "TestDataFiles"); - //codexLogLevel = ParseEnum.Parse(GetEnvVarOrDefault("LOGLEVEL", nameof(CodexLogLevel.Trace))); - k8sNamespacePrefix = "ct-"; } - public Configuration(string? kubeConfigFile, string logPath, bool logDebug, string dataFilesPath, /*CodexLogLevel codexLogLevel,*/ string k8sNamespacePrefix) + public Configuration(string? kubeConfigFile, string logPath, bool logDebug, string dataFilesPath) { this.kubeConfigFile = kubeConfigFile; this.logPath = logPath; this.logDebug = logDebug; this.dataFilesPath = dataFilesPath; - //this.codexLogLevel = codexLogLevel; - this.k8sNamespacePrefix = k8sNamespacePrefix; } public KubernetesWorkflow.Configuration GetK8sConfiguration(ITimeSet timeSet, string k8sNamespace) { - return new KubernetesWorkflow.Configuration( + var config = new KubernetesWorkflow.Configuration( kubeConfigFile: kubeConfigFile, operationTimeout: timeSet.K8sOperationTimeout(), retryDelay: timeSet.WaitForK8sServiceDelay(), kubernetesNamespace: k8sNamespace ); + + config.AllowNamespaceOverride = false; + + return config; } public Logging.LogConfig GetLogConfig() @@ -51,11 +49,6 @@ namespace DistTestCore return dataFilesPath; } - //public CodexLogLevel GetCodexLogLevel() - //{ - // return codexLogLevel; - //} - private static string GetEnvVarOrDefault(string varName, string defaultValue) { var v = Environment.GetEnvironmentVariable(varName); @@ -70,6 +63,4 @@ namespace DistTestCore return v; } } - - } diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index ac7063d..5ae23a4 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -113,59 +113,6 @@ namespace DistTestCore Get().GetFileManager().ScopedFiles(action); } - //public IOnlineCodexNode SetupCodexBootstrapNode() - //{ - // return SetupCodexBootstrapNode(s => { }); - //} - - //public virtual IOnlineCodexNode SetupCodexBootstrapNode(Action setup) - //{ - // return SetupCodexNode(s => - // { - // setup(s); - // s.WithName("Bootstrap"); - // }); - //} - - //public IOnlineCodexNode SetupCodexNode() - //{ - // return SetupCodexNode(s => { }); - //} - - //public IOnlineCodexNode SetupCodexNode(Action setup) - //{ - // return SetupCodexNodes(1, setup)[0]; - //} - - //public ICodexNodeGroup SetupCodexNodes(int numberOfNodes) - //{ - // return SetupCodexNodes(numberOfNodes, s => { }); - //} - - //public virtual ICodexNodeGroup SetupCodexNodes(int numberOfNodes, Action setup) - //{ - // var codexSetup = CreateCodexSetup(numberOfNodes); - - // setup(codexSetup); - - // return BringOnline(codexSetup); - //} - - //public ICodexNodeGroup BringOnline(ICodexSetup codexSetup) - //{ - // return Get().CodexStarter.BringOnline((CodexSetup)codexSetup); - //} - - //public IEnumerable GetAllOnlineCodexNodes() - //{ - // return Get().CodexStarter.RunningGroups.SelectMany(g => g.Nodes); - //} - - //public override T GetPlugin() - //{ - // return Get().GetPlugin(); - //} - public ILog GetTestLog() { return Get().Log; @@ -188,11 +135,6 @@ namespace DistTestCore Stopwatch.Measure(Get().Log, name, action); } - //protected CodexSetup CreateCodexSetup(int numberOfNodes) - //{ - // return new CodexSetup(numberOfNodes, configuration.GetCodexLogLevel()); - //} - protected TestLifecycle Get() { lock (lifecycleLock) @@ -293,37 +235,6 @@ namespace DistTestCore } } - //private void DownloadAllLogs(TestLifecycle lifecycle) - //{ - // OnEachCodexNode(lifecycle, node => - // { - // lifecycle.DownloadLog(node.CodexAccess.Container); - // }); - //} - - //private void DownloadAllMetrics(TestLifecycle lifecycle) - //{ - // var metricsDownloader = new MetricsDownloader(lifecycle.Log); - - // OnEachCodexNode(lifecycle, node => - // { - // var m = node.Metrics as MetricsAccess; - // if (m != null) - // { - // metricsDownloader.DownloadAllMetricsForNode(node.GetName(), m); - // } - // }); - //} - - //private void OnEachCodexNode(TestLifecycle lifecycle, Action action) - //{ - // var allNodes = lifecycle.CodexStarter.RunningGroups.SelectMany(g => g.Nodes); - // foreach (var node in allNodes) - // { - // action(node); - // } - //} - private string GetCurrentTestName() { return $"[{TestContext.CurrentContext.Test.Name}]"; diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 4af6bc5..ef04186 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -45,29 +45,12 @@ namespace DistTestCore return entryPoint.Tools.GetFileManager(); } - //public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null) - //{ - // var subFile = Log.CreateSubfile(); - // var description = container.Name; - // var handler = new LogDownloadHandler(container, description, subFile); - - // Log.Log($"Downloading logs for {description} to file '{subFile.FullFilename}'"); - // //CodexStarter.DownloadLog(container, handler, tailLines); - - // return new DownloadedLog(subFile, description); - //} - public string GetTestDuration() { var testDuration = DateTime.UtcNow - testStart; return Time.FormatDuration(testDuration); } - ////public void SetCodexVersion(CodexDebugVersionResponse version) - ////{ - //// if (CodexVersion == null) CodexVersion = version; - ////} - //public ApplicationIds GetApplicationIds() //{ // //return new ApplicationIds( diff --git a/KubernetesWorkflow/Configuration.cs b/KubernetesWorkflow/Configuration.cs index 80505bf..2c7f440 100644 --- a/KubernetesWorkflow/Configuration.cs +++ b/KubernetesWorkflow/Configuration.cs @@ -14,5 +14,6 @@ public TimeSpan OperationTimeout { get; } public TimeSpan RetryDelay { get; } public string KubernetesNamespace { get; } + public bool AllowNamespaceOverride { get; set; } = true; } } diff --git a/KubernetesWorkflow/WorkflowCreator.cs b/KubernetesWorkflow/WorkflowCreator.cs index bc0edab..213db92 100644 --- a/KubernetesWorkflow/WorkflowCreator.cs +++ b/KubernetesWorkflow/WorkflowCreator.cs @@ -10,22 +10,33 @@ namespace KubernetesWorkflow private readonly KnownK8sPods knownPods = new KnownK8sPods(); private readonly K8sCluster cluster; private readonly ILog log; + private readonly Configuration configuration; private readonly string k8sNamespace; public WorkflowCreator(ILog log, Configuration configuration) { this.log = log; - + this.configuration = configuration; cluster = new K8sCluster(configuration); k8sNamespace = configuration.KubernetesNamespace.ToLowerInvariant(); } - public IStartupWorkflow CreateWorkflow() + public IStartupWorkflow CreateWorkflow(string? namespaceOverride = null) { var workflowNumberSource = new WorkflowNumberSource(numberSource.GetNextNumber(), containerNumberSource); - return new StartupWorkflow(log, workflowNumberSource, cluster, knownPods, k8sNamespace); + return new StartupWorkflow(log, workflowNumberSource, cluster, knownPods, GetNamespace(namespaceOverride)); + } + + private string GetNamespace(string? namespaceOverride) + { + if (namespaceOverride != null) + { + if (!configuration.AllowNamespaceOverride) throw new Exception("Namespace override is not allowed."); + return namespaceOverride; + } + return k8sNamespace; } } } From a2e07fbd2e50a86133a84234b58aa54422bb2e63 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 15:10:19 +0200 Subject: [PATCH 22/51] Automatic downloading of container logs on test failure --- DistTestCore/Configuration.cs | 7 ++++ DistTestCore/DistTest.cs | 17 +++++---- ...ownloadLogsAndMetricsOnFailureAttribute.cs | 15 -------- .../DontDownloadLogsOnFailureAttribute.cs | 15 ++++++++ DistTestCore/TestLifecycle.cs | 36 +++++++++++++++++-- KubernetesWorkflow/Configuration.cs | 1 + KubernetesWorkflow/K8sHooks.cs | 19 ++++++++++ KubernetesWorkflow/StartupWorkflow.cs | 5 ++- 8 files changed, 88 insertions(+), 27 deletions(-) delete mode 100644 DistTestCore/DontDownloadLogsAndMetricsOnFailureAttribute.cs create mode 100644 DistTestCore/DontDownloadLogsOnFailureAttribute.cs create mode 100644 KubernetesWorkflow/K8sHooks.cs diff --git a/DistTestCore/Configuration.cs b/DistTestCore/Configuration.cs index b22415d..35b9823 100644 --- a/DistTestCore/Configuration.cs +++ b/DistTestCore/Configuration.cs @@ -1,4 +1,5 @@ using Core; +using KubernetesWorkflow; namespace DistTestCore { @@ -26,6 +27,11 @@ namespace DistTestCore } public KubernetesWorkflow.Configuration GetK8sConfiguration(ITimeSet timeSet, string k8sNamespace) + { + return GetK8sConfiguration(timeSet, new DoNothingK8sHooks(), k8sNamespace); + } + + public KubernetesWorkflow.Configuration GetK8sConfiguration(ITimeSet timeSet, IK8sHooks hooks, string k8sNamespace) { var config = new KubernetesWorkflow.Configuration( kubeConfigFile: kubeConfigFile, @@ -35,6 +41,7 @@ namespace DistTestCore ); config.AllowNamespaceOverride = false; + config.Hooks = hooks; return config; } diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index 5ae23a4..408576c 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -171,7 +171,7 @@ namespace DistTestCore { WriteEndTestLog(lifecycle.Log); - IncludeLogsAndMetricsOnTestFailure(lifecycle); + IncludeLogsOnTestFailure(lifecycle); lifecycle.DeleteAllResources(); lifecycle = null!; }); @@ -215,22 +215,21 @@ namespace DistTestCore return testMethods.Any(m => m.GetCustomAttribute() != null); } - private void IncludeLogsAndMetricsOnTestFailure(TestLifecycle lifecycle) + private void IncludeLogsOnTestFailure(TestLifecycle lifecycle) { var result = TestContext.CurrentContext.Result; if (result.Outcome.Status == NUnit.Framework.Interfaces.TestStatus.Failed) { fixtureLog.MarkAsFailed(); - if (IsDownloadingLogsAndMetricsEnabled()) + if (IsDownloadingLogsEnabled()) { - lifecycle.Log.Log("Downloading all CodexNode logs and metrics because of test failure..."); - //DownloadAllLogs(lifecycle); - //DownloadAllMetrics(lifecycle); + lifecycle.Log.Log("Downloading all container logs because of test failure..."); + lifecycle.DownloadAllLogs(); } else { - lifecycle.Log.Log("Skipping download of all CodexNode logs and metrics due to [DontDownloadLogsAndMetricsOnFailure] attribute."); + lifecycle.Log.Log("Skipping download of all container logs due to [DontDownloadLogsOnFailure] attribute."); } } } @@ -245,10 +244,10 @@ namespace DistTestCore return TestContext.CurrentContext.Result.Outcome.Status.ToString(); } - private bool IsDownloadingLogsAndMetricsEnabled() + private bool IsDownloadingLogsEnabled() { var testProperties = TestContext.CurrentContext.Test.Properties; - return !testProperties.ContainsKey(DontDownloadLogsAndMetricsOnFailureAttribute.DontDownloadKey); + return !testProperties.ContainsKey(DontDownloadLogsOnFailureAttribute.DontDownloadKey); } } diff --git a/DistTestCore/DontDownloadLogsAndMetricsOnFailureAttribute.cs b/DistTestCore/DontDownloadLogsAndMetricsOnFailureAttribute.cs deleted file mode 100644 index 335bbe3..0000000 --- a/DistTestCore/DontDownloadLogsAndMetricsOnFailureAttribute.cs +++ /dev/null @@ -1,15 +0,0 @@ -using NUnit.Framework; - -namespace DistTestCore -{ - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class DontDownloadLogsAndMetricsOnFailureAttribute : PropertyAttribute - { - public const string DontDownloadKey = "DontDownloadLogsAndMetrics"; - - public DontDownloadLogsAndMetricsOnFailureAttribute() - : base(DontDownloadKey) - { - } - } -} diff --git a/DistTestCore/DontDownloadLogsOnFailureAttribute.cs b/DistTestCore/DontDownloadLogsOnFailureAttribute.cs new file mode 100644 index 0000000..800b35b --- /dev/null +++ b/DistTestCore/DontDownloadLogsOnFailureAttribute.cs @@ -0,0 +1,15 @@ +using NUnit.Framework; + +namespace DistTestCore +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class DontDownloadLogsOnFailureAttribute : PropertyAttribute + { + public const string DontDownloadKey = "DontDownloadLogs"; + + public DontDownloadLogsOnFailureAttribute() + : base(DontDownloadKey) + { + } + } +} diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index ef04186..999beb7 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -1,14 +1,16 @@ using Core; using FileUtils; +using KubernetesWorkflow; using Logging; using Utils; namespace DistTestCore { - public class TestLifecycle + public class TestLifecycle : IK8sHooks { private readonly DateTime testStart; private readonly EntryPoint entryPoint; + private readonly List runningContainers = new List(); public TestLifecycle(TestLog log, Configuration configuration, ITimeSet timeSet, string testNamespace) { @@ -17,7 +19,7 @@ namespace DistTestCore TimeSet = timeSet; testStart = DateTime.UtcNow; - entryPoint = new EntryPoint(log, configuration.GetK8sConfiguration(timeSet, testNamespace), configuration.GetFileManagerFolder(), timeSet); + entryPoint = new EntryPoint(log, configuration.GetK8sConfiguration(timeSet, this, testNamespace), configuration.GetFileManagerFolder(), timeSet); CoreInterface = entryPoint.CreateInterface(); log.WriteLogTag(); @@ -51,6 +53,36 @@ namespace DistTestCore return Time.FormatDuration(testDuration); } + public void OnContainersStarted(RunningContainers rc) + { + runningContainers.Add(rc); + } + + public void OnContainersStopped(RunningContainers rc) + { + runningContainers.Remove(rc); + } + + public void DownloadAllLogs() + { + var workflow = entryPoint.Tools.CreateWorkflow(); + foreach (var rc in runningContainers) + { + foreach (var c in rc.Containers) + { + DownloadContainerLog(workflow, c); + } + } + } + + private void DownloadContainerLog(IStartupWorkflow workflow, RunningContainer c) + { + var file = Log.CreateSubfile(); + Log.Log($"Downloading container log for '{c.Name}' to file '{file.FullFilename}'..."); + var handler = new LogDownloadHandler(c.Name, file); + workflow.DownloadContainerLog(c, handler); + } + //public ApplicationIds GetApplicationIds() //{ // //return new ApplicationIds( diff --git a/KubernetesWorkflow/Configuration.cs b/KubernetesWorkflow/Configuration.cs index 2c7f440..5cb7da2 100644 --- a/KubernetesWorkflow/Configuration.cs +++ b/KubernetesWorkflow/Configuration.cs @@ -15,5 +15,6 @@ public TimeSpan RetryDelay { get; } public string KubernetesNamespace { get; } public bool AllowNamespaceOverride { get; set; } = true; + public IK8sHooks Hooks { get; set; } = new DoNothingK8sHooks(); } } diff --git a/KubernetesWorkflow/K8sHooks.cs b/KubernetesWorkflow/K8sHooks.cs new file mode 100644 index 0000000..3bea4d9 --- /dev/null +++ b/KubernetesWorkflow/K8sHooks.cs @@ -0,0 +1,19 @@ +namespace KubernetesWorkflow +{ + public interface IK8sHooks + { + void OnContainersStarted(RunningContainers runningContainers); + void OnContainersStopped(RunningContainers runningContainers); + } + + public class DoNothingK8sHooks : IK8sHooks + { + public void OnContainersStarted(RunningContainers runningContainers) + { + } + + public void OnContainersStopped(RunningContainers runningContainers) + { + } + } +} diff --git a/KubernetesWorkflow/StartupWorkflow.cs b/KubernetesWorkflow/StartupWorkflow.cs index 5cc8f1e..3a95262 100644 --- a/KubernetesWorkflow/StartupWorkflow.cs +++ b/KubernetesWorkflow/StartupWorkflow.cs @@ -41,7 +41,9 @@ namespace KubernetesWorkflow if (startupConfig.CreateCrashWatcher) CreateCrashWatchers(controller, containers); - return new RunningContainers(startupConfig, runningPod, containers); + var rc = new RunningContainers(startupConfig, runningPod, containers); + cluster.Configuration.Hooks.OnContainersStarted(rc); + return rc; }); } @@ -50,6 +52,7 @@ namespace KubernetesWorkflow K8s(controller => { controller.Stop(runningContainers.RunningPod); + cluster.Configuration.Hooks.OnContainersStopped(runningContainers); }); } From 0b67dc56bb40d7f402e419cf36d10c032ebdf2d1 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 13 Sep 2023 16:06:05 +0200 Subject: [PATCH 23/51] wiring up metadata --- CodexPlugin/CodexContainerRecipe.cs | 6 +- CodexPlugin/CodexPlugin.cs | 9 ++- CodexPlugin/CodexStarter.cs | 12 +++- Core/DefaultContainerRecipe.cs | 40 ------------- Core/EntryPoint.cs | 5 ++ Core/PluginManager.cs | 18 ++++++ Core/PluginMetadata.cs | 27 +++++++++ DistTestCore/DistTest.cs | 5 +- DistTestCore/StatusLog.cs | 46 +++++++++++++++ DistTestCore/TestLifecycle.cs | 37 +++++------- KubernetesWorkflow/Configuration.cs | 1 + KubernetesWorkflow/K8sHooks.cs | 5 ++ KubernetesWorkflow/StartupWorkflow.cs | 5 +- Logging/StatusLog.cs | 69 ---------------------- MetricsPlugin/MetricsPlugin.cs | 11 +++- MetricsPlugin/PrometheusContainerRecipe.cs | 7 +-- MetricsPlugin/PrometheusStarter.cs | 8 ++- 17 files changed, 162 insertions(+), 149 deletions(-) delete mode 100644 Core/DefaultContainerRecipe.cs create mode 100644 Core/PluginMetadata.cs create mode 100644 DistTestCore/StatusLog.cs delete mode 100644 Logging/StatusLog.cs diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index 08f5eae..47680a0 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -5,7 +5,7 @@ using Utils; namespace CodexPlugin { - public class CodexContainerRecipe : DefaultContainerRecipe + public class CodexContainerRecipe : ContainerRecipeFactory { private const string DefaultDockerImage = "codexstorage/nim-codex:latest-dist-tests"; @@ -27,7 +27,7 @@ namespace CodexPlugin //Resources.Limits = new ContainerResourceSet(milliCPUs: 4000, memory: 12.GB()); } - protected override void InitializeRecipe(StartupConfig startupConfig) + protected override void Initialize(StartupConfig startupConfig) { var config = startupConfig.Get(); @@ -97,6 +97,8 @@ namespace CodexPlugin // AddEnvVar("CODEX_VALIDATOR", "true"); // } //} + + AddPodLabel("codexid", Image); } private ByteSize GetVolumeCapacity(CodexStartupConfig config) diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index e862f78..599ca24 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -3,7 +3,7 @@ using KubernetesWorkflow; namespace CodexPlugin { - public class CodexPlugin : IProjectPlugin, IHasLogPrefix + public class CodexPlugin : IProjectPlugin, IHasLogPrefix, IHasMetadata { private readonly CodexStarter codexStarter; private readonly IPluginTools tools; @@ -19,7 +19,12 @@ namespace CodexPlugin public void Announce() { - tools.GetLog().Log("hello from codex plugin. codex container info here."); + tools.GetLog().Log($"Loaded with Codex ID: '{codexStarter.GetCodexId()}'"); + } + + public void AddMetadata(IAddMetadata metadata) + { + metadata.Add("codexid", codexStarter.GetCodexId()); } public void Decommission() diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index fc12369..69a82e8 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -7,6 +7,8 @@ namespace CodexPlugin public class CodexStarter { private readonly IPluginTools pluginTools; + private readonly CodexContainerRecipe recipe = new CodexContainerRecipe(); + private CodexDebugVersionResponse? versionResponse; public CodexStarter(IPluginTools pluginTools) { @@ -46,8 +48,7 @@ namespace CodexPlugin var group = CreateCodexGroup(containers, codexNodeFactory); Log($"Codex version: {group.Version}"); - - //lifecycle.SetCodexVersion(group.Version); + versionResponse = group.Version; return group; } @@ -64,6 +65,12 @@ namespace CodexPlugin Log("Stopped."); } + public string GetCodexId() + { + if (versionResponse != null) return versionResponse.version; + return recipe.Image; + } + //public void DeleteAllResources() //{ // //var workflow = CreateWorkflow(); @@ -103,7 +110,6 @@ namespace CodexPlugin private RunningContainers[] StartCodexContainers(StartupConfig startupConfig, int numberOfNodes, Location location) { var result = new List(); - var recipe = new CodexContainerRecipe(); for (var i = 0; i < numberOfNodes; i++) { var workflow = pluginTools.CreateWorkflow(); diff --git a/Core/DefaultContainerRecipe.cs b/Core/DefaultContainerRecipe.cs deleted file mode 100644 index 1a34e71..0000000 --- a/Core/DefaultContainerRecipe.cs +++ /dev/null @@ -1,40 +0,0 @@ -using KubernetesWorkflow; -using Logging; - -namespace Core -{ - public abstract class DefaultContainerRecipe : ContainerRecipeFactory - { - public static string TestsType { get; set; } = "NotSet"; - public static ApplicationIds? ApplicationIds { get; set; } = null; - - protected abstract void InitializeRecipe(StartupConfig config); - - protected override void Initialize(StartupConfig config) - { - Add("tests-type", TestsType); - Add("runid", NameUtils.GetRunId()); - Add("testid", NameUtils.GetTestId()); - Add("category", NameUtils.GetCategoryName()); - Add("fixturename", NameUtils.GetRawFixtureName()); - Add("testname", NameUtils.GetTestMethodName()); - - if (ApplicationIds != null) - { - Add("codexid", ApplicationIds.CodexId); - Add("gethid", ApplicationIds.GethId); - Add("prometheusid", ApplicationIds.PrometheusId); - Add("codexcontractsid", ApplicationIds.CodexContractsId); - Add("grafanaid", ApplicationIds.GrafanaId); - } - Add("app", AppName); - - InitializeRecipe(config); - } - - private void Add(string name, string value) - { - AddPodLabel(name, value); - } - } -} diff --git a/Core/EntryPoint.cs b/Core/EntryPoint.cs index 788c15a..5ce9af1 100644 --- a/Core/EntryPoint.cs +++ b/Core/EntryPoint.cs @@ -28,6 +28,11 @@ namespace Core manager.AnnouncePlugins(); } + public Dictionary GetPluginMetadata() + { + return manager.GatherPluginMetadata().Get(); + } + public CoreInterface CreateInterface() { return new CoreInterface(this); diff --git a/Core/PluginManager.cs b/Core/PluginManager.cs index 4edccb8..2b24788 100644 --- a/Core/PluginManager.cs +++ b/Core/PluginManager.cs @@ -21,6 +21,19 @@ foreach (var plugin in projectPlugins) plugin.Announce(); } + public PluginMetadata GatherPluginMetadata() + { + var metadata = new PluginMetadata(); + foreach (var plugin in projectPlugins) + { + if (plugin is IHasMetadata m) + { + m.AddMetadata(metadata); + } + } + return metadata; + } + public void DecommissionPlugins() { foreach (var plugin in projectPlugins) plugin.Decommission(); @@ -57,4 +70,9 @@ { string LogPrefix { get; } } + + public interface IHasMetadata + { + void AddMetadata(IAddMetadata metadata); + } } diff --git a/Core/PluginMetadata.cs b/Core/PluginMetadata.cs new file mode 100644 index 0000000..c84af76 --- /dev/null +++ b/Core/PluginMetadata.cs @@ -0,0 +1,27 @@ +namespace Core +{ + public interface IPluginMetadata + { + Dictionary Get(); + } + + public interface IAddMetadata + { + void Add(string key, string value); + } + + public class PluginMetadata : IPluginMetadata, IAddMetadata + { + private readonly Dictionary metadata = new Dictionary(); + + public void Add(string key, string value) + { + metadata.Add(key, value); + } + + public Dictionary Get() + { + return new Dictionary(metadata); + } + } +} diff --git a/DistTestCore/DistTest.cs b/DistTestCore/DistTest.cs index 408576c..5555ed8 100644 --- a/DistTestCore/DistTest.cs +++ b/DistTestCore/DistTest.cs @@ -10,7 +10,6 @@ namespace DistTestCore [Parallelizable(ParallelScope.All)] public abstract class DistTest { - private const string TestsType = "dist-tests"; private const string TestNamespacePrefix = "ct-"; private readonly Configuration configuration = new Configuration(); private readonly Assembly[] testAssemblies; @@ -154,8 +153,6 @@ namespace DistTestCore var testNamespace = TestNamespacePrefix + Guid.NewGuid().ToString(); var lifecycle = new TestLifecycle(fixtureLog.CreateTestLog(), configuration, GetTimeSet(), testNamespace); lifecycles.Add(testName, lifecycle); - DefaultContainerRecipe.TestsType = TestsType; - //DefaultContainerRecipe.ApplicationIds = lifecycle.GetApplicationIds(); } }); } @@ -166,7 +163,7 @@ namespace DistTestCore var testResult = GetTestResult(); var testDuration = lifecycle.GetTestDuration(); fixtureLog.Log($"{GetCurrentTestName()} = {testResult} ({testDuration})"); - statusLog.ConcludeTest(testResult, testDuration);//, lifecycle.GetApplicationIds()); + statusLog.ConcludeTest(testResult, testDuration, lifecycle.GetPluginMetadata()); Stopwatch.Measure(fixtureLog, $"Teardown for {GetCurrentTestName()}", () => { WriteEndTestLog(lifecycle.Log); diff --git a/DistTestCore/StatusLog.cs b/DistTestCore/StatusLog.cs new file mode 100644 index 0000000..e937469 --- /dev/null +++ b/DistTestCore/StatusLog.cs @@ -0,0 +1,46 @@ +using Logging; +using Newtonsoft.Json; + +namespace DistTestCore +{ + public class StatusLog + { + private readonly object fileLock = new object(); + private readonly string fullName; + private readonly string fixtureName; + + public StatusLog(LogConfig config, DateTime start, string name = "") + { + fullName = NameUtils.GetFixtureFullName(config, start, name) + "_STATUS.log"; + fixtureName = NameUtils.GetRawFixtureName(); + } + + public void ConcludeTest(string resultStatus, string testDuration, Dictionary data) + { + data.Add("timestamp", DateTime.UtcNow.ToString("o")); + data.Add("runid", NameUtils.GetRunId()); + data.Add("status", resultStatus); + data.Add("testid", NameUtils.GetTestId()); + data.Add("category", NameUtils.GetCategoryName()); + data.Add("fixturename", fixtureName); + data.Add("testname", NameUtils.GetTestMethodName()); + data.Add("testduration", testDuration); + Write(data); + } + + private void Write(Dictionary data) + { + try + { + lock (fileLock) + { + File.AppendAllLines(fullName, new[] { JsonConvert.SerializeObject(data) }); + } + } + catch (Exception ex) + { + Console.WriteLine("Unable to write to status log: " + ex); + } + } + } +} diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 999beb7..4723dd2 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -8,6 +8,7 @@ namespace DistTestCore { public class TestLifecycle : IK8sHooks { + private const string TestsType = "dist-tests"; private readonly DateTime testStart; private readonly EntryPoint entryPoint; private readonly List runningContainers = new List(); @@ -47,6 +48,11 @@ namespace DistTestCore return entryPoint.Tools.GetFileManager(); } + public Dictionary GetPluginMetadata() + { + return entryPoint.GetPluginMetadata(); + } + public string GetTestDuration() { var testDuration = DateTime.UtcNow - testStart; @@ -63,6 +69,16 @@ namespace DistTestCore runningContainers.Remove(rc); } + public void OnContainerRecipeCreated(ContainerRecipe recipe) + { + recipe.PodLabels.Add("tests-type", TestsType); + recipe.PodLabels.Add("runid", NameUtils.GetRunId()); + recipe.PodLabels.Add("testid", NameUtils.GetTestId()); + recipe.PodLabels.Add("category", NameUtils.GetCategoryName()); + recipe.PodLabels.Add("fixturename", NameUtils.GetRawFixtureName()); + recipe.PodLabels.Add("testname", NameUtils.GetTestMethodName()); + } + public void DownloadAllLogs() { var workflow = entryPoint.Tools.CreateWorkflow(); @@ -82,26 +98,5 @@ namespace DistTestCore var handler = new LogDownloadHandler(c.Name, file); workflow.DownloadContainerLog(c, handler); } - - //public ApplicationIds GetApplicationIds() - //{ - // //return new ApplicationIds( - // // codexId: GetCodexId(), - // // gethId: new GethContainerRecipe().Image, - // // prometheusId: new PrometheusContainerRecipe().Image, - // // codexContractsId: new CodexContractsContainerRecipe().Image, - // // grafanaId: new GrafanaContainerRecipe().Image - // //); - // return null!; - //} - - //private string GetCodexId() - //{ - // return ""; - // //var v = CodexVersion; - // //if (v == null) return new CodexContainerRecipe().Image; - // //if (v.version != "untagged build") return v.version; - // //return v.revision; - //} } } diff --git a/KubernetesWorkflow/Configuration.cs b/KubernetesWorkflow/Configuration.cs index 5cb7da2..1cf30cf 100644 --- a/KubernetesWorkflow/Configuration.cs +++ b/KubernetesWorkflow/Configuration.cs @@ -15,6 +15,7 @@ public TimeSpan RetryDelay { get; } public string KubernetesNamespace { get; } public bool AllowNamespaceOverride { get; set; } = true; + public bool AddAppPodLabel { get; set; } = true; public IK8sHooks Hooks { get; set; } = new DoNothingK8sHooks(); } } diff --git a/KubernetesWorkflow/K8sHooks.cs b/KubernetesWorkflow/K8sHooks.cs index 3bea4d9..91c25a3 100644 --- a/KubernetesWorkflow/K8sHooks.cs +++ b/KubernetesWorkflow/K8sHooks.cs @@ -4,6 +4,7 @@ { void OnContainersStarted(RunningContainers runningContainers); void OnContainersStopped(RunningContainers runningContainers); + void OnContainerRecipeCreated(ContainerRecipe recipe); } public class DoNothingK8sHooks : IK8sHooks @@ -15,5 +16,9 @@ public void OnContainersStopped(RunningContainers runningContainers) { } + + public void OnContainerRecipeCreated(ContainerRecipe recipe) + { + } } } diff --git a/KubernetesWorkflow/StartupWorkflow.cs b/KubernetesWorkflow/StartupWorkflow.cs index 3a95262..d8221d1 100644 --- a/KubernetesWorkflow/StartupWorkflow.cs +++ b/KubernetesWorkflow/StartupWorkflow.cs @@ -161,7 +161,10 @@ namespace KubernetesWorkflow var result = new List(); for (var i = 0; i < numberOfContainers; i++) { - result.Add(recipeFactory.CreateRecipe(i, numberSource.GetContainerNumber(), componentFactory, startupConfig)); + var recipe = recipeFactory.CreateRecipe(i, numberSource.GetContainerNumber(), componentFactory, startupConfig); + if (cluster.Configuration.AddAppPodLabel) recipe.PodLabels.Add("app", recipeFactory.AppName); + cluster.Configuration.Hooks.OnContainerRecipeCreated(recipe); + result.Add(recipe); } return result.ToArray(); diff --git a/Logging/StatusLog.cs b/Logging/StatusLog.cs deleted file mode 100644 index 3fe3a7d..0000000 --- a/Logging/StatusLog.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Newtonsoft.Json; - -namespace Logging -{ - public class StatusLog - { - private readonly object fileLock = new object(); - private readonly string fullName; - private readonly string fixtureName; - - public StatusLog(LogConfig config, DateTime start, string name = "") - { - fullName = NameUtils.GetFixtureFullName(config, start, name) + "_STATUS.log"; - fixtureName = NameUtils.GetRawFixtureName(); - } - - public void ConcludeTest(string resultStatus, string testDuration/*, ApplicationIds applicationIds*/) - { - Write(new StatusLogJson - { - @timestamp = DateTime.UtcNow.ToString("o"), - runid = NameUtils.GetRunId(), - status = resultStatus, - testid = NameUtils.GetTestId(), - //codexid = applicationIds.CodexId, - //gethid = applicationIds.GethId, - //prometheusid = applicationIds.PrometheusId, - //codexcontractsid = applicationIds.CodexContractsId, - //grafanaid = applicationIds.GrafanaId, - category = NameUtils.GetCategoryName(), - fixturename = fixtureName, - testname = NameUtils.GetTestMethodName(), - testduration = testDuration - }); - } - - private void Write(StatusLogJson json) - { - try - { - lock (fileLock) - { - File.AppendAllLines(fullName, new[] { JsonConvert.SerializeObject(json) }); - } - } - catch (Exception ex) - { - Console.WriteLine("Unable to write to status log: " + ex); - } - } - } - - public class StatusLogJson - { - public string @timestamp { get; set; } = string.Empty; - public string runid { get; set; } = string.Empty; - public string status { get; set; } = string.Empty; - public string testid { get; set; } = string.Empty; - public string codexid { get; set; } = string.Empty; - public string gethid { get; set; } = string.Empty; - public string prometheusid { get; set; } = string.Empty; - public string codexcontractsid { get; set; } = string.Empty; - public string grafanaid { get; set; } = string.Empty; - public string category { get; set; } = string.Empty; - public string fixturename { get; set; } = string.Empty; - public string testname { get; set; } = string.Empty; - public string testduration { get; set;} = string.Empty; - } -} diff --git a/MetricsPlugin/MetricsPlugin.cs b/MetricsPlugin/MetricsPlugin.cs index c2ed11c..09ad08c 100644 --- a/MetricsPlugin/MetricsPlugin.cs +++ b/MetricsPlugin/MetricsPlugin.cs @@ -4,7 +4,7 @@ using Logging; namespace MetricsPlugin { - public class MetricsPlugin : IProjectPlugin + public class MetricsPlugin : IProjectPlugin, IHasLogPrefix, IHasMetadata { private readonly IPluginTools tools; private readonly PrometheusStarter starter; @@ -15,9 +15,16 @@ namespace MetricsPlugin starter = new PrometheusStarter(tools); } + public string LogPrefix => "(Metrics) "; + public void Announce() { - tools.GetLog().Log("Hi from the metrics plugin."); + tools.GetLog().Log($"Prometheus plugin loaded with '{starter.GetPrometheusId()}'."); + } + + public void AddMetadata(IAddMetadata metadata) + { + metadata.Add("prometheusid", starter.GetPrometheusId()); } public void Decommission() diff --git a/MetricsPlugin/PrometheusContainerRecipe.cs b/MetricsPlugin/PrometheusContainerRecipe.cs index 584ad82..26b9b48 100644 --- a/MetricsPlugin/PrometheusContainerRecipe.cs +++ b/MetricsPlugin/PrometheusContainerRecipe.cs @@ -1,14 +1,13 @@ -using Core; -using KubernetesWorkflow; +using KubernetesWorkflow; namespace MetricsPlugin { - public class PrometheusContainerRecipe : DefaultContainerRecipe + public class PrometheusContainerRecipe : ContainerRecipeFactory { public override string AppName => "prometheus"; public override string Image => "codexstorage/dist-tests-prometheus:latest"; - protected override void InitializeRecipe(StartupConfig startupConfig) + protected override void Initialize(StartupConfig startupConfig) { var config = startupConfig.Get(); diff --git a/MetricsPlugin/PrometheusStarter.cs b/MetricsPlugin/PrometheusStarter.cs index 37814ea..57b8b3e 100644 --- a/MetricsPlugin/PrometheusStarter.cs +++ b/MetricsPlugin/PrometheusStarter.cs @@ -6,6 +6,7 @@ namespace MetricsPlugin { public class PrometheusStarter { + private readonly PrometheusContainerRecipe recipe = new PrometheusContainerRecipe(); private readonly IPluginTools tools; public PrometheusStarter(IPluginTools tools) @@ -20,7 +21,7 @@ namespace MetricsPlugin startupConfig.Add(new PrometheusStartupConfig(GeneratePrometheusConfig(targets))); var workflow = tools.CreateWorkflow(); - var runningContainers = workflow.Start(1, Location.Unspecified, new PrometheusContainerRecipe(), startupConfig); + var runningContainers = workflow.Start(1, Location.Unspecified, recipe, startupConfig); if (runningContainers.Containers.Length != 1) throw new InvalidOperationException("Expected only 1 Prometheus container to be created."); Log("Metrics server started."); @@ -33,6 +34,11 @@ namespace MetricsPlugin return new MetricsAccess(metricsQuery, target); } + public string GetPrometheusId() + { + return recipe.Image; + } + private void Log(string msg) { tools.GetLog().Log(msg); From 7ef3f615e11f2f965f90803ed8470ac1a0b41b76 Mon Sep 17 00:00:00 2001 From: ThatBen Date: Thu, 14 Sep 2023 15:26:46 +0200 Subject: [PATCH 24/51] Moves log downloading to core. --- CodexPlugin/OnlineCodexNode.cs | 13 +------------ Core/CoreInterface.cs | 23 ++++++++++++++++++++++- Tests/BasicTests/ContinuousSubstitute.cs | 4 ++-- Tests/BasicTests/ExampleTests.cs | 2 +- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/CodexPlugin/OnlineCodexNode.cs b/CodexPlugin/OnlineCodexNode.cs index 6a1d376..4ee798a 100644 --- a/CodexPlugin/OnlineCodexNode.cs +++ b/CodexPlugin/OnlineCodexNode.cs @@ -8,16 +8,14 @@ using Utils; namespace CodexPlugin { - public interface IOnlineCodexNode + public interface IOnlineCodexNode : IHasContainer { string GetName(); - RunningContainer Container { get; } CodexDebugResponse GetDebugInfo(); CodexDebugPeerResponse GetDebugPeer(string peerId); ContentId UploadFile(TrackedFile file); TrackedFile? DownloadContent(ContentId contentId, string fileLabel = ""); void ConnectToPeer(IOnlineCodexNode node); - IDownloadedLog DownloadLog(int? tailLines = null); CodexDebugVersionResponse Version { get; } void BringOffline(); IMetricsScrapeTarget MetricsScrapeTarget { get; } @@ -109,15 +107,6 @@ namespace CodexPlugin Log($"Successfully connected to peer {peer.GetName()}."); } - public IDownloadedLog DownloadLog(int? tailLines = null) - { - var workflow = tools.CreateWorkflow(); - var file = tools.GetLog().CreateSubfile(); - var logHandler = new LogDownloadHandler(CodexAccess.GetName(), file); - workflow.DownloadContainerLog(CodexAccess.Container, logHandler); - return logHandler.DownloadLog(); - } - public void BringOffline() { if (Group.Count() > 1) throw new InvalidOperationException("Codex-nodes that are part of a group cannot be " + diff --git a/Core/CoreInterface.cs b/Core/CoreInterface.cs index cad0eea..8b6b1b4 100644 --- a/Core/CoreInterface.cs +++ b/Core/CoreInterface.cs @@ -1,4 +1,6 @@ -namespace Core +using KubernetesWorkflow; + +namespace Core { public sealed class CoreInterface { @@ -13,5 +15,24 @@ { return entryPoint.GetPlugin(); } + + public IDownloadedLog DownloadLog(IHasContainer containerSource, int? tailLines = null) + { + return DownloadLog(containerSource.Container, tailLines); + } + + public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null) + { + var workflow = entryPoint.Tools.CreateWorkflow(); + var file = entryPoint.Tools.GetLog().CreateSubfile(); + var logHandler = new LogDownloadHandler(container.Name, file); + workflow.DownloadContainerLog(container, logHandler, tailLines); + return logHandler.DownloadLog(); + } + } + + public interface IHasContainer + { + RunningContainer Container { get; } } } diff --git a/Tests/BasicTests/ContinuousSubstitute.cs b/Tests/BasicTests/ContinuousSubstitute.cs index 41b8da6..cdcf2d3 100644 --- a/Tests/BasicTests/ContinuousSubstitute.cs +++ b/Tests/BasicTests/ContinuousSubstitute.cs @@ -160,7 +160,7 @@ namespace Tests.BasicTests var cidTag = cid.Id.Substring(cid.Id.Length - 6); Measure("upload-log-asserts", () => { - var uploadLog = node.DownloadLog(tailLines: 50000); + var uploadLog = Ci.DownloadLog(node, tailLines: 50000); var storeLines = uploadLog.FindLinesThatContain("Stored data", "topics=\"codex node\""); uploadLog.DeleteFile(); @@ -181,7 +181,7 @@ namespace Tests.BasicTests Measure("download-log-asserts", () => { - var downloadLog = node.DownloadLog(tailLines: 50000); + var downloadLog = Ci.DownloadLog(node, tailLines: 50000); var sentLines = downloadLog.FindLinesThatContain("Sent bytes", "topics=\"codex restapi\""); downloadLog.DeleteFile(); diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index f9e76de..861a8af 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -16,7 +16,7 @@ namespace Tests.BasicTests primary.UploadFile(GenerateTestFile(5.MB())); - var log = primary.DownloadLog(); + var log = Ci.DownloadLog(primary); log.AssertLogContains("Uploaded file"); } From ae7ab3d84bcb6fe1e584cedeb8b3c33c3e32cb8c Mon Sep 17 00:00:00 2001 From: ThatBen Date: Thu, 14 Sep 2023 15:30:09 +0200 Subject: [PATCH 25/51] Container simplify for metrics plugin --- MetricsPlugin/CoreInterfaceExtensions.cs | 4 ++-- MetricsPlugin/MetricsAccess.cs | 7 +++++-- MetricsPlugin/MetricsPlugin.cs | 6 +++--- MetricsPlugin/MetricsQuery.cs | 8 ++++---- MetricsPlugin/PrometheusStarter.cs | 6 +++--- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/MetricsPlugin/CoreInterfaceExtensions.cs b/MetricsPlugin/CoreInterfaceExtensions.cs index 03ea973..46a7021 100644 --- a/MetricsPlugin/CoreInterfaceExtensions.cs +++ b/MetricsPlugin/CoreInterfaceExtensions.cs @@ -6,12 +6,12 @@ namespace MetricsPlugin { public static class CoreInterfaceExtensions { - public static RunningContainers StartMetricsCollector(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) + public static RunningContainer StartMetricsCollector(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) { return Plugin(ci).StartMetricsCollector(scrapeTargets); } - public static IMetricsAccess GetMetricsFor(this CoreInterface ci, RunningContainers metricsContainer, IMetricsScrapeTarget scrapeTarget) + public static IMetricsAccess GetMetricsFor(this CoreInterface ci, RunningContainer metricsContainer, IMetricsScrapeTarget scrapeTarget) { return Plugin(ci).CreateAccessForTarget(metricsContainer, scrapeTarget); } diff --git a/MetricsPlugin/MetricsAccess.cs b/MetricsPlugin/MetricsAccess.cs index aa172f9..ae6470c 100644 --- a/MetricsPlugin/MetricsAccess.cs +++ b/MetricsPlugin/MetricsAccess.cs @@ -1,8 +1,10 @@ -using Utils; +using Core; +using KubernetesWorkflow; +using Utils; namespace MetricsPlugin { - public interface IMetricsAccess + public interface IMetricsAccess : IHasContainer { string TargetName { get; } Metrics? GetAllMetrics(); @@ -23,6 +25,7 @@ namespace MetricsPlugin } public string TargetName { get; } + public RunningContainer Container => query.RunningContainer; public Metrics? GetAllMetrics() { diff --git a/MetricsPlugin/MetricsPlugin.cs b/MetricsPlugin/MetricsPlugin.cs index 09ad08c..0a7c7d0 100644 --- a/MetricsPlugin/MetricsPlugin.cs +++ b/MetricsPlugin/MetricsPlugin.cs @@ -31,14 +31,14 @@ namespace MetricsPlugin { } - public RunningContainers StartMetricsCollector(IMetricsScrapeTarget[] scrapeTargets) + public RunningContainer StartMetricsCollector(IMetricsScrapeTarget[] scrapeTargets) { return starter.CollectMetricsFor(scrapeTargets); } - public MetricsAccess CreateAccessForTarget(RunningContainers runningContainers, IMetricsScrapeTarget target) + public MetricsAccess CreateAccessForTarget(RunningContainer runningContainer, IMetricsScrapeTarget target) { - return starter.CreateAccessForTarget(runningContainers, target); + return starter.CreateAccessForTarget(runningContainer, target); } public LogFile? DownloadAllMetrics(IMetricsAccess metricsAccess, string targetName) diff --git a/MetricsPlugin/MetricsQuery.cs b/MetricsPlugin/MetricsQuery.cs index 8d75f20..425907e 100644 --- a/MetricsPlugin/MetricsQuery.cs +++ b/MetricsPlugin/MetricsQuery.cs @@ -8,13 +8,13 @@ namespace MetricsPlugin { private readonly Http http; - public MetricsQuery(IPluginTools tools, RunningContainers runningContainers) + public MetricsQuery(IPluginTools tools, RunningContainer runningContainer) { - RunningContainers = runningContainers; - http = tools.CreateHttp(runningContainers.Containers[0].Address, "api/v1"); + RunningContainer = runningContainer; + http = tools.CreateHttp(RunningContainer.Address, "api/v1"); } - public RunningContainers RunningContainers { get; } + public RunningContainer RunningContainer { get; } public Metrics? GetMostRecent(string metricName, IMetricsScrapeTarget target) { diff --git a/MetricsPlugin/PrometheusStarter.cs b/MetricsPlugin/PrometheusStarter.cs index 57b8b3e..b1b79a1 100644 --- a/MetricsPlugin/PrometheusStarter.cs +++ b/MetricsPlugin/PrometheusStarter.cs @@ -14,7 +14,7 @@ namespace MetricsPlugin this.tools = tools; } - public RunningContainers CollectMetricsFor(IMetricsScrapeTarget[] targets) + public RunningContainer CollectMetricsFor(IMetricsScrapeTarget[] targets) { Log($"Starting metrics server for {targets.Length} targets..."); var startupConfig = new StartupConfig(); @@ -25,10 +25,10 @@ namespace MetricsPlugin if (runningContainers.Containers.Length != 1) throw new InvalidOperationException("Expected only 1 Prometheus container to be created."); Log("Metrics server started."); - return runningContainers; + return runningContainers.Containers.Single(); } - public MetricsAccess CreateAccessForTarget(RunningContainers metricsContainer, IMetricsScrapeTarget target) + public MetricsAccess CreateAccessForTarget(RunningContainer metricsContainer, IMetricsScrapeTarget target) { var metricsQuery = new MetricsQuery(tools, metricsContainer); return new MetricsAccess(metricsQuery, target); From fb7488769d43afe12c92326ae4b3169d26ee0281 Mon Sep 17 00:00:00 2001 From: ThatBen Date: Thu, 14 Sep 2023 15:40:15 +0200 Subject: [PATCH 26/51] Clean up core accessibility --- CodexPlugin/CodexAccess.cs | 3 +-- Core/CoreInterface.cs | 1 + Core/DownloadedLog.cs | 4 ++-- Core/Http.cs | 18 +++++++++++++++--- Core/LogDownloadHandler.cs | 6 +++--- Core/PluginFinder.cs | 4 ++-- Core/PluginManager.cs | 10 +++++----- Core/PluginMetadata.cs | 4 ++-- Core/PluginTools.cs | 10 +++++----- DistTestCore/TestLifecycle.cs | 11 +---------- MetricsPlugin/MetricsQuery.cs | 2 +- 11 files changed, 38 insertions(+), 35 deletions(-) diff --git a/CodexPlugin/CodexAccess.cs b/CodexPlugin/CodexAccess.cs index 40433cf..fa9c00a 100644 --- a/CodexPlugin/CodexAccess.cs +++ b/CodexPlugin/CodexAccess.cs @@ -1,6 +1,5 @@ using Core; using KubernetesWorkflow; -using Utils; namespace CodexPlugin { @@ -83,7 +82,7 @@ namespace CodexPlugin return Container.Name; } - private Http Http() + private IHttp Http() { return tools.CreateHttp(Container.Address, baseUrl: "/api/codex/v1", CheckContainerCrashed, Container.Name); } diff --git a/Core/CoreInterface.cs b/Core/CoreInterface.cs index 8b6b1b4..fca6e62 100644 --- a/Core/CoreInterface.cs +++ b/Core/CoreInterface.cs @@ -25,6 +25,7 @@ namespace Core { var workflow = entryPoint.Tools.CreateWorkflow(); var file = entryPoint.Tools.GetLog().CreateSubfile(); + entryPoint.Tools.GetLog().Log($"Downloading container log for '{container.Name}' to file '{file.FullFilename}'..."); var logHandler = new LogDownloadHandler(container.Name, file); workflow.DownloadContainerLog(container, logHandler, tailLines); return logHandler.DownloadLog(); diff --git a/Core/DownloadedLog.cs b/Core/DownloadedLog.cs index ee31ed7..9242d9b 100644 --- a/Core/DownloadedLog.cs +++ b/Core/DownloadedLog.cs @@ -9,11 +9,11 @@ namespace Core void DeleteFile(); } - public class DownloadedLog : IDownloadedLog + internal class DownloadedLog : IDownloadedLog { private readonly LogFile logFile; - public DownloadedLog(LogFile logFile) + internal DownloadedLog(LogFile logFile) { this.logFile = logFile; } diff --git a/Core/Http.cs b/Core/Http.cs index dbb2f99..5bbd132 100644 --- a/Core/Http.cs +++ b/Core/Http.cs @@ -7,7 +7,19 @@ using Utils; namespace Core { - public class Http + public interface IHttp + { + string HttpGetString(string route); + T HttpGetJson(string route); + TResponse HttpPostJson(string route, TRequest body); + string HttpPostJson(string route, TRequest body); + string HttpPostString(string route, string body); + string HttpPostStream(string route, Stream stream); + Stream HttpGetStream(string route); + T TryJsonDeserialize(string json); + } + + internal class Http : IHttp { private readonly ILog log; private readonly ITimeSet timeSet; @@ -16,12 +28,12 @@ namespace Core private readonly Action onClientCreated; private readonly string? logAlias; - public Http(ILog log, ITimeSet timeSet, Address address, string baseUrl, string? logAlias = null) + internal Http(ILog log, ITimeSet timeSet, Address address, string baseUrl, string? logAlias = null) : this(log, timeSet, address, baseUrl, DoNothing, logAlias) { } - public Http(ILog log, ITimeSet timeSet, Address address, string baseUrl, Action onClientCreated, string? logAlias = null) + internal Http(ILog log, ITimeSet timeSet, Address address, string baseUrl, Action onClientCreated, string? logAlias = null) { this.log = log; this.timeSet = timeSet; diff --git a/Core/LogDownloadHandler.cs b/Core/LogDownloadHandler.cs index ae6a1da..e1736ed 100644 --- a/Core/LogDownloadHandler.cs +++ b/Core/LogDownloadHandler.cs @@ -3,11 +3,11 @@ using Logging; namespace Core { - public class LogDownloadHandler : LogHandler, ILogHandler + internal class LogDownloadHandler : LogHandler, ILogHandler { private readonly LogFile log; - public LogDownloadHandler(string description, LogFile log) + internal LogDownloadHandler(string description, LogFile log) { this.log = log; @@ -15,7 +15,7 @@ namespace Core log.WriteRaw(description); } - public IDownloadedLog DownloadLog() + internal IDownloadedLog DownloadLog() { return new DownloadedLog(log); } diff --git a/Core/PluginFinder.cs b/Core/PluginFinder.cs index 693db86..69df075 100644 --- a/Core/PluginFinder.cs +++ b/Core/PluginFinder.cs @@ -2,11 +2,11 @@ namespace Core { - public static class PluginFinder + internal static class PluginFinder { private static Type[]? pluginTypes = null; - public static Type[] GetPluginTypes() + internal static Type[] GetPluginTypes() { if (pluginTypes != null) return pluginTypes; diff --git a/Core/PluginManager.cs b/Core/PluginManager.cs index 2b24788..69ee4bb 100644 --- a/Core/PluginManager.cs +++ b/Core/PluginManager.cs @@ -1,6 +1,6 @@ namespace Core { - public class PluginManager + internal class PluginManager { private readonly List projectPlugins = new List(); @@ -16,12 +16,12 @@ } } - public void AnnouncePlugins() + internal void AnnouncePlugins() { foreach (var plugin in projectPlugins) plugin.Announce(); } - public PluginMetadata GatherPluginMetadata() + internal PluginMetadata GatherPluginMetadata() { var metadata = new PluginMetadata(); foreach (var plugin in projectPlugins) @@ -34,12 +34,12 @@ return metadata; } - public void DecommissionPlugins() + internal void DecommissionPlugins() { foreach (var plugin in projectPlugins) plugin.Decommission(); } - public T GetPlugin() where T : IProjectPlugin + internal T GetPlugin() where T : IProjectPlugin { return (T)projectPlugins.Single(p => p.GetType() == typeof(T)); } diff --git a/Core/PluginMetadata.cs b/Core/PluginMetadata.cs index c84af76..7bbe1e1 100644 --- a/Core/PluginMetadata.cs +++ b/Core/PluginMetadata.cs @@ -1,6 +1,6 @@ namespace Core { - public interface IPluginMetadata + internal interface IPluginMetadata { Dictionary Get(); } @@ -10,7 +10,7 @@ void Add(string key, string value); } - public class PluginMetadata : IPluginMetadata, IAddMetadata + internal class PluginMetadata : IPluginMetadata, IAddMetadata { private readonly Dictionary metadata = new Dictionary(); diff --git a/Core/PluginTools.cs b/Core/PluginTools.cs index 704588a..dd0360c 100644 --- a/Core/PluginTools.cs +++ b/Core/PluginTools.cs @@ -21,8 +21,8 @@ namespace Core public interface IHttpFactoryTool { - Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null); - Http CreateHttp(Address address, string baseUrl, string? logAlias = null); + IHttp CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null); + IHttp CreateHttp(Address address, string baseUrl, string? logAlias = null); } public interface IFileTool @@ -37,7 +37,7 @@ namespace Core private readonly IFileManager fileManager; private ILog log; - public PluginTools(ILog log, WorkflowCreator workflowCreator, string fileManagerRootFolder, ITimeSet timeSet) + internal PluginTools(ILog log, WorkflowCreator workflowCreator, string fileManagerRootFolder, ITimeSet timeSet) { this.log = log; this.workflowCreator = workflowCreator; @@ -50,12 +50,12 @@ namespace Core log = new LogPrefixer(log, prefix); } - public Http CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) + public IHttp CreateHttp(Address address, string baseUrl, Action onClientCreated, string? logAlias = null) { return new Http(log, timeSet, address, baseUrl, onClientCreated, logAlias); } - public Http CreateHttp(Address address, string baseUrl, string? logAlias = null) + public IHttp CreateHttp(Address address, string baseUrl, string? logAlias = null) { return new Http(log, timeSet, address, baseUrl, logAlias); } diff --git a/DistTestCore/TestLifecycle.cs b/DistTestCore/TestLifecycle.cs index 4723dd2..1ceea34 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/DistTestCore/TestLifecycle.cs @@ -81,22 +81,13 @@ namespace DistTestCore public void DownloadAllLogs() { - var workflow = entryPoint.Tools.CreateWorkflow(); foreach (var rc in runningContainers) { foreach (var c in rc.Containers) { - DownloadContainerLog(workflow, c); + CoreInterface.DownloadLog(c); } } } - - private void DownloadContainerLog(IStartupWorkflow workflow, RunningContainer c) - { - var file = Log.CreateSubfile(); - Log.Log($"Downloading container log for '{c.Name}' to file '{file.FullFilename}'..."); - var handler = new LogDownloadHandler(c.Name, file); - workflow.DownloadContainerLog(c, handler); - } } } diff --git a/MetricsPlugin/MetricsQuery.cs b/MetricsPlugin/MetricsQuery.cs index 425907e..7d63477 100644 --- a/MetricsPlugin/MetricsQuery.cs +++ b/MetricsPlugin/MetricsQuery.cs @@ -6,7 +6,7 @@ namespace MetricsPlugin { public class MetricsQuery { - private readonly Http http; + private readonly IHttp http; public MetricsQuery(IPluginTools tools, RunningContainer runningContainer) { From f7c69d6f24fdca2f6eb0f8a06fd85a701d3698dd Mon Sep 17 00:00:00 2001 From: ThatBen Date: Fri, 15 Sep 2023 12:25:10 +0200 Subject: [PATCH 27/51] Nicer deployment names. --- Core/CoreInterface.cs | 6 ++++++ KubernetesWorkflow/ContainerRecipe.cs | 15 +++++++++++++-- KubernetesWorkflow/ContainerRecipeFactory.cs | 2 +- KubernetesWorkflow/K8sController.cs | 2 +- KubernetesWorkflow/K8sNameUtils.cs | 19 +++++++++++++++++++ KubernetesWorkflow/PodLabels.cs | 15 +-------------- 6 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 KubernetesWorkflow/K8sNameUtils.cs diff --git a/Core/CoreInterface.cs b/Core/CoreInterface.cs index fca6e62..43a66b5 100644 --- a/Core/CoreInterface.cs +++ b/Core/CoreInterface.cs @@ -30,6 +30,12 @@ namespace Core workflow.DownloadContainerLog(container, logHandler, tailLines); return logHandler.DownloadLog(); } + + public string ExecuteContainerCommand(RunningContainer container, string command, params string[] args) + { + var workflow = entryPoint.Tools.CreateWorkflow(); + return workflow.ExecuteCommand(container, command, args); + } } public interface IHasContainer diff --git a/KubernetesWorkflow/ContainerRecipe.cs b/KubernetesWorkflow/ContainerRecipe.cs index 318d3ce..991928f 100644 --- a/KubernetesWorkflow/ContainerRecipe.cs +++ b/KubernetesWorkflow/ContainerRecipe.cs @@ -2,9 +2,10 @@ { public class ContainerRecipe { - public ContainerRecipe(int number, string image, ContainerResources resources, Port[] exposedPorts, Port[] internalPorts, EnvVar[] envVars, PodLabels podLabels, PodAnnotations podAnnotations, VolumeMount[] volumes, object[] additionals) + public ContainerRecipe(int number, string? nameOverride, string image, ContainerResources resources, Port[] exposedPorts, Port[] internalPorts, EnvVar[] envVars, PodLabels podLabels, PodAnnotations podAnnotations, VolumeMount[] volumes, object[] additionals) { Number = number; + NameOverride = nameOverride; Image = image; Resources = resources; ExposedPorts = exposedPorts; @@ -14,10 +15,20 @@ PodAnnotations = podAnnotations; Volumes = volumes; Additionals = additionals; + + if (NameOverride != null) + { + Name = $"{K8sNameUtils.Format(NameOverride)}-{Number}"; + } + else + { + Name = $"ctnr{Number}"; + } } - public string Name { get { return $"ctnr{Number}"; } } + public string Name { get; } public int Number { get; } + public string? NameOverride { get; } public ContainerResources Resources { get; } public string Image { get; } public Port[] ExposedPorts { get; } diff --git a/KubernetesWorkflow/ContainerRecipeFactory.cs b/KubernetesWorkflow/ContainerRecipeFactory.cs index f8c88ef..c8d26f7 100644 --- a/KubernetesWorkflow/ContainerRecipeFactory.cs +++ b/KubernetesWorkflow/ContainerRecipeFactory.cs @@ -21,7 +21,7 @@ namespace KubernetesWorkflow Initialize(config); - var recipe = new ContainerRecipe(containerNumber, Image, Resources, + var recipe = new ContainerRecipe(containerNumber, config.NameOverride, Image, Resources, exposedPorts.ToArray(), internalPorts.ToArray(), envVars.ToArray(), diff --git a/KubernetesWorkflow/K8sController.cs b/KubernetesWorkflow/K8sController.cs index ff53f66..a3a69fd 100644 --- a/KubernetesWorkflow/K8sController.cs +++ b/KubernetesWorkflow/K8sController.cs @@ -399,7 +399,7 @@ namespace KubernetesWorkflow { return new V1ObjectMeta { - Name = "deploy-" + workflowNumberSource.WorkflowNumber, + Name = string.Join('-',containerRecipes.Select(r => r.Name)), NamespaceProperty = K8sNamespace, Labels = GetSelector(containerRecipes), Annotations = GetAnnotations(containerRecipes) diff --git a/KubernetesWorkflow/K8sNameUtils.cs b/KubernetesWorkflow/K8sNameUtils.cs new file mode 100644 index 0000000..c888870 --- /dev/null +++ b/KubernetesWorkflow/K8sNameUtils.cs @@ -0,0 +1,19 @@ +namespace KubernetesWorkflow +{ + public static class K8sNameUtils + { + public static string Format(string s) + { + var result = s.ToLowerInvariant() + .Replace(" ", "-") + .Replace(":", "-") + .Replace("/", "-") + .Replace("\\", "-") + .Replace("[", "-") + .Replace("]", "-") + .Replace(",", "-"); + + return result.Trim('-'); + } + } +} diff --git a/KubernetesWorkflow/PodLabels.cs b/KubernetesWorkflow/PodLabels.cs index 78aa518..d8b7333 100644 --- a/KubernetesWorkflow/PodLabels.cs +++ b/KubernetesWorkflow/PodLabels.cs @@ -6,7 +6,7 @@ public void Add(string key, string value) { - labels.Add(key, Format(value)); + labels.Add(key, K8sNameUtils.Format(value)); } public PodLabels Clone() @@ -21,19 +21,6 @@ labels.Clear(); } - private static string Format(string s) - { - var result = s.ToLowerInvariant() - .Replace(":", "-") - .Replace("/", "-") - .Replace("\\", "-") - .Replace("[", "-") - .Replace("]", "-") - .Replace(",", "-"); - - return result.Trim('-'); - } - internal Dictionary GetLabels() { return labels; From f2a8c123a519e565fad7bb526af3950c73e2da09 Mon Sep 17 00:00:00 2001 From: ThatBen Date: Fri, 15 Sep 2023 12:36:35 +0200 Subject: [PATCH 28/51] Setting up geth plugin --- CodexPlugin/CodexPlugin.csproj | 1 + CodexPlugin/GethStarter.cs | 88 ------------------- .../Marketplace/GethBootstrapNodeInfo.cs | 42 --------- .../Marketplace/GethBootstrapNodeStarter.cs | 40 --------- .../Marketplace/GethCompanionNodeInfo.cs | 38 -------- .../Marketplace/GethCompanionNodeStarter.cs | 77 ---------------- .../Marketplace/GethContainerRecipe.cs | 73 --------------- CodexPlugin/Marketplace/GethStartResult.cs | 19 ---- CodexPlugin/Marketplace/GethStartupConfig.cs | 18 ---- Core/CoreInterface.cs | 5 ++ GethPlugin/CoreInterfaceExtensions.cs | 44 ++++++++++ GethPlugin/GethBootstrapNodeInfo.cs | 42 +++++++++ GethPlugin/GethBootstrapNodeStarter.cs | 35 ++++++++ GethPlugin/GethCompanionNodeInfo.cs | 38 ++++++++ GethPlugin/GethCompanionNodeStarter.cs | 72 +++++++++++++++ GethPlugin/GethContainerRecipe.cs | 73 +++++++++++++++ GethPlugin/GethPlugin.cs | 45 ++++++++++ GethPlugin/GethPlugin.csproj | 15 ++++ GethPlugin/GethStartResult.cs | 19 ++++ GethPlugin/GethStarter.cs | 86 ++++++++++++++++++ GethPlugin/GethStartupConfig.cs | 18 ++++ cs-codex-dist-testing.sln | 8 +- 22 files changed, 500 insertions(+), 396 deletions(-) delete mode 100644 CodexPlugin/GethStarter.cs delete mode 100644 CodexPlugin/Marketplace/GethBootstrapNodeInfo.cs delete mode 100644 CodexPlugin/Marketplace/GethBootstrapNodeStarter.cs delete mode 100644 CodexPlugin/Marketplace/GethCompanionNodeInfo.cs delete mode 100644 CodexPlugin/Marketplace/GethCompanionNodeStarter.cs delete mode 100644 CodexPlugin/Marketplace/GethContainerRecipe.cs delete mode 100644 CodexPlugin/Marketplace/GethStartResult.cs delete mode 100644 CodexPlugin/Marketplace/GethStartupConfig.cs create mode 100644 GethPlugin/CoreInterfaceExtensions.cs create mode 100644 GethPlugin/GethBootstrapNodeInfo.cs create mode 100644 GethPlugin/GethBootstrapNodeStarter.cs create mode 100644 GethPlugin/GethCompanionNodeInfo.cs create mode 100644 GethPlugin/GethCompanionNodeStarter.cs create mode 100644 GethPlugin/GethContainerRecipe.cs create mode 100644 GethPlugin/GethPlugin.cs create mode 100644 GethPlugin/GethPlugin.csproj create mode 100644 GethPlugin/GethStartResult.cs create mode 100644 GethPlugin/GethStarter.cs create mode 100644 GethPlugin/GethStartupConfig.cs diff --git a/CodexPlugin/CodexPlugin.csproj b/CodexPlugin/CodexPlugin.csproj index 4619e26..a00f24f 100644 --- a/CodexPlugin/CodexPlugin.csproj +++ b/CodexPlugin/CodexPlugin.csproj @@ -14,6 +14,7 @@ + diff --git a/CodexPlugin/GethStarter.cs b/CodexPlugin/GethStarter.cs deleted file mode 100644 index f6381b3..0000000 --- a/CodexPlugin/GethStarter.cs +++ /dev/null @@ -1,88 +0,0 @@ -//using DistTestCore.Marketplace; - -//namespace CodexPlugin -//{ -// public class GethStarter : BaseStarter -// { -// private readonly MarketplaceNetworkCache marketplaceNetworkCache; -// private readonly GethCompanionNodeStarter companionNodeStarter; - -// public GethStarter(TestLifecycle lifecycle) -// : base(lifecycle) -// { -// marketplaceNetworkCache = new MarketplaceNetworkCache( -// new GethBootstrapNodeStarter(lifecycle), -// new CodexContractsStarter(lifecycle)); -// companionNodeStarter = new GethCompanionNodeStarter(lifecycle); -// } - -// public GethStartResult BringOnlineMarketplaceFor(CodexSetup codexSetup) -// { -// if (codexSetup.MarketplaceConfig == null) return CreateMarketplaceUnavailableResult(); - -// var marketplaceNetwork = marketplaceNetworkCache.Get(); -// var companionNode = StartCompanionNode(codexSetup, marketplaceNetwork); - -// LogStart("Setting up initial balance..."); -// TransferInitialBalance(marketplaceNetwork, codexSetup.MarketplaceConfig, companionNode); -// LogEnd($"Initial balance of {codexSetup.MarketplaceConfig.InitialTestTokens} set for {codexSetup.NumberOfNodes} nodes."); - -// return CreateGethStartResult(marketplaceNetwork, companionNode); -// } - -// private void TransferInitialBalance(MarketplaceNetwork marketplaceNetwork, MarketplaceInitialConfig marketplaceConfig, GethCompanionNodeInfo companionNode) -// { -// if (marketplaceConfig.InitialTestTokens.Amount == 0) return; - -// var interaction = marketplaceNetwork.StartInteraction(lifecycle); -// var tokenAddress = marketplaceNetwork.Marketplace.TokenAddress; - -// var accounts = companionNode.Accounts.Select(a => a.Account).ToArray(); -// interaction.MintTestTokens(accounts, marketplaceConfig.InitialTestTokens.Amount, tokenAddress); -// } - -// private GethStartResult CreateGethStartResult(MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) -// { -// return new GethStartResult(CreateMarketplaceAccessFactory(marketplaceNetwork), marketplaceNetwork, companionNode); -// } - -// private GethStartResult CreateMarketplaceUnavailableResult() -// { -// return new GethStartResult(new MarketplaceUnavailableAccessFactory(), null!, null!); -// } - -// private IMarketplaceAccessFactory CreateMarketplaceAccessFactory(MarketplaceNetwork marketplaceNetwork) -// { -// return new GethMarketplaceAccessFactory(lifecycle, marketplaceNetwork); -// } - -// private GethCompanionNodeInfo StartCompanionNode(CodexSetup codexSetup, MarketplaceNetwork marketplaceNetwork) -// { -// return companionNodeStarter.StartCompanionNodeFor(codexSetup, marketplaceNetwork); -// } -// } - -// public class MarketplaceNetworkCache -// { -// private readonly GethBootstrapNodeStarter bootstrapNodeStarter; -// private readonly CodexContractsStarter codexContractsStarter; -// private MarketplaceNetwork? network; - -// public MarketplaceNetworkCache(GethBootstrapNodeStarter bootstrapNodeStarter, CodexContractsStarter codexContractsStarter) -// { -// this.bootstrapNodeStarter = bootstrapNodeStarter; -// this.codexContractsStarter = codexContractsStarter; -// } - -// public MarketplaceNetwork Get() -// { -// if (network == null) -// { -// var bootstrapInfo = bootstrapNodeStarter.StartGethBootstrapNode(); -// var marketplaceInfo = codexContractsStarter.Start(bootstrapInfo); -// network = new MarketplaceNetwork(bootstrapInfo, marketplaceInfo ); -// } -// return network; -// } -// } -//} diff --git a/CodexPlugin/Marketplace/GethBootstrapNodeInfo.cs b/CodexPlugin/Marketplace/GethBootstrapNodeInfo.cs deleted file mode 100644 index 3e84dec..0000000 --- a/CodexPlugin/Marketplace/GethBootstrapNodeInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -//using KubernetesWorkflow; -//using NethereumWorkflow; - -//namespace DistTestCore.Marketplace -//{ -// public class GethBootstrapNodeInfo -// { -// public GethBootstrapNodeInfo(RunningContainers runningContainers, AllGethAccounts allAccounts, string pubKey, Port discoveryPort) -// { -// RunningContainers = runningContainers; -// AllAccounts = allAccounts; -// Account = allAccounts.Accounts[0]; -// PubKey = pubKey; -// DiscoveryPort = discoveryPort; -// } - -// public RunningContainers RunningContainers { get; } -// public AllGethAccounts AllAccounts { get; } -// public GethAccount Account { get; } -// public string PubKey { get; } -// public Port DiscoveryPort { get; } - -// public NethereumInteraction StartInteraction(TestLifecycle lifecycle) -// { -// var address = lifecycle.Configuration.GetAddress(RunningContainers.Containers[0]); -// var account = Account; - -// var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, account.PrivateKey); -// return creator.CreateWorkflow(); -// } -// } - -// public class AllGethAccounts -// { -// public GethAccount[] Accounts { get; } - -// public AllGethAccounts(GethAccount[] accounts) -// { -// Accounts = accounts; -// } -// } -//} diff --git a/CodexPlugin/Marketplace/GethBootstrapNodeStarter.cs b/CodexPlugin/Marketplace/GethBootstrapNodeStarter.cs deleted file mode 100644 index b94d041..0000000 --- a/CodexPlugin/Marketplace/GethBootstrapNodeStarter.cs +++ /dev/null @@ -1,40 +0,0 @@ -//using KubernetesWorkflow; - -//namespace DistTestCore.Marketplace -//{ -// public class GethBootstrapNodeStarter : BaseStarter -// { -// public GethBootstrapNodeStarter(TestLifecycle lifecycle) -// : base(lifecycle) -// { -// } - -// public GethBootstrapNodeInfo StartGethBootstrapNode() -// { -// LogStart("Starting Geth bootstrap node..."); -// var startupConfig = CreateBootstrapStartupConfig(); - -// var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); -// var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), startupConfig); -// if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Geth bootstrap node to be created. Test infra failure."); -// var bootstrapContainer = containers.Containers[0]; - -// var extractor = new ContainerInfoExtractor(lifecycle.Log, workflow, bootstrapContainer); -// var accounts = extractor.ExtractAccounts(); -// var pubKey = extractor.ExtractPubKey(); -// var discoveryPort = bootstrapContainer.Recipe.GetPortByTag(GethContainerRecipe.DiscoveryPortTag); -// var result = new GethBootstrapNodeInfo(containers, accounts, pubKey, discoveryPort); - -// LogEnd($"Geth bootstrap node started with account '{result.Account.Account}'"); - -// return result; -// } - -// private StartupConfig CreateBootstrapStartupConfig() -// { -// var config = new StartupConfig(); -// config.Add(new GethStartupConfig(true, null!, 0, 0)); -// return config; -// } -// } -//} diff --git a/CodexPlugin/Marketplace/GethCompanionNodeInfo.cs b/CodexPlugin/Marketplace/GethCompanionNodeInfo.cs deleted file mode 100644 index 30f2e78..0000000 --- a/CodexPlugin/Marketplace/GethCompanionNodeInfo.cs +++ /dev/null @@ -1,38 +0,0 @@ -//using KubernetesWorkflow; -//using NethereumWorkflow; - -//namespace DistTestCore.Marketplace -//{ -// public class GethCompanionNodeInfo -// { -// public GethCompanionNodeInfo(RunningContainer runningContainer, GethAccount[] accounts) -// { -// RunningContainer = runningContainer; -// Accounts = accounts; -// } - -// public RunningContainer RunningContainer { get; } -// public GethAccount[] Accounts { get; } - -// public NethereumInteraction StartInteraction(TestLifecycle lifecycle, GethAccount account) -// { -// var address = lifecycle.Configuration.GetAddress(RunningContainer); -// var privateKey = account.PrivateKey; - -// var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, privateKey); -// return creator.CreateWorkflow(); -// } -// } - -// public class GethAccount -// { -// public GethAccount(string account, string privateKey) -// { -// Account = account; -// PrivateKey = privateKey; -// } - -// public string Account { get; } -// public string PrivateKey { get; } -// } -//} diff --git a/CodexPlugin/Marketplace/GethCompanionNodeStarter.cs b/CodexPlugin/Marketplace/GethCompanionNodeStarter.cs deleted file mode 100644 index 9c8a303..0000000 --- a/CodexPlugin/Marketplace/GethCompanionNodeStarter.cs +++ /dev/null @@ -1,77 +0,0 @@ -//using KubernetesWorkflow; -//using Utils; - -//namespace DistTestCore.Marketplace -//{ -// public class GethCompanionNodeStarter : BaseStarter -// { -// private int companionAccountIndex = 0; - -// public GethCompanionNodeStarter(TestLifecycle lifecycle) -// : base(lifecycle) -// { -// } - -// public GethCompanionNodeInfo StartCompanionNodeFor(CodexSetup codexSetup, MarketplaceNetwork marketplace) -// { -// LogStart($"Initializing companion for {codexSetup.NumberOfNodes} Codex nodes."); - -// var config = CreateCompanionNodeStartupConfig(marketplace.Bootstrap, codexSetup.NumberOfNodes); - -// var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); -// var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), CreateStartupConfig(config)); -// if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected one Geth companion node to be created. Test infra failure."); -// var container = containers.Containers[0]; - -// var node = CreateCompanionInfo(container, marketplace, config); -// EnsureCompanionNodeIsSynced(node, marketplace); - -// LogEnd($"Initialized one companion node for {codexSetup.NumberOfNodes} Codex nodes. Their accounts: [{string.Join(",", node.Accounts.Select(a => a.Account))}]"); -// return node; -// } - -// private GethCompanionNodeInfo CreateCompanionInfo(RunningContainer container, MarketplaceNetwork marketplace, GethStartupConfig config) -// { -// var accounts = ExtractAccounts(marketplace, config); -// return new GethCompanionNodeInfo(container, accounts); -// } - -// private static GethAccount[] ExtractAccounts(MarketplaceNetwork marketplace, GethStartupConfig config) -// { -// return marketplace.Bootstrap.AllAccounts.Accounts -// .Skip(1 + config.CompanionAccountStartIndex) -// .Take(config.NumberOfCompanionAccounts) -// .ToArray(); -// } - -// private void EnsureCompanionNodeIsSynced(GethCompanionNodeInfo node, MarketplaceNetwork marketplace) -// { -// try -// { -// Time.WaitUntil(() => -// { -// var interaction = node.StartInteraction(lifecycle, node.Accounts.First()); -// return interaction.IsSynced(marketplace.Marketplace.Address, marketplace.Marketplace.Abi); -// }, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(3)); -// } -// catch (Exception e) -// { -// throw new Exception("Geth companion node did not sync within timeout. Test infra failure.", e); -// } -// } - -// private GethStartupConfig CreateCompanionNodeStartupConfig(GethBootstrapNodeInfo bootstrapNode, int numberOfAccounts) -// { -// var config = new GethStartupConfig(false, bootstrapNode, companionAccountIndex, numberOfAccounts); -// companionAccountIndex += numberOfAccounts; -// return config; -// } - -// private StartupConfig CreateStartupConfig(GethStartupConfig gethConfig) -// { -// var config = new StartupConfig(); -// config.Add(gethConfig); -// return config; -// } -// } -//} diff --git a/CodexPlugin/Marketplace/GethContainerRecipe.cs b/CodexPlugin/Marketplace/GethContainerRecipe.cs deleted file mode 100644 index e5b2f9b..0000000 --- a/CodexPlugin/Marketplace/GethContainerRecipe.cs +++ /dev/null @@ -1,73 +0,0 @@ -//using KubernetesWorkflow; - -//namespace DistTestCore.Marketplace -//{ -// public class GethContainerRecipe : DefaultContainerRecipe -// { -// private const string defaultArgs = "--ipcdisable --syncmode full"; - -// public const string HttpPortTag = "http_port"; -// public const string DiscoveryPortTag = "disc_port"; -// public const string AccountsFilename = "accounts.csv"; - -// public override string AppName => "geth"; -// public override string Image => "codexstorage/dist-tests-geth:latest"; - -// protected override void InitializeRecipe(StartupConfig startupConfig) -// { -// var config = startupConfig.Get(); - -// var args = CreateArgs(config); - -// AddEnvVar("GETH_ARGS", args); -// } - -// private string CreateArgs(GethStartupConfig config) -// { -// var discovery = AddInternalPort(tag: DiscoveryPortTag); - -// if (config.IsBootstrapNode) -// { -// return CreateBootstapArgs(discovery); -// } - -// return CreateCompanionArgs(discovery, config); -// } - -// private string CreateBootstapArgs(Port discovery) -// { -// AddEnvVar("ENABLE_MINER", "1"); -// UnlockAccounts(0, 1); -// var exposedPort = AddExposedPort(tag: HttpPortTag); -// return $"--http.port {exposedPort.Number} --port {discovery.Number} --discovery.port {discovery.Number} {defaultArgs}"; -// } - -// private string CreateCompanionArgs(Port discovery, GethStartupConfig config) -// { -// UnlockAccounts( -// config.CompanionAccountStartIndex + 1, -// config.NumberOfCompanionAccounts); - -// var port = AddInternalPort(); -// var authRpc = AddInternalPort(); -// var httpPort = AddExposedPort(tag: HttpPortTag); - -// var bootPubKey = config.BootstrapNode.PubKey; -// var bootIp = config.BootstrapNode.RunningContainers.Containers[0].Pod.PodInfo.Ip; -// var bootPort = config.BootstrapNode.DiscoveryPort.Number; -// var bootstrapArg = $"--bootnodes enode://{bootPubKey}@{bootIp}:{bootPort} --nat=extip:{bootIp}"; - -// return $"--port {port.Number} --discovery.port {discovery.Number} --authrpc.port {authRpc.Number} --http.addr 0.0.0.0 --http.port {httpPort.Number} --ws --ws.addr 0.0.0.0 --ws.port {httpPort.Number} {bootstrapArg} {defaultArgs}"; -// } - -// private void UnlockAccounts(int startIndex, int numberOfAccounts) -// { -// if (startIndex < 0) throw new ArgumentException(); -// if (numberOfAccounts < 1) throw new ArgumentException(); -// if (startIndex + numberOfAccounts > 1000) throw new ArgumentException("Out of accounts!"); - -// AddEnvVar("UNLOCK_START_INDEX", startIndex.ToString()); -// AddEnvVar("UNLOCK_NUMBER", numberOfAccounts.ToString()); -// } -// } -//} diff --git a/CodexPlugin/Marketplace/GethStartResult.cs b/CodexPlugin/Marketplace/GethStartResult.cs deleted file mode 100644 index f9e1048..0000000 --- a/CodexPlugin/Marketplace/GethStartResult.cs +++ /dev/null @@ -1,19 +0,0 @@ -//using Newtonsoft.Json; - -//namespace DistTestCore.Marketplace -//{ -// public class GethStartResult -// { -// public GethStartResult(IMarketplaceAccessFactory marketplaceAccessFactory, MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) -// { -// MarketplaceAccessFactory = marketplaceAccessFactory; -// MarketplaceNetwork = marketplaceNetwork; -// CompanionNode = companionNode; -// } - -// [JsonIgnore] -// public IMarketplaceAccessFactory MarketplaceAccessFactory { get; } -// public MarketplaceNetwork MarketplaceNetwork { get; } -// public GethCompanionNodeInfo CompanionNode { get; } -// } -//} diff --git a/CodexPlugin/Marketplace/GethStartupConfig.cs b/CodexPlugin/Marketplace/GethStartupConfig.cs deleted file mode 100644 index 67ad0d5..0000000 --- a/CodexPlugin/Marketplace/GethStartupConfig.cs +++ /dev/null @@ -1,18 +0,0 @@ -//namespace DistTestCore.Marketplace -//{ -// public class GethStartupConfig -// { -// public GethStartupConfig(bool isBootstrapNode, GethBootstrapNodeInfo bootstrapNode, int companionAccountStartIndex, int numberOfCompanionAccounts) -// { -// IsBootstrapNode = isBootstrapNode; -// BootstrapNode = bootstrapNode; -// CompanionAccountStartIndex = companionAccountStartIndex; -// NumberOfCompanionAccounts = numberOfCompanionAccounts; -// } - -// public bool IsBootstrapNode { get; } -// public GethBootstrapNodeInfo BootstrapNode { get; } -// public int CompanionAccountStartIndex { get; } -// public int NumberOfCompanionAccounts { get; } -// } -//} diff --git a/Core/CoreInterface.cs b/Core/CoreInterface.cs index 43a66b5..83ea593 100644 --- a/Core/CoreInterface.cs +++ b/Core/CoreInterface.cs @@ -31,6 +31,11 @@ namespace Core return logHandler.DownloadLog(); } + public string ExecuteContainerCommand(IHasContainer containerSource, string command, params string[] args) + { + return ExecuteContainerCommand(containerSource.Container, command, args); + } + public string ExecuteContainerCommand(RunningContainer container, string command, params string[] args) { var workflow = entryPoint.Tools.CreateWorkflow(); diff --git a/GethPlugin/CoreInterfaceExtensions.cs b/GethPlugin/CoreInterfaceExtensions.cs new file mode 100644 index 0000000..6625d58 --- /dev/null +++ b/GethPlugin/CoreInterfaceExtensions.cs @@ -0,0 +1,44 @@ +using Core; +using KubernetesWorkflow; + +namespace GethPlugin +{ + public static class CoreInterfaceExtensions + { + //public static RunningContainers[] StartCodexNodes(this CoreInterface ci, int number, Action setup) + //{ + // return Plugin(ci).StartCodexNodes(number, setup); + //} + + //public static ICodexNodeGroup WrapCodexContainers(this CoreInterface ci, RunningContainers[] containers) + //{ + // return Plugin(ci).WrapCodexContainers(containers); + //} + + //public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci) + //{ + // return ci.SetupCodexNodes(1)[0]; + //} + + //public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci, Action setup) + //{ + // return ci.SetupCodexNodes(1, setup)[0]; + //} + + //public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number, Action setup) + //{ + // var rc = ci.StartCodexNodes(number, setup); + // return ci.WrapCodexContainers(rc); + //} + + //public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number) + //{ + // return ci.SetupCodexNodes(number, s => { }); + //} + + //private static CodexPlugin Plugin(CoreInterface ci) + //{ + // return ci.GetPlugin(); + //} + } +} diff --git a/GethPlugin/GethBootstrapNodeInfo.cs b/GethPlugin/GethBootstrapNodeInfo.cs new file mode 100644 index 0000000..8dffbc9 --- /dev/null +++ b/GethPlugin/GethBootstrapNodeInfo.cs @@ -0,0 +1,42 @@ +using KubernetesWorkflow; +using NethereumWorkflow; + +namespace GethPlugin +{ + public class GethBootstrapNodeInfo + { + public GethBootstrapNodeInfo(RunningContainers runningContainers, AllGethAccounts allAccounts, string pubKey, Port discoveryPort) + { + RunningContainers = runningContainers; + AllAccounts = allAccounts; + Account = allAccounts.Accounts[0]; + PubKey = pubKey; + DiscoveryPort = discoveryPort; + } + + public RunningContainers RunningContainers { get; } + public AllGethAccounts AllAccounts { get; } + public GethAccount Account { get; } + public string PubKey { get; } + public Port DiscoveryPort { get; } + + public NethereumInteraction StartInteraction(TestLifecycle lifecycle) + { + var address = lifecycle.Configuration.GetAddress(RunningContainers.Containers[0]); + var account = Account; + + var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, account.PrivateKey); + return creator.CreateWorkflow(); + } + } + + public class AllGethAccounts + { + public GethAccount[] Accounts { get; } + + public AllGethAccounts(GethAccount[] accounts) + { + Accounts = accounts; + } + } +} diff --git a/GethPlugin/GethBootstrapNodeStarter.cs b/GethPlugin/GethBootstrapNodeStarter.cs new file mode 100644 index 0000000..b18292a --- /dev/null +++ b/GethPlugin/GethBootstrapNodeStarter.cs @@ -0,0 +1,35 @@ +using KubernetesWorkflow; + +namespace GethPlugin +{ + public class GethBootstrapNodeStarter + { + public GethBootstrapNodeInfo StartGethBootstrapNode() + { + LogStart("Starting Geth bootstrap node..."); + var startupConfig = CreateBootstrapStartupConfig(); + + var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); + var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), startupConfig); + if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Geth bootstrap node to be created. Test infra failure."); + var bootstrapContainer = containers.Containers[0]; + + var extractor = new ContainerInfoExtractor(lifecycle.Log, workflow, bootstrapContainer); + var accounts = extractor.ExtractAccounts(); + var pubKey = extractor.ExtractPubKey(); + var discoveryPort = bootstrapContainer.Recipe.GetPortByTag(GethContainerRecipe.DiscoveryPortTag); + var result = new GethBootstrapNodeInfo(containers, accounts, pubKey, discoveryPort); + + LogEnd($"Geth bootstrap node started with account '{result.Account.Account}'"); + + return result; + } + + private StartupConfig CreateBootstrapStartupConfig() + { + var config = new StartupConfig(); + config.Add(new GethStartupConfig(true, null!, 0, 0)); + return config; + } + } +} diff --git a/GethPlugin/GethCompanionNodeInfo.cs b/GethPlugin/GethCompanionNodeInfo.cs new file mode 100644 index 0000000..313271c --- /dev/null +++ b/GethPlugin/GethCompanionNodeInfo.cs @@ -0,0 +1,38 @@ +using KubernetesWorkflow; +using NethereumWorkflow; + +namespace GethPlugin +{ + public class GethCompanionNodeInfo + { + public GethCompanionNodeInfo(RunningContainer runningContainer, GethAccount[] accounts) + { + RunningContainer = runningContainer; + Accounts = accounts; + } + + public RunningContainer RunningContainer { get; } + public GethAccount[] Accounts { get; } + + public NethereumInteraction StartInteraction(TestLifecycle lifecycle, GethAccount account) + { + var address = lifecycle.Configuration.GetAddress(RunningContainer); + var privateKey = account.PrivateKey; + + var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, privateKey); + return creator.CreateWorkflow(); + } + } + + public class GethAccount + { + public GethAccount(string account, string privateKey) + { + Account = account; + PrivateKey = privateKey; + } + + public string Account { get; } + public string PrivateKey { get; } + } +} diff --git a/GethPlugin/GethCompanionNodeStarter.cs b/GethPlugin/GethCompanionNodeStarter.cs new file mode 100644 index 0000000..998e8f2 --- /dev/null +++ b/GethPlugin/GethCompanionNodeStarter.cs @@ -0,0 +1,72 @@ +using KubernetesWorkflow; +using Utils; + +namespace GethPlugin +{ + public class GethCompanionNodeStarter + { + private int companionAccountIndex = 0; + + public GethCompanionNodeInfo StartCompanionNodeFor(CodexSetup codexSetup, MarketplaceNetwork marketplace) + { + LogStart($"Initializing companion for {codexSetup.NumberOfNodes} Codex nodes."); + + var config = CreateCompanionNodeStartupConfig(marketplace.Bootstrap, codexSetup.NumberOfNodes); + + var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); + var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), CreateStartupConfig(config)); + if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected one Geth companion node to be created. Test infra failure."); + var container = containers.Containers[0]; + + var node = CreateCompanionInfo(container, marketplace, config); + EnsureCompanionNodeIsSynced(node, marketplace); + + LogEnd($"Initialized one companion node for {codexSetup.NumberOfNodes} Codex nodes. Their accounts: [{string.Join(",", node.Accounts.Select(a => a.Account))}]"); + return node; + } + + private GethCompanionNodeInfo CreateCompanionInfo(RunningContainer container, MarketplaceNetwork marketplace, GethStartupConfig config) + { + var accounts = ExtractAccounts(marketplace, config); + return new GethCompanionNodeInfo(container, accounts); + } + + private static GethAccount[] ExtractAccounts(MarketplaceNetwork marketplace, GethStartupConfig config) + { + return marketplace.Bootstrap.AllAccounts.Accounts + .Skip(1 + config.CompanionAccountStartIndex) + .Take(config.NumberOfCompanionAccounts) + .ToArray(); + } + + private void EnsureCompanionNodeIsSynced(GethCompanionNodeInfo node, MarketplaceNetwork marketplace) + { + try + { + Time.WaitUntil(() => + { + var interaction = node.StartInteraction(lifecycle, node.Accounts.First()); + return interaction.IsSynced(marketplace.Marketplace.Address, marketplace.Marketplace.Abi); + }, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(3)); + } + catch (Exception e) + { + throw new Exception("Geth companion node did not sync within timeout. Test infra failure.", e); + } + } + + private GethStartupConfig CreateCompanionNodeStartupConfig(GethBootstrapNodeInfo bootstrapNode, int numberOfAccounts) + { + var config = new GethStartupConfig(false, bootstrapNode, companionAccountIndex, numberOfAccounts); + companionAccountIndex += numberOfAccounts; + return config; + } + + private StartupConfig CreateStartupConfig(GethStartupConfig gethConfig) + { + var config = new StartupConfig(); + config.Add(gethConfig); + return config; + } + } +} diff --git a/GethPlugin/GethContainerRecipe.cs b/GethPlugin/GethContainerRecipe.cs new file mode 100644 index 0000000..1c1b2e1 --- /dev/null +++ b/GethPlugin/GethContainerRecipe.cs @@ -0,0 +1,73 @@ +using KubernetesWorkflow; + +namespace GethPlugin +{ + public class GethContainerRecipe : ContainerRecipeFactory + { + private const string defaultArgs = "--ipcdisable --syncmode full"; + + public const string HttpPortTag = "http_port"; + public const string DiscoveryPortTag = "disc_port"; + public const string AccountsFilename = "accounts.csv"; + + public override string AppName => "geth"; + public override string Image => "codexstorage/dist-tests-geth:latest"; + + protected override void Initialize(StartupConfig startupConfig) + { + var config = startupConfig.Get(); + + var args = CreateArgs(config); + + AddEnvVar("GETH_ARGS", args); + } + + private string CreateArgs(GethStartupConfig config) + { + var discovery = AddInternalPort(tag: DiscoveryPortTag); + + if (config.IsBootstrapNode) + { + return CreateBootstapArgs(discovery); + } + + return CreateCompanionArgs(discovery, config); + } + + private string CreateBootstapArgs(Port discovery) + { + AddEnvVar("ENABLE_MINER", "1"); + UnlockAccounts(0, 1); + var exposedPort = AddExposedPort(tag: HttpPortTag); + return $"--http.port {exposedPort.Number} --port {discovery.Number} --discovery.port {discovery.Number} {defaultArgs}"; + } + + private string CreateCompanionArgs(Port discovery, GethStartupConfig config) + { + UnlockAccounts( + config.CompanionAccountStartIndex + 1, + config.NumberOfCompanionAccounts); + + var port = AddInternalPort(); + var authRpc = AddInternalPort(); + var httpPort = AddExposedPort(tag: HttpPortTag); + + var bootPubKey = config.BootstrapNode.PubKey; + var bootIp = config.BootstrapNode.RunningContainers.Containers[0].Pod.PodInfo.Ip; + var bootPort = config.BootstrapNode.DiscoveryPort.Number; + var bootstrapArg = $"--bootnodes enode://{bootPubKey}@{bootIp}:{bootPort} --nat=extip:{bootIp}"; + + return $"--port {port.Number} --discovery.port {discovery.Number} --authrpc.port {authRpc.Number} --http.addr 0.0.0.0 --http.port {httpPort.Number} --ws --ws.addr 0.0.0.0 --ws.port {httpPort.Number} {bootstrapArg} {defaultArgs}"; + } + + private void UnlockAccounts(int startIndex, int numberOfAccounts) + { + if (startIndex < 0) throw new ArgumentException(); + if (numberOfAccounts < 1) throw new ArgumentException(); + if (startIndex + numberOfAccounts > 1000) throw new ArgumentException("Out of accounts!"); + + AddEnvVar("UNLOCK_START_INDEX", startIndex.ToString()); + AddEnvVar("UNLOCK_NUMBER", numberOfAccounts.ToString()); + } + } +} diff --git a/GethPlugin/GethPlugin.cs b/GethPlugin/GethPlugin.cs new file mode 100644 index 0000000..fcd15c2 --- /dev/null +++ b/GethPlugin/GethPlugin.cs @@ -0,0 +1,45 @@ +using Core; +using KubernetesWorkflow; + +namespace GethPlugin +{ + public class GethPlugin : IProjectPlugin, IHasLogPrefix, IHasMetadata + { + private readonly IPluginTools tools; + + public GethPlugin(IPluginTools tools) + { + //codexStarter = new CodexStarter(tools); + this.tools = tools; + } + + public string LogPrefix => "(Geth) "; + + public void Announce() + { + //tools.GetLog().Log($"Loaded with Codex ID: '{codexStarter.GetCodexId()}'"); + } + + public void AddMetadata(IAddMetadata metadata) + { + //metadata.Add("codexid", codexStarter.GetCodexId()); + } + + public void Decommission() + { + } + + //public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) + //{ + // var codexSetup = new CodexSetup(numberOfNodes); + // codexSetup.LogLevel = defaultLogLevel; + // setup(codexSetup); + // return codexStarter.BringOnline(codexSetup); + //} + + //public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) + //{ + // return codexStarter.WrapCodexContainers(containers); + //} + } +} diff --git a/GethPlugin/GethPlugin.csproj b/GethPlugin/GethPlugin.csproj new file mode 100644 index 0000000..3b52869 --- /dev/null +++ b/GethPlugin/GethPlugin.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + enable + enable + + + + + + + + + diff --git a/GethPlugin/GethStartResult.cs b/GethPlugin/GethStartResult.cs new file mode 100644 index 0000000..c098870 --- /dev/null +++ b/GethPlugin/GethStartResult.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace GethPlugin +{ + public class GethStartResult + { + public GethStartResult(IMarketplaceAccessFactory marketplaceAccessFactory, MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) + { + MarketplaceAccessFactory = marketplaceAccessFactory; + MarketplaceNetwork = marketplaceNetwork; + CompanionNode = companionNode; + } + + [JsonIgnore] + public IMarketplaceAccessFactory MarketplaceAccessFactory { get; } + public MarketplaceNetwork MarketplaceNetwork { get; } + public GethCompanionNodeInfo CompanionNode { get; } + } +} diff --git a/GethPlugin/GethStarter.cs b/GethPlugin/GethStarter.cs new file mode 100644 index 0000000..f9e2162 --- /dev/null +++ b/GethPlugin/GethStarter.cs @@ -0,0 +1,86 @@ +namespace CodexPlugin +{ + public class GethStarter + { + private readonly MarketplaceNetworkCache marketplaceNetworkCache; + private readonly GethCompanionNodeStarter companionNodeStarter; + + public GethStarter(TestLifecycle lifecycle) + : base(lifecycle) + { + marketplaceNetworkCache = new MarketplaceNetworkCache( + new GethBootstrapNodeStarter(lifecycle), + new CodexContractsStarter(lifecycle)); + companionNodeStarter = new GethCompanionNodeStarter(lifecycle); + } + + public GethStartResult BringOnlineMarketplaceFor(CodexSetup codexSetup) + { + if (codexSetup.MarketplaceConfig == null) return CreateMarketplaceUnavailableResult(); + + var marketplaceNetwork = marketplaceNetworkCache.Get(); + var companionNode = StartCompanionNode(codexSetup, marketplaceNetwork); + + LogStart("Setting up initial balance..."); + TransferInitialBalance(marketplaceNetwork, codexSetup.MarketplaceConfig, companionNode); + LogEnd($"Initial balance of {codexSetup.MarketplaceConfig.InitialTestTokens} set for {codexSetup.NumberOfNodes} nodes."); + + return CreateGethStartResult(marketplaceNetwork, companionNode); + } + + private void TransferInitialBalance(MarketplaceNetwork marketplaceNetwork, MarketplaceInitialConfig marketplaceConfig, GethCompanionNodeInfo companionNode) + { + if (marketplaceConfig.InitialTestTokens.Amount == 0) return; + + var interaction = marketplaceNetwork.StartInteraction(lifecycle); + var tokenAddress = marketplaceNetwork.Marketplace.TokenAddress; + + var accounts = companionNode.Accounts.Select(a => a.Account).ToArray(); + interaction.MintTestTokens(accounts, marketplaceConfig.InitialTestTokens.Amount, tokenAddress); + } + + private GethStartResult CreateGethStartResult(MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) + { + return new GethStartResult(CreateMarketplaceAccessFactory(marketplaceNetwork), marketplaceNetwork, companionNode); + } + + private GethStartResult CreateMarketplaceUnavailableResult() + { + return new GethStartResult(new MarketplaceUnavailableAccessFactory(), null!, null!); + } + + private IMarketplaceAccessFactory CreateMarketplaceAccessFactory(MarketplaceNetwork marketplaceNetwork) + { + return new GethMarketplaceAccessFactory(lifecycle, marketplaceNetwork); + } + + private GethCompanionNodeInfo StartCompanionNode(CodexSetup codexSetup, MarketplaceNetwork marketplaceNetwork) + { + return companionNodeStarter.StartCompanionNodeFor(codexSetup, marketplaceNetwork); + } + } + + public class MarketplaceNetworkCache + { + private readonly GethBootstrapNodeStarter bootstrapNodeStarter; + private readonly CodexContractsStarter codexContractsStarter; + private MarketplaceNetwork? network; + + public MarketplaceNetworkCache(GethBootstrapNodeStarter bootstrapNodeStarter, CodexContractsStarter codexContractsStarter) + { + this.bootstrapNodeStarter = bootstrapNodeStarter; + this.codexContractsStarter = codexContractsStarter; + } + + public MarketplaceNetwork Get() + { + if (network == null) + { + var bootstrapInfo = bootstrapNodeStarter.StartGethBootstrapNode(); + var marketplaceInfo = codexContractsStarter.Start(bootstrapInfo); + network = new MarketplaceNetwork(bootstrapInfo, marketplaceInfo); + } + return network; + } + } +} diff --git a/GethPlugin/GethStartupConfig.cs b/GethPlugin/GethStartupConfig.cs new file mode 100644 index 0000000..d662a42 --- /dev/null +++ b/GethPlugin/GethStartupConfig.cs @@ -0,0 +1,18 @@ +namespace GethPlugin +{ + public class GethStartupConfig + { + public GethStartupConfig(bool isBootstrapNode, GethBootstrapNodeInfo bootstrapNode, int companionAccountStartIndex, int numberOfCompanionAccounts) + { + IsBootstrapNode = isBootstrapNode; + BootstrapNode = bootstrapNode; + CompanionAccountStartIndex = companionAccountStartIndex; + NumberOfCompanionAccounts = numberOfCompanionAccounts; + } + + public bool IsBootstrapNode { get; } + public GethBootstrapNodeInfo BootstrapNode { get; } + public int CompanionAccountStartIndex { get; } + public int NumberOfCompanionAccounts { get; } + } +} diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index 4065079..d0e90d0 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -29,7 +29,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexPlugin", "CodexPlugin\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "Core\Core.csproj", "{F2BF34B3-C660-43EF-BD42-BC5C60237FC4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetricsPlugin", "MetricsPlugin\MetricsPlugin.csproj", "{FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MetricsPlugin", "MetricsPlugin\MetricsPlugin.csproj", "{FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GethPlugin", "GethPlugin\GethPlugin.csproj", "{5A1EF1DD-9E81-4501-B44C-493C72D2B166}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -93,6 +95,10 @@ Global {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Debug|Any CPU.Build.0 = Debug|Any CPU {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Release|Any CPU.ActiveCfg = Release|Any CPU {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Release|Any CPU.Build.0 = Release|Any CPU + {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 4cc93eba73a5b561ef3fa641181b66ae6a1e243b Mon Sep 17 00:00:00 2001 From: ThatBen Date: Fri, 15 Sep 2023 15:52:02 +0200 Subject: [PATCH 29/51] Can start Geth --- CodexPlugin/CodexContainerRecipe.cs | 4 +- GethPlugin/ContainerInfoExtractor.cs | 147 +++++++++++++++++ GethPlugin/CoreInterfaceExtensions.cs | 43 +---- GethPlugin/GethAccount.cs | 24 +++ GethPlugin/GethBootstrapNodeInfo.cs | 42 ----- GethPlugin/GethBootstrapNodeStarter.cs | 35 ---- GethPlugin/GethCompanionNodeInfo.cs | 56 +++---- GethPlugin/GethCompanionNodeStarter.cs | 72 --------- GethPlugin/GethContainerRecipe.cs | 37 ++--- GethPlugin/GethNodeInfo.cs | 35 ++++ GethPlugin/GethPlugin.cs | 10 +- GethPlugin/GethStartResult.cs | 34 ++-- GethPlugin/GethStarter.cs | 159 +++++++++++-------- GethPlugin/GethStartupConfig.cs | 50 ++++-- KubernetesWorkflow/ContainerRecipeFactory.cs | 19 ++- Tests/BasicTests/ExampleTests.cs | 3 + Tests/Tests.csproj | 1 + 17 files changed, 428 insertions(+), 343 deletions(-) create mode 100644 GethPlugin/ContainerInfoExtractor.cs create mode 100644 GethPlugin/GethAccount.cs delete mode 100644 GethPlugin/GethBootstrapNodeInfo.cs delete mode 100644 GethPlugin/GethBootstrapNodeStarter.cs delete mode 100644 GethPlugin/GethCompanionNodeStarter.cs create mode 100644 GethPlugin/GethNodeInfo.cs diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index 47680a0..15311c3 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -1,6 +1,4 @@ -//using DistTestCore.Marketplace; -using Core; -using KubernetesWorkflow; +using KubernetesWorkflow; using Utils; namespace CodexPlugin diff --git a/GethPlugin/ContainerInfoExtractor.cs b/GethPlugin/ContainerInfoExtractor.cs new file mode 100644 index 0000000..fa7dc61 --- /dev/null +++ b/GethPlugin/ContainerInfoExtractor.cs @@ -0,0 +1,147 @@ +using KubernetesWorkflow; +using Logging; +using Utils; + +namespace GethPlugin +{ + public class ContainerInfoExtractor + { + private readonly ILog log; + private readonly IStartupWorkflow workflow; + private readonly RunningContainer container; + + public ContainerInfoExtractor(ILog log, IStartupWorkflow workflow, RunningContainer container) + { + this.log = log; + this.workflow = workflow; + this.container = container; + } + + public AllGethAccounts ExtractAccounts() + { + log.Debug(); + var accountsCsv = Retry(() => FetchAccountsCsv()); + if (string.IsNullOrEmpty(accountsCsv)) throw new InvalidOperationException("Unable to fetch accounts.csv for geth node. Test infra failure."); + + var lines = accountsCsv.Split('\n'); + return new AllGethAccounts(lines.Select(ParseLineToAccount).ToArray()); + } + + public string ExtractPubKey() + { + log.Debug(); + var pubKey = Retry(FetchPubKey); + if (string.IsNullOrEmpty(pubKey)) throw new InvalidOperationException("Unable to fetch enode from geth node. Test infra failure."); + + return pubKey; + } + + //public string ExtractMarketplaceAddress() + //{ + // log.Debug(); + // var marketplaceAddress = Retry(FetchMarketplaceAddress); + // if (string.IsNullOrEmpty(marketplaceAddress)) throw new InvalidOperationException("Unable to fetch marketplace account from codex-contracts node. Test infra failure."); + + // return marketplaceAddress; + //} + + //public string ExtractMarketplaceAbi() + //{ + // log.Debug(); + // var marketplaceAbi = Retry(FetchMarketplaceAbi); + // if (string.IsNullOrEmpty(marketplaceAbi)) throw new InvalidOperationException("Unable to fetch marketplace artifacts from codex-contracts node. Test infra failure."); + + // return marketplaceAbi; + //} + + private string FetchAccountsCsv() + { + return workflow.ExecuteCommand(container, "cat", GethContainerRecipe.AccountsFilename); + } + + //private string FetchMarketplaceAddress() + //{ + // var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceAddressFilename); + // var marketplace = JsonConvert.DeserializeObject(json); + // return marketplace!.address; + //} + + //private string FetchMarketplaceAbi() + //{ + // var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceArtifactFilename); + + // var artifact = JObject.Parse(json); + // var abi = artifact["abi"]; + // return abi!.ToString(Formatting.None); + //} + + private string FetchPubKey() + { + var enodeFinder = new PubKeyFinder(s => log.Debug(s)); + workflow.DownloadContainerLog(container, enodeFinder, null); + return enodeFinder.GetPubKey(); + } + + private GethAccount ParseLineToAccount(string l) + { + var tokens = l.Replace("\r", "").Split(','); + if (tokens.Length != 2) throw new InvalidOperationException(); + var account = tokens[0]; + var privateKey = tokens[1]; + return new GethAccount(account, privateKey); + } + + private static string Retry(Func fetch) + { + return Time.Retry(fetch, nameof(ContainerInfoExtractor)); + } + } + + public class PubKeyFinder : LogHandler, ILogHandler + { + private const string openTag = "self=enode://"; + private const string openTagQuote = "self=\"enode://"; + private readonly Action debug; + private string pubKey = string.Empty; + + public PubKeyFinder(Action debug) + { + this.debug = debug; + debug($"Looking for '{openTag}' in container logs..."); + } + + public string GetPubKey() + { + if (string.IsNullOrEmpty(pubKey)) throw new Exception("Not found yet exception."); + return pubKey; + } + + protected override void ProcessLine(string line) + { + debug(line); + if (line.Contains(openTag)) + { + ExtractPubKey(openTag, line); + } + else if (line.Contains(openTagQuote)) + { + ExtractPubKey(openTagQuote, line); + } + } + + private void ExtractPubKey(string tag, string line) + { + var openIndex = line.IndexOf(tag) + tag.Length; + var closeIndex = line.IndexOf("@"); + + pubKey = line.Substring( + startIndex: openIndex, + length: closeIndex - openIndex); + } + } + + //public class MarketplaceJson + //{ + // public string address { get; set; } = string.Empty; + //} +} diff --git a/GethPlugin/CoreInterfaceExtensions.cs b/GethPlugin/CoreInterfaceExtensions.cs index 6625d58..e5049a9 100644 --- a/GethPlugin/CoreInterfaceExtensions.cs +++ b/GethPlugin/CoreInterfaceExtensions.cs @@ -1,44 +1,17 @@ using Core; -using KubernetesWorkflow; namespace GethPlugin { public static class CoreInterfaceExtensions { - //public static RunningContainers[] StartCodexNodes(this CoreInterface ci, int number, Action setup) - //{ - // return Plugin(ci).StartCodexNodes(number, setup); - //} + public static IGethNodeInfo StartGethNode(this CoreInterface ci, Action setup) + { + return Plugin(ci).StartGeth(setup); + } - //public static ICodexNodeGroup WrapCodexContainers(this CoreInterface ci, RunningContainers[] containers) - //{ - // return Plugin(ci).WrapCodexContainers(containers); - //} - - //public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci) - //{ - // return ci.SetupCodexNodes(1)[0]; - //} - - //public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci, Action setup) - //{ - // return ci.SetupCodexNodes(1, setup)[0]; - //} - - //public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number, Action setup) - //{ - // var rc = ci.StartCodexNodes(number, setup); - // return ci.WrapCodexContainers(rc); - //} - - //public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number) - //{ - // return ci.SetupCodexNodes(number, s => { }); - //} - - //private static CodexPlugin Plugin(CoreInterface ci) - //{ - // return ci.GetPlugin(); - //} + private static GethPlugin Plugin(CoreInterface ci) + { + return ci.GetPlugin(); + } } } diff --git a/GethPlugin/GethAccount.cs b/GethPlugin/GethAccount.cs new file mode 100644 index 0000000..10974e2 --- /dev/null +++ b/GethPlugin/GethAccount.cs @@ -0,0 +1,24 @@ +namespace GethPlugin +{ + public class GethAccount + { + public GethAccount(string account, string privateKey) + { + Account = account; + PrivateKey = privateKey; + } + + public string Account { get; } + public string PrivateKey { get; } + } + + public class AllGethAccounts + { + public GethAccount[] Accounts { get; } + + public AllGethAccounts(GethAccount[] accounts) + { + Accounts = accounts; + } + } +} diff --git a/GethPlugin/GethBootstrapNodeInfo.cs b/GethPlugin/GethBootstrapNodeInfo.cs deleted file mode 100644 index 8dffbc9..0000000 --- a/GethPlugin/GethBootstrapNodeInfo.cs +++ /dev/null @@ -1,42 +0,0 @@ -using KubernetesWorkflow; -using NethereumWorkflow; - -namespace GethPlugin -{ - public class GethBootstrapNodeInfo - { - public GethBootstrapNodeInfo(RunningContainers runningContainers, AllGethAccounts allAccounts, string pubKey, Port discoveryPort) - { - RunningContainers = runningContainers; - AllAccounts = allAccounts; - Account = allAccounts.Accounts[0]; - PubKey = pubKey; - DiscoveryPort = discoveryPort; - } - - public RunningContainers RunningContainers { get; } - public AllGethAccounts AllAccounts { get; } - public GethAccount Account { get; } - public string PubKey { get; } - public Port DiscoveryPort { get; } - - public NethereumInteraction StartInteraction(TestLifecycle lifecycle) - { - var address = lifecycle.Configuration.GetAddress(RunningContainers.Containers[0]); - var account = Account; - - var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, account.PrivateKey); - return creator.CreateWorkflow(); - } - } - - public class AllGethAccounts - { - public GethAccount[] Accounts { get; } - - public AllGethAccounts(GethAccount[] accounts) - { - Accounts = accounts; - } - } -} diff --git a/GethPlugin/GethBootstrapNodeStarter.cs b/GethPlugin/GethBootstrapNodeStarter.cs deleted file mode 100644 index b18292a..0000000 --- a/GethPlugin/GethBootstrapNodeStarter.cs +++ /dev/null @@ -1,35 +0,0 @@ -using KubernetesWorkflow; - -namespace GethPlugin -{ - public class GethBootstrapNodeStarter - { - public GethBootstrapNodeInfo StartGethBootstrapNode() - { - LogStart("Starting Geth bootstrap node..."); - var startupConfig = CreateBootstrapStartupConfig(); - - var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), startupConfig); - if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Geth bootstrap node to be created. Test infra failure."); - var bootstrapContainer = containers.Containers[0]; - - var extractor = new ContainerInfoExtractor(lifecycle.Log, workflow, bootstrapContainer); - var accounts = extractor.ExtractAccounts(); - var pubKey = extractor.ExtractPubKey(); - var discoveryPort = bootstrapContainer.Recipe.GetPortByTag(GethContainerRecipe.DiscoveryPortTag); - var result = new GethBootstrapNodeInfo(containers, accounts, pubKey, discoveryPort); - - LogEnd($"Geth bootstrap node started with account '{result.Account.Account}'"); - - return result; - } - - private StartupConfig CreateBootstrapStartupConfig() - { - var config = new StartupConfig(); - config.Add(new GethStartupConfig(true, null!, 0, 0)); - return config; - } - } -} diff --git a/GethPlugin/GethCompanionNodeInfo.cs b/GethPlugin/GethCompanionNodeInfo.cs index 313271c..8c349c3 100644 --- a/GethPlugin/GethCompanionNodeInfo.cs +++ b/GethPlugin/GethCompanionNodeInfo.cs @@ -1,38 +1,26 @@ -using KubernetesWorkflow; -using NethereumWorkflow; +//using KubernetesWorkflow; +//using NethereumWorkflow; -namespace GethPlugin -{ - public class GethCompanionNodeInfo - { - public GethCompanionNodeInfo(RunningContainer runningContainer, GethAccount[] accounts) - { - RunningContainer = runningContainer; - Accounts = accounts; - } +//namespace GethPlugin +//{ +// public class GethCompanionNodeInfo +// { +// public GethCompanionNodeInfo(RunningContainer runningContainer, GethAccount[] accounts) +// { +// RunningContainer = runningContainer; +// Accounts = accounts; +// } - public RunningContainer RunningContainer { get; } - public GethAccount[] Accounts { get; } +// public RunningContainer RunningContainer { get; } +// public GethAccount[] Accounts { get; } - public NethereumInteraction StartInteraction(TestLifecycle lifecycle, GethAccount account) - { - var address = lifecycle.Configuration.GetAddress(RunningContainer); - var privateKey = account.PrivateKey; +// public NethereumInteraction StartInteraction(TestLifecycle lifecycle, GethAccount account) +// { +// var address = lifecycle.Configuration.GetAddress(RunningContainer); +// var privateKey = account.PrivateKey; - var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, privateKey); - return creator.CreateWorkflow(); - } - } - - public class GethAccount - { - public GethAccount(string account, string privateKey) - { - Account = account; - PrivateKey = privateKey; - } - - public string Account { get; } - public string PrivateKey { get; } - } -} +// var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, privateKey); +// return creator.CreateWorkflow(); +// } +// } +//} diff --git a/GethPlugin/GethCompanionNodeStarter.cs b/GethPlugin/GethCompanionNodeStarter.cs deleted file mode 100644 index 998e8f2..0000000 --- a/GethPlugin/GethCompanionNodeStarter.cs +++ /dev/null @@ -1,72 +0,0 @@ -using KubernetesWorkflow; -using Utils; - -namespace GethPlugin -{ - public class GethCompanionNodeStarter - { - private int companionAccountIndex = 0; - - public GethCompanionNodeInfo StartCompanionNodeFor(CodexSetup codexSetup, MarketplaceNetwork marketplace) - { - LogStart($"Initializing companion for {codexSetup.NumberOfNodes} Codex nodes."); - - var config = CreateCompanionNodeStartupConfig(marketplace.Bootstrap, codexSetup.NumberOfNodes); - - var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), CreateStartupConfig(config)); - if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected one Geth companion node to be created. Test infra failure."); - var container = containers.Containers[0]; - - var node = CreateCompanionInfo(container, marketplace, config); - EnsureCompanionNodeIsSynced(node, marketplace); - - LogEnd($"Initialized one companion node for {codexSetup.NumberOfNodes} Codex nodes. Their accounts: [{string.Join(",", node.Accounts.Select(a => a.Account))}]"); - return node; - } - - private GethCompanionNodeInfo CreateCompanionInfo(RunningContainer container, MarketplaceNetwork marketplace, GethStartupConfig config) - { - var accounts = ExtractAccounts(marketplace, config); - return new GethCompanionNodeInfo(container, accounts); - } - - private static GethAccount[] ExtractAccounts(MarketplaceNetwork marketplace, GethStartupConfig config) - { - return marketplace.Bootstrap.AllAccounts.Accounts - .Skip(1 + config.CompanionAccountStartIndex) - .Take(config.NumberOfCompanionAccounts) - .ToArray(); - } - - private void EnsureCompanionNodeIsSynced(GethCompanionNodeInfo node, MarketplaceNetwork marketplace) - { - try - { - Time.WaitUntil(() => - { - var interaction = node.StartInteraction(lifecycle, node.Accounts.First()); - return interaction.IsSynced(marketplace.Marketplace.Address, marketplace.Marketplace.Abi); - }, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(3)); - } - catch (Exception e) - { - throw new Exception("Geth companion node did not sync within timeout. Test infra failure.", e); - } - } - - private GethStartupConfig CreateCompanionNodeStartupConfig(GethBootstrapNodeInfo bootstrapNode, int numberOfAccounts) - { - var config = new GethStartupConfig(false, bootstrapNode, companionAccountIndex, numberOfAccounts); - companionAccountIndex += numberOfAccounts; - return config; - } - - private StartupConfig CreateStartupConfig(GethStartupConfig gethConfig) - { - var config = new StartupConfig(); - config.Add(gethConfig); - return config; - } - } -} diff --git a/GethPlugin/GethContainerRecipe.cs b/GethPlugin/GethContainerRecipe.cs index 1c1b2e1..a3ad320 100644 --- a/GethPlugin/GethContainerRecipe.cs +++ b/GethPlugin/GethContainerRecipe.cs @@ -26,38 +26,23 @@ namespace GethPlugin { var discovery = AddInternalPort(tag: DiscoveryPortTag); - if (config.IsBootstrapNode) - { - return CreateBootstapArgs(discovery); - } - - return CreateCompanionArgs(discovery, config); - } - - private string CreateBootstapArgs(Port discovery) - { - AddEnvVar("ENABLE_MINER", "1"); + if (config.IsMiner) AddEnvVar("ENABLE_MINER", "1"); UnlockAccounts(0, 1); var exposedPort = AddExposedPort(tag: HttpPortTag); - return $"--http.port {exposedPort.Number} --port {discovery.Number} --discovery.port {discovery.Number} {defaultArgs}"; - } + var args = $"--http.addr 0.0.0.0 --http.port {exposedPort.Number} --port {discovery.Number} --discovery.port {discovery.Number} {defaultArgs}"; - private string CreateCompanionArgs(Port discovery, GethStartupConfig config) - { - UnlockAccounts( - config.CompanionAccountStartIndex + 1, - config.NumberOfCompanionAccounts); - - var port = AddInternalPort(); var authRpc = AddInternalPort(); - var httpPort = AddExposedPort(tag: HttpPortTag); - var bootPubKey = config.BootstrapNode.PubKey; - var bootIp = config.BootstrapNode.RunningContainers.Containers[0].Pod.PodInfo.Ip; - var bootPort = config.BootstrapNode.DiscoveryPort.Number; - var bootstrapArg = $"--bootnodes enode://{bootPubKey}@{bootIp}:{bootPort} --nat=extip:{bootIp}"; + if (config.BootstrapNode != null) + { + var bootPubKey = config.BootstrapNode.PublicKey; + var bootIp = config.BootstrapNode.IpAddress; + var bootPort = config.BootstrapNode.Port; + var bootstrapArg = $" --bootnodes enode://{bootPubKey}@{bootIp}:{bootPort} --nat=extip:{bootIp}"; + args += bootstrapArg; + } - return $"--port {port.Number} --discovery.port {discovery.Number} --authrpc.port {authRpc.Number} --http.addr 0.0.0.0 --http.port {httpPort.Number} --ws --ws.addr 0.0.0.0 --ws.port {httpPort.Number} {bootstrapArg} {defaultArgs}"; + return args + $" --authrpc.port {authRpc.Number} --ws --ws.addr 0.0.0.0 --ws.port {exposedPort.Number}"; } private void UnlockAccounts(int startIndex, int numberOfAccounts) diff --git a/GethPlugin/GethNodeInfo.cs b/GethPlugin/GethNodeInfo.cs new file mode 100644 index 0000000..c6e958d --- /dev/null +++ b/GethPlugin/GethNodeInfo.cs @@ -0,0 +1,35 @@ +using KubernetesWorkflow; + +namespace GethPlugin +{ + public interface IGethNodeInfo + { + } + + public class GethNodeInfo : IGethNodeInfo + { + public GethNodeInfo(RunningContainer runningContainer, AllGethAccounts allAccounts, string pubKey, Port discoveryPort) + { + RunningContainer = runningContainer; + AllAccounts = allAccounts; + Account = allAccounts.Accounts[0]; + PubKey = pubKey; + DiscoveryPort = discoveryPort; + } + + public RunningContainer RunningContainer { get; } + public AllGethAccounts AllAccounts { get; } + public GethAccount Account { get; } + public string PubKey { get; } + public Port DiscoveryPort { get; } + + //public NethereumInteraction StartInteraction(TestLifecycle lifecycle) + //{ + // var address = lifecycle.Configuration.GetAddress(RunningContainers.Containers[0]); + // var account = Account; + + // var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, account.PrivateKey); + // return creator.CreateWorkflow(); + //} + } +} diff --git a/GethPlugin/GethPlugin.cs b/GethPlugin/GethPlugin.cs index fcd15c2..08f36a4 100644 --- a/GethPlugin/GethPlugin.cs +++ b/GethPlugin/GethPlugin.cs @@ -6,11 +6,12 @@ namespace GethPlugin public class GethPlugin : IProjectPlugin, IHasLogPrefix, IHasMetadata { private readonly IPluginTools tools; + private readonly GethStarter starter; public GethPlugin(IPluginTools tools) { - //codexStarter = new CodexStarter(tools); this.tools = tools; + starter = new GethStarter(tools); } public string LogPrefix => "(Geth) "; @@ -29,6 +30,13 @@ namespace GethPlugin { } + public IGethNodeInfo StartGeth(Action setup) + { + var startupConfig = new GethStartupConfig(); + setup(startupConfig); + return starter.StartGeth(startupConfig); + } + //public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) //{ // var codexSetup = new CodexSetup(numberOfNodes); diff --git a/GethPlugin/GethStartResult.cs b/GethPlugin/GethStartResult.cs index c098870..79a04a3 100644 --- a/GethPlugin/GethStartResult.cs +++ b/GethPlugin/GethStartResult.cs @@ -1,19 +1,19 @@ -using Newtonsoft.Json; +//using Newtonsoft.Json; -namespace GethPlugin -{ - public class GethStartResult - { - public GethStartResult(IMarketplaceAccessFactory marketplaceAccessFactory, MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) - { - MarketplaceAccessFactory = marketplaceAccessFactory; - MarketplaceNetwork = marketplaceNetwork; - CompanionNode = companionNode; - } +//namespace GethPlugin +//{ +// public class GethStartResult +// { +// public GethStartResult(IMarketplaceAccessFactory marketplaceAccessFactory, MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) +// { +// MarketplaceAccessFactory = marketplaceAccessFactory; +// MarketplaceNetwork = marketplaceNetwork; +// CompanionNode = companionNode; +// } - [JsonIgnore] - public IMarketplaceAccessFactory MarketplaceAccessFactory { get; } - public MarketplaceNetwork MarketplaceNetwork { get; } - public GethCompanionNodeInfo CompanionNode { get; } - } -} +// [JsonIgnore] +// public IMarketplaceAccessFactory MarketplaceAccessFactory { get; } +// public MarketplaceNetwork MarketplaceNetwork { get; } +// public GethCompanionNodeInfo CompanionNode { get; } +// } +//} diff --git a/GethPlugin/GethStarter.cs b/GethPlugin/GethStarter.cs index f9e2162..d883ec8 100644 --- a/GethPlugin/GethStarter.cs +++ b/GethPlugin/GethStarter.cs @@ -1,86 +1,121 @@ -namespace CodexPlugin +using Core; +using KubernetesWorkflow; + +namespace GethPlugin { public class GethStarter { - private readonly MarketplaceNetworkCache marketplaceNetworkCache; - private readonly GethCompanionNodeStarter companionNodeStarter; + private readonly IPluginTools tools; - public GethStarter(TestLifecycle lifecycle) - : base(lifecycle) + //private readonly MarketplaceNetworkCache marketplaceNetworkCache; + //private readonly GethCompanionNodeStarter companionNodeStarter; + + public GethStarter(IPluginTools tools) { - marketplaceNetworkCache = new MarketplaceNetworkCache( - new GethBootstrapNodeStarter(lifecycle), - new CodexContractsStarter(lifecycle)); - companionNodeStarter = new GethCompanionNodeStarter(lifecycle); + this.tools = tools; + //marketplaceNetworkCache = new MarketplaceNetworkCache( + // new GethBootstrapNodeStarter(lifecycle), + // new CodexContractsStarter(lifecycle)); + //companionNodeStarter = new GethCompanionNodeStarter(lifecycle); } - public GethStartResult BringOnlineMarketplaceFor(CodexSetup codexSetup) + public IGethNodeInfo StartGeth(GethStartupConfig gethStartupConfig) { - if (codexSetup.MarketplaceConfig == null) return CreateMarketplaceUnavailableResult(); + Log("Starting Geth bootstrap node..."); - var marketplaceNetwork = marketplaceNetworkCache.Get(); - var companionNode = StartCompanionNode(codexSetup, marketplaceNetwork); + var startupConfig = new StartupConfig(); + startupConfig.Add(gethStartupConfig); + startupConfig.NameOverride = gethStartupConfig.NameOverride; - LogStart("Setting up initial balance..."); - TransferInitialBalance(marketplaceNetwork, codexSetup.MarketplaceConfig, companionNode); - LogEnd($"Initial balance of {codexSetup.MarketplaceConfig.InitialTestTokens} set for {codexSetup.NumberOfNodes} nodes."); + var workflow = tools.CreateWorkflow(); + var containers = workflow.Start(1, Location.Unspecified, new GethContainerRecipe(), startupConfig); + if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Geth bootstrap node to be created. Test infra failure."); + var container = containers.Containers[0]; - return CreateGethStartResult(marketplaceNetwork, companionNode); + var extractor = new ContainerInfoExtractor(tools.GetLog(), workflow, container); + var accounts = extractor.ExtractAccounts(); + var pubKey = extractor.ExtractPubKey(); + var discoveryPort = container.Recipe.GetPortByTag(GethContainerRecipe.DiscoveryPortTag); + if (discoveryPort == null) throw new Exception("Expected discovery port to be created."); + var result = new GethNodeInfo(container, accounts, pubKey, discoveryPort); + + Log($"Geth bootstrap node started with account '{result.Account.Account}'"); + + return result; } - private void TransferInitialBalance(MarketplaceNetwork marketplaceNetwork, MarketplaceInitialConfig marketplaceConfig, GethCompanionNodeInfo companionNode) + private void Log(string msg) { - if (marketplaceConfig.InitialTestTokens.Amount == 0) return; - - var interaction = marketplaceNetwork.StartInteraction(lifecycle); - var tokenAddress = marketplaceNetwork.Marketplace.TokenAddress; - - var accounts = companionNode.Accounts.Select(a => a.Account).ToArray(); - interaction.MintTestTokens(accounts, marketplaceConfig.InitialTestTokens.Amount, tokenAddress); + tools.GetLog().Log(msg); } - private GethStartResult CreateGethStartResult(MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) - { - return new GethStartResult(CreateMarketplaceAccessFactory(marketplaceNetwork), marketplaceNetwork, companionNode); - } + //public GethStartResult BringOnlineMarketplaceFor(CodexSetup codexSetup) + //{ + // if (codexSetup.MarketplaceConfig == null) return CreateMarketplaceUnavailableResult(); - private GethStartResult CreateMarketplaceUnavailableResult() - { - return new GethStartResult(new MarketplaceUnavailableAccessFactory(), null!, null!); - } + // var marketplaceNetwork = marketplaceNetworkCache.Get(); + // var companionNode = StartCompanionNode(codexSetup, marketplaceNetwork); - private IMarketplaceAccessFactory CreateMarketplaceAccessFactory(MarketplaceNetwork marketplaceNetwork) - { - return new GethMarketplaceAccessFactory(lifecycle, marketplaceNetwork); - } + // LogStart("Setting up initial balance..."); + // TransferInitialBalance(marketplaceNetwork, codexSetup.MarketplaceConfig, companionNode); + // LogEnd($"Initial balance of {codexSetup.MarketplaceConfig.InitialTestTokens} set for {codexSetup.NumberOfNodes} nodes."); - private GethCompanionNodeInfo StartCompanionNode(CodexSetup codexSetup, MarketplaceNetwork marketplaceNetwork) - { - return companionNodeStarter.StartCompanionNodeFor(codexSetup, marketplaceNetwork); - } + // return CreateGethStartResult(marketplaceNetwork, companionNode); + //} + + //private void TransferInitialBalance(MarketplaceNetwork marketplaceNetwork, MarketplaceInitialConfig marketplaceConfig, GethCompanionNodeInfo companionNode) + //{ + // if (marketplaceConfig.InitialTestTokens.Amount == 0) return; + + // var interaction = marketplaceNetwork.StartInteraction(lifecycle); + // var tokenAddress = marketplaceNetwork.Marketplace.TokenAddress; + + // var accounts = companionNode.Accounts.Select(a => a.Account).ToArray(); + // interaction.MintTestTokens(accounts, marketplaceConfig.InitialTestTokens.Amount, tokenAddress); + //} + + //private GethStartResult CreateGethStartResult(MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) + //{ + // return new GethStartResult(CreateMarketplaceAccessFactory(marketplaceNetwork), marketplaceNetwork, companionNode); + //} + + //private GethStartResult CreateMarketplaceUnavailableResult() + //{ + // return new GethStartResult(new MarketplaceUnavailableAccessFactory(), null!, null!); + //} + + //private IMarketplaceAccessFactory CreateMarketplaceAccessFactory(MarketplaceNetwork marketplaceNetwork) + //{ + // return new GethMarketplaceAccessFactory(lifecycle, marketplaceNetwork); + //} + + //private GethCompanionNodeInfo StartCompanionNode(CodexSetup codexSetup, MarketplaceNetwork marketplaceNetwork) + //{ + // return companionNodeStarter.StartCompanionNodeFor(codexSetup, marketplaceNetwork); + //} } - public class MarketplaceNetworkCache - { - private readonly GethBootstrapNodeStarter bootstrapNodeStarter; - private readonly CodexContractsStarter codexContractsStarter; - private MarketplaceNetwork? network; + //public class MarketplaceNetworkCache + //{ + // private readonly GethBootstrapNodeStarter bootstrapNodeStarter; + // private readonly CodexContractsStarter codexContractsStarter; + // private MarketplaceNetwork? network; - public MarketplaceNetworkCache(GethBootstrapNodeStarter bootstrapNodeStarter, CodexContractsStarter codexContractsStarter) - { - this.bootstrapNodeStarter = bootstrapNodeStarter; - this.codexContractsStarter = codexContractsStarter; - } + // public MarketplaceNetworkCache(GethBootstrapNodeStarter bootstrapNodeStarter, CodexContractsStarter codexContractsStarter) + // { + // this.bootstrapNodeStarter = bootstrapNodeStarter; + // this.codexContractsStarter = codexContractsStarter; + // } - public MarketplaceNetwork Get() - { - if (network == null) - { - var bootstrapInfo = bootstrapNodeStarter.StartGethBootstrapNode(); - var marketplaceInfo = codexContractsStarter.Start(bootstrapInfo); - network = new MarketplaceNetwork(bootstrapInfo, marketplaceInfo); - } - return network; - } - } + // public MarketplaceNetwork Get() + // { + // if (network == null) + // { + // var bootstrapInfo = bootstrapNodeStarter.StartGethBootstrapNode(); + // var marketplaceInfo = codexContractsStarter.Start(bootstrapInfo); + // network = new MarketplaceNetwork(bootstrapInfo, marketplaceInfo); + // } + // return network; + // } + //} } diff --git a/GethPlugin/GethStartupConfig.cs b/GethPlugin/GethStartupConfig.cs index d662a42..fb1991a 100644 --- a/GethPlugin/GethStartupConfig.cs +++ b/GethPlugin/GethStartupConfig.cs @@ -1,18 +1,48 @@ namespace GethPlugin { - public class GethStartupConfig + public interface IGethSetup { - public GethStartupConfig(bool isBootstrapNode, GethBootstrapNodeInfo bootstrapNode, int companionAccountStartIndex, int numberOfCompanionAccounts) + IGethSetup IsMiner(); + IGethSetup WithBootstrapNode(GethBootstrapNode node); + IGethSetup WithName(string name); + } + + public class GethStartupConfig : IGethSetup + { + public bool IsMiner { get; private set; } + public GethBootstrapNode? BootstrapNode { get; private set; } + public string? NameOverride { get; private set; } + + public IGethSetup WithBootstrapNode(GethBootstrapNode node) { - IsBootstrapNode = isBootstrapNode; - BootstrapNode = bootstrapNode; - CompanionAccountStartIndex = companionAccountStartIndex; - NumberOfCompanionAccounts = numberOfCompanionAccounts; + BootstrapNode = node; + return this; } - public bool IsBootstrapNode { get; } - public GethBootstrapNodeInfo BootstrapNode { get; } - public int CompanionAccountStartIndex { get; } - public int NumberOfCompanionAccounts { get; } + public IGethSetup WithName(string name) + { + NameOverride = name; + return this; + } + + IGethSetup IGethSetup.IsMiner() + { + IsMiner = true; + return this; + } + } + + public class GethBootstrapNode + { + public GethBootstrapNode(string publicKey, string ipAddress, int port) + { + PublicKey = publicKey; + IpAddress = ipAddress; + Port = port; + } + + public string PublicKey { get; } + public string IpAddress { get; } + public int Port { get; } } } diff --git a/KubernetesWorkflow/ContainerRecipeFactory.cs b/KubernetesWorkflow/ContainerRecipeFactory.cs index c8d26f7..bfd0919 100644 --- a/KubernetesWorkflow/ContainerRecipeFactory.cs +++ b/KubernetesWorkflow/ContainerRecipeFactory.cs @@ -51,16 +51,12 @@ namespace KubernetesWorkflow protected Port AddExposedPort(string tag = "") { - var p = factory.CreatePort(tag); - exposedPorts.Add(p); - return p; + return AddExposedPort(factory.CreatePort(tag)); } protected Port AddExposedPort(int number, string tag = "") { - var p = factory.CreatePort(number, tag); - exposedPorts.Add(p); - return p; + return AddExposedPort(factory.CreatePort(number, tag)); } protected Port AddInternalPort(string tag = "") @@ -112,5 +108,16 @@ namespace KubernetesWorkflow { additionals.Add(userData); } + + private Port AddExposedPort(Port port) + { + if (exposedPorts.Any()) + { + throw new NotImplementedException("Current implementation only support 1 exposed port per container recipe. " + + $"Methods for determining container addresses in {nameof(StartupWorkflow)} currently rely on this constraint."); + } + exposedPorts.Add(port); + return port; + } } } diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 861a8af..9a79e89 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -1,5 +1,6 @@ using CodexPlugin; using DistTestCore; +using GethPlugin; using MetricsPlugin; using NUnit.Framework; using Utils; @@ -46,6 +47,8 @@ namespace Tests.BasicTests [Test] public void MarketplaceExample() { + var geth = Ci.StartGethNode(s => s.IsMiner().WithName("disttest-geth")); + //var sellerInitialBalance = 234.TestTokens(); //var buyerInitialBalance = 1000.TestTokens(); //var fileSize = 10.MB(); diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index 27552d7..a735ca9 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -15,6 +15,7 @@ + From 3d4370b154eae24b1a586bbcab0a356e6a5c98b1 Mon Sep 17 00:00:00 2001 From: ThatBen Date: Fri, 15 Sep 2023 16:27:08 +0200 Subject: [PATCH 30/51] deploys codex contract --- .../CodexContractsContainerConfig.cs | 15 ++ .../CodexContractsContainerRecipe.cs | 25 +++ CodexContractsPlugin/CodexContractsPlugin.cs | 51 ++++++ .../CodexContractsPlugin.csproj | 14 ++ CodexContractsPlugin/CodexContractsStarter.cs | 99 ++++++++++++ .../ContractsContainerInfoExtractor.cs | 66 ++++++++ .../CoreInterfaceExtensions.cs | 18 +++ .../MarketplaceAccess.cs | 0 .../MarketplaceAccessFactory.cs | 0 CodexContractsPlugin/MarketplaceInfo.cs | 20 +++ .../MarketplaceInitialConfig.cs | 0 .../MarketplaceNetwork.cs | 0 .../CodexContractsContainerConfig.cs | 16 -- .../CodexContractsContainerRecipe.cs | 25 --- .../Marketplace/CodexContractsStarter.cs | 103 ------------ .../Marketplace/ContainerInfoExtractor.cs | 149 ------------------ ...actor.cs => GethContainerInfoExtractor.cs} | 6 +- GethPlugin/GethContainerRecipe.cs | 8 +- GethPlugin/GethNodeInfo.cs | 28 +++- GethPlugin/GethStarter.cs | 17 +- Nethereum/NethereumInteraction.cs | 4 +- Nethereum/NethereumInteractionCreator.cs | 4 +- Tests/BasicTests/ExampleTests.cs | 5 +- Tests/Tests.csproj | 1 + cs-codex-dist-testing.sln | 6 + 25 files changed, 359 insertions(+), 321 deletions(-) create mode 100644 CodexContractsPlugin/CodexContractsContainerConfig.cs create mode 100644 CodexContractsPlugin/CodexContractsContainerRecipe.cs create mode 100644 CodexContractsPlugin/CodexContractsPlugin.cs create mode 100644 CodexContractsPlugin/CodexContractsPlugin.csproj create mode 100644 CodexContractsPlugin/CodexContractsStarter.cs create mode 100644 CodexContractsPlugin/ContractsContainerInfoExtractor.cs create mode 100644 CodexContractsPlugin/CoreInterfaceExtensions.cs rename {CodexPlugin/Marketplace => CodexContractsPlugin}/MarketplaceAccess.cs (100%) rename {CodexPlugin/Marketplace => CodexContractsPlugin}/MarketplaceAccessFactory.cs (100%) create mode 100644 CodexContractsPlugin/MarketplaceInfo.cs rename {CodexPlugin/Marketplace => CodexContractsPlugin}/MarketplaceInitialConfig.cs (100%) rename {CodexPlugin/Marketplace => CodexContractsPlugin}/MarketplaceNetwork.cs (100%) delete mode 100644 CodexPlugin/Marketplace/CodexContractsContainerConfig.cs delete mode 100644 CodexPlugin/Marketplace/CodexContractsContainerRecipe.cs delete mode 100644 CodexPlugin/Marketplace/CodexContractsStarter.cs delete mode 100644 CodexPlugin/Marketplace/ContainerInfoExtractor.cs rename GethPlugin/{ContainerInfoExtractor.cs => GethContainerInfoExtractor.cs} (95%) diff --git a/CodexContractsPlugin/CodexContractsContainerConfig.cs b/CodexContractsPlugin/CodexContractsContainerConfig.cs new file mode 100644 index 0000000..ee710d1 --- /dev/null +++ b/CodexContractsPlugin/CodexContractsContainerConfig.cs @@ -0,0 +1,15 @@ +using GethPlugin; +using KubernetesWorkflow; + +namespace CodexContractsPlugin +{ + public class CodexContractsContainerConfig + { + public CodexContractsContainerConfig(IGethNodeInfo gethNode) + { + GethNode = gethNode; + } + + public IGethNodeInfo GethNode { get; } + } +} diff --git a/CodexContractsPlugin/CodexContractsContainerRecipe.cs b/CodexContractsPlugin/CodexContractsContainerRecipe.cs new file mode 100644 index 0000000..341502f --- /dev/null +++ b/CodexContractsPlugin/CodexContractsContainerRecipe.cs @@ -0,0 +1,25 @@ +using KubernetesWorkflow; + +namespace CodexContractsPlugin +{ + public class CodexContractsContainerRecipe : ContainerRecipeFactory + { + public const string MarketplaceAddressFilename = "/hardhat/deployments/codexdisttestnetwork/Marketplace.json"; + public const string MarketplaceArtifactFilename = "/hardhat/artifacts/contracts/Marketplace.sol/Marketplace.json"; + + public override string AppName => "codex-contracts"; + public override string Image => "codexstorage/codex-contracts-eth:latest-dist-tests"; + + protected override void Initialize(StartupConfig startupConfig) + { + var config = startupConfig.Get(); + + var ip = config.GethNode.RunningContainer.Pod.PodInfo.Ip; + var port = config.GethNode.HttpPort.Number; + + AddEnvVar("DISTTEST_NETWORK_URL", $"http://{ip}:{port}"); + AddEnvVar("HARDHAT_NETWORK", "codexdisttestnetwork"); + AddEnvVar("KEEP_ALIVE", "1"); + } + } +} diff --git a/CodexContractsPlugin/CodexContractsPlugin.cs b/CodexContractsPlugin/CodexContractsPlugin.cs new file mode 100644 index 0000000..312b6d1 --- /dev/null +++ b/CodexContractsPlugin/CodexContractsPlugin.cs @@ -0,0 +1,51 @@ +using Core; +using GethPlugin; + +namespace CodexContractsPlugin +{ + public class CodexContractsPlugin : IProjectPlugin, IHasLogPrefix, IHasMetadata + { + private readonly IPluginTools tools; + private readonly CodexContractsStarter starter; + + public CodexContractsPlugin(IPluginTools tools) + { + this.tools = tools; + starter = new CodexContractsStarter(tools); + } + + public string LogPrefix => "(CodexContracts) "; + + public void Announce() + { + //tools.GetLog().Log($"Loaded with Codex ID: '{codexStarter.GetCodexId()}'"); + } + + public void AddMetadata(IAddMetadata metadata) + { + //metadata.Add("codexid", codexStarter.GetCodexId()); + } + + public void Decommission() + { + } + + public IMarketplaceInfo DeployContracts(IGethNodeInfo gethNode) + { + return starter.Start(gethNode); + } + + //public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) + //{ + // var codexSetup = new CodexSetup(numberOfNodes); + // codexSetup.LogLevel = defaultLogLevel; + // setup(codexSetup); + // return codexStarter.BringOnline(codexSetup); + //} + + //public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) + //{ + // return codexStarter.WrapCodexContainers(containers); + //} + } +} diff --git a/CodexContractsPlugin/CodexContractsPlugin.csproj b/CodexContractsPlugin/CodexContractsPlugin.csproj new file mode 100644 index 0000000..f7a4e57 --- /dev/null +++ b/CodexContractsPlugin/CodexContractsPlugin.csproj @@ -0,0 +1,14 @@ + + + + net7.0 + enable + enable + + + + + + + + diff --git a/CodexContractsPlugin/CodexContractsStarter.cs b/CodexContractsPlugin/CodexContractsStarter.cs new file mode 100644 index 0000000..8f9600c --- /dev/null +++ b/CodexContractsPlugin/CodexContractsStarter.cs @@ -0,0 +1,99 @@ +using Core; +using GethPlugin; +using KubernetesWorkflow; +using Logging; +using Utils; + +namespace CodexContractsPlugin +{ + public class CodexContractsStarter + { + private readonly IPluginTools tools; + + public CodexContractsStarter(IPluginTools tools) + { + this.tools = tools; + } + + public IMarketplaceInfo Start(IGethNodeInfo gethNode) + { + Log("Deploying Codex Marketplace..."); + + var workflow = tools.CreateWorkflow(); + var startupConfig = CreateStartupConfig(gethNode); + + var containers = workflow.Start(1, Location.Unspecified, new CodexContractsContainerRecipe(), startupConfig); + if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Codex contracts container to be created. Test infra failure."); + var container = containers.Containers[0]; + + WaitUntil(() => + { + var logHandler = new ContractsReadyLogHandler(tools.GetLog()); + workflow.DownloadContainerLog(container, logHandler, null); + return logHandler.Found; + }); + Log("Contracts deployed. Extracting addresses..."); + + var extractor = new ContractsContainerInfoExtractor(tools.GetLog(), workflow, container); + var marketplaceAddress = extractor.ExtractMarketplaceAddress(); + var abi = extractor.ExtractMarketplaceAbi(); + + var interaction = gethNode.StartInteraction(tools.GetLog()); + var tokenAddress = interaction.GetTokenAddress(marketplaceAddress); + + Log("Extract completed. Marketplace deployed."); + + return new MarketplaceInfo(marketplaceAddress, abi, tokenAddress); + } + + private void Log(string msg) + { + tools.GetLog().Log(msg); + } + + private void WaitUntil(Func predicate) + { + Time.WaitUntil(predicate, TimeSpan.FromMinutes(3), TimeSpan.FromSeconds(2)); + } + + private StartupConfig CreateStartupConfig(IGethNodeInfo gethNode) + { + var startupConfig = new StartupConfig(); + var contractsConfig = new CodexContractsContainerConfig(gethNode); + startupConfig.Add(contractsConfig); + return startupConfig; + } + } + + public class ContractsReadyLogHandler : LogHandler + { + // Log should contain 'Compiled 15 Solidity files successfully' at some point. + private const string RequiredCompiledString = "Solidity files successfully"; + // When script is done, it prints the ready-string. + private const string ReadyString = "Done! Sleeping indefinitely..."; + private readonly ILog log; + + public ContractsReadyLogHandler(ILog log) + { + this.log = log; + + log.Debug($"Looking for '{RequiredCompiledString}' and '{ReadyString}' in container logs..."); + } + + public bool SeenCompileString { get; private set; } + public bool Found { get; private set; } + + protected override void ProcessLine(string line) + { + log.Debug(line); + if (line.Contains(RequiredCompiledString)) SeenCompileString = true; + if (line.Contains(ReadyString)) + { + if (!SeenCompileString) throw new Exception("CodexContracts deployment failed. " + + "Solidity files not compiled before process exited."); + + Found = true; + } + } + } +} diff --git a/CodexContractsPlugin/ContractsContainerInfoExtractor.cs b/CodexContractsPlugin/ContractsContainerInfoExtractor.cs new file mode 100644 index 0000000..78b3896 --- /dev/null +++ b/CodexContractsPlugin/ContractsContainerInfoExtractor.cs @@ -0,0 +1,66 @@ +using KubernetesWorkflow; +using Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Utils; + +namespace CodexContractsPlugin +{ + public class ContractsContainerInfoExtractor + { + private readonly ILog log; + private readonly IStartupWorkflow workflow; + private readonly RunningContainer container; + + public ContractsContainerInfoExtractor(ILog log, IStartupWorkflow workflow, RunningContainer container) + { + this.log = log; + this.workflow = workflow; + this.container = container; + } + + public string ExtractMarketplaceAddress() + { + log.Debug(); + var marketplaceAddress = Retry(FetchMarketplaceAddress); + if (string.IsNullOrEmpty(marketplaceAddress)) throw new InvalidOperationException("Unable to fetch marketplace account from codex-contracts node. Test infra failure."); + + return marketplaceAddress; + } + + public string ExtractMarketplaceAbi() + { + log.Debug(); + var marketplaceAbi = Retry(FetchMarketplaceAbi); + if (string.IsNullOrEmpty(marketplaceAbi)) throw new InvalidOperationException("Unable to fetch marketplace artifacts from codex-contracts node. Test infra failure."); + + return marketplaceAbi; + } + + private string FetchMarketplaceAddress() + { + var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceAddressFilename); + var marketplace = JsonConvert.DeserializeObject(json); + return marketplace!.address; + } + + private string FetchMarketplaceAbi() + { + var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceArtifactFilename); + + var artifact = JObject.Parse(json); + var abi = artifact["abi"]; + return abi!.ToString(Formatting.None); + } + + private static string Retry(Func fetch) + { + return Time.Retry(fetch, nameof(ContractsContainerInfoExtractor)); + } + } + + public class MarketplaceJson + { + public string address { get; set; } = string.Empty; + } +} diff --git a/CodexContractsPlugin/CoreInterfaceExtensions.cs b/CodexContractsPlugin/CoreInterfaceExtensions.cs new file mode 100644 index 0000000..80a97aa --- /dev/null +++ b/CodexContractsPlugin/CoreInterfaceExtensions.cs @@ -0,0 +1,18 @@ +using Core; +using GethPlugin; + +namespace CodexContractsPlugin +{ + public static class CoreInterfaceExtensions + { + public static IMarketplaceInfo DeployCodexContracts(this CoreInterface ci, IGethNodeInfo gethNode) + { + return Plugin(ci).DeployContracts(gethNode); + } + + private static CodexContractsPlugin Plugin(CoreInterface ci) + { + return ci.GetPlugin(); + } + } +} diff --git a/CodexPlugin/Marketplace/MarketplaceAccess.cs b/CodexContractsPlugin/MarketplaceAccess.cs similarity index 100% rename from CodexPlugin/Marketplace/MarketplaceAccess.cs rename to CodexContractsPlugin/MarketplaceAccess.cs diff --git a/CodexPlugin/Marketplace/MarketplaceAccessFactory.cs b/CodexContractsPlugin/MarketplaceAccessFactory.cs similarity index 100% rename from CodexPlugin/Marketplace/MarketplaceAccessFactory.cs rename to CodexContractsPlugin/MarketplaceAccessFactory.cs diff --git a/CodexContractsPlugin/MarketplaceInfo.cs b/CodexContractsPlugin/MarketplaceInfo.cs new file mode 100644 index 0000000..cac1983 --- /dev/null +++ b/CodexContractsPlugin/MarketplaceInfo.cs @@ -0,0 +1,20 @@ +namespace CodexContractsPlugin +{ + public interface IMarketplaceInfo + { + } + + public class MarketplaceInfo : IMarketplaceInfo + { + public MarketplaceInfo(string address, string abi, string tokenAddress) + { + Address = address; + Abi = abi; + TokenAddress = tokenAddress; + } + + public string Address { get; } + public string Abi { get; } + public string TokenAddress { get; } + } +} diff --git a/CodexPlugin/Marketplace/MarketplaceInitialConfig.cs b/CodexContractsPlugin/MarketplaceInitialConfig.cs similarity index 100% rename from CodexPlugin/Marketplace/MarketplaceInitialConfig.cs rename to CodexContractsPlugin/MarketplaceInitialConfig.cs diff --git a/CodexPlugin/Marketplace/MarketplaceNetwork.cs b/CodexContractsPlugin/MarketplaceNetwork.cs similarity index 100% rename from CodexPlugin/Marketplace/MarketplaceNetwork.cs rename to CodexContractsPlugin/MarketplaceNetwork.cs diff --git a/CodexPlugin/Marketplace/CodexContractsContainerConfig.cs b/CodexPlugin/Marketplace/CodexContractsContainerConfig.cs deleted file mode 100644 index f6e9896..0000000 --- a/CodexPlugin/Marketplace/CodexContractsContainerConfig.cs +++ /dev/null @@ -1,16 +0,0 @@ -//using KubernetesWorkflow; - -//namespace DistTestCore.Marketplace -//{ -// public class CodexContractsContainerConfig -// { -// public CodexContractsContainerConfig(string bootstrapNodeIp, Port jsonRpcPort) -// { -// BootstrapNodeIp = bootstrapNodeIp; -// JsonRpcPort = jsonRpcPort; -// } - -// public string BootstrapNodeIp { get; } -// public Port JsonRpcPort { get; } -// } -//} diff --git a/CodexPlugin/Marketplace/CodexContractsContainerRecipe.cs b/CodexPlugin/Marketplace/CodexContractsContainerRecipe.cs deleted file mode 100644 index db8e88f..0000000 --- a/CodexPlugin/Marketplace/CodexContractsContainerRecipe.cs +++ /dev/null @@ -1,25 +0,0 @@ -//using KubernetesWorkflow; - -//namespace DistTestCore.Marketplace -//{ -// public class CodexContractsContainerRecipe : DefaultContainerRecipe -// { -// public const string MarketplaceAddressFilename = "/hardhat/deployments/codexdisttestnetwork/Marketplace.json"; -// public const string MarketplaceArtifactFilename = "/hardhat/artifacts/contracts/Marketplace.sol/Marketplace.json"; - -// public override string AppName => "codex-contracts"; -// public override string Image => "codexstorage/codex-contracts-eth:latest-dist-tests"; - -// protected override void InitializeRecipe(StartupConfig startupConfig) -// { -// var config = startupConfig.Get(); - -// var ip = config.BootstrapNodeIp; -// var port = config.JsonRpcPort.Number; - -// AddEnvVar("DISTTEST_NETWORK_URL", $"http://{ip}:{port}"); -// AddEnvVar("HARDHAT_NETWORK", "codexdisttestnetwork"); -// AddEnvVar("KEEP_ALIVE", "1"); -// } -// } -//} diff --git a/CodexPlugin/Marketplace/CodexContractsStarter.cs b/CodexPlugin/Marketplace/CodexContractsStarter.cs deleted file mode 100644 index e09b1f1..0000000 --- a/CodexPlugin/Marketplace/CodexContractsStarter.cs +++ /dev/null @@ -1,103 +0,0 @@ -//using KubernetesWorkflow; -//using Utils; - -//namespace DistTestCore.Marketplace -//{ -// public class CodexContractsStarter : BaseStarter -// { - -// public CodexContractsStarter(TestLifecycle lifecycle) -// : base(lifecycle) -// { -// } - -// public MarketplaceInfo Start(GethBootstrapNodeInfo bootstrapNode) -// { -// LogStart("Deploying Codex Marketplace..."); - -// var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); -// var startupConfig = CreateStartupConfig(bootstrapNode.RunningContainers.Containers[0]); - -// var containers = workflow.Start(1, Location.Unspecified, new CodexContractsContainerRecipe(), startupConfig); -// if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Codex contracts container to be created. Test infra failure."); -// var container = containers.Containers[0]; - -// WaitUntil(() => -// { -// var logHandler = new ContractsReadyLogHandler(Debug); -// workflow.DownloadContainerLog(container, logHandler, null); -// return logHandler.Found; -// }); -// Log("Contracts deployed. Extracting addresses..."); - -// var extractor = new ContainerInfoExtractor(lifecycle.Log, workflow, container); -// var marketplaceAddress = extractor.ExtractMarketplaceAddress(); -// var abi = extractor.ExtractMarketplaceAbi(); - -// var interaction = bootstrapNode.StartInteraction(lifecycle); -// var tokenAddress = interaction.GetTokenAddress(marketplaceAddress); - -// LogEnd("Extract completed. Marketplace deployed."); - -// return new MarketplaceInfo(marketplaceAddress, abi, tokenAddress); -// } - -// private void WaitUntil(Func predicate) -// { -// Time.WaitUntil(predicate, TimeSpan.FromMinutes(3), TimeSpan.FromSeconds(2)); -// } - -// private StartupConfig CreateStartupConfig(RunningContainer bootstrapContainer) -// { -// var startupConfig = new StartupConfig(); -// var contractsConfig = new CodexContractsContainerConfig(bootstrapContainer.Pod.PodInfo.Ip, bootstrapContainer.Recipe.GetPortByTag(GethContainerRecipe.HttpPortTag)); -// startupConfig.Add(contractsConfig); -// return startupConfig; -// } -// } - -// public class MarketplaceInfo -// { -// public MarketplaceInfo(string address, string abi, string tokenAddress) -// { -// Address = address; -// Abi = abi; -// TokenAddress = tokenAddress; -// } - -// public string Address { get; } -// public string Abi { get; } -// public string TokenAddress { get; } -// } - -// public class ContractsReadyLogHandler : LogHandler -// { -// // Log should contain 'Compiled 15 Solidity files successfully' at some point. -// private const string RequiredCompiledString = "Solidity files successfully"; -// // When script is done, it prints the ready-string. -// private const string ReadyString = "Done! Sleeping indefinitely..."; -// private readonly Action debug; - -// public ContractsReadyLogHandler(Action debug) -// { -// this.debug = debug; -// debug($"Looking for '{RequiredCompiledString}' and '{ReadyString}' in container logs..."); -// } - -// public bool SeenCompileString { get; private set; } -// public bool Found { get; private set; } - -// protected override void ProcessLine(string line) -// { -// debug(line); -// if (line.Contains(RequiredCompiledString)) SeenCompileString = true; -// if (line.Contains(ReadyString)) -// { -// if (!SeenCompileString) throw new Exception("CodexContracts deployment failed. " + -// "Solidity files not compiled before process exited."); - -// Found = true; -// } -// } -// } -//} diff --git a/CodexPlugin/Marketplace/ContainerInfoExtractor.cs b/CodexPlugin/Marketplace/ContainerInfoExtractor.cs deleted file mode 100644 index ced47e1..0000000 --- a/CodexPlugin/Marketplace/ContainerInfoExtractor.cs +++ /dev/null @@ -1,149 +0,0 @@ -//using KubernetesWorkflow; -//using Logging; -//using Newtonsoft.Json; -//using Newtonsoft.Json.Linq; -//using Utils; - -//namespace DistTestCore.Marketplace -//{ -// public class ContainerInfoExtractor -// { -// private readonly BaseLog log; -// private readonly StartupWorkflow workflow; -// private readonly RunningContainer container; - -// public ContainerInfoExtractor(BaseLog log, StartupWorkflow workflow, RunningContainer container) -// { -// this.log = log; -// this.workflow = workflow; -// this.container = container; -// } - -// public AllGethAccounts ExtractAccounts() -// { -// log.Debug(); -// var accountsCsv = Retry(() => FetchAccountsCsv()); -// if (string.IsNullOrEmpty(accountsCsv)) throw new InvalidOperationException("Unable to fetch accounts.csv for geth node. Test infra failure."); - -// var lines = accountsCsv.Split('\n'); -// return new AllGethAccounts(lines.Select(ParseLineToAccount).ToArray()); -// } - -// public string ExtractPubKey() -// { -// log.Debug(); -// var pubKey = Retry(FetchPubKey); -// if (string.IsNullOrEmpty(pubKey)) throw new InvalidOperationException("Unable to fetch enode from geth node. Test infra failure."); - -// return pubKey; -// } - -// public string ExtractMarketplaceAddress() -// { -// log.Debug(); -// var marketplaceAddress = Retry(FetchMarketplaceAddress); -// if (string.IsNullOrEmpty(marketplaceAddress)) throw new InvalidOperationException("Unable to fetch marketplace account from codex-contracts node. Test infra failure."); - -// return marketplaceAddress; -// } - -// public string ExtractMarketplaceAbi() -// { -// log.Debug(); -// var marketplaceAbi = Retry(FetchMarketplaceAbi); -// if (string.IsNullOrEmpty(marketplaceAbi)) throw new InvalidOperationException("Unable to fetch marketplace artifacts from codex-contracts node. Test infra failure."); - -// return marketplaceAbi; -// } - -// private string FetchAccountsCsv() -// { -// return workflow.ExecuteCommand(container, "cat", GethContainerRecipe.AccountsFilename); -// } - -// private string FetchMarketplaceAddress() -// { -// var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceAddressFilename); -// var marketplace = JsonConvert.DeserializeObject(json); -// return marketplace!.address; -// } - -// private string FetchMarketplaceAbi() -// { -// var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceArtifactFilename); - -// var artifact = JObject.Parse(json); -// var abi = artifact["abi"]; -// return abi!.ToString(Formatting.None); -// } - -// private string FetchPubKey() -// { -// var enodeFinder = new PubKeyFinder(s => log.Debug(s)); -// workflow.DownloadContainerLog(container, enodeFinder, null); -// return enodeFinder.GetPubKey(); -// } - -// private GethAccount ParseLineToAccount(string l) -// { -// var tokens = l.Replace("\r", "").Split(','); -// if (tokens.Length != 2) throw new InvalidOperationException(); -// var account = tokens[0]; -// var privateKey = tokens[1]; -// return new GethAccount(account, privateKey); -// } - -// private static string Retry(Func fetch) -// { -// return Time.Retry(fetch, nameof(ContainerInfoExtractor)); -// } -// } - -// public class PubKeyFinder : LogHandler, ILogHandler -// { -// private const string openTag = "self=enode://"; -// private const string openTagQuote = "self=\"enode://"; -// private readonly Action debug; -// private string pubKey = string.Empty; - -// public PubKeyFinder(Action debug) -// { -// this.debug = debug; -// debug($"Looking for '{openTag}' in container logs..."); -// } - -// public string GetPubKey() -// { -// if (string.IsNullOrEmpty(pubKey)) throw new Exception("Not found yet exception."); -// return pubKey; -// } - -// protected override void ProcessLine(string line) -// { -// debug(line); -// if (line.Contains(openTag)) -// { -// ExtractPubKey(openTag, line); -// } -// else if (line.Contains(openTagQuote)) -// { -// ExtractPubKey(openTagQuote, line); -// } -// } - -// private void ExtractPubKey(string tag, string line) -// { -// var openIndex = line.IndexOf(tag) + tag.Length; -// var closeIndex = line.IndexOf("@"); - -// pubKey = line.Substring( -// startIndex: openIndex, -// length: closeIndex - openIndex); -// } -// } - -// public class MarketplaceJson -// { -// public string address { get; set; } = string.Empty; -// } -//} diff --git a/GethPlugin/ContainerInfoExtractor.cs b/GethPlugin/GethContainerInfoExtractor.cs similarity index 95% rename from GethPlugin/ContainerInfoExtractor.cs rename to GethPlugin/GethContainerInfoExtractor.cs index fa7dc61..649da4f 100644 --- a/GethPlugin/ContainerInfoExtractor.cs +++ b/GethPlugin/GethContainerInfoExtractor.cs @@ -4,13 +4,13 @@ using Utils; namespace GethPlugin { - public class ContainerInfoExtractor + public class GethContainerInfoExtractor { private readonly ILog log; private readonly IStartupWorkflow workflow; private readonly RunningContainer container; - public ContainerInfoExtractor(ILog log, IStartupWorkflow workflow, RunningContainer container) + public GethContainerInfoExtractor(ILog log, IStartupWorkflow workflow, RunningContainer container) { this.log = log; this.workflow = workflow; @@ -93,7 +93,7 @@ namespace GethPlugin private static string Retry(Func fetch) { - return Time.Retry(fetch, nameof(ContainerInfoExtractor)); + return Time.Retry(fetch, nameof(GethContainerInfoExtractor)); } } diff --git a/GethPlugin/GethContainerRecipe.cs b/GethPlugin/GethContainerRecipe.cs index a3ad320..44673a8 100644 --- a/GethPlugin/GethContainerRecipe.cs +++ b/GethPlugin/GethContainerRecipe.cs @@ -8,6 +8,7 @@ namespace GethPlugin public const string HttpPortTag = "http_port"; public const string DiscoveryPortTag = "disc_port"; + public const string wsPortTag = "ws_port"; public const string AccountsFilename = "accounts.csv"; public override string AppName => "geth"; @@ -28,10 +29,11 @@ namespace GethPlugin if (config.IsMiner) AddEnvVar("ENABLE_MINER", "1"); UnlockAccounts(0, 1); - var exposedPort = AddExposedPort(tag: HttpPortTag); - var args = $"--http.addr 0.0.0.0 --http.port {exposedPort.Number} --port {discovery.Number} --discovery.port {discovery.Number} {defaultArgs}"; + var httpPort = AddExposedPort(tag: HttpPortTag); + var args = $"--http.addr 0.0.0.0 --http.port {httpPort.Number} --port {discovery.Number} --discovery.port {discovery.Number} {defaultArgs}"; var authRpc = AddInternalPort(); + var wsPort = AddInternalPort(tag: wsPortTag); if (config.BootstrapNode != null) { @@ -42,7 +44,7 @@ namespace GethPlugin args += bootstrapArg; } - return args + $" --authrpc.port {authRpc.Number} --ws --ws.addr 0.0.0.0 --ws.port {exposedPort.Number}"; + return args + $" --authrpc.port {authRpc.Number} --ws --ws.addr 0.0.0.0 --ws.port {wsPort.Number}"; } private void UnlockAccounts(int startIndex, int numberOfAccounts) diff --git a/GethPlugin/GethNodeInfo.cs b/GethPlugin/GethNodeInfo.cs index c6e958d..4bd6b11 100644 --- a/GethPlugin/GethNodeInfo.cs +++ b/GethPlugin/GethNodeInfo.cs @@ -1,20 +1,30 @@ using KubernetesWorkflow; +using Logging; +using NethereumWorkflow; namespace GethPlugin { public interface IGethNodeInfo { + RunningContainer RunningContainer { get; } + Port DiscoveryPort { get; } + Port HttpPort { get; } + Port WsPort { get; } + + NethereumInteraction StartInteraction(ILog log); } public class GethNodeInfo : IGethNodeInfo { - public GethNodeInfo(RunningContainer runningContainer, AllGethAccounts allAccounts, string pubKey, Port discoveryPort) + public GethNodeInfo(RunningContainer runningContainer, AllGethAccounts allAccounts, string pubKey, Port discoveryPort, Port httpPort, Port wsPort) { RunningContainer = runningContainer; AllAccounts = allAccounts; Account = allAccounts.Accounts[0]; PubKey = pubKey; DiscoveryPort = discoveryPort; + HttpPort = httpPort; + WsPort = wsPort; } public RunningContainer RunningContainer { get; } @@ -22,14 +32,16 @@ namespace GethPlugin public GethAccount Account { get; } public string PubKey { get; } public Port DiscoveryPort { get; } + public Port HttpPort { get; } + public Port WsPort { get; } - //public NethereumInteraction StartInteraction(TestLifecycle lifecycle) - //{ - // var address = lifecycle.Configuration.GetAddress(RunningContainers.Containers[0]); - // var account = Account; + public NethereumInteraction StartInteraction(ILog log) + { + var address = RunningContainer.Address; + var account = Account; - // var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, account.PrivateKey); - // return creator.CreateWorkflow(); - //} + var creator = new NethereumInteractionCreator(log, address.Host, address.Port, account.PrivateKey); + return creator.CreateWorkflow(); + } } } diff --git a/GethPlugin/GethStarter.cs b/GethPlugin/GethStarter.cs index d883ec8..8ce2b3b 100644 --- a/GethPlugin/GethStarter.cs +++ b/GethPlugin/GethStarter.cs @@ -7,16 +7,9 @@ namespace GethPlugin { private readonly IPluginTools tools; - //private readonly MarketplaceNetworkCache marketplaceNetworkCache; - //private readonly GethCompanionNodeStarter companionNodeStarter; - public GethStarter(IPluginTools tools) { this.tools = tools; - //marketplaceNetworkCache = new MarketplaceNetworkCache( - // new GethBootstrapNodeStarter(lifecycle), - // new CodexContractsStarter(lifecycle)); - //companionNodeStarter = new GethCompanionNodeStarter(lifecycle); } public IGethNodeInfo StartGeth(GethStartupConfig gethStartupConfig) @@ -32,12 +25,18 @@ namespace GethPlugin if (containers.Containers.Length != 1) throw new InvalidOperationException("Expected 1 Geth bootstrap node to be created. Test infra failure."); var container = containers.Containers[0]; - var extractor = new ContainerInfoExtractor(tools.GetLog(), workflow, container); + var extractor = new GethContainerInfoExtractor(tools.GetLog(), workflow, container); var accounts = extractor.ExtractAccounts(); var pubKey = extractor.ExtractPubKey(); + var discoveryPort = container.Recipe.GetPortByTag(GethContainerRecipe.DiscoveryPortTag); if (discoveryPort == null) throw new Exception("Expected discovery port to be created."); - var result = new GethNodeInfo(container, accounts, pubKey, discoveryPort); + var httpPort = container.Recipe.GetPortByTag(GethContainerRecipe.HttpPortTag); + if (httpPort == null) throw new Exception("Expected http port to be created."); + var wsPort = container.Recipe.GetPortByTag(GethContainerRecipe.wsPortTag); + if (wsPort == null) throw new Exception("Expected ws port to be created."); + + var result = new GethNodeInfo(container, accounts, pubKey, discoveryPort, httpPort, wsPort); Log($"Geth bootstrap node started with account '{result.Account.Account}'"); diff --git a/Nethereum/NethereumInteraction.cs b/Nethereum/NethereumInteraction.cs index 624f20c..dad4633 100644 --- a/Nethereum/NethereumInteraction.cs +++ b/Nethereum/NethereumInteraction.cs @@ -10,10 +10,10 @@ namespace NethereumWorkflow { public class NethereumInteraction { - private readonly BaseLog log; + private readonly ILog log; private readonly Web3 web3; - internal NethereumInteraction(BaseLog log, Web3 web3) + internal NethereumInteraction(ILog log, Web3 web3) { this.log = log; this.web3 = web3; diff --git a/Nethereum/NethereumInteractionCreator.cs b/Nethereum/NethereumInteractionCreator.cs index ab5449c..bad1194 100644 --- a/Nethereum/NethereumInteractionCreator.cs +++ b/Nethereum/NethereumInteractionCreator.cs @@ -5,12 +5,12 @@ namespace NethereumWorkflow { public class NethereumInteractionCreator { - private readonly BaseLog log; + private readonly ILog log; private readonly string ip; private readonly int port; private readonly string privateKey; - public NethereumInteractionCreator(BaseLog log, string ip, int port, string privateKey) + public NethereumInteractionCreator(ILog log, string ip, int port, string privateKey) { this.log = log; this.ip = ip; diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 9a79e89..3a5c1fc 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -1,4 +1,5 @@ -using CodexPlugin; +using CodexContractsPlugin; +using CodexPlugin; using DistTestCore; using GethPlugin; using MetricsPlugin; @@ -49,6 +50,8 @@ namespace Tests.BasicTests { var geth = Ci.StartGethNode(s => s.IsMiner().WithName("disttest-geth")); + var contracts = Ci.DeployCodexContracts(geth); + //var sellerInitialBalance = 234.TestTokens(); //var buyerInitialBalance = 1000.TestTokens(); //var fileSize = 10.MB(); diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index a735ca9..e6faada 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index d0e90d0..92e1c82 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -33,6 +33,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MetricsPlugin", "MetricsPlu EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GethPlugin", "GethPlugin\GethPlugin.csproj", "{5A1EF1DD-9E81-4501-B44C-493C72D2B166}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexContractsPlugin", "CodexContractsPlugin\CodexContractsPlugin.csproj", "{F315AEB1-C254-45FD-A0D2-5CEF401E0442}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -99,6 +101,10 @@ Global {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Debug|Any CPU.Build.0 = Debug|Any CPU {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Release|Any CPU.ActiveCfg = Release|Any CPU {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Release|Any CPU.Build.0 = Release|Any CPU + {F315AEB1-C254-45FD-A0D2-5CEF401E0442}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F315AEB1-C254-45FD-A0D2-5CEF401E0442}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F315AEB1-C254-45FD-A0D2-5CEF401E0442}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F315AEB1-C254-45FD-A0D2-5CEF401E0442}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 825200b3866ada645cd3662fc1b040f93d01f11e Mon Sep 17 00:00:00 2001 From: ThatBen Date: Mon, 18 Sep 2023 15:45:21 +0200 Subject: [PATCH 31/51] Allows container resources to be conditional in container recipes. --- CodexPlugin/CodexContainerRecipe.cs | 6 ++--- KubernetesWorkflow/ContainerRecipeFactory.cs | 25 ++++++++++++++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index 15311c3..2225d59 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -20,13 +20,13 @@ namespace CodexPlugin public CodexContainerRecipe() { Image = GetDockerImage(); - - //Resources.Requests = new ContainerResourceSet(milliCPUs: 1000, memory: 6.GB()); - //Resources.Limits = new ContainerResourceSet(milliCPUs: 4000, memory: 12.GB()); } protected override void Initialize(StartupConfig startupConfig) { + SetResourcesRequest(milliCPUs: 1000, memory: 6.GB()); + SetResourceLimits(milliCPUs: 4000, memory: 12.GB()); + var config = startupConfig.Get(); AddExposedPortAndVar("CODEX_API_PORT"); diff --git a/KubernetesWorkflow/ContainerRecipeFactory.cs b/KubernetesWorkflow/ContainerRecipeFactory.cs index bfd0919..d606598 100644 --- a/KubernetesWorkflow/ContainerRecipeFactory.cs +++ b/KubernetesWorkflow/ContainerRecipeFactory.cs @@ -12,6 +12,7 @@ namespace KubernetesWorkflow private readonly List volumeMounts = new List(); private readonly List additionals = new List(); private RecipeComponentFactory factory = null!; + private ContainerResources resources = new ContainerResources(); public ContainerRecipe CreateRecipe(int index, int containerNumber, RecipeComponentFactory factory, StartupConfig config) { @@ -21,7 +22,7 @@ namespace KubernetesWorkflow Initialize(config); - var recipe = new ContainerRecipe(containerNumber, config.NameOverride, Image, Resources, + var recipe = new ContainerRecipe(containerNumber, config.NameOverride, Image, resources, exposedPorts.ToArray(), internalPorts.ToArray(), envVars.ToArray(), @@ -38,13 +39,13 @@ namespace KubernetesWorkflow volumeMounts.Clear(); additionals.Clear(); this.factory = null!; + resources = new ContainerResources(); return recipe; } public abstract string AppName { get; } public abstract string Image { get; } - public ContainerResources Resources { get; } = new ContainerResources(); protected int ContainerNumber { get; private set; } = 0; protected int Index { get; private set; } = 0; protected abstract void Initialize(StartupConfig config); @@ -109,6 +110,26 @@ namespace KubernetesWorkflow additionals.Add(userData); } + protected void SetResourcesRequest(int milliCPUs, ByteSize memory) + { + SetResourcesRequest(new ContainerResourceSet(milliCPUs, memory)); + } + + protected void SetResourceLimits(int milliCPUs, ByteSize memory) + { + SetResourceLimits(new ContainerResourceSet(milliCPUs, memory)); + } + + protected void SetResourcesRequest(ContainerResourceSet requests) + { + resources.Requests = requests; + } + + protected void SetResourceLimits(ContainerResourceSet limits) + { + resources.Limits = limits; + } + private Port AddExposedPort(Port port) { if (exposedPorts.Any()) From a20fc6864bcc5d2c06bfb9e47012b0912307d18f Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 19 Sep 2023 10:24:43 +0200 Subject: [PATCH 32/51] working out marketplace details --- CodexContractsPlugin/CodexContractsAccess.cs | 21 ++++++++++ .../CodexContractsContainerConfig.cs | 4 +- CodexContractsPlugin/CodexContractsPlugin.cs | 2 +- CodexContractsPlugin/CodexContractsStarter.cs | 6 +-- .../CoreInterfaceExtensions.cs | 2 +- CodexContractsPlugin/MarketplaceInfo.cs | 20 ---------- .../MarketplaceInitialConfig.cs | 17 --------- CodexPlugin/CodexContainerRecipe.cs | 38 ++++++++++--------- CodexPlugin/CodexPlugin.csproj | 2 + CodexPlugin/CodexSetup.cs | 12 +++++- CodexPlugin/CodexStartupConfig.cs | 2 +- CodexPlugin/MarketplaceInitialConfig.cs | 31 +++++++++++++++ CodexPlugin/MarketplaceStartResults.cs | 15 ++++++++ CodexPlugin/MarketplaceStarter.cs | 17 +++++++++ GethPlugin/CoreInterfaceExtensions.cs | 2 +- GethPlugin/{GethNodeInfo.cs => GethNode.cs} | 6 +-- GethPlugin/GethPlugin.cs | 2 +- GethPlugin/GethStarter.cs | 4 +- Tests/BasicTests/ExampleTests.cs | 15 ++++++++ 19 files changed, 148 insertions(+), 70 deletions(-) create mode 100644 CodexContractsPlugin/CodexContractsAccess.cs delete mode 100644 CodexContractsPlugin/MarketplaceInfo.cs delete mode 100644 CodexContractsPlugin/MarketplaceInitialConfig.cs create mode 100644 CodexPlugin/MarketplaceInitialConfig.cs create mode 100644 CodexPlugin/MarketplaceStartResults.cs create mode 100644 CodexPlugin/MarketplaceStarter.cs rename GethPlugin/{GethNodeInfo.cs => GethNode.cs} (84%) diff --git a/CodexContractsPlugin/CodexContractsAccess.cs b/CodexContractsPlugin/CodexContractsAccess.cs new file mode 100644 index 0000000..ba84a70 --- /dev/null +++ b/CodexContractsPlugin/CodexContractsAccess.cs @@ -0,0 +1,21 @@ +namespace CodexContractsPlugin +{ + public interface ICodexContracts + { + string MarketplaceAddress { get; } + } + + public class CodexContractsAccess : ICodexContracts + { + public CodexContractsAccess(string marketplaceAddress, string abi, string tokenAddress) + { + MarketplaceAddress = marketplaceAddress; + Abi = abi; + TokenAddress = tokenAddress; + } + + public string MarketplaceAddress { get; } + public string Abi { get; } + public string TokenAddress { get; } + } +} diff --git a/CodexContractsPlugin/CodexContractsContainerConfig.cs b/CodexContractsPlugin/CodexContractsContainerConfig.cs index ee710d1..876c9b1 100644 --- a/CodexContractsPlugin/CodexContractsContainerConfig.cs +++ b/CodexContractsPlugin/CodexContractsContainerConfig.cs @@ -5,11 +5,11 @@ namespace CodexContractsPlugin { public class CodexContractsContainerConfig { - public CodexContractsContainerConfig(IGethNodeInfo gethNode) + public CodexContractsContainerConfig(IGethNode gethNode) { GethNode = gethNode; } - public IGethNodeInfo GethNode { get; } + public IGethNode GethNode { get; } } } diff --git a/CodexContractsPlugin/CodexContractsPlugin.cs b/CodexContractsPlugin/CodexContractsPlugin.cs index 312b6d1..beeb233 100644 --- a/CodexContractsPlugin/CodexContractsPlugin.cs +++ b/CodexContractsPlugin/CodexContractsPlugin.cs @@ -30,7 +30,7 @@ namespace CodexContractsPlugin { } - public IMarketplaceInfo DeployContracts(IGethNodeInfo gethNode) + public ICodexContracts DeployContracts(IGethNode gethNode) { return starter.Start(gethNode); } diff --git a/CodexContractsPlugin/CodexContractsStarter.cs b/CodexContractsPlugin/CodexContractsStarter.cs index 8f9600c..8dcce36 100644 --- a/CodexContractsPlugin/CodexContractsStarter.cs +++ b/CodexContractsPlugin/CodexContractsStarter.cs @@ -15,7 +15,7 @@ namespace CodexContractsPlugin this.tools = tools; } - public IMarketplaceInfo Start(IGethNodeInfo gethNode) + public ICodexContracts Start(IGethNode gethNode) { Log("Deploying Codex Marketplace..."); @@ -43,7 +43,7 @@ namespace CodexContractsPlugin Log("Extract completed. Marketplace deployed."); - return new MarketplaceInfo(marketplaceAddress, abi, tokenAddress); + return new CodexContractsAccess(marketplaceAddress, abi, tokenAddress); } private void Log(string msg) @@ -56,7 +56,7 @@ namespace CodexContractsPlugin Time.WaitUntil(predicate, TimeSpan.FromMinutes(3), TimeSpan.FromSeconds(2)); } - private StartupConfig CreateStartupConfig(IGethNodeInfo gethNode) + private StartupConfig CreateStartupConfig(IGethNode gethNode) { var startupConfig = new StartupConfig(); var contractsConfig = new CodexContractsContainerConfig(gethNode); diff --git a/CodexContractsPlugin/CoreInterfaceExtensions.cs b/CodexContractsPlugin/CoreInterfaceExtensions.cs index 80a97aa..20c4645 100644 --- a/CodexContractsPlugin/CoreInterfaceExtensions.cs +++ b/CodexContractsPlugin/CoreInterfaceExtensions.cs @@ -5,7 +5,7 @@ namespace CodexContractsPlugin { public static class CoreInterfaceExtensions { - public static IMarketplaceInfo DeployCodexContracts(this CoreInterface ci, IGethNodeInfo gethNode) + public static ICodexContracts DeployCodexContracts(this CoreInterface ci, IGethNode gethNode) { return Plugin(ci).DeployContracts(gethNode); } diff --git a/CodexContractsPlugin/MarketplaceInfo.cs b/CodexContractsPlugin/MarketplaceInfo.cs deleted file mode 100644 index cac1983..0000000 --- a/CodexContractsPlugin/MarketplaceInfo.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace CodexContractsPlugin -{ - public interface IMarketplaceInfo - { - } - - public class MarketplaceInfo : IMarketplaceInfo - { - public MarketplaceInfo(string address, string abi, string tokenAddress) - { - Address = address; - Abi = abi; - TokenAddress = tokenAddress; - } - - public string Address { get; } - public string Abi { get; } - public string TokenAddress { get; } - } -} diff --git a/CodexContractsPlugin/MarketplaceInitialConfig.cs b/CodexContractsPlugin/MarketplaceInitialConfig.cs deleted file mode 100644 index da16bca..0000000 --- a/CodexContractsPlugin/MarketplaceInitialConfig.cs +++ /dev/null @@ -1,17 +0,0 @@ -//namespace DistTestCore.Marketplace -//{ -// public class MarketplaceInitialConfig -// { -// public MarketplaceInitialConfig(Ether initialEth, TestToken initialTestTokens, bool isValidator) -// { -// InitialEth = initialEth; -// InitialTestTokens = initialTestTokens; -// IsValidator = isValidator; -// } - -// public Ether InitialEth { get; } -// public TestToken InitialTestTokens { get; } -// public bool IsValidator { get; } -// public int? AccountIndexOverride { get; set; } -// } -//} diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index 2225d59..6e82eb3 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -5,6 +5,8 @@ namespace CodexPlugin { public class CodexContainerRecipe : ContainerRecipeFactory { + private readonly MarketplaceStarter marketplaceStarter = new MarketplaceStarter(); + private const string DefaultDockerImage = "codexstorage/nim-codex:latest-dist-tests"; public const string MetricsPortTag = "metrics_port"; @@ -75,26 +77,28 @@ namespace CodexPlugin AddPodAnnotation("prometheus.io/port", metricsPort.Number.ToString()); } - //if (config.MarketplaceConfig != null) - //{ - // var gethConfig = startupConfig.Get(); - // var companionNode = gethConfig.CompanionNode; - // var companionNodeAccount = companionNode.Accounts[GetAccountIndex(config.MarketplaceConfig)]; - // Additional(companionNodeAccount); + if (config.MarketplaceConfig != null) + { + var mconfig = config.MarketplaceConfig; + var ip = mconfig.GethNode.RunningContainer.Pod.PodInfo.Ip; + var port = mconfig.GethNode.WsPort.Number; + var marketplaceAddress = mconfig.CodexContracts.MarketplaceAddress; - // var ip = companionNode.RunningContainer.Pod.PodInfo.Ip; - // var port = companionNode.RunningContainer.Recipe.GetPortByTag(GethContainerRecipe.HttpPortTag).Number; + AddEnvVar("CODEX_ETH_PROVIDER", $"ws://{ip}:{port}"); + AddEnvVar("CODEX_MARKETPLACE_ADDRESS", marketplaceAddress); + AddEnvVar("CODEX_PERSISTENCE", "true"); - // AddEnvVar("CODEX_ETH_PROVIDER", $"ws://{ip}:{port}"); - // AddEnvVar("CODEX_ETH_ACCOUNT", companionNodeAccount.Account); - // AddEnvVar("CODEX_MARKETPLACE_ADDRESS", gethConfig.MarketplaceNetwork.Marketplace.Address); - // AddEnvVar("CODEX_PERSISTENCE", "true"); + // Custom scripting in the Codex test image will write this variable to a private-key file, + // and pass the correct filename to Codex. + var mStart = marketplaceStarter.Start(); + AddEnvVar("PRIV_KEY", mStart.PrivateKey); + Additional(mStart); - // if (config.MarketplaceConfig.IsValidator) - // { - // AddEnvVar("CODEX_VALIDATOR", "true"); - // } - //} + if (config.MarketplaceConfig.IsValidator) + { + AddEnvVar("CODEX_VALIDATOR", "true"); + } + } AddPodLabel("codexid", Image); } diff --git a/CodexPlugin/CodexPlugin.csproj b/CodexPlugin/CodexPlugin.csproj index a00f24f..aac490c 100644 --- a/CodexPlugin/CodexPlugin.csproj +++ b/CodexPlugin/CodexPlugin.csproj @@ -11,7 +11,9 @@ + + diff --git a/CodexPlugin/CodexSetup.cs b/CodexPlugin/CodexSetup.cs index d6356e1..672165d 100644 --- a/CodexPlugin/CodexSetup.cs +++ b/CodexPlugin/CodexSetup.cs @@ -1,4 +1,6 @@ -using KubernetesWorkflow; +using CodexContractsPlugin; +using GethPlugin; +using KubernetesWorkflow; using Utils; namespace CodexPlugin @@ -14,6 +16,8 @@ namespace CodexPlugin ICodexSetup WithBlockMaintenanceInterval(TimeSpan duration); ICodexSetup WithBlockMaintenanceNumber(int numberOfBlocks); ICodexSetup EnableMetrics(); + ICodexSetup EnableMarketplace(IGethNode gethNode, ICodexContracts codexContracts, bool isValidator = false); + //ICodexSetup EnableMarketplace(TestToken initialBalance); //ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther); //ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther, bool isValidator); @@ -82,6 +86,12 @@ namespace CodexPlugin return this; } + public ICodexSetup EnableMarketplace(IGethNode gethNode, ICodexContracts codexContracts, bool isValidator = false) + { + MarketplaceConfig = new MarketplaceInitialConfig(gethNode, codexContracts, isValidator); + return this; + } + //public ICodexSetup EnableMarketplace(TestToken initialBalance) //{ // return EnableMarketplace(initialBalance, 1000.Eth()); diff --git a/CodexPlugin/CodexStartupConfig.cs b/CodexPlugin/CodexStartupConfig.cs index 56b41d9..0cabfaf 100644 --- a/CodexPlugin/CodexStartupConfig.cs +++ b/CodexPlugin/CodexStartupConfig.cs @@ -10,7 +10,7 @@ namespace CodexPlugin public CodexLogLevel LogLevel { get; set; } public ByteSize? StorageQuota { get; set; } public bool MetricsEnabled { get; set; } - //public MarketplaceInitialConfig? MarketplaceConfig { get; set; } + public MarketplaceInitialConfig? MarketplaceConfig { get; set; } public string? BootstrapSpr { get; set; } public int? BlockTTL { get; set; } public TimeSpan? BlockMaintenanceInterval { get; set; } diff --git a/CodexPlugin/MarketplaceInitialConfig.cs b/CodexPlugin/MarketplaceInitialConfig.cs new file mode 100644 index 0000000..f1f6af6 --- /dev/null +++ b/CodexPlugin/MarketplaceInitialConfig.cs @@ -0,0 +1,31 @@ +using CodexContractsPlugin; +using GethPlugin; + +namespace CodexPlugin +{ + public class MarketplaceInitialConfig + { + public MarketplaceInitialConfig(IGethNode gethNode, ICodexContracts codexContracts, bool isValidator) + { + GethNode = gethNode; + CodexContracts = codexContracts; + IsValidator = isValidator; + } + + public IGethNode GethNode { get; } + public ICodexContracts CodexContracts { get; } + public bool IsValidator { get; } + + //public MarketplaceInitialConfig(Ether initialEth, TestToken initialTestTokens, bool isValidator) + //{ + // InitialEth = initialEth; + // InitialTestTokens = initialTestTokens; + // IsValidator = isValidator; + //} + + //public Ether InitialEth { get; } + //public TestToken InitialTestTokens { get; } + //public bool IsValidator { get; } + //public int? AccountIndexOverride { get; set; } + } +} diff --git a/CodexPlugin/MarketplaceStartResults.cs b/CodexPlugin/MarketplaceStartResults.cs new file mode 100644 index 0000000..dd73ed6 --- /dev/null +++ b/CodexPlugin/MarketplaceStartResults.cs @@ -0,0 +1,15 @@ +namespace CodexPlugin +{ + [Serializable] + public class MarketplaceStartResults + { + public MarketplaceStartResults(string ethAddress, string privateKey) + { + EthAddress = ethAddress; + PrivateKey = privateKey; + } + + public string EthAddress { get; } + public string PrivateKey { get; } + } +} diff --git a/CodexPlugin/MarketplaceStarter.cs b/CodexPlugin/MarketplaceStarter.cs new file mode 100644 index 0000000..dc9d525 --- /dev/null +++ b/CodexPlugin/MarketplaceStarter.cs @@ -0,0 +1,17 @@ +using Nethereum.Hex.HexConvertors.Extensions; +using Nethereum.Web3.Accounts; + +namespace CodexPlugin +{ + public class MarketplaceStarter + { + public MarketplaceStartResults Start() + { + var ecKey = Nethereum.Signer.EthECKey.GenerateKey(); + var privateKey = ecKey.GetPrivateKeyAsBytes().ToHex(); + var account = new Account(privateKey); + + return new MarketplaceStartResults(account.Address, account.PrivateKey); + } + } +} diff --git a/GethPlugin/CoreInterfaceExtensions.cs b/GethPlugin/CoreInterfaceExtensions.cs index e5049a9..34d9d2f 100644 --- a/GethPlugin/CoreInterfaceExtensions.cs +++ b/GethPlugin/CoreInterfaceExtensions.cs @@ -4,7 +4,7 @@ namespace GethPlugin { public static class CoreInterfaceExtensions { - public static IGethNodeInfo StartGethNode(this CoreInterface ci, Action setup) + public static IGethNode StartGethNode(this CoreInterface ci, Action setup) { return Plugin(ci).StartGeth(setup); } diff --git a/GethPlugin/GethNodeInfo.cs b/GethPlugin/GethNode.cs similarity index 84% rename from GethPlugin/GethNodeInfo.cs rename to GethPlugin/GethNode.cs index 4bd6b11..bb096b2 100644 --- a/GethPlugin/GethNodeInfo.cs +++ b/GethPlugin/GethNode.cs @@ -4,7 +4,7 @@ using NethereumWorkflow; namespace GethPlugin { - public interface IGethNodeInfo + public interface IGethNode { RunningContainer RunningContainer { get; } Port DiscoveryPort { get; } @@ -14,9 +14,9 @@ namespace GethPlugin NethereumInteraction StartInteraction(ILog log); } - public class GethNodeInfo : IGethNodeInfo + public class GethNode : IGethNode { - public GethNodeInfo(RunningContainer runningContainer, AllGethAccounts allAccounts, string pubKey, Port discoveryPort, Port httpPort, Port wsPort) + public GethNode(RunningContainer runningContainer, AllGethAccounts allAccounts, string pubKey, Port discoveryPort, Port httpPort, Port wsPort) { RunningContainer = runningContainer; AllAccounts = allAccounts; diff --git a/GethPlugin/GethPlugin.cs b/GethPlugin/GethPlugin.cs index 08f36a4..645e93a 100644 --- a/GethPlugin/GethPlugin.cs +++ b/GethPlugin/GethPlugin.cs @@ -30,7 +30,7 @@ namespace GethPlugin { } - public IGethNodeInfo StartGeth(Action setup) + public IGethNode StartGeth(Action setup) { var startupConfig = new GethStartupConfig(); setup(startupConfig); diff --git a/GethPlugin/GethStarter.cs b/GethPlugin/GethStarter.cs index 8ce2b3b..8cc1b8e 100644 --- a/GethPlugin/GethStarter.cs +++ b/GethPlugin/GethStarter.cs @@ -12,7 +12,7 @@ namespace GethPlugin this.tools = tools; } - public IGethNodeInfo StartGeth(GethStartupConfig gethStartupConfig) + public IGethNode StartGeth(GethStartupConfig gethStartupConfig) { Log("Starting Geth bootstrap node..."); @@ -36,7 +36,7 @@ namespace GethPlugin var wsPort = container.Recipe.GetPortByTag(GethContainerRecipe.wsPortTag); if (wsPort == null) throw new Exception("Expected ws port to be created."); - var result = new GethNodeInfo(container, accounts, pubKey, discoveryPort, httpPort, wsPort); + var result = new GethNode(container, accounts, pubKey, discoveryPort, httpPort, wsPort); Log($"Geth bootstrap node started with account '{result.Account.Account}'"); diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 3a5c1fc..ef519af 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -52,6 +52,21 @@ namespace Tests.BasicTests var contracts = Ci.DeployCodexContracts(geth); + var node = Ci.SetupCodexNode(s => s.EnableMarketplace(geth, contracts)); + + var i = 0; + + //geth.SendEth(node.EthAddress, 10.Eth()); + + //contracts.MintTestTokens(geth, node.EthAddress, 100.TestTokens()); + + //geth.GetEthBalance(node.EthAddress); + + //contracts.GetTestTokenBalance(geth, node.EthAddress); + + + + //var sellerInitialBalance = 234.TestTokens(); //var buyerInitialBalance = 1000.TestTokens(); //var fileSize = 10.MB(); From 58b1c1e03c89ed049799459ddc9ade21aa189aa0 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 19 Sep 2023 11:51:59 +0200 Subject: [PATCH 33/51] Can send eth --- .../CodexContractsContainerRecipe.cs | 4 +- CodexContractsPlugin/CodexContractsStarter.cs | 2 +- CodexContractsPlugin/TestTokenExtensions.cs | 45 +++++++++++++ CodexPlugin/CodexContainerRecipe.cs | 5 +- .../{OnlineCodexNode.cs => CodexNode.cs} | 30 ++++++--- CodexPlugin/CodexNodeFactory.cs | 17 ++++- CodexPlugin/CodexNodeGroup.cs | 16 ++--- CodexPlugin/CodexSetup.cs | 4 +- CodexPlugin/CoreInterfaceExtensions.cs | 4 +- CodexPlugin/MarketplaceStartResults.cs | 8 ++- CodexPlugin/MarketplaceStarter.cs | 6 +- GethPlugin/CoreInterfaceExtensions.cs | 3 +- GethPlugin/EthAddress.cs | 22 +++++++ .../EthTokenExtensions.cs | 48 ++------------ GethPlugin/GethNode.cs | 66 ++++++++++++------- GethPlugin/GethPlugin.cs | 21 ++---- GethPlugin/GethStartResult.cs | 49 +++++++++----- GethPlugin/GethStarter.cs | 13 ++-- MetricsPlugin/CoreInterfaceExtensions.cs | 12 +++- MetricsPlugin/MetricsScrapeTarget.cs | 7 +- Nethereum/NethereumInteraction.cs | 18 +++++ Tests/AutoBootstrapDistTest.cs | 6 +- Tests/BasicTests/ContinuousSubstitute.cs | 12 ++-- Tests/BasicTests/ExampleTests.cs | 10 +-- Tests/BasicTests/NetworkIsolationTest.cs | 2 +- Tests/BasicTests/OneClientTests.cs | 4 +- Tests/BasicTests/TwoClientTests.cs | 4 +- Tests/CodexDistTest.cs | 8 +-- Tests/Helpers/FullConnectivityHelper.cs | 10 +-- Tests/Helpers/PeerConnectionTestHelpers.cs | 2 +- Tests/Helpers/PeerDownloadTestHelpers.cs | 6 +- 31 files changed, 289 insertions(+), 175 deletions(-) create mode 100644 CodexContractsPlugin/TestTokenExtensions.cs rename CodexPlugin/{OnlineCodexNode.cs => CodexNode.cs} (86%) create mode 100644 GethPlugin/EthAddress.cs rename DistTestCore/Tokens.cs => GethPlugin/EthTokenExtensions.cs (50%) diff --git a/CodexContractsPlugin/CodexContractsContainerRecipe.cs b/CodexContractsPlugin/CodexContractsContainerRecipe.cs index 341502f..caf3be9 100644 --- a/CodexContractsPlugin/CodexContractsContainerRecipe.cs +++ b/CodexContractsPlugin/CodexContractsContainerRecipe.cs @@ -14,8 +14,8 @@ namespace CodexContractsPlugin { var config = startupConfig.Get(); - var ip = config.GethNode.RunningContainer.Pod.PodInfo.Ip; - var port = config.GethNode.HttpPort.Number; + var ip = config.GethNode.StartResult.RunningContainer.Pod.PodInfo.Ip; + var port = config.GethNode.StartResult.HttpPort.Number; AddEnvVar("DISTTEST_NETWORK_URL", $"http://{ip}:{port}"); AddEnvVar("HARDHAT_NETWORK", "codexdisttestnetwork"); diff --git a/CodexContractsPlugin/CodexContractsStarter.cs b/CodexContractsPlugin/CodexContractsStarter.cs index 8dcce36..531f0c9 100644 --- a/CodexContractsPlugin/CodexContractsStarter.cs +++ b/CodexContractsPlugin/CodexContractsStarter.cs @@ -38,7 +38,7 @@ namespace CodexContractsPlugin var marketplaceAddress = extractor.ExtractMarketplaceAddress(); var abi = extractor.ExtractMarketplaceAbi(); - var interaction = gethNode.StartInteraction(tools.GetLog()); + var interaction = gethNode.StartInteraction(); var tokenAddress = interaction.GetTokenAddress(marketplaceAddress); Log("Extract completed. Marketplace deployed."); diff --git a/CodexContractsPlugin/TestTokenExtensions.cs b/CodexContractsPlugin/TestTokenExtensions.cs new file mode 100644 index 0000000..a19abde --- /dev/null +++ b/CodexContractsPlugin/TestTokenExtensions.cs @@ -0,0 +1,45 @@ +namespace CodexContractsPlugin +{ + public class TestToken : IComparable + { + public TestToken(decimal amount) + { + Amount = amount; + } + + public decimal Amount { get; } + + public int CompareTo(TestToken? other) + { + return Amount.CompareTo(other!.Amount); + } + + public override bool Equals(object? obj) + { + return obj is TestToken token && Amount == token.Amount; + } + + public override int GetHashCode() + { + return HashCode.Combine(Amount); + } + + public override string ToString() + { + return $"{Amount} TestTokens"; + } + } + + public static class TokensIntExtensions + { + public static TestToken TestTokens(this int i) + { + return TestTokens(Convert.ToDecimal(i)); + } + + public static TestToken TestTokens(this decimal i) + { + return new TestToken(i); + } + } +} diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index 6e82eb3..724f2c4 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -80,8 +80,9 @@ namespace CodexPlugin if (config.MarketplaceConfig != null) { var mconfig = config.MarketplaceConfig; - var ip = mconfig.GethNode.RunningContainer.Pod.PodInfo.Ip; - var port = mconfig.GethNode.WsPort.Number; + var gethStart = mconfig.GethNode.StartResult; + var ip = gethStart.RunningContainer.Pod.PodInfo.Ip; + var port = gethStart.WsPort.Number; var marketplaceAddress = mconfig.CodexContracts.MarketplaceAddress; AddEnvVar("CODEX_ETH_PROVIDER", $"ws://{ip}:{port}"); diff --git a/CodexPlugin/OnlineCodexNode.cs b/CodexPlugin/CodexNode.cs similarity index 86% rename from CodexPlugin/OnlineCodexNode.cs rename to CodexPlugin/CodexNode.cs index 4ee798a..6b006c0 100644 --- a/CodexPlugin/OnlineCodexNode.cs +++ b/CodexPlugin/CodexNode.cs @@ -1,5 +1,6 @@ using Core; using FileUtils; +using GethPlugin; using KubernetesWorkflow; using Logging; using MetricsPlugin; @@ -8,28 +9,29 @@ using Utils; namespace CodexPlugin { - public interface IOnlineCodexNode : IHasContainer + public interface ICodexNode : IHasContainer, IHasMetricsScrapeTarget, IHasEthAddress { string GetName(); CodexDebugResponse GetDebugInfo(); CodexDebugPeerResponse GetDebugPeer(string peerId); ContentId UploadFile(TrackedFile file); TrackedFile? DownloadContent(ContentId contentId, string fileLabel = ""); - void ConnectToPeer(IOnlineCodexNode node); + void ConnectToPeer(ICodexNode node); CodexDebugVersionResponse Version { get; } - void BringOffline(); - IMetricsScrapeTarget MetricsScrapeTarget { get; } + void Stop(); } - public class OnlineCodexNode : IOnlineCodexNode + public class CodexNode : ICodexNode { private const string SuccessfullyConnectedMessage = "Successfully connected to peer"; private const string UploadFailedMessage = "Unable to store block"; private readonly IPluginTools tools; + private readonly IEthAddress? ethAddress; - public OnlineCodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group) + public CodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group, IEthAddress? ethAddress) { this.tools = tools; + this.ethAddress = ethAddress; CodexAccess = codexAccess; Group = group; Version = new CodexDebugVersionResponse(); @@ -48,6 +50,14 @@ namespace CodexPlugin return new MetricsScrapeTarget(CodexAccess.Container, port); } } + public IEthAddress EthAddress + { + get + { + if (ethAddress == null) throw new Exception("Marketplace is not enabled for this Codex node. Please start it with the option '.EnableMarketplace(...)' to enable it."); + return ethAddress; + } + } public string GetName() { @@ -95,9 +105,9 @@ namespace CodexPlugin return file; } - public void ConnectToPeer(IOnlineCodexNode node) + public void ConnectToPeer(ICodexNode node) { - var peer = (OnlineCodexNode)node; + var peer = (CodexNode)node; Log($"Connecting to peer {peer.GetName()}..."); var peerInfo = node.GetDebugInfo(); @@ -107,7 +117,7 @@ namespace CodexPlugin Log($"Successfully connected to peer {peer.GetName()}."); } - public void BringOffline() + public void Stop() { if (Group.Count() > 1) throw new InvalidOperationException("Codex-nodes that are part of a group cannot be " + "individually shut down. Use 'BringOffline()' on the group object to stop the group. This method is only " + @@ -132,7 +142,7 @@ namespace CodexPlugin Version = debugInfo.codex; } - private string GetPeerMultiAddress(OnlineCodexNode peer, CodexDebugResponse peerInfo) + private string GetPeerMultiAddress(CodexNode peer, CodexDebugResponse peerInfo) { var multiAddress = peerInfo.addrs.First(); // Todo: Is there a case where First address in list is not the way? diff --git a/CodexPlugin/CodexNodeFactory.cs b/CodexPlugin/CodexNodeFactory.cs index 89b69eb..cce9a61 100644 --- a/CodexPlugin/CodexNodeFactory.cs +++ b/CodexPlugin/CodexNodeFactory.cs @@ -1,10 +1,11 @@ using Core; +using GethPlugin; namespace CodexPlugin { public interface ICodexNodeFactory { - OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group); + CodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group); } public class CodexNodeFactory : ICodexNodeFactory @@ -27,11 +28,21 @@ namespace CodexPlugin // this.marketplaceAccessFactory = marketplaceAccessFactory; //} - public OnlineCodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group) + public CodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group) { + var ethAddress = GetEthAddress(access); + //var metricsAccess = metricsAccessFactory.CreateMetricsAccess(access.Container); //var marketplaceAccess = marketplaceAccessFactory.CreateMarketplaceAccess(access); - return new OnlineCodexNode(tools, access, group/*, metricsAccess, marketplaceAccess*/); + return new CodexNode(tools, access, group, ethAddress); + } + + private IEthAddress? GetEthAddress(CodexAccess access) + { + var mStart = access.Container.Recipe.Additionals.SingleOrDefault(a => a is MarketplaceStartResults) as MarketplaceStartResults; + if (mStart == null) return null; + return mStart.EthAddress; + } } } diff --git a/CodexPlugin/CodexNodeGroup.cs b/CodexPlugin/CodexNodeGroup.cs index 62158d7..cff4f8c 100644 --- a/CodexPlugin/CodexNodeGroup.cs +++ b/CodexPlugin/CodexNodeGroup.cs @@ -5,10 +5,10 @@ using System.Collections; namespace CodexPlugin { - public interface ICodexNodeGroup : IEnumerable, IManyMetricScrapeTargets + public interface ICodexNodeGroup : IEnumerable, IHasManyMetricScrapeTargets { void BringOffline(); - IOnlineCodexNode this[int index] { get; } + ICodexNode this[int index] { get; } } public class CodexNodeGroup : ICodexNodeGroup @@ -23,7 +23,7 @@ namespace CodexPlugin Version = new CodexDebugVersionResponse(); } - public IOnlineCodexNode this[int index] + public ICodexNode this[int index] { get { @@ -35,18 +35,18 @@ namespace CodexPlugin { starter.BringOffline(this); // Clear everything. Prevent accidental use. - Nodes = Array.Empty(); + Nodes = Array.Empty(); Containers = null!; } public RunningContainers[] Containers { get; private set; } - public OnlineCodexNode[] Nodes { get; private set; } + public CodexNode[] Nodes { get; private set; } public CodexDebugVersionResponse Version { get; private set; } public IMetricsScrapeTarget[] ScrapeTargets => Nodes.Select(n => n.MetricsScrapeTarget).ToArray(); - public IEnumerator GetEnumerator() + public IEnumerator GetEnumerator() { - return Nodes.Cast().GetEnumerator(); + return Nodes.Cast().GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() @@ -74,7 +74,7 @@ namespace CodexPlugin Version = first; } - private OnlineCodexNode CreateOnlineCodexNode(RunningContainer c, IPluginTools tools, ICodexNodeFactory factory) + private CodexNode CreateOnlineCodexNode(RunningContainer c, IPluginTools tools, ICodexNodeFactory factory) { var access = new CodexAccess(tools, c); return factory.CreateOnlineCodexNode(access, this); diff --git a/CodexPlugin/CodexSetup.cs b/CodexPlugin/CodexSetup.cs index 672165d..1817791 100644 --- a/CodexPlugin/CodexSetup.cs +++ b/CodexPlugin/CodexSetup.cs @@ -10,7 +10,7 @@ namespace CodexPlugin ICodexSetup WithLogLevel(CodexLogLevel logLevel); ICodexSetup WithName(string name); ICodexSetup At(Location location); - ICodexSetup WithBootstrapNode(IOnlineCodexNode node); + ICodexSetup WithBootstrapNode(ICodexNode node); ICodexSetup WithStorageQuota(ByteSize storageQuota); ICodexSetup WithBlockTTL(TimeSpan duration); ICodexSetup WithBlockMaintenanceInterval(TimeSpan duration); @@ -50,7 +50,7 @@ namespace CodexPlugin return this; } - public ICodexSetup WithBootstrapNode(IOnlineCodexNode node) + public ICodexSetup WithBootstrapNode(ICodexNode node) { BootstrapSpr = node.GetDebugInfo().spr; return this; diff --git a/CodexPlugin/CoreInterfaceExtensions.cs b/CodexPlugin/CoreInterfaceExtensions.cs index 75b779f..544c718 100644 --- a/CodexPlugin/CoreInterfaceExtensions.cs +++ b/CodexPlugin/CoreInterfaceExtensions.cs @@ -15,12 +15,12 @@ namespace CodexPlugin return Plugin(ci).WrapCodexContainers(containers); } - public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci) + public static ICodexNode SetupCodexNode(this CoreInterface ci) { return ci.SetupCodexNodes(1)[0]; } - public static IOnlineCodexNode SetupCodexNode(this CoreInterface ci, Action setup) + public static ICodexNode SetupCodexNode(this CoreInterface ci, Action setup) { return ci.SetupCodexNodes(1, setup)[0]; } diff --git a/CodexPlugin/MarketplaceStartResults.cs b/CodexPlugin/MarketplaceStartResults.cs index dd73ed6..6273c1e 100644 --- a/CodexPlugin/MarketplaceStartResults.cs +++ b/CodexPlugin/MarketplaceStartResults.cs @@ -1,15 +1,17 @@ -namespace CodexPlugin +using GethPlugin; + +namespace CodexPlugin { [Serializable] public class MarketplaceStartResults { - public MarketplaceStartResults(string ethAddress, string privateKey) + public MarketplaceStartResults(IEthAddress ethAddress, string privateKey) { EthAddress = ethAddress; PrivateKey = privateKey; } - public string EthAddress { get; } + public IEthAddress EthAddress { get; } public string PrivateKey { get; } } } diff --git a/CodexPlugin/MarketplaceStarter.cs b/CodexPlugin/MarketplaceStarter.cs index dc9d525..42a5851 100644 --- a/CodexPlugin/MarketplaceStarter.cs +++ b/CodexPlugin/MarketplaceStarter.cs @@ -1,4 +1,5 @@ -using Nethereum.Hex.HexConvertors.Extensions; +using GethPlugin; +using Nethereum.Hex.HexConvertors.Extensions; using Nethereum.Web3.Accounts; namespace CodexPlugin @@ -10,8 +11,9 @@ namespace CodexPlugin var ecKey = Nethereum.Signer.EthECKey.GenerateKey(); var privateKey = ecKey.GetPrivateKeyAsBytes().ToHex(); var account = new Account(privateKey); + var ethAddress = new EthAddress(account.Address); - return new MarketplaceStartResults(account.Address, account.PrivateKey); + return new MarketplaceStartResults(ethAddress, account.PrivateKey); } } } diff --git a/GethPlugin/CoreInterfaceExtensions.cs b/GethPlugin/CoreInterfaceExtensions.cs index 34d9d2f..b486c5c 100644 --- a/GethPlugin/CoreInterfaceExtensions.cs +++ b/GethPlugin/CoreInterfaceExtensions.cs @@ -6,7 +6,8 @@ namespace GethPlugin { public static IGethNode StartGethNode(this CoreInterface ci, Action setup) { - return Plugin(ci).StartGeth(setup); + var p = Plugin(ci); + return p.WrapGethContainer(p.StartGeth(setup)); } private static GethPlugin Plugin(CoreInterface ci) diff --git a/GethPlugin/EthAddress.cs b/GethPlugin/EthAddress.cs new file mode 100644 index 0000000..893a76d --- /dev/null +++ b/GethPlugin/EthAddress.cs @@ -0,0 +1,22 @@ +namespace GethPlugin +{ + public interface IEthAddress + { + string Address { get; } + } + + public interface IHasEthAddress + { + IEthAddress EthAddress { get; } + } + + public class EthAddress : IEthAddress + { + public EthAddress(string address) + { + Address = address; + } + + public string Address { get; } + } +} diff --git a/DistTestCore/Tokens.cs b/GethPlugin/EthTokenExtensions.cs similarity index 50% rename from DistTestCore/Tokens.cs rename to GethPlugin/EthTokenExtensions.cs index 5593ffc..950a7e1 100644 --- a/DistTestCore/Tokens.cs +++ b/GethPlugin/EthTokenExtensions.cs @@ -1,13 +1,15 @@ -namespace DistTestCore +namespace GethPlugin { public class Ether : IComparable { public Ether(decimal wei) { Wei = wei; + Eth = wei / TokensIntExtensions.WeiPerEth; } public decimal Wei { get; } + public decimal Eth { get; } public int CompareTo(Ether? other) { @@ -30,49 +32,9 @@ } } - public class TestToken : IComparable - { - public TestToken(decimal amount) - { - Amount = amount; - } - - public decimal Amount { get; } - - public int CompareTo(TestToken? other) - { - return Amount.CompareTo(other!.Amount); - } - - public override bool Equals(object? obj) - { - return obj is TestToken token && Amount == token.Amount; - } - - public override int GetHashCode() - { - return HashCode.Combine(Amount); - } - - public override string ToString() - { - return $"{Amount} TestTokens"; - } - } - public static class TokensIntExtensions { - private const decimal weiPerEth = 1000000000000000000; - - public static TestToken TestTokens(this int i) - { - return TestTokens(Convert.ToDecimal(i)); - } - - public static TestToken TestTokens(this decimal i) - { - return new TestToken(i); - } + public const decimal WeiPerEth = 1000000000000000000; public static Ether Eth(this int i) { @@ -86,7 +48,7 @@ public static Ether Eth(this decimal i) { - return new Ether(i * weiPerEth); + return new Ether(i * WeiPerEth); } public static Ether Wei(this decimal i) diff --git a/GethPlugin/GethNode.cs b/GethPlugin/GethNode.cs index bb096b2..99bfbf9 100644 --- a/GethPlugin/GethNode.cs +++ b/GethPlugin/GethNode.cs @@ -1,47 +1,67 @@ -using KubernetesWorkflow; -using Logging; +using Logging; using NethereumWorkflow; namespace GethPlugin { public interface IGethNode { - RunningContainer RunningContainer { get; } - Port DiscoveryPort { get; } - Port HttpPort { get; } - Port WsPort { get; } + IGethStartResult StartResult { get; } - NethereumInteraction StartInteraction(ILog log); + NethereumInteraction StartInteraction(); + Ether GetEthBalance(); + Ether GetEthBalance(IHasEthAddress address); + Ether GetEthBalance(IEthAddress address); + void SendEth(IHasEthAddress account, Ether eth); + void SendEth(IEthAddress account, Ether eth); } public class GethNode : IGethNode { - public GethNode(RunningContainer runningContainer, AllGethAccounts allAccounts, string pubKey, Port discoveryPort, Port httpPort, Port wsPort) + private readonly ILog log; + + public GethNode(ILog log, IGethStartResult startResult) { - RunningContainer = runningContainer; - AllAccounts = allAccounts; - Account = allAccounts.Accounts[0]; - PubKey = pubKey; - DiscoveryPort = discoveryPort; - HttpPort = httpPort; - WsPort = wsPort; + this.log = log; + StartResult = startResult; + Account = startResult.AllAccounts.Accounts.First(); } - public RunningContainer RunningContainer { get; } - public AllGethAccounts AllAccounts { get; } + public IGethStartResult StartResult { get; } public GethAccount Account { get; } - public string PubKey { get; } - public Port DiscoveryPort { get; } - public Port HttpPort { get; } - public Port WsPort { get; } - public NethereumInteraction StartInteraction(ILog log) + public NethereumInteraction StartInteraction() { - var address = RunningContainer.Address; + var address = StartResult.RunningContainer.Address; var account = Account; var creator = new NethereumInteractionCreator(log, address.Host, address.Port, account.PrivateKey); return creator.CreateWorkflow(); } + + public Ether GetEthBalance() + { + return StartInteraction().GetEthBalance().Eth(); + } + + public Ether GetEthBalance(IHasEthAddress owner) + { + return GetEthBalance(owner.EthAddress); + } + + public Ether GetEthBalance(IEthAddress address) + { + return StartInteraction().GetEthBalance(address.Address).Eth(); + } + + public void SendEth(IHasEthAddress owner, Ether eth) + { + SendEth(owner.EthAddress, eth); + } + + public void SendEth(IEthAddress account, Ether eth) + { + var i = StartInteraction(); + i.SendEth(account.Address, eth.Eth); + } } } diff --git a/GethPlugin/GethPlugin.cs b/GethPlugin/GethPlugin.cs index 645e93a..ec31cde 100644 --- a/GethPlugin/GethPlugin.cs +++ b/GethPlugin/GethPlugin.cs @@ -1,16 +1,13 @@ using Core; -using KubernetesWorkflow; namespace GethPlugin { public class GethPlugin : IProjectPlugin, IHasLogPrefix, IHasMetadata { - private readonly IPluginTools tools; private readonly GethStarter starter; public GethPlugin(IPluginTools tools) { - this.tools = tools; starter = new GethStarter(tools); } @@ -30,24 +27,16 @@ namespace GethPlugin { } - public IGethNode StartGeth(Action setup) + public IGethStartResult StartGeth(Action setup) { var startupConfig = new GethStartupConfig(); setup(startupConfig); return starter.StartGeth(startupConfig); } - //public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) - //{ - // var codexSetup = new CodexSetup(numberOfNodes); - // codexSetup.LogLevel = defaultLogLevel; - // setup(codexSetup); - // return codexStarter.BringOnline(codexSetup); - //} - - //public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) - //{ - // return codexStarter.WrapCodexContainers(containers); - //} + public IGethNode WrapGethContainer(IGethStartResult startResult) + { + return starter.WrapGethContainer(startResult); + } } } diff --git a/GethPlugin/GethStartResult.cs b/GethPlugin/GethStartResult.cs index 79a04a3..90bb895 100644 --- a/GethPlugin/GethStartResult.cs +++ b/GethPlugin/GethStartResult.cs @@ -1,19 +1,34 @@ -//using Newtonsoft.Json; +using KubernetesWorkflow; -//namespace GethPlugin -//{ -// public class GethStartResult -// { -// public GethStartResult(IMarketplaceAccessFactory marketplaceAccessFactory, MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) -// { -// MarketplaceAccessFactory = marketplaceAccessFactory; -// MarketplaceNetwork = marketplaceNetwork; -// CompanionNode = companionNode; -// } +namespace GethPlugin +{ + public interface IGethStartResult + { + RunningContainer RunningContainer { get; } + Port DiscoveryPort { get; } + Port HttpPort { get; } + Port WsPort { get; } + AllGethAccounts AllAccounts { get; } + string PubKey { get; } + } -// [JsonIgnore] -// public IMarketplaceAccessFactory MarketplaceAccessFactory { get; } -// public MarketplaceNetwork MarketplaceNetwork { get; } -// public GethCompanionNodeInfo CompanionNode { get; } -// } -//} + public class GethStartResult : IGethStartResult + { + public GethStartResult(RunningContainer runningContainer, Port discoveryPort, Port httpPort, Port wsPort, AllGethAccounts allAccounts, string pubKey) + { + RunningContainer = runningContainer; + DiscoveryPort = discoveryPort; + HttpPort = httpPort; + WsPort = wsPort; + AllAccounts = allAccounts; + PubKey = pubKey; + } + + public RunningContainer RunningContainer { get; } + public Port DiscoveryPort { get; } + public Port HttpPort { get; } + public Port WsPort { get; } + public AllGethAccounts AllAccounts { get; } + public string PubKey { get; } + } +} diff --git a/GethPlugin/GethStarter.cs b/GethPlugin/GethStarter.cs index 8cc1b8e..f6b6c22 100644 --- a/GethPlugin/GethStarter.cs +++ b/GethPlugin/GethStarter.cs @@ -12,7 +12,7 @@ namespace GethPlugin this.tools = tools; } - public IGethNode StartGeth(GethStartupConfig gethStartupConfig) + public IGethStartResult StartGeth(GethStartupConfig gethStartupConfig) { Log("Starting Geth bootstrap node..."); @@ -28,7 +28,7 @@ namespace GethPlugin var extractor = new GethContainerInfoExtractor(tools.GetLog(), workflow, container); var accounts = extractor.ExtractAccounts(); var pubKey = extractor.ExtractPubKey(); - + var discoveryPort = container.Recipe.GetPortByTag(GethContainerRecipe.DiscoveryPortTag); if (discoveryPort == null) throw new Exception("Expected discovery port to be created."); var httpPort = container.Recipe.GetPortByTag(GethContainerRecipe.HttpPortTag); @@ -36,11 +36,14 @@ namespace GethPlugin var wsPort = container.Recipe.GetPortByTag(GethContainerRecipe.wsPortTag); if (wsPort == null) throw new Exception("Expected ws port to be created."); - var result = new GethNode(container, accounts, pubKey, discoveryPort, httpPort, wsPort); + Log($"Geth node started."); - Log($"Geth bootstrap node started with account '{result.Account.Account}'"); + return new GethStartResult(container, discoveryPort, httpPort, wsPort, accounts, pubKey); + } - return result; + public IGethNode WrapGethContainer(IGethStartResult startResult) + { + return new GethNode(tools.GetLog(), startResult); } private void Log(string msg) diff --git a/MetricsPlugin/CoreInterfaceExtensions.cs b/MetricsPlugin/CoreInterfaceExtensions.cs index 46a7021..98d36f0 100644 --- a/MetricsPlugin/CoreInterfaceExtensions.cs +++ b/MetricsPlugin/CoreInterfaceExtensions.cs @@ -6,6 +6,11 @@ namespace MetricsPlugin { public static class CoreInterfaceExtensions { + public static RunningContainer StartMetricsCollector(this CoreInterface ci, params IHasMetricsScrapeTarget[] scrapeTargets) + { + return Plugin(ci).StartMetricsCollector(scrapeTargets.Select(t => t.MetricsScrapeTarget).ToArray()); + } + public static RunningContainer StartMetricsCollector(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) { return Plugin(ci).StartMetricsCollector(scrapeTargets); @@ -16,11 +21,16 @@ namespace MetricsPlugin return Plugin(ci).CreateAccessForTarget(metricsContainer, scrapeTarget); } - public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IManyMetricScrapeTargets[] manyScrapeTargets) + public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IHasManyMetricScrapeTargets[] manyScrapeTargets) { return ci.GetMetricsFor(manyScrapeTargets.SelectMany(t => t.ScrapeTargets).ToArray()); } + public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IHasMetricsScrapeTarget[] scrapeTargets) + { + return ci.GetMetricsFor(scrapeTargets.Select(t => t.MetricsScrapeTarget).ToArray()); + } + public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) { var rc = ci.StartMetricsCollector(scrapeTargets); diff --git a/MetricsPlugin/MetricsScrapeTarget.cs b/MetricsPlugin/MetricsScrapeTarget.cs index e2e1979..2c7bda6 100644 --- a/MetricsPlugin/MetricsScrapeTarget.cs +++ b/MetricsPlugin/MetricsScrapeTarget.cs @@ -9,7 +9,12 @@ namespace MetricsPlugin int Port { get; } } - public interface IManyMetricScrapeTargets + public interface IHasMetricsScrapeTarget + { + IMetricsScrapeTarget MetricsScrapeTarget { get; } + } + + public interface IHasManyMetricScrapeTargets { IMetricsScrapeTarget[] ScrapeTargets { get; } } diff --git a/Nethereum/NethereumInteraction.cs b/Nethereum/NethereumInteraction.cs index dad4633..2e595a6 100644 --- a/Nethereum/NethereumInteraction.cs +++ b/Nethereum/NethereumInteraction.cs @@ -2,6 +2,7 @@ using Nethereum.ABI.FunctionEncoding.Attributes; using Nethereum.Contracts; using Nethereum.Hex.HexTypes; +using Nethereum.RPC.Eth.DTOs; using Nethereum.Web3; using System.Numerics; using Utils; @@ -19,6 +20,23 @@ namespace NethereumWorkflow this.web3 = web3; } + public void SendEth(string toAddress, decimal ethAmount) + { + var receipt = Time.Wait(web3.Eth.GetEtherTransferService().TransferEtherAndWaitForReceiptAsync(toAddress, ethAmount)); + if (!receipt.Succeeded()) throw new Exception("Unable to send Eth"); + } + + public decimal GetEthBalance() + { + return GetEthBalance(web3.TransactionManager.Account.Address); + } + + public decimal GetEthBalance(string address) + { + var balance = Time.Wait(web3.Eth.GetBalance.SendRequestAsync(address)); + return Web3.Convert.FromWei(balance.Value); + } + public string GetTokenAddress(string marketplaceAddress) { log.Debug(marketplaceAddress); diff --git a/Tests/AutoBootstrapDistTest.cs b/Tests/AutoBootstrapDistTest.cs index fce68b8..046ff0a 100644 --- a/Tests/AutoBootstrapDistTest.cs +++ b/Tests/AutoBootstrapDistTest.cs @@ -1,13 +1,11 @@ using CodexPlugin; -using DistTestCore; -using DistTestCore.Helpers; using NUnit.Framework; namespace Tests { public class AutoBootstrapDistTest : CodexDistTest { - private readonly List onlineCodexNodes = new List(); + private readonly List onlineCodexNodes = new List(); [SetUp] public void SetUpBootstrapNode() @@ -21,6 +19,6 @@ namespace Tests if (BootstrapNode != null) setup.WithBootstrapNode(BootstrapNode); } - protected IOnlineCodexNode? BootstrapNode { get; private set; } + protected ICodexNode? BootstrapNode { get; private set; } } } diff --git a/Tests/BasicTests/ContinuousSubstitute.cs b/Tests/BasicTests/ContinuousSubstitute.cs index cdcf2d3..5a4b32d 100644 --- a/Tests/BasicTests/ContinuousSubstitute.cs +++ b/Tests/BasicTests/ContinuousSubstitute.cs @@ -21,7 +21,7 @@ namespace Tests.BasicTests .WithBlockTTL(TimeSpan.FromMinutes(2)) .WithStorageQuota(1.GB())); - var nodes = group.Cast().ToArray(); + var nodes = group.Cast().ToArray(); foreach (var node in nodes) { @@ -58,7 +58,7 @@ namespace Tests.BasicTests .WithBlockTTL(TimeSpan.FromMinutes(2)) .WithStorageQuota(1.GB())); - var nodes = group.Cast().ToArray(); + var nodes = group.Cast().ToArray(); var checkTime = DateTime.UtcNow + TimeSpan.FromMinutes(1); var endTime = DateTime.UtcNow + TimeSpan.FromHours(10); @@ -75,7 +75,7 @@ namespace Tests.BasicTests } } - private void CheckRoutingTables(IEnumerable nodes) + private void CheckRoutingTables(IEnumerable nodes) { var all = nodes.ToArray(); var allIds = all.Select(n => n.GetDebugInfo().table.localNode.nodeId).ToArray(); @@ -88,7 +88,7 @@ namespace Tests.BasicTests } } - private string AreAllPresent(IOnlineCodexNode n, string[] allIds) + private string AreAllPresent(ICodexNode n, string[] allIds) { var info = n.GetDebugInfo(); var known = info.table.nodes.Select(n => n.nodeId).ToArray(); @@ -104,7 +104,7 @@ namespace Tests.BasicTests private ByteSize fileSize = 80.MB(); - private void PerformTest(IOnlineCodexNode primary, IOnlineCodexNode secondary) + private void PerformTest(ICodexNode primary, ICodexNode secondary) { ScopedTestFiles(() => { @@ -129,7 +129,7 @@ namespace Tests.BasicTests .WithBlockMaintenanceNumber(10000) .WithStorageQuota(2000.MB())); - var nodes = group.Cast().ToArray(); + var nodes = group.Cast().ToArray(); var endTime = DateTime.UtcNow + TimeSpan.FromHours(24); diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index ef519af..a907276 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -34,7 +34,7 @@ namespace Tests.BasicTests var primary2 = group2[0]; var secondary2 = group2[1]; - var metrics = Ci.GetMetricsFor(primary.MetricsScrapeTarget, primary2.MetricsScrapeTarget); + var metrics = Ci.GetMetricsFor(primary, primary2); primary.ConnectToPeer(secondary); primary2.ConnectToPeer(secondary2); @@ -54,9 +54,9 @@ namespace Tests.BasicTests var node = Ci.SetupCodexNode(s => s.EnableMarketplace(geth, contracts)); - var i = 0; - - //geth.SendEth(node.EthAddress, 10.Eth()); + var myBalance = geth.GetEthBalance(); + geth.SendEth(node, 10.Eth()); + var nodeBalance = geth.GetEthBalance(node); //contracts.MintTestTokens(geth, node.EthAddress, 100.TestTokens()); @@ -64,7 +64,7 @@ namespace Tests.BasicTests //contracts.GetTestTokenBalance(geth, node.EthAddress); - + var i = 0; //var sellerInitialBalance = 234.TestTokens(); diff --git a/Tests/BasicTests/NetworkIsolationTest.cs b/Tests/BasicTests/NetworkIsolationTest.cs index 38e9a49..c6e5491 100644 --- a/Tests/BasicTests/NetworkIsolationTest.cs +++ b/Tests/BasicTests/NetworkIsolationTest.cs @@ -12,7 +12,7 @@ namespace Tests.BasicTests [Ignore("Disabled until a solution is implemented.")] public class NetworkIsolationTest : DistTest { - private IOnlineCodexNode? node = null; + private ICodexNode? node = null; [Test] public void SetUpANodeAndWait() diff --git a/Tests/BasicTests/OneClientTests.cs b/Tests/BasicTests/OneClientTests.cs index 728d718..e878e14 100644 --- a/Tests/BasicTests/OneClientTests.cs +++ b/Tests/BasicTests/OneClientTests.cs @@ -21,14 +21,14 @@ namespace Tests.BasicTests { var primary = Ci.SetupCodexNode(); - primary.BringOffline(); + primary.Stop(); primary = Ci.SetupCodexNode(); PerformOneClientTest(primary); } - private void PerformOneClientTest(IOnlineCodexNode primary) + private void PerformOneClientTest(ICodexNode primary) { var testFile = GenerateTestFile(1.MB()); diff --git a/Tests/BasicTests/TwoClientTests.cs b/Tests/BasicTests/TwoClientTests.cs index 7c34053..5f0db78 100644 --- a/Tests/BasicTests/TwoClientTests.cs +++ b/Tests/BasicTests/TwoClientTests.cs @@ -29,12 +29,12 @@ namespace Tests.BasicTests PerformTwoClientTest(primary, secondary); } - private void PerformTwoClientTest(IOnlineCodexNode primary, IOnlineCodexNode secondary) + private void PerformTwoClientTest(ICodexNode primary, ICodexNode secondary) { PerformTwoClientTest(primary, secondary, 1.MB()); } - private void PerformTwoClientTest(IOnlineCodexNode primary, IOnlineCodexNode secondary, ByteSize size) + private void PerformTwoClientTest(ICodexNode primary, ICodexNode secondary, ByteSize size) { primary.ConnectToPeer(secondary); diff --git a/Tests/CodexDistTest.cs b/Tests/CodexDistTest.cs index 6ecd203..d52fc79 100644 --- a/Tests/CodexDistTest.cs +++ b/Tests/CodexDistTest.cs @@ -6,14 +6,14 @@ namespace Tests { public class CodexDistTest : DistTest { - private readonly List onlineCodexNodes = new List(); + private readonly List onlineCodexNodes = new List(); - public IOnlineCodexNode AddCodex() + public ICodexNode AddCodex() { return AddCodex(s => { }); } - public IOnlineCodexNode AddCodex(Action setup) + public ICodexNode AddCodex(Action setup) { return AddCodex(1, setup)[0]; } @@ -44,7 +44,7 @@ namespace Tests return new PeerDownloadTestHelpers(GetTestLog(), Get().GetFileManager()); } - public IEnumerable GetAllOnlineCodexNodes() + public IEnumerable GetAllOnlineCodexNodes() { return onlineCodexNodes; } diff --git a/Tests/Helpers/FullConnectivityHelper.cs b/Tests/Helpers/FullConnectivityHelper.cs index 419f54d..e0ef335 100644 --- a/Tests/Helpers/FullConnectivityHelper.cs +++ b/Tests/Helpers/FullConnectivityHelper.cs @@ -23,12 +23,12 @@ namespace DistTestCore.Helpers this.implementation = implementation; } - public void AssertFullyConnected(IEnumerable nodes) + public void AssertFullyConnected(IEnumerable nodes) { AssertFullyConnected(nodes.ToArray()); } - private void AssertFullyConnected(IOnlineCodexNode[] nodes) + private void AssertFullyConnected(ICodexNode[] nodes) { Log($"Asserting '{implementation.Description()}' for nodes: '{string.Join(",", nodes.Select(n => n.GetName()))}'..."); var entries = CreateEntries(nodes); @@ -67,7 +67,7 @@ namespace DistTestCore.Helpers Log($"Connections successful:{Nl}{string.Join(Nl, results)}"); } - private Entry[] CreateEntries(IOnlineCodexNode[] nodes) + private Entry[] CreateEntries(ICodexNode[] nodes) { var entries = nodes.Select(n => new Entry(n)).ToArray(); @@ -107,13 +107,13 @@ namespace DistTestCore.Helpers public class Entry { - public Entry(IOnlineCodexNode node) + public Entry(ICodexNode node) { Node = node; Response = node.GetDebugInfo(); } - public IOnlineCodexNode Node { get; } + public ICodexNode Node { get; } public CodexDebugResponse Response { get; } public override string ToString() diff --git a/Tests/Helpers/PeerConnectionTestHelpers.cs b/Tests/Helpers/PeerConnectionTestHelpers.cs index e5f4d83..bde5aeb 100644 --- a/Tests/Helpers/PeerConnectionTestHelpers.cs +++ b/Tests/Helpers/PeerConnectionTestHelpers.cs @@ -13,7 +13,7 @@ namespace DistTestCore.Helpers helper = new FullConnectivityHelper(log, this); } - public void AssertFullyConnected(IEnumerable nodes) + public void AssertFullyConnected(IEnumerable nodes) { helper.AssertFullyConnected(nodes); } diff --git a/Tests/Helpers/PeerDownloadTestHelpers.cs b/Tests/Helpers/PeerDownloadTestHelpers.cs index 3af375d..6ad178f 100644 --- a/Tests/Helpers/PeerDownloadTestHelpers.cs +++ b/Tests/Helpers/PeerDownloadTestHelpers.cs @@ -21,7 +21,7 @@ namespace DistTestCore.Helpers this.fileManager = fileManager; } - public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) + public void AssertFullDownloadInterconnectivity(IEnumerable nodes, ByteSize testFileSize) { this.testFileSize = testFileSize; helper.AssertFullyConnected(nodes); @@ -62,12 +62,12 @@ namespace DistTestCore.Helpers // Should an exception occur during upload, then this try is inconclusive and we try again next loop. } - private TrackedFile? DownloadFile(IOnlineCodexNode node, ContentId contentId, string label) + private TrackedFile? DownloadFile(ICodexNode node, ContentId contentId, string label) { return node.DownloadContent(contentId, label); } - private TrackedFile GenerateTestFile(IOnlineCodexNode uploader, IOnlineCodexNode downloader) + private TrackedFile GenerateTestFile(ICodexNode uploader, ICodexNode downloader) { var up = uploader.GetName().Replace("<", "").Replace(">", ""); var down = downloader.GetName().Replace("<", "").Replace(">", ""); From 6cf86af3b5cfc109b802662ead3c4a00f2c65a05 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 19 Sep 2023 13:39:24 +0200 Subject: [PATCH 34/51] Successful testtoken mint and balance --- CodexContractsPlugin/CodexContractsAccess.cs | 26 ++- CodexContractsPlugin/CodexContractsStarter.cs | 4 +- CodexContractsPlugin/ContractInteractions.cs | 81 +++++++++ GethPlugin/GethNode.cs | 35 ++-- Nethereum/ConversionExtensions.cs | 30 ++++ Nethereum/NethereumInteraction.cs | 157 +++++------------- Tests/BasicTests/ExampleTests.cs | 10 +- 7 files changed, 201 insertions(+), 142 deletions(-) create mode 100644 CodexContractsPlugin/ContractInteractions.cs create mode 100644 Nethereum/ConversionExtensions.cs diff --git a/CodexContractsPlugin/CodexContractsAccess.cs b/CodexContractsPlugin/CodexContractsAccess.cs index ba84a70..f4a4bf5 100644 --- a/CodexContractsPlugin/CodexContractsAccess.cs +++ b/CodexContractsPlugin/CodexContractsAccess.cs @@ -1,14 +1,23 @@ -namespace CodexContractsPlugin +using GethPlugin; +using Logging; + +namespace CodexContractsPlugin { public interface ICodexContracts { string MarketplaceAddress { get; } + + void MintTestTokens(IGethNode gethNode, IEthAddress ethAddress, TestToken testTokens); + TestToken GetTestTokenBalance(IGethNode gethNode, IEthAddress ethAddress); } public class CodexContractsAccess : ICodexContracts { - public CodexContractsAccess(string marketplaceAddress, string abi, string tokenAddress) + private readonly ILog log; + + public CodexContractsAccess(ILog log, string marketplaceAddress, string abi, string tokenAddress) { + this.log = log; MarketplaceAddress = marketplaceAddress; Abi = abi; TokenAddress = tokenAddress; @@ -17,5 +26,18 @@ public string MarketplaceAddress { get; } public string Abi { get; } public string TokenAddress { get; } + + public void MintTestTokens(IGethNode gethNode, IEthAddress ethAddress, TestToken testTokens) + { + var interaction = new ContractInteractions(log, gethNode); + interaction.MintTestTokens(ethAddress, testTokens.Amount, TokenAddress); + } + + public TestToken GetTestTokenBalance(IGethNode gethNode, IEthAddress ethAddress) + { + var interaction = new ContractInteractions(log, gethNode); + var balance = interaction.GetBalance(TokenAddress, ethAddress.Address); + return balance.TestTokens(); + } } } diff --git a/CodexContractsPlugin/CodexContractsStarter.cs b/CodexContractsPlugin/CodexContractsStarter.cs index 531f0c9..01640c1 100644 --- a/CodexContractsPlugin/CodexContractsStarter.cs +++ b/CodexContractsPlugin/CodexContractsStarter.cs @@ -38,12 +38,12 @@ namespace CodexContractsPlugin var marketplaceAddress = extractor.ExtractMarketplaceAddress(); var abi = extractor.ExtractMarketplaceAbi(); - var interaction = gethNode.StartInteraction(); + var interaction = new ContractInteractions(tools.GetLog(), gethNode); var tokenAddress = interaction.GetTokenAddress(marketplaceAddress); Log("Extract completed. Marketplace deployed."); - return new CodexContractsAccess(marketplaceAddress, abi, tokenAddress); + return new CodexContractsAccess(tools.GetLog(), marketplaceAddress, abi, tokenAddress); } private void Log(string msg) diff --git a/CodexContractsPlugin/ContractInteractions.cs b/CodexContractsPlugin/ContractInteractions.cs new file mode 100644 index 0000000..9e54b67 --- /dev/null +++ b/CodexContractsPlugin/ContractInteractions.cs @@ -0,0 +1,81 @@ +using GethPlugin; +using Logging; +using Nethereum.ABI.FunctionEncoding.Attributes; +using Nethereum.Contracts; +using NethereumWorkflow; +using System.Numerics; + +namespace CodexContractsPlugin +{ + public class ContractInteractions + { + private readonly ILog log; + private readonly IGethNode gethNode; + + public ContractInteractions(ILog log, IGethNode gethNode) + { + this.log = log; + this.gethNode = gethNode; + } + + public string GetTokenAddress(string marketplaceAddress) + { + log.Debug(marketplaceAddress); + var function = new GetTokenFunction(); + + return gethNode.Call(marketplaceAddress, function); + } + + public void MintTestTokens(IEthAddress address, decimal amount, string tokenAddress) + { + MintTokens(address.Address, amount, tokenAddress); + } + + public decimal GetBalance(string tokenAddress, string account) + { + log.Debug($"({tokenAddress}) {account}"); + var function = new GetTokenBalanceFunction + { + Owner = account + }; + + return gethNode.Call(tokenAddress, function).ToDecimal(); + } + + private void MintTokens(string account, decimal amount, string tokenAddress) + { + log.Debug($"({tokenAddress}) {amount} --> {account}"); + if (string.IsNullOrEmpty(account)) throw new ArgumentException("Invalid arguments for MintTestTokens"); + + var function = new MintTokensFunction + { + Holder = account, + Amount = amount.ToBig() + }; + + gethNode.SendTransaction(tokenAddress, function); + } + } + + [Function("token", "address")] + public class GetTokenFunction : FunctionMessage + { + } + + [Function("mint")] + public class MintTokensFunction : FunctionMessage + { + [Parameter("address", "holder", 1)] + public string Holder { get; set; } = string.Empty; + + [Parameter("uint256", "amount", 2)] + public BigInteger Amount { get; set; } + } + + [Function("balanceOf", "uint256")] + public class GetTokenBalanceFunction : FunctionMessage + { + [Parameter("address", "owner", 1)] + public string Owner { get; set; } = string.Empty; + } +} diff --git a/GethPlugin/GethNode.cs b/GethPlugin/GethNode.cs index 99bfbf9..3613904 100644 --- a/GethPlugin/GethNode.cs +++ b/GethPlugin/GethNode.cs @@ -1,4 +1,5 @@ using Logging; +using Nethereum.Contracts; using NethereumWorkflow; namespace GethPlugin @@ -7,12 +8,13 @@ namespace GethPlugin { IGethStartResult StartResult { get; } - NethereumInteraction StartInteraction(); Ether GetEthBalance(); Ether GetEthBalance(IHasEthAddress address); Ether GetEthBalance(IEthAddress address); void SendEth(IHasEthAddress account, Ether eth); void SendEth(IEthAddress account, Ether eth); + TResult Call(string contractAddress, TFunction function) where TFunction : FunctionMessage, new(); + void SendTransaction(string contractAddress, TFunction function) where TFunction : FunctionMessage, new(); } public class GethNode : IGethNode @@ -29,15 +31,6 @@ namespace GethPlugin public IGethStartResult StartResult { get; } public GethAccount Account { get; } - public NethereumInteraction StartInteraction() - { - var address = StartResult.RunningContainer.Address; - var account = Account; - - var creator = new NethereumInteractionCreator(log, address.Host, address.Port, account.PrivateKey); - return creator.CreateWorkflow(); - } - public Ether GetEthBalance() { return StartInteraction().GetEthBalance().Eth(); @@ -60,8 +53,26 @@ namespace GethPlugin public void SendEth(IEthAddress account, Ether eth) { - var i = StartInteraction(); - i.SendEth(account.Address, eth.Eth); + StartInteraction().SendEth(account.Address, eth.Eth); + } + + public TResult Call(string contractAddress, TFunction function) where TFunction : FunctionMessage, new() + { + return StartInteraction().Call(contractAddress, function); + } + + public void SendTransaction(string contractAddress, TFunction function) where TFunction : FunctionMessage, new() + { + StartInteraction().SendTransaction(contractAddress, function); + } + + private NethereumInteraction StartInteraction() + { + var address = StartResult.RunningContainer.Address; + var account = Account; + + var creator = new NethereumInteractionCreator(log, address.Host, address.Port, account.PrivateKey); + return creator.CreateWorkflow(); } } } diff --git a/Nethereum/ConversionExtensions.cs b/Nethereum/ConversionExtensions.cs new file mode 100644 index 0000000..22daa7c --- /dev/null +++ b/Nethereum/ConversionExtensions.cs @@ -0,0 +1,30 @@ +using Nethereum.Hex.HexTypes; +using System.Numerics; + +namespace NethereumWorkflow +{ + public static class ConversionExtensions + { + public static HexBigInteger ToHexBig(this decimal amount) + { + var bigint = ToBig(amount); + var str = bigint.ToString("X"); + return new HexBigInteger(str); + } + + public static BigInteger ToBig(this decimal amount) + { + return new BigInteger(amount); + } + + public static decimal ToDecimal(this HexBigInteger hexBigInteger) + { + return ToDecimal(hexBigInteger.Value); + } + + public static decimal ToDecimal(this BigInteger bigInteger) + { + return (decimal)bigInteger; + } + } +} diff --git a/Nethereum/NethereumInteraction.cs b/Nethereum/NethereumInteraction.cs index 2e595a6..a682ab4 100644 --- a/Nethereum/NethereumInteraction.cs +++ b/Nethereum/NethereumInteraction.cs @@ -1,10 +1,7 @@ using Logging; -using Nethereum.ABI.FunctionEncoding.Attributes; using Nethereum.Contracts; -using Nethereum.Hex.HexTypes; using Nethereum.RPC.Eth.DTOs; using Nethereum.Web3; -using System.Numerics; using Utils; namespace NethereumWorkflow @@ -37,128 +34,52 @@ namespace NethereumWorkflow return Web3.Convert.FromWei(balance.Value); } - public string GetTokenAddress(string marketplaceAddress) + public TResult Call(string contractAddress, TFunction function) where TFunction : FunctionMessage, new() { - log.Debug(marketplaceAddress); - var function = new GetTokenFunction(); - - var handler = web3.Eth.GetContractQueryHandler(); - return Time.Wait(handler.QueryAsync(marketplaceAddress, function)); + var handler = web3.Eth.GetContractQueryHandler(); + return Time.Wait(handler.QueryAsync(contractAddress, function)); } - public void MintTestTokens(string[] accounts, decimal amount, string tokenAddress) + public void SendTransaction(string contractAddress, TFunction function) where TFunction : FunctionMessage, new() { - if (amount < 1 || accounts.Length < 1) throw new ArgumentException("Invalid arguments for MintTestTokens"); - - var tasks = accounts.Select(a => MintTokens(a, amount, tokenAddress)); - - Task.WaitAll(tasks.ToArray()); + var handler = web3.Eth.GetContractTransactionHandler(); + var receipt = Time.Wait(handler.SendRequestAndWaitForReceiptAsync(contractAddress, function)); + if (!receipt.Succeeded()) throw new Exception("Unable to perform contract transaction."); } - public decimal GetBalance(string tokenAddress, string account) - { - log.Debug($"({tokenAddress}) {account}"); - var function = new GetTokenBalanceFunction - { - Owner = account - }; + //public bool IsSynced(string marketplaceAddress, string marketplaceAbi) + //{ + // try + // { + // return IsBlockNumberOK() && IsContractAvailable(marketplaceAddress, marketplaceAbi); + // } + // catch + // { + // return false; + // } + //} - var handler = web3.Eth.GetContractQueryHandler(); - return ToDecimal(Time.Wait(handler.QueryAsync(tokenAddress, function))); - } + //private bool IsBlockNumberOK() + //{ + // log.Debug(); + // var sync = Time.Wait(web3.Eth.Syncing.SendRequestAsync()); + // var number = Time.Wait(web3.Eth.Blocks.GetBlockNumber.SendRequestAsync()); + // var numberOfBlocks = number.ToDecimal(); + // return !sync.IsSyncing && numberOfBlocks > 256; + //} - public bool IsSynced(string marketplaceAddress, string marketplaceAbi) - { - try - { - return IsBlockNumberOK() && IsContractAvailable(marketplaceAddress, marketplaceAbi); - } - catch - { - return false; - } - } - - private Task MintTokens(string account, decimal amount, string tokenAddress) - { - log.Debug($"({tokenAddress}) {amount} --> {account}"); - if (string.IsNullOrEmpty(account)) throw new ArgumentException("Invalid arguments for MintTestTokens"); - - var function = new MintTokensFunction - { - Holder = account, - Amount = ToBig(amount) - }; - - var handler = web3.Eth.GetContractTransactionHandler(); - return handler.SendRequestAndWaitForReceiptAsync(tokenAddress, function); - } - - private bool IsBlockNumberOK() - { - log.Debug(); - var sync = Time.Wait(web3.Eth.Syncing.SendRequestAsync()); - var number = Time.Wait(web3.Eth.Blocks.GetBlockNumber.SendRequestAsync()); - var numberOfBlocks = ToDecimal(number); - return !sync.IsSyncing && numberOfBlocks > 256; - } - - private bool IsContractAvailable(string marketplaceAddress, string marketplaceAbi) - { - log.Debug(); - try - { - var contract = web3.Eth.GetContract(marketplaceAbi, marketplaceAddress); - return contract != null; - } - catch - { - return false; - } - } - - private HexBigInteger ToHexBig(decimal amount) - { - var bigint = ToBig(amount); - var str = bigint.ToString("X"); - return new HexBigInteger(str); - } - - private BigInteger ToBig(decimal amount) - { - return new BigInteger(amount); - } - - private decimal ToDecimal(HexBigInteger hexBigInteger) - { - return ToDecimal(hexBigInteger.Value); - } - - private decimal ToDecimal(BigInteger bigInteger) - { - return (decimal)bigInteger; - } - } - - [Function("token", "address")] - public class GetTokenFunction : FunctionMessage - { - } - - [Function("mint")] - public class MintTokensFunction : FunctionMessage - { - [Parameter("address", "holder", 1)] - public string Holder { get; set; } = string.Empty; - - [Parameter("uint256", "amount", 2)] - public BigInteger Amount { get; set; } - } - - [Function("balanceOf", "uint256")] - public class GetTokenBalanceFunction : FunctionMessage - { - [Parameter("address", "owner", 1)] - public string Owner { get; set; } = string.Empty; + //private bool IsContractAvailable(string marketplaceAddress, string marketplaceAbi) + //{ + // log.Debug(); + // try + // { + // var contract = web3.Eth.GetContract(marketplaceAbi, marketplaceAddress); + // return contract != null; + // } + // catch + // { + // return false; + // } + //} } } diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index a907276..5faaec1 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -58,14 +58,8 @@ namespace Tests.BasicTests geth.SendEth(node, 10.Eth()); var nodeBalance = geth.GetEthBalance(node); - //contracts.MintTestTokens(geth, node.EthAddress, 100.TestTokens()); - - //geth.GetEthBalance(node.EthAddress); - - //contracts.GetTestTokenBalance(geth, node.EthAddress); - - var i = 0; - + contracts.MintTestTokens(geth, node.EthAddress, 100.TestTokens()); + contracts.GetTestTokenBalance(geth, node.EthAddress); //var sellerInitialBalance = 234.TestTokens(); //var buyerInitialBalance = 1000.TestTokens(); From 4bc225f1d9a93f75ea1f8d3f7cda06fc6200dd18 Mon Sep 17 00:00:00 2001 From: benbierens Date: Tue, 19 Sep 2023 13:58:45 +0200 Subject: [PATCH 35/51] Cleanup --- .../CodexContractsContainerConfig.cs | 1 - .../CodexContractsContainerRecipe.cs | 4 +- CodexContractsPlugin/CodexContractsPlugin.cs | 17 +- CodexContractsPlugin/CodexContractsStarter.cs | 8 +- CodexContractsPlugin/ContractInteractions.cs | 23 ++ CodexContractsPlugin/MarketplaceAccess.cs | 243 ------------------ .../MarketplaceAccessFactory.cs | 41 --- CodexContractsPlugin/MarketplaceNetwork.cs | 21 -- CodexPlugin/CodexNodeFactory.cs | 15 -- CodexPlugin/CodexPlugin.cs | 4 +- CodexPlugin/CodexSetup.cs | 20 -- CodexPlugin/CodexStarter.cs | 61 +---- CodexPlugin/CoreInterfaceExtensions.cs | 2 +- CodexPlugin/MarketplaceInitialConfig.cs | 12 - GethPlugin/GethCompanionNodeInfo.cs | 26 -- GethPlugin/GethContainerInfoExtractor.cs | 39 --- GethPlugin/GethContainerRecipe.cs | 3 +- GethPlugin/GethNode.cs | 12 + GethPlugin/GethPlugin.cs | 6 +- GethPlugin/GethStarter.cs | 69 ----- Nethereum/NethereumInteraction.cs | 55 ++-- 21 files changed, 86 insertions(+), 596 deletions(-) delete mode 100644 CodexContractsPlugin/MarketplaceAccess.cs delete mode 100644 CodexContractsPlugin/MarketplaceAccessFactory.cs delete mode 100644 CodexContractsPlugin/MarketplaceNetwork.cs delete mode 100644 GethPlugin/GethCompanionNodeInfo.cs diff --git a/CodexContractsPlugin/CodexContractsContainerConfig.cs b/CodexContractsPlugin/CodexContractsContainerConfig.cs index 876c9b1..fc742a4 100644 --- a/CodexContractsPlugin/CodexContractsContainerConfig.cs +++ b/CodexContractsPlugin/CodexContractsContainerConfig.cs @@ -1,5 +1,4 @@ using GethPlugin; -using KubernetesWorkflow; namespace CodexContractsPlugin { diff --git a/CodexContractsPlugin/CodexContractsContainerRecipe.cs b/CodexContractsPlugin/CodexContractsContainerRecipe.cs index caf3be9..d9ec1f8 100644 --- a/CodexContractsPlugin/CodexContractsContainerRecipe.cs +++ b/CodexContractsPlugin/CodexContractsContainerRecipe.cs @@ -4,11 +4,13 @@ namespace CodexContractsPlugin { public class CodexContractsContainerRecipe : ContainerRecipeFactory { + public static string DockerImage { get; } = "codexstorage/codex-contracts-eth:latest-dist-tests"; + public const string MarketplaceAddressFilename = "/hardhat/deployments/codexdisttestnetwork/Marketplace.json"; public const string MarketplaceArtifactFilename = "/hardhat/artifacts/contracts/Marketplace.sol/Marketplace.json"; public override string AppName => "codex-contracts"; - public override string Image => "codexstorage/codex-contracts-eth:latest-dist-tests"; + public override string Image => DockerImage; protected override void Initialize(StartupConfig startupConfig) { diff --git a/CodexContractsPlugin/CodexContractsPlugin.cs b/CodexContractsPlugin/CodexContractsPlugin.cs index beeb233..6dadfe7 100644 --- a/CodexContractsPlugin/CodexContractsPlugin.cs +++ b/CodexContractsPlugin/CodexContractsPlugin.cs @@ -18,12 +18,12 @@ namespace CodexContractsPlugin public void Announce() { - //tools.GetLog().Log($"Loaded with Codex ID: '{codexStarter.GetCodexId()}'"); + tools.GetLog().Log($"Loaded Codex-Marketplace SmartContracts"); } public void AddMetadata(IAddMetadata metadata) { - //metadata.Add("codexid", codexStarter.GetCodexId()); + metadata.Add("codexcontractsid", CodexContractsContainerRecipe.DockerImage); } public void Decommission() @@ -34,18 +34,5 @@ namespace CodexContractsPlugin { return starter.Start(gethNode); } - - //public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) - //{ - // var codexSetup = new CodexSetup(numberOfNodes); - // codexSetup.LogLevel = defaultLogLevel; - // setup(codexSetup); - // return codexStarter.BringOnline(codexSetup); - //} - - //public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) - //{ - // return codexStarter.WrapCodexContainers(containers); - //} } } diff --git a/CodexContractsPlugin/CodexContractsStarter.cs b/CodexContractsPlugin/CodexContractsStarter.cs index 01640c1..92085bb 100644 --- a/CodexContractsPlugin/CodexContractsStarter.cs +++ b/CodexContractsPlugin/CodexContractsStarter.cs @@ -17,7 +17,7 @@ namespace CodexContractsPlugin public ICodexContracts Start(IGethNode gethNode) { - Log("Deploying Codex Marketplace..."); + Log("Deploying Codex SmartContracts..."); var workflow = tools.CreateWorkflow(); var startupConfig = CreateStartupConfig(gethNode); @@ -41,7 +41,11 @@ namespace CodexContractsPlugin var interaction = new ContractInteractions(tools.GetLog(), gethNode); var tokenAddress = interaction.GetTokenAddress(marketplaceAddress); - Log("Extract completed. Marketplace deployed."); + Log("Extract completed. Checking sync..."); + + Time.WaitUntil(() => interaction.IsSynced(marketplaceAddress, abi)); + + Log("Synced. Codex SmartContracts deployed."); return new CodexContractsAccess(tools.GetLog(), marketplaceAddress, abi, tokenAddress); } diff --git a/CodexContractsPlugin/ContractInteractions.cs b/CodexContractsPlugin/ContractInteractions.cs index 9e54b67..b7c64f3 100644 --- a/CodexContractsPlugin/ContractInteractions.cs +++ b/CodexContractsPlugin/ContractInteractions.cs @@ -42,6 +42,18 @@ namespace CodexContractsPlugin return gethNode.Call(tokenAddress, function).ToDecimal(); } + public bool IsSynced(string marketplaceAddress, string marketplaceAbi) + { + try + { + return IsBlockNumberOK() && IsContractAvailable(marketplaceAddress, marketplaceAbi); + } + catch + { + return false; + } + } + private void MintTokens(string account, decimal amount, string tokenAddress) { log.Debug($"({tokenAddress}) {amount} --> {account}"); @@ -55,6 +67,17 @@ namespace CodexContractsPlugin gethNode.SendTransaction(tokenAddress, function); } + + private bool IsBlockNumberOK() + { + var n = gethNode.GetSyncedBlockNumber(); + return n != null && n > 256; + } + + private bool IsContractAvailable(string marketplaceAddress, string marketplaceAbi) + { + return gethNode.IsContractAvailable(marketplaceAbi, marketplaceAddress); + } } [Function("token", "address")] diff --git a/CodexContractsPlugin/MarketplaceAccess.cs b/CodexContractsPlugin/MarketplaceAccess.cs deleted file mode 100644 index bee6cb7..0000000 --- a/CodexContractsPlugin/MarketplaceAccess.cs +++ /dev/null @@ -1,243 +0,0 @@ -//using DistTestCore.Codex; -//using DistTestCore.Helpers; -//using Logging; -//using Newtonsoft.Json; -//using NUnit.Framework; -//using NUnit.Framework.Constraints; -//using System.Numerics; -//using Utils; - -//namespace DistTestCore.Marketplace -//{ -// public interface IMarketplaceAccess -// { -// string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan maxDuration); -// StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration); -// void AssertThatBalance(IResolveConstraint constraint, string message = ""); -// TestToken GetBalance(); -// } - -// public class MarketplaceAccess : IMarketplaceAccess -// { -// private readonly TestLifecycle lifecycle; -// private readonly MarketplaceNetwork marketplaceNetwork; -// private readonly GethAccount account; -// private readonly CodexAccess codexAccess; - -// public MarketplaceAccess(TestLifecycle lifecycle, MarketplaceNetwork marketplaceNetwork, GethAccount account, CodexAccess codexAccess) -// { -// this.lifecycle = lifecycle; -// this.marketplaceNetwork = marketplaceNetwork; -// this.account = account; -// this.codexAccess = codexAccess; -// } - -// public StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration) -// { -// var request = new CodexSalesRequestStorageRequest -// { -// duration = ToDecInt(duration.TotalSeconds), -// proofProbability = ToDecInt(proofProbability), -// reward = ToDecInt(pricePerSlotPerSecond), -// collateral = ToDecInt(requiredCollateral), -// expiry = null, -// nodes = minRequiredNumberOfNodes, -// tolerance = null, -// }; - -// Log($"Requesting storage for: {contentId.Id}... (" + -// $"pricePerSlotPerSecond: {pricePerSlotPerSecond}, " + -// $"requiredCollateral: {requiredCollateral}, " + -// $"minRequiredNumberOfNodes: {minRequiredNumberOfNodes}, " + -// $"proofProbability: {proofProbability}, " + -// $"duration: {Time.FormatDuration(duration)})"); - -// var response = codexAccess.RequestStorage(request, contentId.Id); - -// if (response == "Purchasing not available") -// { -// throw new InvalidOperationException(response); -// } - -// Log($"Storage requested successfully. PurchaseId: '{response}'."); - -// return new StoragePurchaseContract(lifecycle.Log, codexAccess, response, duration); -// } - -// public string MakeStorageAvailable(ByteSize totalSpace, TestToken minPriceForTotalSpace, TestToken maxCollateral, TimeSpan maxDuration) -// { -// var request = new CodexSalesAvailabilityRequest -// { -// size = ToDecInt(totalSpace.SizeInBytes), -// duration = ToDecInt(maxDuration.TotalSeconds), -// maxCollateral = ToDecInt(maxCollateral), -// minPrice = ToDecInt(minPriceForTotalSpace) -// }; - -// Log($"Making storage available... (" + -// $"size: {totalSpace}, " + -// $"minPriceForTotalSpace: {minPriceForTotalSpace}, " + -// $"maxCollateral: {maxCollateral}, " + -// $"maxDuration: {Time.FormatDuration(maxDuration)})"); - -// var response = codexAccess.SalesAvailability(request); - -// Log($"Storage successfully made available. Id: {response.id}"); - -// return response.id; -// } - -// private string ToDecInt(double d) -// { -// var i = new BigInteger(d); -// return i.ToString("D"); -// } - -// public string ToDecInt(TestToken t) -// { -// var i = new BigInteger(t.Amount); -// return i.ToString("D"); -// } - -// public void AssertThatBalance(IResolveConstraint constraint, string message = "") -// { -// AssertHelpers.RetryAssert(constraint, GetBalance, message); -// } - -// public TestToken GetBalance() -// { -// var interaction = marketplaceNetwork.StartInteraction(lifecycle); -// var amount = interaction.GetBalance(marketplaceNetwork.Marketplace.TokenAddress, account.Account); -// var balance = new TestToken(amount); - -// Log($"Balance of {account.Account} is {balance}."); - -// return balance; -// } - -// private void Log(string msg) -// { -// lifecycle.Log.Log($"{codexAccess.Container.Name} {msg}"); -// } -// } - -// public class MarketplaceUnavailable : IMarketplaceAccess -// { -// public StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerBytePerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration) -// { -// Unavailable(); -// return null!; -// } - -// public string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan duration) -// { -// Unavailable(); -// return string.Empty; -// } - -// public void AssertThatBalance(IResolveConstraint constraint, string message = "") -// { -// Unavailable(); -// } - -// public TestToken GetBalance() -// { -// Unavailable(); -// return new TestToken(0); -// } - -// private void Unavailable() -// { -// Assert.Fail("Incorrect test setup: Marketplace was not enabled for this group of Codex nodes. Add 'EnableMarketplace(...)' after 'SetupCodexNodes()' to enable it."); -// throw new InvalidOperationException(); -// } -// } - -// public class StoragePurchaseContract -// { -// private readonly BaseLog log; -// private readonly CodexAccess codexAccess; -// private DateTime? contractStartUtc; - -// public StoragePurchaseContract(BaseLog log, CodexAccess codexAccess, string purchaseId, TimeSpan contractDuration) -// { -// this.log = log; -// this.codexAccess = codexAccess; -// PurchaseId = purchaseId; -// ContractDuration = contractDuration; -// } - -// public string PurchaseId { get; } -// public TimeSpan ContractDuration { get; } - -// public void WaitForStorageContractStarted() -// { -// WaitForStorageContractStarted(TimeSpan.FromSeconds(30)); -// } - -// public void WaitForStorageContractFinished() -// { -// if (!contractStartUtc.HasValue) -// { -// WaitForStorageContractStarted(); -// } -// var gracePeriod = TimeSpan.FromSeconds(10); -// var currentContractTime = DateTime.UtcNow - contractStartUtc!.Value; -// var timeout = (ContractDuration - currentContractTime) + gracePeriod; -// WaitForStorageContractState(timeout, "finished"); -// } - -// /// -// /// Wait for contract to start. Max timeout depends on contract filesize. Allows more time for larger files. -// /// -// public void WaitForStorageContractStarted(ByteSize contractFileSize) -// { -// var filesizeInMb = contractFileSize.SizeInBytes / (1024 * 1024); -// var maxWaitTime = TimeSpan.FromSeconds(filesizeInMb * 10.0); - -// WaitForStorageContractStarted(maxWaitTime); -// } - -// public void WaitForStorageContractStarted(TimeSpan timeout) -// { -// WaitForStorageContractState(timeout, "started"); -// contractStartUtc = DateTime.UtcNow; -// } - -// private void WaitForStorageContractState(TimeSpan timeout, string desiredState) -// { -// var lastState = ""; -// var waitStart = DateTime.UtcNow; - -// log.Log($"Waiting for {Time.FormatDuration(timeout)} for contract '{PurchaseId}' to reach state '{desiredState}'."); -// while (lastState != desiredState) -// { -// var purchaseStatus = codexAccess.GetPurchaseStatus(PurchaseId); -// var statusJson = JsonConvert.SerializeObject(purchaseStatus); -// if (purchaseStatus != null && purchaseStatus.state != lastState) -// { -// lastState = purchaseStatus.state; -// log.Debug("Purchase status: " + statusJson); -// } - -// Thread.Sleep(1000); - -// if (lastState == "errored") -// { -// Assert.Fail("Contract errored: " + statusJson); -// } - -// if (DateTime.UtcNow - waitStart > timeout) -// { -// Assert.Fail($"Contract did not reach '{desiredState}' within timeout. {statusJson}"); -// } -// } -// log.Log($"Contract '{desiredState}'."); -// } - -// public CodexStoragePurchase GetPurchaseStatus(string purchaseId) -// { -// return codexAccess.GetPurchaseStatus(purchaseId); -// } -// } -//} diff --git a/CodexContractsPlugin/MarketplaceAccessFactory.cs b/CodexContractsPlugin/MarketplaceAccessFactory.cs deleted file mode 100644 index efc6841..0000000 --- a/CodexContractsPlugin/MarketplaceAccessFactory.cs +++ /dev/null @@ -1,41 +0,0 @@ -//using DistTestCore.Codex; - -//namespace DistTestCore.Marketplace -//{ -// public interface IMarketplaceAccessFactory -// { -// IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access); -// } - -// public class MarketplaceUnavailableAccessFactory : IMarketplaceAccessFactory -// { -// public IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access) -// { -// return new MarketplaceUnavailable(); -// } -// } - -// public class GethMarketplaceAccessFactory : IMarketplaceAccessFactory -// { -// private readonly TestLifecycle lifecycle; -// private readonly MarketplaceNetwork marketplaceNetwork; - -// public GethMarketplaceAccessFactory(TestLifecycle lifecycle, MarketplaceNetwork marketplaceNetwork) -// { -// this.lifecycle = lifecycle; -// this.marketplaceNetwork = marketplaceNetwork; -// } - -// public IMarketplaceAccess CreateMarketplaceAccess(CodexAccess access) -// { -// var companionNode = GetGethCompanionNode(access); -// return new MarketplaceAccess(lifecycle, marketplaceNetwork, companionNode, access); -// } - -// private GethAccount GetGethCompanionNode(CodexAccess access) -// { -// var account = access.Container.Recipe.Additionals.Single(a => a is GethAccount); -// return (GethAccount)account; -// } -// } -//} diff --git a/CodexContractsPlugin/MarketplaceNetwork.cs b/CodexContractsPlugin/MarketplaceNetwork.cs deleted file mode 100644 index d828f37..0000000 --- a/CodexContractsPlugin/MarketplaceNetwork.cs +++ /dev/null @@ -1,21 +0,0 @@ -//using NethereumWorkflow; - -//namespace DistTestCore.Marketplace -//{ -// public class MarketplaceNetwork -// { -// public MarketplaceNetwork(GethBootstrapNodeInfo bootstrap, MarketplaceInfo marketplace) -// { -// Bootstrap = bootstrap; -// Marketplace = marketplace; -// } - -// public GethBootstrapNodeInfo Bootstrap { get; } -// public MarketplaceInfo Marketplace { get; } - -// public NethereumInteraction StartInteraction(TestLifecycle lifecycle) -// { -// return Bootstrap.StartInteraction(lifecycle); -// } -// } -//} diff --git a/CodexPlugin/CodexNodeFactory.cs b/CodexPlugin/CodexNodeFactory.cs index cce9a61..babae9e 100644 --- a/CodexPlugin/CodexNodeFactory.cs +++ b/CodexPlugin/CodexNodeFactory.cs @@ -17,23 +17,9 @@ namespace CodexPlugin this.tools = tools; } - //private readonly TestLifecycle lifecycle; - //private readonly IMetricsAccessFactory metricsAccessFactory; - //private readonly IMarketplaceAccessFactory marketplaceAccessFactory; - - //public CodexNodeFactory(TestLifecycle lifecycle, IMetricsAccessFactory metricsAccessFactory, IMarketplaceAccessFactory marketplaceAccessFactory) - //{ - // this.lifecycle = lifecycle; - // this.metricsAccessFactory = metricsAccessFactory; - // this.marketplaceAccessFactory = marketplaceAccessFactory; - //} - public CodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group) { var ethAddress = GetEthAddress(access); - - //var metricsAccess = metricsAccessFactory.CreateMetricsAccess(access.Container); - //var marketplaceAccess = marketplaceAccessFactory.CreateMarketplaceAccess(access); return new CodexNode(tools, access, group, ethAddress); } @@ -42,7 +28,6 @@ namespace CodexPlugin var mStart = access.Container.Recipe.Additionals.SingleOrDefault(a => a is MarketplaceStartResults) as MarketplaceStartResults; if (mStart == null) return null; return mStart.EthAddress; - } } } diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index 599ca24..fe63460 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -39,9 +39,9 @@ namespace CodexPlugin return codexStarter.BringOnline(codexSetup); } - public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) + public ICodexNodeGroup WrapCodexContainers(CoreInterface coreInterface, RunningContainers[] containers) { - return codexStarter.WrapCodexContainers(containers); + return codexStarter.WrapCodexContainers(coreInterface, containers); } } } diff --git a/CodexPlugin/CodexSetup.cs b/CodexPlugin/CodexSetup.cs index 1817791..00719a0 100644 --- a/CodexPlugin/CodexSetup.cs +++ b/CodexPlugin/CodexSetup.cs @@ -17,10 +17,6 @@ namespace CodexPlugin ICodexSetup WithBlockMaintenanceNumber(int numberOfBlocks); ICodexSetup EnableMetrics(); ICodexSetup EnableMarketplace(IGethNode gethNode, ICodexContracts codexContracts, bool isValidator = false); - - //ICodexSetup EnableMarketplace(TestToken initialBalance); - //ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther); - //ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther, bool isValidator); } public class CodexSetup : CodexStartupConfig, ICodexSetup @@ -92,22 +88,6 @@ namespace CodexPlugin return this; } - //public ICodexSetup EnableMarketplace(TestToken initialBalance) - //{ - // return EnableMarketplace(initialBalance, 1000.Eth()); - //} - - //public ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther) - //{ - // return EnableMarketplace(initialBalance, initialEther, false); - //} - - //public ICodexSetup EnableMarketplace(TestToken initialBalance, Ether initialEther, bool isValidator) - //{ - // MarketplaceConfig = new MarketplaceInitialConfig(initialEther, initialBalance, isValidator); - // return this; - //} - public string Describe() { var args = string.Join(',', DescribeArgs()); diff --git a/CodexPlugin/CodexStarter.cs b/CodexPlugin/CodexStarter.cs index 69a82e8..3c987da 100644 --- a/CodexPlugin/CodexStarter.cs +++ b/CodexPlugin/CodexStarter.cs @@ -19,19 +19,11 @@ namespace CodexPlugin { LogSeparator(); Log($"Starting {codexSetup.Describe()}..."); - //var gethStartResult = lifecycle.GethStarter.BringOnlineMarketplaceFor(codexSetup); - var startupConfig = CreateStartupConfig(/*gethStartResult,*/ codexSetup); + var startupConfig = CreateStartupConfig(codexSetup); var containers = StartCodexContainers(startupConfig, codexSetup.NumberOfNodes, codexSetup.Location); - //var metricAccessFactory = CollectMetrics(codexSetup, containers); - - //var codexNodeFactory = new CodexNodeFactory(lifecycle, metricAccessFactory, gethStartResult.MarketplaceAccessFactory); - - //var group = CreateCodexGroup(codexSetup, containers, codexNodeFactory); - //lifecycle.SetCodexVersion(group.Version); - var podInfos = string.Join(", ", containers.Containers().Select(c => $"Container: '{c.Name}' runs at '{c.Pod.PodInfo.K8SNodeName}'={c.Pod.PodInfo.Ip}")); Log($"Started {codexSetup.NumberOfNodes} nodes of image '{containers.Containers().First().Recipe.Image}'. ({podInfos})"); LogSeparator(); @@ -39,13 +31,11 @@ namespace CodexPlugin return containers; } - public ICodexNodeGroup WrapCodexContainers(RunningContainers[] containers) + public ICodexNodeGroup WrapCodexContainers(CoreInterface coreInterface, RunningContainers[] containers) { - //var metricAccessFactory = CollectMetrics(codexSetup, containers); + var codexNodeFactory = new CodexNodeFactory(pluginTools); - var codexNodeFactory = new CodexNodeFactory(pluginTools);// (lifecycle, metricAccessFactory, gethStartResult.MarketplaceAccessFactory); - - var group = CreateCodexGroup(containers, codexNodeFactory); + var group = CreateCodexGroup(coreInterface, containers, codexNodeFactory); Log($"Codex version: {group.Version}"); versionResponse = group.Version; @@ -71,39 +61,12 @@ namespace CodexPlugin return recipe.Image; } - //public void DeleteAllResources() - //{ - // //var workflow = CreateWorkflow(); - // //workflow.DeleteTestResources(); - //} - - //public void DownloadLog(RunningContainer container, ILogHandler logHandler, int? tailLines) - //{ - // //var workflow = CreateWorkflow(); - // //workflow.DownloadContainerLog(container, logHandler, tailLines); - //} - - //private IMetricsAccessFactory CollectMetrics(CodexSetup codexSetup, RunningContainers[] containers) - //{ - // if (codexSetup.MetricsMode == MetricsMode.None) return new MetricsUnavailableAccessFactory(); - - // var runningContainers = lifecycle.PrometheusStarter.CollectMetricsFor(containers); - - // if (codexSetup.MetricsMode == MetricsMode.Dashboard) - // { - // lifecycle.GrafanaStarter.StartDashboard(runningContainers.Containers.First(), codexSetup); - // } - - // return new CodexNodeMetricsAccessFactory(lifecycle, runningContainers); - //} - - private StartupConfig CreateStartupConfig(/*GethStartResult gethStartResult, */ CodexSetup codexSetup) + private StartupConfig CreateStartupConfig(CodexSetup codexSetup) { var startupConfig = new StartupConfig(); startupConfig.NameOverride = codexSetup.NameOverride; startupConfig.CreateCrashWatcher = true; startupConfig.Add(codexSetup); - //startupConfig.Add(gethStartResult); return startupConfig; } @@ -118,7 +81,7 @@ namespace CodexPlugin return result.ToArray(); } - private CodexNodeGroup CreateCodexGroup(RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) + private CodexNodeGroup CreateCodexGroup(CoreInterface coreInterface, RunningContainers[] runningContainers, CodexNodeFactory codexNodeFactory) { var group = new CodexNodeGroup(this, pluginTools, runningContainers, codexNodeFactory); @@ -128,25 +91,19 @@ namespace CodexPlugin } catch { - CodexNodesNotOnline(runningContainers); + CodexNodesNotOnline(coreInterface, runningContainers); throw; } return group; } - private void CodexNodesNotOnline(RunningContainers[] runningContainers) + private void CodexNodesNotOnline(CoreInterface coreInterface, RunningContainers[] runningContainers) { Log("Codex nodes failed to start"); - // todo: - //foreach (var container in runningContainers.Containers()) lifecycle.DownloadLog(container); + foreach (var container in runningContainers.Containers()) coreInterface.DownloadLog(container); } - //private StartupWorkflow CreateWorkflow() - //{ - // return lifecycle.WorkflowCreator.CreateWorkflow(); - //} - private void LogSeparator() { Log("----------------------------------------------------------------------------"); diff --git a/CodexPlugin/CoreInterfaceExtensions.cs b/CodexPlugin/CoreInterfaceExtensions.cs index 544c718..946fa88 100644 --- a/CodexPlugin/CoreInterfaceExtensions.cs +++ b/CodexPlugin/CoreInterfaceExtensions.cs @@ -12,7 +12,7 @@ namespace CodexPlugin public static ICodexNodeGroup WrapCodexContainers(this CoreInterface ci, RunningContainers[] containers) { - return Plugin(ci).WrapCodexContainers(containers); + return Plugin(ci).WrapCodexContainers(ci, containers); } public static ICodexNode SetupCodexNode(this CoreInterface ci) diff --git a/CodexPlugin/MarketplaceInitialConfig.cs b/CodexPlugin/MarketplaceInitialConfig.cs index f1f6af6..f6a4918 100644 --- a/CodexPlugin/MarketplaceInitialConfig.cs +++ b/CodexPlugin/MarketplaceInitialConfig.cs @@ -15,17 +15,5 @@ namespace CodexPlugin public IGethNode GethNode { get; } public ICodexContracts CodexContracts { get; } public bool IsValidator { get; } - - //public MarketplaceInitialConfig(Ether initialEth, TestToken initialTestTokens, bool isValidator) - //{ - // InitialEth = initialEth; - // InitialTestTokens = initialTestTokens; - // IsValidator = isValidator; - //} - - //public Ether InitialEth { get; } - //public TestToken InitialTestTokens { get; } - //public bool IsValidator { get; } - //public int? AccountIndexOverride { get; set; } } } diff --git a/GethPlugin/GethCompanionNodeInfo.cs b/GethPlugin/GethCompanionNodeInfo.cs deleted file mode 100644 index 8c349c3..0000000 --- a/GethPlugin/GethCompanionNodeInfo.cs +++ /dev/null @@ -1,26 +0,0 @@ -//using KubernetesWorkflow; -//using NethereumWorkflow; - -//namespace GethPlugin -//{ -// public class GethCompanionNodeInfo -// { -// public GethCompanionNodeInfo(RunningContainer runningContainer, GethAccount[] accounts) -// { -// RunningContainer = runningContainer; -// Accounts = accounts; -// } - -// public RunningContainer RunningContainer { get; } -// public GethAccount[] Accounts { get; } - -// public NethereumInteraction StartInteraction(TestLifecycle lifecycle, GethAccount account) -// { -// var address = lifecycle.Configuration.GetAddress(RunningContainer); -// var privateKey = account.PrivateKey; - -// var creator = new NethereumInteractionCreator(lifecycle.Log, address.Host, address.Port, privateKey); -// return creator.CreateWorkflow(); -// } -// } -//} diff --git a/GethPlugin/GethContainerInfoExtractor.cs b/GethPlugin/GethContainerInfoExtractor.cs index 649da4f..51c8728 100644 --- a/GethPlugin/GethContainerInfoExtractor.cs +++ b/GethPlugin/GethContainerInfoExtractor.cs @@ -36,45 +36,11 @@ namespace GethPlugin return pubKey; } - //public string ExtractMarketplaceAddress() - //{ - // log.Debug(); - // var marketplaceAddress = Retry(FetchMarketplaceAddress); - // if (string.IsNullOrEmpty(marketplaceAddress)) throw new InvalidOperationException("Unable to fetch marketplace account from codex-contracts node. Test infra failure."); - - // return marketplaceAddress; - //} - - //public string ExtractMarketplaceAbi() - //{ - // log.Debug(); - // var marketplaceAbi = Retry(FetchMarketplaceAbi); - // if (string.IsNullOrEmpty(marketplaceAbi)) throw new InvalidOperationException("Unable to fetch marketplace artifacts from codex-contracts node. Test infra failure."); - - // return marketplaceAbi; - //} - private string FetchAccountsCsv() { return workflow.ExecuteCommand(container, "cat", GethContainerRecipe.AccountsFilename); } - //private string FetchMarketplaceAddress() - //{ - // var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceAddressFilename); - // var marketplace = JsonConvert.DeserializeObject(json); - // return marketplace!.address; - //} - - //private string FetchMarketplaceAbi() - //{ - // var json = workflow.ExecuteCommand(container, "cat", CodexContractsContainerRecipe.MarketplaceArtifactFilename); - - // var artifact = JObject.Parse(json); - // var abi = artifact["abi"]; - // return abi!.ToString(Formatting.None); - //} - private string FetchPubKey() { var enodeFinder = new PubKeyFinder(s => log.Debug(s)); @@ -139,9 +105,4 @@ namespace GethPlugin length: closeIndex - openIndex); } } - - //public class MarketplaceJson - //{ - // public string address { get; set; } = string.Empty; - //} } diff --git a/GethPlugin/GethContainerRecipe.cs b/GethPlugin/GethContainerRecipe.cs index 44673a8..5214e0b 100644 --- a/GethPlugin/GethContainerRecipe.cs +++ b/GethPlugin/GethContainerRecipe.cs @@ -4,6 +4,7 @@ namespace GethPlugin { public class GethContainerRecipe : ContainerRecipeFactory { + public static string DockerImage { get; } = "codexstorage/dist-tests-geth:latest"; private const string defaultArgs = "--ipcdisable --syncmode full"; public const string HttpPortTag = "http_port"; @@ -12,7 +13,7 @@ namespace GethPlugin public const string AccountsFilename = "accounts.csv"; public override string AppName => "geth"; - public override string Image => "codexstorage/dist-tests-geth:latest"; + public override string Image => DockerImage; protected override void Initialize(StartupConfig startupConfig) { diff --git a/GethPlugin/GethNode.cs b/GethPlugin/GethNode.cs index 3613904..170896b 100644 --- a/GethPlugin/GethNode.cs +++ b/GethPlugin/GethNode.cs @@ -15,6 +15,8 @@ namespace GethPlugin void SendEth(IEthAddress account, Ether eth); TResult Call(string contractAddress, TFunction function) where TFunction : FunctionMessage, new(); void SendTransaction(string contractAddress, TFunction function) where TFunction : FunctionMessage, new(); + decimal? GetSyncedBlockNumber(); + bool IsContractAvailable(string abi, string contractAddress); } public class GethNode : IGethNode @@ -74,5 +76,15 @@ namespace GethPlugin var creator = new NethereumInteractionCreator(log, address.Host, address.Port, account.PrivateKey); return creator.CreateWorkflow(); } + + public decimal? GetSyncedBlockNumber() + { + return StartInteraction().GetSyncedBlockNumber(); + } + + public bool IsContractAvailable(string abi, string contractAddress) + { + return StartInteraction().IsContractAvailable(abi, contractAddress); + } } } diff --git a/GethPlugin/GethPlugin.cs b/GethPlugin/GethPlugin.cs index ec31cde..9c2f0bb 100644 --- a/GethPlugin/GethPlugin.cs +++ b/GethPlugin/GethPlugin.cs @@ -5,22 +5,24 @@ namespace GethPlugin public class GethPlugin : IProjectPlugin, IHasLogPrefix, IHasMetadata { private readonly GethStarter starter; + private readonly IPluginTools tools; public GethPlugin(IPluginTools tools) { starter = new GethStarter(tools); + this.tools = tools; } public string LogPrefix => "(Geth) "; public void Announce() { - //tools.GetLog().Log($"Loaded with Codex ID: '{codexStarter.GetCodexId()}'"); + tools.GetLog().Log($"Loaded Geth plugin."); } public void AddMetadata(IAddMetadata metadata) { - //metadata.Add("codexid", codexStarter.GetCodexId()); + metadata.Add("gethid", GethContainerRecipe.DockerImage); } public void Decommission() diff --git a/GethPlugin/GethStarter.cs b/GethPlugin/GethStarter.cs index f6b6c22..dc9246e 100644 --- a/GethPlugin/GethStarter.cs +++ b/GethPlugin/GethStarter.cs @@ -50,74 +50,5 @@ namespace GethPlugin { tools.GetLog().Log(msg); } - - //public GethStartResult BringOnlineMarketplaceFor(CodexSetup codexSetup) - //{ - // if (codexSetup.MarketplaceConfig == null) return CreateMarketplaceUnavailableResult(); - - // var marketplaceNetwork = marketplaceNetworkCache.Get(); - // var companionNode = StartCompanionNode(codexSetup, marketplaceNetwork); - - // LogStart("Setting up initial balance..."); - // TransferInitialBalance(marketplaceNetwork, codexSetup.MarketplaceConfig, companionNode); - // LogEnd($"Initial balance of {codexSetup.MarketplaceConfig.InitialTestTokens} set for {codexSetup.NumberOfNodes} nodes."); - - // return CreateGethStartResult(marketplaceNetwork, companionNode); - //} - - //private void TransferInitialBalance(MarketplaceNetwork marketplaceNetwork, MarketplaceInitialConfig marketplaceConfig, GethCompanionNodeInfo companionNode) - //{ - // if (marketplaceConfig.InitialTestTokens.Amount == 0) return; - - // var interaction = marketplaceNetwork.StartInteraction(lifecycle); - // var tokenAddress = marketplaceNetwork.Marketplace.TokenAddress; - - // var accounts = companionNode.Accounts.Select(a => a.Account).ToArray(); - // interaction.MintTestTokens(accounts, marketplaceConfig.InitialTestTokens.Amount, tokenAddress); - //} - - //private GethStartResult CreateGethStartResult(MarketplaceNetwork marketplaceNetwork, GethCompanionNodeInfo companionNode) - //{ - // return new GethStartResult(CreateMarketplaceAccessFactory(marketplaceNetwork), marketplaceNetwork, companionNode); - //} - - //private GethStartResult CreateMarketplaceUnavailableResult() - //{ - // return new GethStartResult(new MarketplaceUnavailableAccessFactory(), null!, null!); - //} - - //private IMarketplaceAccessFactory CreateMarketplaceAccessFactory(MarketplaceNetwork marketplaceNetwork) - //{ - // return new GethMarketplaceAccessFactory(lifecycle, marketplaceNetwork); - //} - - //private GethCompanionNodeInfo StartCompanionNode(CodexSetup codexSetup, MarketplaceNetwork marketplaceNetwork) - //{ - // return companionNodeStarter.StartCompanionNodeFor(codexSetup, marketplaceNetwork); - //} } - - //public class MarketplaceNetworkCache - //{ - // private readonly GethBootstrapNodeStarter bootstrapNodeStarter; - // private readonly CodexContractsStarter codexContractsStarter; - // private MarketplaceNetwork? network; - - // public MarketplaceNetworkCache(GethBootstrapNodeStarter bootstrapNodeStarter, CodexContractsStarter codexContractsStarter) - // { - // this.bootstrapNodeStarter = bootstrapNodeStarter; - // this.codexContractsStarter = codexContractsStarter; - // } - - // public MarketplaceNetwork Get() - // { - // if (network == null) - // { - // var bootstrapInfo = bootstrapNodeStarter.StartGethBootstrapNode(); - // var marketplaceInfo = codexContractsStarter.Start(bootstrapInfo); - // network = new MarketplaceNetwork(bootstrapInfo, marketplaceInfo); - // } - // return network; - // } - //} } diff --git a/Nethereum/NethereumInteraction.cs b/Nethereum/NethereumInteraction.cs index a682ab4..e3aa5bf 100644 --- a/Nethereum/NethereumInteraction.cs +++ b/Nethereum/NethereumInteraction.cs @@ -47,39 +47,28 @@ namespace NethereumWorkflow if (!receipt.Succeeded()) throw new Exception("Unable to perform contract transaction."); } - //public bool IsSynced(string marketplaceAddress, string marketplaceAbi) - //{ - // try - // { - // return IsBlockNumberOK() && IsContractAvailable(marketplaceAddress, marketplaceAbi); - // } - // catch - // { - // return false; - // } - //} + public decimal? GetSyncedBlockNumber() + { + log.Debug(); + var sync = Time.Wait(web3.Eth.Syncing.SendRequestAsync()); + var number = Time.Wait(web3.Eth.Blocks.GetBlockNumber.SendRequestAsync()); + var numberOfBlocks = number.ToDecimal(); + if (sync.IsSyncing) return null; + return numberOfBlocks; + } - //private bool IsBlockNumberOK() - //{ - // log.Debug(); - // var sync = Time.Wait(web3.Eth.Syncing.SendRequestAsync()); - // var number = Time.Wait(web3.Eth.Blocks.GetBlockNumber.SendRequestAsync()); - // var numberOfBlocks = number.ToDecimal(); - // return !sync.IsSyncing && numberOfBlocks > 256; - //} - - //private bool IsContractAvailable(string marketplaceAddress, string marketplaceAbi) - //{ - // log.Debug(); - // try - // { - // var contract = web3.Eth.GetContract(marketplaceAbi, marketplaceAddress); - // return contract != null; - // } - // catch - // { - // return false; - // } - //} + public bool IsContractAvailable(string abi, string contractAddress) + { + log.Debug(); + try + { + var contract = web3.Eth.GetContract(abi, contractAddress); + return contract != null; + } + catch + { + return false; + } + } } } From 6cbf363bb1cca809dc4b9aa1b403a6504fc6b973 Mon Sep 17 00:00:00 2001 From: ThatBen Date: Tue, 19 Sep 2023 16:22:07 +0200 Subject: [PATCH 36/51] restores balance asserts. --- CodexContractsPlugin/CodexContractsAccess.cs | 12 +++ Tests/BasicTests/ExampleTests.cs | 90 +++++++++----------- Tests/CodexDistTest.cs | 11 ++- 3 files changed, 64 insertions(+), 49 deletions(-) diff --git a/CodexContractsPlugin/CodexContractsAccess.cs b/CodexContractsPlugin/CodexContractsAccess.cs index f4a4bf5..c807c40 100644 --- a/CodexContractsPlugin/CodexContractsAccess.cs +++ b/CodexContractsPlugin/CodexContractsAccess.cs @@ -7,7 +7,9 @@ namespace CodexContractsPlugin { string MarketplaceAddress { get; } + void MintTestTokens(IGethNode gethNode, IHasEthAddress owner, TestToken testTokens); void MintTestTokens(IGethNode gethNode, IEthAddress ethAddress, TestToken testTokens); + TestToken GetTestTokenBalance(IGethNode gethNode, IHasEthAddress owner); TestToken GetTestTokenBalance(IGethNode gethNode, IEthAddress ethAddress); } @@ -27,12 +29,22 @@ namespace CodexContractsPlugin public string Abi { get; } public string TokenAddress { get; } + public void MintTestTokens(IGethNode gethNode, IHasEthAddress owner, TestToken testTokens) + { + MintTestTokens(gethNode, owner.EthAddress, testTokens); + } + public void MintTestTokens(IGethNode gethNode, IEthAddress ethAddress, TestToken testTokens) { var interaction = new ContractInteractions(log, gethNode); interaction.MintTestTokens(ethAddress, testTokens.Amount, TokenAddress); } + public TestToken GetTestTokenBalance(IGethNode gethNode, IHasEthAddress owner) + { + return GetTestTokenBalance(gethNode, owner.EthAddress); + } + public TestToken GetTestTokenBalance(IGethNode gethNode, IEthAddress ethAddress) { var interaction = new ContractInteractions(log, gethNode); diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 5faaec1..9a00c18 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -9,12 +9,12 @@ using Utils; namespace Tests.BasicTests { [TestFixture] - public class ExampleTests : DistTest + public class ExampleTests : CodexDistTest { [Test] public void CodexLogExample() { - var primary = Ci.SetupCodexNode(); + var primary = AddCodex(); primary.UploadFile(GenerateTestFile(5.MB())); @@ -26,8 +26,8 @@ namespace Tests.BasicTests [Test] public void TwoMetricsExample() { - var group = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); - var group2 = Ci.SetupCodexNodes(2, s => s.EnableMetrics()); + var group = AddCodex(2, s => s.EnableMetrics()); + var group2 = AddCodex(2, s => s.EnableMetrics()); var primary = group[0]; var secondary = group[1]; @@ -48,58 +48,52 @@ namespace Tests.BasicTests [Test] public void MarketplaceExample() { - var geth = Ci.StartGethNode(s => s.IsMiner().WithName("disttest-geth")); + var sellerInitialBalance = 234.TestTokens(); + var buyerInitialBalance = 1000.TestTokens(); + var fileSize = 10.MB(); + var geth = Ci.StartGethNode(s => s.IsMiner().WithName("disttest-geth")); var contracts = Ci.DeployCodexContracts(geth); - var node = Ci.SetupCodexNode(s => s.EnableMarketplace(geth, contracts)); - - var myBalance = geth.GetEthBalance(); - geth.SendEth(node, 10.Eth()); - var nodeBalance = geth.GetEthBalance(node); - - contracts.MintTestTokens(geth, node.EthAddress, 100.TestTokens()); - contracts.GetTestTokenBalance(geth, node.EthAddress); - - //var sellerInitialBalance = 234.TestTokens(); - //var buyerInitialBalance = 1000.TestTokens(); - //var fileSize = 10.MB(); - - //var seller = Ci.SetupCodexNode(s => s - // .WithStorageQuota(11.GB()) - // .EnableMarketplace(sellerInitialBalance)); - - //seller.Marketplace.AssertThatBalance(Is.EqualTo(sellerInitialBalance)); - //seller.Marketplace.MakeStorageAvailable( - // size: 10.GB(), - // minPricePerBytePerSecond: 1.TestTokens(), - // maxCollateral: 20.TestTokens(), - // maxDuration: TimeSpan.FromMinutes(3)); - - //var testFile = GenerateTestFile(fileSize); - - //var buyer = Ci.SetupCodexNode(s => s - // .WithBootstrapNode(seller) - // .EnableMarketplace(buyerInitialBalance)); - - //buyer.Marketplace.AssertThatBalance(Is.EqualTo(buyerInitialBalance)); + var seller = AddCodex(s => s + .WithStorageQuota(11.GB()) + .EnableMarketplace(geth, contracts)); + geth.SendEth(seller, 10.Eth()); + contracts.MintTestTokens(geth, seller, sellerInitialBalance); - //var contentId = buyer.UploadFile(testFile); - //var purchaseContract = buyer.Marketplace.RequestStorage(contentId, - // pricePerSlotPerSecond: 2.TestTokens(), - // requiredCollateral: 10.TestTokens(), - // minRequiredNumberOfNodes: 1, - // proofProbability: 5, - // duration: TimeSpan.FromMinutes(1)); + AssertBalance(geth, contracts, seller, Is.EqualTo(sellerInitialBalance)); + seller.Marketplace.MakeStorageAvailable( + size: 10.GB(), + minPricePerBytePerSecond: 1.TestTokens(), + maxCollateral: 20.TestTokens(), + maxDuration: TimeSpan.FromMinutes(3)); - //purchaseContract.WaitForStorageContractStarted(fileSize); + var testFile = GenerateTestFile(fileSize); - //seller.Marketplace.AssertThatBalance(Is.LessThan(sellerInitialBalance), "Collateral was not placed."); + var buyer = AddCodex(s => s + .WithBootstrapNode(seller) + .EnableMarketplace(geth, contracts)); + geth.SendEth(seller, 10.Eth()); + contracts.MintTestTokens(geth, seller, buyerInitialBalance); - //purchaseContract.WaitForStorageContractFinished(); + AssertBalance(geth, contracts, buyer, Is.EqualTo(buyerInitialBalance)); - //seller.Marketplace.AssertThatBalance(Is.GreaterThan(sellerInitialBalance), "Seller was not paid for storage."); - //buyer.Marketplace.AssertThatBalance(Is.LessThan(buyerInitialBalance), "Buyer was not charged for storage."); + var contentId = buyer.UploadFile(testFile); + var purchaseContract = buyer.Marketplace.RequestStorage(contentId, + pricePerSlotPerSecond: 2.TestTokens(), + requiredCollateral: 10.TestTokens(), + minRequiredNumberOfNodes: 1, + proofProbability: 5, + duration: TimeSpan.FromMinutes(1)); + + purchaseContract.WaitForStorageContractStarted(fileSize); + + AssertBalance(geth, contracts, seller, Is.LessThan(sellerInitialBalance), "Collateral was not placed."); + + purchaseContract.WaitForStorageContractFinished(); + + AssertBalance(geth, contracts, seller, Is.GreaterThan(sellerInitialBalance), "Seller was not paid for storage."); + AssertBalance(geth, contracts, buyer, Is.LessThan(buyerInitialBalance), "Buyer was not charged for storage."); } } } diff --git a/Tests/CodexDistTest.cs b/Tests/CodexDistTest.cs index d52fc79..6c33676 100644 --- a/Tests/CodexDistTest.cs +++ b/Tests/CodexDistTest.cs @@ -1,6 +1,10 @@ -using CodexPlugin; +using CodexContractsPlugin; +using CodexPlugin; using DistTestCore; using DistTestCore.Helpers; +using GethPlugin; +using NUnit.Framework.Constraints; +using Utils; namespace Tests { @@ -49,6 +53,11 @@ namespace Tests return onlineCodexNodes; } + public void AssertBalance(IGethNode gethNode, ICodexContracts contracts, ICodexNode codexNode, Constraint constraint, string msg = "") + { + AssertHelpers.RetryAssert(constraint, () => contracts.GetTestTokenBalance(gethNode, codexNode), nameof(AssertBalance) + msg); + } + protected virtual void OnCodexSetup(ICodexSetup setup) { } From cedaf8474031f21f8f5f6d4c98aa8bdb83f75fb6 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 08:45:55 +0200 Subject: [PATCH 37/51] Wires up marketplace access --- CodexPlugin/CodexContainerRecipe.cs | 6 - CodexPlugin/CodexNode.cs | 5 +- CodexPlugin/CodexNodeFactory.cs | 9 +- CodexPlugin/CodexPlugin.cs | 25 ++- CodexPlugin/CodexSetup.cs | 6 +- CodexPlugin/CoreInterfaceExtensions.cs | 4 +- CodexPlugin/MarketplaceAccess.cs | 208 ++++++++++++++++++++++++ CodexPlugin/MarketplaceInitialConfig.cs | 6 +- Tests/BasicTests/ExampleTests.cs | 11 +- 9 files changed, 256 insertions(+), 24 deletions(-) create mode 100644 CodexPlugin/MarketplaceAccess.cs diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index 724f2c4..e173fc6 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -111,12 +111,6 @@ namespace CodexPlugin return 8.GB().Multiply(1.2); } - //private int GetAccountIndex(MarketplaceInitialConfig marketplaceConfig) - //{ - // if (marketplaceConfig.AccountIndexOverride != null) return marketplaceConfig.AccountIndexOverride.Value; - // return Index; - //} - private string GetDockerImage() { var image = Environment.GetEnvironmentVariable("CODEXDOCKERIMAGE"); diff --git a/CodexPlugin/CodexNode.cs b/CodexPlugin/CodexNode.cs index 6b006c0..eb17a49 100644 --- a/CodexPlugin/CodexNode.cs +++ b/CodexPlugin/CodexNode.cs @@ -18,6 +18,7 @@ namespace CodexPlugin TrackedFile? DownloadContent(ContentId contentId, string fileLabel = ""); void ConnectToPeer(ICodexNode node); CodexDebugVersionResponse Version { get; } + IMarketplaceAccess Marketplace { get; } void Stop(); } @@ -28,18 +29,20 @@ namespace CodexPlugin private readonly IPluginTools tools; private readonly IEthAddress? ethAddress; - public CodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group, IEthAddress? ethAddress) + public CodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group, IMarketplaceAccess marketplaceAccess, IEthAddress? ethAddress) { this.tools = tools; this.ethAddress = ethAddress; CodexAccess = codexAccess; Group = group; + Marketplace = marketplaceAccess; Version = new CodexDebugVersionResponse(); } public RunningContainer Container { get { return CodexAccess.Container; } } public CodexAccess CodexAccess { get; } public CodexNodeGroup Group { get; } + public IMarketplaceAccess Marketplace { get; } public CodexDebugVersionResponse Version { get; private set; } public IMetricsScrapeTarget MetricsScrapeTarget { diff --git a/CodexPlugin/CodexNodeFactory.cs b/CodexPlugin/CodexNodeFactory.cs index babae9e..f340fde 100644 --- a/CodexPlugin/CodexNodeFactory.cs +++ b/CodexPlugin/CodexNodeFactory.cs @@ -20,7 +20,14 @@ namespace CodexPlugin public CodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group) { var ethAddress = GetEthAddress(access); - return new CodexNode(tools, access, group, ethAddress); + var marketplaceAccess = GetMarketplaceAccess(access, ethAddress); + return new CodexNode(tools, access, group, marketplaceAccess, ethAddress); + } + + private IMarketplaceAccess GetMarketplaceAccess(CodexAccess codexAccess, IEthAddress? ethAddress) + { + if (ethAddress == null) return new MarketplaceUnavailable(); + return new MarketplaceAccess(tools.GetLog(), codexAccess); } private IEthAddress? GetEthAddress(CodexAccess access) diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index fe63460..b2ae282 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -33,9 +33,7 @@ namespace CodexPlugin public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) { - var codexSetup = new CodexSetup(numberOfNodes); - codexSetup.LogLevel = defaultLogLevel; - setup(codexSetup); + var codexSetup = GetSetup(numberOfNodes, setup); return codexStarter.BringOnline(codexSetup); } @@ -43,5 +41,26 @@ namespace CodexPlugin { return codexStarter.WrapCodexContainers(coreInterface, containers); } + + public void WireUpMarketplace(ICodexNodeGroup result, Action setup) + { + var codexSetup = GetSetup(1, setup); + if (codexSetup.MarketplaceConfig == null) return; + + var mconfig = codexSetup.MarketplaceConfig; + foreach (var node in result) + { + mconfig.GethNode.SendEth(node, mconfig.InitialEth); + mconfig.CodexContracts.MintTestTokens(mconfig.GethNode, node, mconfig.InitialTokens); + } + } + + private CodexSetup GetSetup(int numberOfNodes, Action setup) + { + var codexSetup = new CodexSetup(numberOfNodes); + codexSetup.LogLevel = defaultLogLevel; + setup(codexSetup); + return codexSetup; + } } } diff --git a/CodexPlugin/CodexSetup.cs b/CodexPlugin/CodexSetup.cs index 00719a0..46289a9 100644 --- a/CodexPlugin/CodexSetup.cs +++ b/CodexPlugin/CodexSetup.cs @@ -16,7 +16,7 @@ namespace CodexPlugin ICodexSetup WithBlockMaintenanceInterval(TimeSpan duration); ICodexSetup WithBlockMaintenanceNumber(int numberOfBlocks); ICodexSetup EnableMetrics(); - ICodexSetup EnableMarketplace(IGethNode gethNode, ICodexContracts codexContracts, bool isValidator = false); + ICodexSetup EnableMarketplace(IGethNode gethNode, ICodexContracts codexContracts, Ether initialEth, TestToken initialTokens, bool isValidator = false); } public class CodexSetup : CodexStartupConfig, ICodexSetup @@ -82,9 +82,9 @@ namespace CodexPlugin return this; } - public ICodexSetup EnableMarketplace(IGethNode gethNode, ICodexContracts codexContracts, bool isValidator = false) + public ICodexSetup EnableMarketplace(IGethNode gethNode, ICodexContracts codexContracts, Ether initialEth, TestToken initialTokens, bool isValidator = false) { - MarketplaceConfig = new MarketplaceInitialConfig(gethNode, codexContracts, isValidator); + MarketplaceConfig = new MarketplaceInitialConfig(gethNode, codexContracts, initialEth, initialTokens, isValidator); return this; } diff --git a/CodexPlugin/CoreInterfaceExtensions.cs b/CodexPlugin/CoreInterfaceExtensions.cs index 946fa88..c9cd6a4 100644 --- a/CodexPlugin/CoreInterfaceExtensions.cs +++ b/CodexPlugin/CoreInterfaceExtensions.cs @@ -28,7 +28,9 @@ namespace CodexPlugin public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number, Action setup) { var rc = ci.StartCodexNodes(number, setup); - return ci.WrapCodexContainers(rc); + var result = ci.WrapCodexContainers(rc); + Plugin(ci).WireUpMarketplace(result, setup); + return result; } public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number) diff --git a/CodexPlugin/MarketplaceAccess.cs b/CodexPlugin/MarketplaceAccess.cs new file mode 100644 index 0000000..2a0683a --- /dev/null +++ b/CodexPlugin/MarketplaceAccess.cs @@ -0,0 +1,208 @@ +using CodexContractsPlugin; +using Logging; +using Newtonsoft.Json; +using NUnit.Framework; +using Utils; +using System.Numerics; + +namespace CodexPlugin +{ + public interface IMarketplaceAccess + { + string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan maxDuration); + StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration); + } + + public class MarketplaceAccess : IMarketplaceAccess + { + private readonly ILog log; + private readonly CodexAccess codexAccess; + + public MarketplaceAccess(ILog log, CodexAccess codexAccess) + { + this.log = log; + this.codexAccess = codexAccess; + } + + public StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration) + { + var request = new CodexSalesRequestStorageRequest + { + duration = ToDecInt(duration.TotalSeconds), + proofProbability = ToDecInt(proofProbability), + reward = ToDecInt(pricePerSlotPerSecond), + collateral = ToDecInt(requiredCollateral), + expiry = null, + nodes = minRequiredNumberOfNodes, + tolerance = null, + }; + + Log($"Requesting storage for: {contentId.Id}... (" + + $"pricePerSlotPerSecond: {pricePerSlotPerSecond}, " + + $"requiredCollateral: {requiredCollateral}, " + + $"minRequiredNumberOfNodes: {minRequiredNumberOfNodes}, " + + $"proofProbability: {proofProbability}, " + + $"duration: {Time.FormatDuration(duration)})"); + + var response = codexAccess.RequestStorage(request, contentId.Id); + + if (response == "Purchasing not available") + { + throw new InvalidOperationException(response); + } + + Log($"Storage requested successfully. PurchaseId: '{response}'."); + + return new StoragePurchaseContract(log, codexAccess, response, duration); + } + + public string MakeStorageAvailable(ByteSize totalSpace, TestToken minPriceForTotalSpace, TestToken maxCollateral, TimeSpan maxDuration) + { + var request = new CodexSalesAvailabilityRequest + { + size = ToDecInt(totalSpace.SizeInBytes), + duration = ToDecInt(maxDuration.TotalSeconds), + maxCollateral = ToDecInt(maxCollateral), + minPrice = ToDecInt(minPriceForTotalSpace) + }; + + Log($"Making storage available... (" + + $"size: {totalSpace}, " + + $"minPriceForTotalSpace: {minPriceForTotalSpace}, " + + $"maxCollateral: {maxCollateral}, " + + $"maxDuration: {Time.FormatDuration(maxDuration)})"); + + var response = codexAccess.SalesAvailability(request); + + Log($"Storage successfully made available. Id: {response.id}"); + + return response.id; + } + + private string ToDecInt(double d) + { + var i = new BigInteger(d); + return i.ToString("D"); + } + + public string ToDecInt(TestToken t) + { + var i = new BigInteger(t.Amount); + return i.ToString("D"); + } + + private void Log(string msg) + { + log.Log($"{codexAccess.Container.Name} {msg}"); + } + } + + public class MarketplaceUnavailable : IMarketplaceAccess + { + public StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerBytePerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration) + { + Unavailable(); + return null!; + } + + public string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan duration) + { + Unavailable(); + return string.Empty; + } + + private void Unavailable() + { + Assert.Fail("Incorrect test setup: Marketplace was not enabled for this group of Codex nodes. Add 'EnableMarketplace(...)' after 'SetupCodexNodes()' to enable it."); + throw new InvalidOperationException(); + } + } + + public class StoragePurchaseContract + { + private readonly ILog log; + private readonly CodexAccess codexAccess; + private DateTime? contractStartUtc; + + public StoragePurchaseContract(ILog log, CodexAccess codexAccess, string purchaseId, TimeSpan contractDuration) + { + this.log = log; + this.codexAccess = codexAccess; + PurchaseId = purchaseId; + ContractDuration = contractDuration; + } + + public string PurchaseId { get; } + public TimeSpan ContractDuration { get; } + + public void WaitForStorageContractStarted() + { + WaitForStorageContractStarted(TimeSpan.FromSeconds(30)); + } + + public void WaitForStorageContractFinished() + { + if (!contractStartUtc.HasValue) + { + WaitForStorageContractStarted(); + } + var gracePeriod = TimeSpan.FromSeconds(10); + var currentContractTime = DateTime.UtcNow - contractStartUtc!.Value; + var timeout = (ContractDuration - currentContractTime) + gracePeriod; + WaitForStorageContractState(timeout, "finished"); + } + + /// + /// Wait for contract to start. Max timeout depends on contract filesize. Allows more time for larger files. + /// + public void WaitForStorageContractStarted(ByteSize contractFileSize) + { + var filesizeInMb = contractFileSize.SizeInBytes / (1024 * 1024); + var maxWaitTime = TimeSpan.FromSeconds(filesizeInMb * 10.0); + + WaitForStorageContractStarted(maxWaitTime); + } + + public void WaitForStorageContractStarted(TimeSpan timeout) + { + WaitForStorageContractState(timeout, "started"); + contractStartUtc = DateTime.UtcNow; + } + + private void WaitForStorageContractState(TimeSpan timeout, string desiredState) + { + var lastState = ""; + var waitStart = DateTime.UtcNow; + + log.Log($"Waiting for {Time.FormatDuration(timeout)} for contract '{PurchaseId}' to reach state '{desiredState}'."); + while (lastState != desiredState) + { + var purchaseStatus = codexAccess.GetPurchaseStatus(PurchaseId); + var statusJson = JsonConvert.SerializeObject(purchaseStatus); + if (purchaseStatus != null && purchaseStatus.state != lastState) + { + lastState = purchaseStatus.state; + log.Debug("Purchase status: " + statusJson); + } + + Thread.Sleep(1000); + + if (lastState == "errored") + { + Assert.Fail("Contract errored: " + statusJson); + } + + if (DateTime.UtcNow - waitStart > timeout) + { + Assert.Fail($"Contract did not reach '{desiredState}' within timeout. {statusJson}"); + } + } + log.Log($"Contract '{desiredState}'."); + } + + public CodexStoragePurchase GetPurchaseStatus(string purchaseId) + { + return codexAccess.GetPurchaseStatus(purchaseId); + } + } +} diff --git a/CodexPlugin/MarketplaceInitialConfig.cs b/CodexPlugin/MarketplaceInitialConfig.cs index f6a4918..7c92591 100644 --- a/CodexPlugin/MarketplaceInitialConfig.cs +++ b/CodexPlugin/MarketplaceInitialConfig.cs @@ -5,15 +5,19 @@ namespace CodexPlugin { public class MarketplaceInitialConfig { - public MarketplaceInitialConfig(IGethNode gethNode, ICodexContracts codexContracts, bool isValidator) + public MarketplaceInitialConfig(IGethNode gethNode, ICodexContracts codexContracts, Ether initialEth, TestToken initialTokens, bool isValidator) { GethNode = gethNode; CodexContracts = codexContracts; + InitialEth = initialEth; + InitialTokens = initialTokens; IsValidator = isValidator; } public IGethNode GethNode { get; } public ICodexContracts CodexContracts { get; } + public Ether InitialEth { get; } + public TestToken InitialTokens { get; } public bool IsValidator { get; } } } diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 9a00c18..40609a8 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -1,5 +1,4 @@ using CodexContractsPlugin; -using CodexPlugin; using DistTestCore; using GethPlugin; using MetricsPlugin; @@ -57,9 +56,7 @@ namespace Tests.BasicTests var seller = AddCodex(s => s .WithStorageQuota(11.GB()) - .EnableMarketplace(geth, contracts)); - geth.SendEth(seller, 10.Eth()); - contracts.MintTestTokens(geth, seller, sellerInitialBalance); + .EnableMarketplace(geth, contracts, initialEth: 10.Eth(), initialTokens: sellerInitialBalance)); AssertBalance(geth, contracts, seller, Is.EqualTo(sellerInitialBalance)); seller.Marketplace.MakeStorageAvailable( @@ -72,10 +69,8 @@ namespace Tests.BasicTests var buyer = AddCodex(s => s .WithBootstrapNode(seller) - .EnableMarketplace(geth, contracts)); - geth.SendEth(seller, 10.Eth()); - contracts.MintTestTokens(geth, seller, buyerInitialBalance); - + .EnableMarketplace(geth, contracts, initialEth: 10.Eth(), initialTokens: buyerInitialBalance)); + AssertBalance(geth, contracts, buyer, Is.EqualTo(buyerInitialBalance)); var contentId = buyer.UploadFile(testFile); From 5fa4e0ff9fd4ffa88d7cb2ab206fe204825befc8 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 09:16:57 +0200 Subject: [PATCH 38/51] Sets serialization gate between each deploy and wrap to ensure application lifecycle invariance. --- CodexContractsPlugin/CodexContractsAccess.cs | 16 +++++-------- .../CodexContractsDeployment.cs | 23 +++++++++++++++++++ CodexContractsPlugin/CodexContractsPlugin.cs | 9 ++++++-- CodexContractsPlugin/CodexContractsStarter.cs | 9 ++++++-- .../CoreInterfaceExtensions.cs | 13 ++++++++++- CodexPlugin/CodexContainerRecipe.cs | 6 ++--- CodexPlugin/CodexPlugin.cs | 3 ++- Core/SerializeGate.cs | 13 +++++++++++ GethPlugin/CoreInterfaceExtensions.cs | 14 +++++++++-- .../{GethStartResult.cs => GethDeployment.cs} | 6 ++--- GethPlugin/GethNode.cs | 6 ++--- GethPlugin/GethPlugin.cs | 6 ++--- GethPlugin/GethStarter.cs | 8 +++---- MetricsPlugin/CoreInterfaceExtensions.cs | 16 ++++++------- MetricsPlugin/MetricsPlugin.cs | 6 ++--- Tests/BasicTests/ExampleTests.cs | 2 +- 16 files changed, 110 insertions(+), 46 deletions(-) create mode 100644 CodexContractsPlugin/CodexContractsDeployment.cs create mode 100644 Core/SerializeGate.cs rename GethPlugin/{GethStartResult.cs => GethDeployment.cs} (76%) diff --git a/CodexContractsPlugin/CodexContractsAccess.cs b/CodexContractsPlugin/CodexContractsAccess.cs index c807c40..b432121 100644 --- a/CodexContractsPlugin/CodexContractsAccess.cs +++ b/CodexContractsPlugin/CodexContractsAccess.cs @@ -5,7 +5,7 @@ namespace CodexContractsPlugin { public interface ICodexContracts { - string MarketplaceAddress { get; } + ICodexContractsDeployment Deployment { get; } void MintTestTokens(IGethNode gethNode, IHasEthAddress owner, TestToken testTokens); void MintTestTokens(IGethNode gethNode, IEthAddress ethAddress, TestToken testTokens); @@ -17,17 +17,13 @@ namespace CodexContractsPlugin { private readonly ILog log; - public CodexContractsAccess(ILog log, string marketplaceAddress, string abi, string tokenAddress) + public CodexContractsAccess(ILog log, ICodexContractsDeployment deployment) { this.log = log; - MarketplaceAddress = marketplaceAddress; - Abi = abi; - TokenAddress = tokenAddress; + Deployment = deployment; } - public string MarketplaceAddress { get; } - public string Abi { get; } - public string TokenAddress { get; } + public ICodexContractsDeployment Deployment { get; } public void MintTestTokens(IGethNode gethNode, IHasEthAddress owner, TestToken testTokens) { @@ -37,7 +33,7 @@ namespace CodexContractsPlugin public void MintTestTokens(IGethNode gethNode, IEthAddress ethAddress, TestToken testTokens) { var interaction = new ContractInteractions(log, gethNode); - interaction.MintTestTokens(ethAddress, testTokens.Amount, TokenAddress); + interaction.MintTestTokens(ethAddress, testTokens.Amount, Deployment.TokenAddress); } public TestToken GetTestTokenBalance(IGethNode gethNode, IHasEthAddress owner) @@ -48,7 +44,7 @@ namespace CodexContractsPlugin public TestToken GetTestTokenBalance(IGethNode gethNode, IEthAddress ethAddress) { var interaction = new ContractInteractions(log, gethNode); - var balance = interaction.GetBalance(TokenAddress, ethAddress.Address); + var balance = interaction.GetBalance(Deployment.TokenAddress, ethAddress.Address); return balance.TestTokens(); } } diff --git a/CodexContractsPlugin/CodexContractsDeployment.cs b/CodexContractsPlugin/CodexContractsDeployment.cs new file mode 100644 index 0000000..3784865 --- /dev/null +++ b/CodexContractsPlugin/CodexContractsDeployment.cs @@ -0,0 +1,23 @@ +namespace CodexContractsPlugin +{ + public interface ICodexContractsDeployment + { + string MarketplaceAddress { get; } + string Abi { get; } + string TokenAddress { get; } + } + + public class CodexContractsDeployment : ICodexContractsDeployment + { + public CodexContractsDeployment(string marketplaceAddress, string abi, string tokenAddress) + { + MarketplaceAddress = marketplaceAddress; + Abi = abi; + TokenAddress = tokenAddress; + } + + public string MarketplaceAddress { get; } + public string Abi { get; } + public string TokenAddress { get; } + } +} diff --git a/CodexContractsPlugin/CodexContractsPlugin.cs b/CodexContractsPlugin/CodexContractsPlugin.cs index 6dadfe7..6b15721 100644 --- a/CodexContractsPlugin/CodexContractsPlugin.cs +++ b/CodexContractsPlugin/CodexContractsPlugin.cs @@ -30,9 +30,14 @@ namespace CodexContractsPlugin { } - public ICodexContracts DeployContracts(IGethNode gethNode) + public ICodexContractsDeployment DeployContracts(IGethNode gethNode) { - return starter.Start(gethNode); + return starter.Deploy(gethNode); + } + + public ICodexContracts WrapDeploy(ICodexContractsDeployment deployment) + { + return starter.Wrap(SerializeGate.Gate(deployment)); } } } diff --git a/CodexContractsPlugin/CodexContractsStarter.cs b/CodexContractsPlugin/CodexContractsStarter.cs index 92085bb..081bb6b 100644 --- a/CodexContractsPlugin/CodexContractsStarter.cs +++ b/CodexContractsPlugin/CodexContractsStarter.cs @@ -15,7 +15,7 @@ namespace CodexContractsPlugin this.tools = tools; } - public ICodexContracts Start(IGethNode gethNode) + public ICodexContractsDeployment Deploy(IGethNode gethNode) { Log("Deploying Codex SmartContracts..."); @@ -47,7 +47,12 @@ namespace CodexContractsPlugin Log("Synced. Codex SmartContracts deployed."); - return new CodexContractsAccess(tools.GetLog(), marketplaceAddress, abi, tokenAddress); + return new CodexContractsDeployment(marketplaceAddress, abi, tokenAddress); + } + + public ICodexContracts Wrap(ICodexContractsDeployment deployment) + { + return new CodexContractsAccess(tools.GetLog(), deployment); } private void Log(string msg) diff --git a/CodexContractsPlugin/CoreInterfaceExtensions.cs b/CodexContractsPlugin/CoreInterfaceExtensions.cs index 20c4645..34b50e3 100644 --- a/CodexContractsPlugin/CoreInterfaceExtensions.cs +++ b/CodexContractsPlugin/CoreInterfaceExtensions.cs @@ -5,11 +5,22 @@ namespace CodexContractsPlugin { public static class CoreInterfaceExtensions { - public static ICodexContracts DeployCodexContracts(this CoreInterface ci, IGethNode gethNode) + public static ICodexContractsDeployment DeployCodexContracts(this CoreInterface ci, IGethNode gethNode) { return Plugin(ci).DeployContracts(gethNode); } + public static ICodexContracts WrapCodexContractsDeployment(this CoreInterface ci, ICodexContractsDeployment deployment) + { + return Plugin(ci).WrapDeploy(deployment); + } + + public static ICodexContracts StartCodexContracts(this CoreInterface ci, IGethNode gethNode) + { + var deployment = DeployCodexContracts(ci, gethNode); + return WrapCodexContractsDeployment(ci, deployment); + } + private static CodexContractsPlugin Plugin(CoreInterface ci) { return ci.GetPlugin(); diff --git a/CodexPlugin/CodexContainerRecipe.cs b/CodexPlugin/CodexContainerRecipe.cs index e173fc6..986a1d0 100644 --- a/CodexPlugin/CodexContainerRecipe.cs +++ b/CodexPlugin/CodexContainerRecipe.cs @@ -26,8 +26,8 @@ namespace CodexPlugin protected override void Initialize(StartupConfig startupConfig) { - SetResourcesRequest(milliCPUs: 1000, memory: 6.GB()); - SetResourceLimits(milliCPUs: 4000, memory: 12.GB()); + //SetResourcesRequest(milliCPUs: 1000, memory: 6.GB()); + //SetResourceLimits(milliCPUs: 4000, memory: 12.GB()); var config = startupConfig.Get(); @@ -83,7 +83,7 @@ namespace CodexPlugin var gethStart = mconfig.GethNode.StartResult; var ip = gethStart.RunningContainer.Pod.PodInfo.Ip; var port = gethStart.WsPort.Number; - var marketplaceAddress = mconfig.CodexContracts.MarketplaceAddress; + var marketplaceAddress = mconfig.CodexContracts.Deployment.MarketplaceAddress; AddEnvVar("CODEX_ETH_PROVIDER", $"ws://{ip}:{port}"); AddEnvVar("CODEX_MARKETPLACE_ADDRESS", marketplaceAddress); diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index b2ae282..4619636 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -39,7 +39,8 @@ namespace CodexPlugin public ICodexNodeGroup WrapCodexContainers(CoreInterface coreInterface, RunningContainers[] containers) { - return codexStarter.WrapCodexContainers(coreInterface, containers); + var cs = containers.Select(c => SerializeGate.Gate(c)).ToArray(); + return codexStarter.WrapCodexContainers(coreInterface, cs); } public void WireUpMarketplace(ICodexNodeGroup result, Action setup) diff --git a/Core/SerializeGate.cs b/Core/SerializeGate.cs new file mode 100644 index 0000000..762d1c1 --- /dev/null +++ b/Core/SerializeGate.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Core +{ + public static class SerializeGate + { + public static T Gate(T anything) + { + var json = JsonConvert.SerializeObject(anything); + return JsonConvert.DeserializeObject(json)!; + } + } +} diff --git a/GethPlugin/CoreInterfaceExtensions.cs b/GethPlugin/CoreInterfaceExtensions.cs index b486c5c..e07eca2 100644 --- a/GethPlugin/CoreInterfaceExtensions.cs +++ b/GethPlugin/CoreInterfaceExtensions.cs @@ -4,10 +4,20 @@ namespace GethPlugin { public static class CoreInterfaceExtensions { + public static IGethDeployment DeployGeth(this CoreInterface ci, Action setup) + { + return Plugin(ci).DeployGeth(setup); + } + + public static IGethNode WrapGethDeployment(this CoreInterface ci, IGethDeployment deployment) + { + return Plugin(ci).WrapGethDeployment(deployment); + } + public static IGethNode StartGethNode(this CoreInterface ci, Action setup) { - var p = Plugin(ci); - return p.WrapGethContainer(p.StartGeth(setup)); + var deploy = DeployGeth(ci, setup); + return WrapGethDeployment(ci, deploy); } private static GethPlugin Plugin(CoreInterface ci) diff --git a/GethPlugin/GethStartResult.cs b/GethPlugin/GethDeployment.cs similarity index 76% rename from GethPlugin/GethStartResult.cs rename to GethPlugin/GethDeployment.cs index 90bb895..62a9fad 100644 --- a/GethPlugin/GethStartResult.cs +++ b/GethPlugin/GethDeployment.cs @@ -2,7 +2,7 @@ namespace GethPlugin { - public interface IGethStartResult + public interface IGethDeployment { RunningContainer RunningContainer { get; } Port DiscoveryPort { get; } @@ -12,9 +12,9 @@ namespace GethPlugin string PubKey { get; } } - public class GethStartResult : IGethStartResult + public class GethDeployment : IGethDeployment { - public GethStartResult(RunningContainer runningContainer, Port discoveryPort, Port httpPort, Port wsPort, AllGethAccounts allAccounts, string pubKey) + public GethDeployment(RunningContainer runningContainer, Port discoveryPort, Port httpPort, Port wsPort, AllGethAccounts allAccounts, string pubKey) { RunningContainer = runningContainer; DiscoveryPort = discoveryPort; diff --git a/GethPlugin/GethNode.cs b/GethPlugin/GethNode.cs index 170896b..6388b08 100644 --- a/GethPlugin/GethNode.cs +++ b/GethPlugin/GethNode.cs @@ -6,7 +6,7 @@ namespace GethPlugin { public interface IGethNode { - IGethStartResult StartResult { get; } + IGethDeployment StartResult { get; } Ether GetEthBalance(); Ether GetEthBalance(IHasEthAddress address); @@ -23,14 +23,14 @@ namespace GethPlugin { private readonly ILog log; - public GethNode(ILog log, IGethStartResult startResult) + public GethNode(ILog log, IGethDeployment startResult) { this.log = log; StartResult = startResult; Account = startResult.AllAccounts.Accounts.First(); } - public IGethStartResult StartResult { get; } + public IGethDeployment StartResult { get; } public GethAccount Account { get; } public Ether GetEthBalance() diff --git a/GethPlugin/GethPlugin.cs b/GethPlugin/GethPlugin.cs index 9c2f0bb..1dff3e6 100644 --- a/GethPlugin/GethPlugin.cs +++ b/GethPlugin/GethPlugin.cs @@ -29,16 +29,16 @@ namespace GethPlugin { } - public IGethStartResult StartGeth(Action setup) + public IGethDeployment DeployGeth(Action setup) { var startupConfig = new GethStartupConfig(); setup(startupConfig); return starter.StartGeth(startupConfig); } - public IGethNode WrapGethContainer(IGethStartResult startResult) + public IGethNode WrapGethDeployment(IGethDeployment startResult) { - return starter.WrapGethContainer(startResult); + return starter.WrapGethContainer(SerializeGate.Gate(startResult)); } } } diff --git a/GethPlugin/GethStarter.cs b/GethPlugin/GethStarter.cs index dc9246e..541e75a 100644 --- a/GethPlugin/GethStarter.cs +++ b/GethPlugin/GethStarter.cs @@ -12,7 +12,7 @@ namespace GethPlugin this.tools = tools; } - public IGethStartResult StartGeth(GethStartupConfig gethStartupConfig) + public IGethDeployment StartGeth(GethStartupConfig gethStartupConfig) { Log("Starting Geth bootstrap node..."); @@ -38,12 +38,12 @@ namespace GethPlugin Log($"Geth node started."); - return new GethStartResult(container, discoveryPort, httpPort, wsPort, accounts, pubKey); + return new GethDeployment(container, discoveryPort, httpPort, wsPort, accounts, pubKey); } - public IGethNode WrapGethContainer(IGethStartResult startResult) + public IGethNode WrapGethContainer(IGethDeployment startResult) { - return new GethNode(tools.GetLog(), startResult); + return new GethNode(tools.GetLog(), SerializeGate.Gate(startResult)); } private void Log(string msg) diff --git a/MetricsPlugin/CoreInterfaceExtensions.cs b/MetricsPlugin/CoreInterfaceExtensions.cs index 98d36f0..0c32007 100644 --- a/MetricsPlugin/CoreInterfaceExtensions.cs +++ b/MetricsPlugin/CoreInterfaceExtensions.cs @@ -6,19 +6,19 @@ namespace MetricsPlugin { public static class CoreInterfaceExtensions { - public static RunningContainer StartMetricsCollector(this CoreInterface ci, params IHasMetricsScrapeTarget[] scrapeTargets) + public static RunningContainer DeployMetricsCollector(this CoreInterface ci, params IHasMetricsScrapeTarget[] scrapeTargets) { - return Plugin(ci).StartMetricsCollector(scrapeTargets.Select(t => t.MetricsScrapeTarget).ToArray()); + return Plugin(ci).DeployMetricsCollector(scrapeTargets.Select(t => t.MetricsScrapeTarget).ToArray()); } - public static RunningContainer StartMetricsCollector(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) + public static RunningContainer DeployMetricsCollector(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) { - return Plugin(ci).StartMetricsCollector(scrapeTargets); + return Plugin(ci).DeployMetricsCollector(scrapeTargets); } - public static IMetricsAccess GetMetricsFor(this CoreInterface ci, RunningContainer metricsContainer, IMetricsScrapeTarget scrapeTarget) + public static IMetricsAccess WrapMetricsCollector(this CoreInterface ci, RunningContainer metricsContainer, IMetricsScrapeTarget scrapeTarget) { - return Plugin(ci).CreateAccessForTarget(metricsContainer, scrapeTarget); + return Plugin(ci).WrapMetricsCollectorDeployment(metricsContainer, scrapeTarget); } public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IHasManyMetricScrapeTargets[] manyScrapeTargets) @@ -33,8 +33,8 @@ namespace MetricsPlugin public static IMetricsAccess[] GetMetricsFor(this CoreInterface ci, params IMetricsScrapeTarget[] scrapeTargets) { - var rc = ci.StartMetricsCollector(scrapeTargets); - return scrapeTargets.Select(t => ci.GetMetricsFor(rc, t)).ToArray(); + var rc = ci.DeployMetricsCollector(scrapeTargets); + return scrapeTargets.Select(t => ci.WrapMetricsCollector(rc, t)).ToArray(); } public static LogFile? DownloadAllMetrics(this CoreInterface ci, IMetricsAccess metricsAccess, string targetName) diff --git a/MetricsPlugin/MetricsPlugin.cs b/MetricsPlugin/MetricsPlugin.cs index 0a7c7d0..431aea9 100644 --- a/MetricsPlugin/MetricsPlugin.cs +++ b/MetricsPlugin/MetricsPlugin.cs @@ -31,14 +31,14 @@ namespace MetricsPlugin { } - public RunningContainer StartMetricsCollector(IMetricsScrapeTarget[] scrapeTargets) + public RunningContainer DeployMetricsCollector(IMetricsScrapeTarget[] scrapeTargets) { return starter.CollectMetricsFor(scrapeTargets); } - public MetricsAccess CreateAccessForTarget(RunningContainer runningContainer, IMetricsScrapeTarget target) + public IMetricsAccess WrapMetricsCollectorDeployment(RunningContainer runningContainer, IMetricsScrapeTarget target) { - return starter.CreateAccessForTarget(runningContainer, target); + return starter.CreateAccessForTarget(SerializeGate.Gate(runningContainer), target); } public LogFile? DownloadAllMetrics(IMetricsAccess metricsAccess, string targetName) diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/BasicTests/ExampleTests.cs index 40609a8..2858c6e 100644 --- a/Tests/BasicTests/ExampleTests.cs +++ b/Tests/BasicTests/ExampleTests.cs @@ -52,7 +52,7 @@ namespace Tests.BasicTests var fileSize = 10.MB(); var geth = Ci.StartGethNode(s => s.IsMiner().WithName("disttest-geth")); - var contracts = Ci.DeployCodexContracts(geth); + var contracts = Ci.StartCodexContracts(geth); var seller = AddCodex(s => s .WithStorageQuota(11.GB()) From df8ab6bcf50c703902fae8f84587e3be3e8b4f98 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 10:13:29 +0200 Subject: [PATCH 39/51] Serialization gate requirement passed. --- CodexContractsPlugin/CodexContractsAccess.cs | 14 ++--- .../CodexContractsDeployment.cs | 9 +--- CodexContractsPlugin/CodexContractsPlugin.cs | 7 +-- CodexContractsPlugin/CodexContractsStarter.cs | 4 +- CodexContractsPlugin/ContractInteractions.cs | 2 +- .../CoreInterfaceExtensions.cs | 4 +- CodexPlugin/CodexNode.cs | 6 +-- CodexPlugin/CodexNodeFactory.cs | 6 +-- CodexPlugin/CodexPlugin.cs | 4 +- CodexPlugin/MarketplaceStartResults.cs | 4 +- Core/SerializeGate.cs | 7 +++ GethPlugin/CoreInterfaceExtensions.cs | 4 +- GethPlugin/EthAddress.cs | 9 +--- GethPlugin/GethDeployment.cs | 12 +---- GethPlugin/GethNode.cs | 14 ++--- GethPlugin/GethPlugin.cs | 7 +-- GethPlugin/GethStarter.cs | 7 +-- KubernetesWorkflow/Configuration.cs | 6 ++- KubernetesWorkflow/ContainerAdditionals.cs | 53 +++++++++++++++++++ KubernetesWorkflow/ContainerRecipe.cs | 4 +- KubernetesWorkflow/ContainerRecipeFactory.cs | 2 +- MetricsPlugin/MetricsPlugin.cs | 3 +- 22 files changed, 117 insertions(+), 71 deletions(-) create mode 100644 KubernetesWorkflow/ContainerAdditionals.cs diff --git a/CodexContractsPlugin/CodexContractsAccess.cs b/CodexContractsPlugin/CodexContractsAccess.cs index b432121..f7675e1 100644 --- a/CodexContractsPlugin/CodexContractsAccess.cs +++ b/CodexContractsPlugin/CodexContractsAccess.cs @@ -5,32 +5,32 @@ namespace CodexContractsPlugin { public interface ICodexContracts { - ICodexContractsDeployment Deployment { get; } + CodexContractsDeployment Deployment { get; } void MintTestTokens(IGethNode gethNode, IHasEthAddress owner, TestToken testTokens); - void MintTestTokens(IGethNode gethNode, IEthAddress ethAddress, TestToken testTokens); + void MintTestTokens(IGethNode gethNode, EthAddress ethAddress, TestToken testTokens); TestToken GetTestTokenBalance(IGethNode gethNode, IHasEthAddress owner); - TestToken GetTestTokenBalance(IGethNode gethNode, IEthAddress ethAddress); + TestToken GetTestTokenBalance(IGethNode gethNode, EthAddress ethAddress); } public class CodexContractsAccess : ICodexContracts { private readonly ILog log; - public CodexContractsAccess(ILog log, ICodexContractsDeployment deployment) + public CodexContractsAccess(ILog log, CodexContractsDeployment deployment) { this.log = log; Deployment = deployment; } - public ICodexContractsDeployment Deployment { get; } + public CodexContractsDeployment Deployment { get; } public void MintTestTokens(IGethNode gethNode, IHasEthAddress owner, TestToken testTokens) { MintTestTokens(gethNode, owner.EthAddress, testTokens); } - public void MintTestTokens(IGethNode gethNode, IEthAddress ethAddress, TestToken testTokens) + public void MintTestTokens(IGethNode gethNode, EthAddress ethAddress, TestToken testTokens) { var interaction = new ContractInteractions(log, gethNode); interaction.MintTestTokens(ethAddress, testTokens.Amount, Deployment.TokenAddress); @@ -41,7 +41,7 @@ namespace CodexContractsPlugin return GetTestTokenBalance(gethNode, owner.EthAddress); } - public TestToken GetTestTokenBalance(IGethNode gethNode, IEthAddress ethAddress) + public TestToken GetTestTokenBalance(IGethNode gethNode, EthAddress ethAddress) { var interaction = new ContractInteractions(log, gethNode); var balance = interaction.GetBalance(Deployment.TokenAddress, ethAddress.Address); diff --git a/CodexContractsPlugin/CodexContractsDeployment.cs b/CodexContractsPlugin/CodexContractsDeployment.cs index 3784865..d61857c 100644 --- a/CodexContractsPlugin/CodexContractsDeployment.cs +++ b/CodexContractsPlugin/CodexContractsDeployment.cs @@ -1,13 +1,6 @@ namespace CodexContractsPlugin { - public interface ICodexContractsDeployment - { - string MarketplaceAddress { get; } - string Abi { get; } - string TokenAddress { get; } - } - - public class CodexContractsDeployment : ICodexContractsDeployment + public class CodexContractsDeployment { public CodexContractsDeployment(string marketplaceAddress, string abi, string tokenAddress) { diff --git a/CodexContractsPlugin/CodexContractsPlugin.cs b/CodexContractsPlugin/CodexContractsPlugin.cs index 6b15721..5c03e33 100644 --- a/CodexContractsPlugin/CodexContractsPlugin.cs +++ b/CodexContractsPlugin/CodexContractsPlugin.cs @@ -30,14 +30,15 @@ namespace CodexContractsPlugin { } - public ICodexContractsDeployment DeployContracts(IGethNode gethNode) + public CodexContractsDeployment DeployContracts(IGethNode gethNode) { return starter.Deploy(gethNode); } - public ICodexContracts WrapDeploy(ICodexContractsDeployment deployment) + public ICodexContracts WrapDeploy(CodexContractsDeployment deployment) { - return starter.Wrap(SerializeGate.Gate(deployment)); + deployment = SerializeGate.Gate(deployment); + return starter.Wrap(deployment); } } } diff --git a/CodexContractsPlugin/CodexContractsStarter.cs b/CodexContractsPlugin/CodexContractsStarter.cs index 081bb6b..cc314b7 100644 --- a/CodexContractsPlugin/CodexContractsStarter.cs +++ b/CodexContractsPlugin/CodexContractsStarter.cs @@ -15,7 +15,7 @@ namespace CodexContractsPlugin this.tools = tools; } - public ICodexContractsDeployment Deploy(IGethNode gethNode) + public CodexContractsDeployment Deploy(IGethNode gethNode) { Log("Deploying Codex SmartContracts..."); @@ -50,7 +50,7 @@ namespace CodexContractsPlugin return new CodexContractsDeployment(marketplaceAddress, abi, tokenAddress); } - public ICodexContracts Wrap(ICodexContractsDeployment deployment) + public ICodexContracts Wrap(CodexContractsDeployment deployment) { return new CodexContractsAccess(tools.GetLog(), deployment); } diff --git a/CodexContractsPlugin/ContractInteractions.cs b/CodexContractsPlugin/ContractInteractions.cs index b7c64f3..719a603 100644 --- a/CodexContractsPlugin/ContractInteractions.cs +++ b/CodexContractsPlugin/ContractInteractions.cs @@ -26,7 +26,7 @@ namespace CodexContractsPlugin return gethNode.Call(marketplaceAddress, function); } - public void MintTestTokens(IEthAddress address, decimal amount, string tokenAddress) + public void MintTestTokens(EthAddress address, decimal amount, string tokenAddress) { MintTokens(address.Address, amount, tokenAddress); } diff --git a/CodexContractsPlugin/CoreInterfaceExtensions.cs b/CodexContractsPlugin/CoreInterfaceExtensions.cs index 34b50e3..d2c3355 100644 --- a/CodexContractsPlugin/CoreInterfaceExtensions.cs +++ b/CodexContractsPlugin/CoreInterfaceExtensions.cs @@ -5,12 +5,12 @@ namespace CodexContractsPlugin { public static class CoreInterfaceExtensions { - public static ICodexContractsDeployment DeployCodexContracts(this CoreInterface ci, IGethNode gethNode) + public static CodexContractsDeployment DeployCodexContracts(this CoreInterface ci, IGethNode gethNode) { return Plugin(ci).DeployContracts(gethNode); } - public static ICodexContracts WrapCodexContractsDeployment(this CoreInterface ci, ICodexContractsDeployment deployment) + public static ICodexContracts WrapCodexContractsDeployment(this CoreInterface ci, CodexContractsDeployment deployment) { return Plugin(ci).WrapDeploy(deployment); } diff --git a/CodexPlugin/CodexNode.cs b/CodexPlugin/CodexNode.cs index eb17a49..c7560ff 100644 --- a/CodexPlugin/CodexNode.cs +++ b/CodexPlugin/CodexNode.cs @@ -27,9 +27,9 @@ namespace CodexPlugin private const string SuccessfullyConnectedMessage = "Successfully connected to peer"; private const string UploadFailedMessage = "Unable to store block"; private readonly IPluginTools tools; - private readonly IEthAddress? ethAddress; + private readonly EthAddress? ethAddress; - public CodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group, IMarketplaceAccess marketplaceAccess, IEthAddress? ethAddress) + public CodexNode(IPluginTools tools, CodexAccess codexAccess, CodexNodeGroup group, IMarketplaceAccess marketplaceAccess, EthAddress? ethAddress) { this.tools = tools; this.ethAddress = ethAddress; @@ -53,7 +53,7 @@ namespace CodexPlugin return new MetricsScrapeTarget(CodexAccess.Container, port); } } - public IEthAddress EthAddress + public EthAddress EthAddress { get { diff --git a/CodexPlugin/CodexNodeFactory.cs b/CodexPlugin/CodexNodeFactory.cs index f340fde..29daf0a 100644 --- a/CodexPlugin/CodexNodeFactory.cs +++ b/CodexPlugin/CodexNodeFactory.cs @@ -24,15 +24,15 @@ namespace CodexPlugin return new CodexNode(tools, access, group, marketplaceAccess, ethAddress); } - private IMarketplaceAccess GetMarketplaceAccess(CodexAccess codexAccess, IEthAddress? ethAddress) + private IMarketplaceAccess GetMarketplaceAccess(CodexAccess codexAccess, EthAddress? ethAddress) { if (ethAddress == null) return new MarketplaceUnavailable(); return new MarketplaceAccess(tools.GetLog(), codexAccess); } - private IEthAddress? GetEthAddress(CodexAccess access) + private EthAddress? GetEthAddress(CodexAccess access) { - var mStart = access.Container.Recipe.Additionals.SingleOrDefault(a => a is MarketplaceStartResults) as MarketplaceStartResults; + var mStart = access.Container.Recipe.Additionals.Get(); if (mStart == null) return null; return mStart.EthAddress; } diff --git a/CodexPlugin/CodexPlugin.cs b/CodexPlugin/CodexPlugin.cs index 4619636..2cfb882 100644 --- a/CodexPlugin/CodexPlugin.cs +++ b/CodexPlugin/CodexPlugin.cs @@ -39,8 +39,8 @@ namespace CodexPlugin public ICodexNodeGroup WrapCodexContainers(CoreInterface coreInterface, RunningContainers[] containers) { - var cs = containers.Select(c => SerializeGate.Gate(c)).ToArray(); - return codexStarter.WrapCodexContainers(coreInterface, cs); + containers = containers.Select(c => SerializeGate.Gate(c)).ToArray(); + return codexStarter.WrapCodexContainers(coreInterface, containers); } public void WireUpMarketplace(ICodexNodeGroup result, Action setup) diff --git a/CodexPlugin/MarketplaceStartResults.cs b/CodexPlugin/MarketplaceStartResults.cs index 6273c1e..4bce517 100644 --- a/CodexPlugin/MarketplaceStartResults.cs +++ b/CodexPlugin/MarketplaceStartResults.cs @@ -5,13 +5,13 @@ namespace CodexPlugin [Serializable] public class MarketplaceStartResults { - public MarketplaceStartResults(IEthAddress ethAddress, string privateKey) + public MarketplaceStartResults(EthAddress ethAddress, string privateKey) { EthAddress = ethAddress; PrivateKey = privateKey; } - public IEthAddress EthAddress { get; } + public EthAddress EthAddress { get; } public string PrivateKey { get; } } } diff --git a/Core/SerializeGate.cs b/Core/SerializeGate.cs index 762d1c1..aab0de9 100644 --- a/Core/SerializeGate.cs +++ b/Core/SerializeGate.cs @@ -4,6 +4,13 @@ namespace Core { public static class SerializeGate { + /// + /// SerializeGate was added to help ensure deployment objects are serializable + /// and remain viable after deserialization. + /// Tools can be built on top of the core interface that rely on deployment objects being serializable. + /// Insert the serialization gate after deployment but before wrapping to ensure any future changes + /// don't break this requirement. + /// public static T Gate(T anything) { var json = JsonConvert.SerializeObject(anything); diff --git a/GethPlugin/CoreInterfaceExtensions.cs b/GethPlugin/CoreInterfaceExtensions.cs index e07eca2..a1d0ecd 100644 --- a/GethPlugin/CoreInterfaceExtensions.cs +++ b/GethPlugin/CoreInterfaceExtensions.cs @@ -4,12 +4,12 @@ namespace GethPlugin { public static class CoreInterfaceExtensions { - public static IGethDeployment DeployGeth(this CoreInterface ci, Action setup) + public static GethDeployment DeployGeth(this CoreInterface ci, Action setup) { return Plugin(ci).DeployGeth(setup); } - public static IGethNode WrapGethDeployment(this CoreInterface ci, IGethDeployment deployment) + public static IGethNode WrapGethDeployment(this CoreInterface ci, GethDeployment deployment) { return Plugin(ci).WrapGethDeployment(deployment); } diff --git a/GethPlugin/EthAddress.cs b/GethPlugin/EthAddress.cs index 893a76d..54c8638 100644 --- a/GethPlugin/EthAddress.cs +++ b/GethPlugin/EthAddress.cs @@ -1,16 +1,11 @@ namespace GethPlugin { - public interface IEthAddress - { - string Address { get; } - } - public interface IHasEthAddress { - IEthAddress EthAddress { get; } + EthAddress EthAddress { get; } } - public class EthAddress : IEthAddress + public class EthAddress { public EthAddress(string address) { diff --git a/GethPlugin/GethDeployment.cs b/GethPlugin/GethDeployment.cs index 62a9fad..8b98259 100644 --- a/GethPlugin/GethDeployment.cs +++ b/GethPlugin/GethDeployment.cs @@ -2,17 +2,7 @@ namespace GethPlugin { - public interface IGethDeployment - { - RunningContainer RunningContainer { get; } - Port DiscoveryPort { get; } - Port HttpPort { get; } - Port WsPort { get; } - AllGethAccounts AllAccounts { get; } - string PubKey { get; } - } - - public class GethDeployment : IGethDeployment + public class GethDeployment { public GethDeployment(RunningContainer runningContainer, Port discoveryPort, Port httpPort, Port wsPort, AllGethAccounts allAccounts, string pubKey) { diff --git a/GethPlugin/GethNode.cs b/GethPlugin/GethNode.cs index 6388b08..b4cdb57 100644 --- a/GethPlugin/GethNode.cs +++ b/GethPlugin/GethNode.cs @@ -6,13 +6,13 @@ namespace GethPlugin { public interface IGethNode { - IGethDeployment StartResult { get; } + GethDeployment StartResult { get; } Ether GetEthBalance(); Ether GetEthBalance(IHasEthAddress address); - Ether GetEthBalance(IEthAddress address); + Ether GetEthBalance(EthAddress address); void SendEth(IHasEthAddress account, Ether eth); - void SendEth(IEthAddress account, Ether eth); + void SendEth(EthAddress account, Ether eth); TResult Call(string contractAddress, TFunction function) where TFunction : FunctionMessage, new(); void SendTransaction(string contractAddress, TFunction function) where TFunction : FunctionMessage, new(); decimal? GetSyncedBlockNumber(); @@ -23,14 +23,14 @@ namespace GethPlugin { private readonly ILog log; - public GethNode(ILog log, IGethDeployment startResult) + public GethNode(ILog log, GethDeployment startResult) { this.log = log; StartResult = startResult; Account = startResult.AllAccounts.Accounts.First(); } - public IGethDeployment StartResult { get; } + public GethDeployment StartResult { get; } public GethAccount Account { get; } public Ether GetEthBalance() @@ -43,7 +43,7 @@ namespace GethPlugin return GetEthBalance(owner.EthAddress); } - public Ether GetEthBalance(IEthAddress address) + public Ether GetEthBalance(EthAddress address) { return StartInteraction().GetEthBalance(address.Address).Eth(); } @@ -53,7 +53,7 @@ namespace GethPlugin SendEth(owner.EthAddress, eth); } - public void SendEth(IEthAddress account, Ether eth) + public void SendEth(EthAddress account, Ether eth) { StartInteraction().SendEth(account.Address, eth.Eth); } diff --git a/GethPlugin/GethPlugin.cs b/GethPlugin/GethPlugin.cs index 1dff3e6..bda6845 100644 --- a/GethPlugin/GethPlugin.cs +++ b/GethPlugin/GethPlugin.cs @@ -29,16 +29,17 @@ namespace GethPlugin { } - public IGethDeployment DeployGeth(Action setup) + public GethDeployment DeployGeth(Action setup) { var startupConfig = new GethStartupConfig(); setup(startupConfig); return starter.StartGeth(startupConfig); } - public IGethNode WrapGethDeployment(IGethDeployment startResult) + public IGethNode WrapGethDeployment(GethDeployment startResult) { - return starter.WrapGethContainer(SerializeGate.Gate(startResult)); + startResult = SerializeGate.Gate(startResult); + return starter.WrapGethContainer(startResult); } } } diff --git a/GethPlugin/GethStarter.cs b/GethPlugin/GethStarter.cs index 541e75a..2cc9ef7 100644 --- a/GethPlugin/GethStarter.cs +++ b/GethPlugin/GethStarter.cs @@ -12,7 +12,7 @@ namespace GethPlugin this.tools = tools; } - public IGethDeployment StartGeth(GethStartupConfig gethStartupConfig) + public GethDeployment StartGeth(GethStartupConfig gethStartupConfig) { Log("Starting Geth bootstrap node..."); @@ -41,9 +41,10 @@ namespace GethPlugin return new GethDeployment(container, discoveryPort, httpPort, wsPort, accounts, pubKey); } - public IGethNode WrapGethContainer(IGethDeployment startResult) + public IGethNode WrapGethContainer(GethDeployment startResult) { - return new GethNode(tools.GetLog(), SerializeGate.Gate(startResult)); + startResult = SerializeGate.Gate(startResult); + return new GethNode(tools.GetLog(), startResult); } private void Log(string msg) diff --git a/KubernetesWorkflow/Configuration.cs b/KubernetesWorkflow/Configuration.cs index 1cf30cf..6214682 100644 --- a/KubernetesWorkflow/Configuration.cs +++ b/KubernetesWorkflow/Configuration.cs @@ -1,4 +1,6 @@ -namespace KubernetesWorkflow +using Newtonsoft.Json; + +namespace KubernetesWorkflow { public class Configuration { @@ -16,6 +18,8 @@ public string KubernetesNamespace { get; } public bool AllowNamespaceOverride { get; set; } = true; public bool AddAppPodLabel { get; set; } = true; + + [JsonIgnore] public IK8sHooks Hooks { get; set; } = new DoNothingK8sHooks(); } } diff --git a/KubernetesWorkflow/ContainerAdditionals.cs b/KubernetesWorkflow/ContainerAdditionals.cs new file mode 100644 index 0000000..015e55b --- /dev/null +++ b/KubernetesWorkflow/ContainerAdditionals.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json.Linq; + +namespace KubernetesWorkflow +{ + public class ContainerAdditionals + { + public ContainerAdditionals(Additional[] additionals) + { + Additionals = additionals; + } + + public static ContainerAdditionals CreateFromUserData(IEnumerable userData) + { + return new ContainerAdditionals(userData.Select(ConvertToAdditional).ToArray()); + } + + public Additional[] Additionals { get; } + + public T? Get() + { + var typeName = GetTypeName(typeof(T)); + var userData = Additionals.SingleOrDefault(a => a.Type == typeName); + if (userData == null) return default(T); + var jobject = (JObject)userData.UserData; + return jobject.ToObject(); + } + + private static Additional ConvertToAdditional(object userData) + { + var typeName = GetTypeName(userData.GetType()); + return new Additional(typeName, userData); + } + + private static string GetTypeName(Type type) + { + var typeName = type.FullName; + if (string.IsNullOrEmpty(typeName)) throw new Exception("Object type fullname is null or empty: " + type); + return typeName; + } + } + + public class Additional + { + public Additional(string type, object userData) + { + Type = type; + UserData = userData; + } + + public string Type { get; } + public object UserData { get; } + } +} diff --git a/KubernetesWorkflow/ContainerRecipe.cs b/KubernetesWorkflow/ContainerRecipe.cs index 991928f..215b228 100644 --- a/KubernetesWorkflow/ContainerRecipe.cs +++ b/KubernetesWorkflow/ContainerRecipe.cs @@ -2,7 +2,7 @@ { public class ContainerRecipe { - public ContainerRecipe(int number, string? nameOverride, string image, ContainerResources resources, Port[] exposedPorts, Port[] internalPorts, EnvVar[] envVars, PodLabels podLabels, PodAnnotations podAnnotations, VolumeMount[] volumes, object[] additionals) + public ContainerRecipe(int number, string? nameOverride, string image, ContainerResources resources, Port[] exposedPorts, Port[] internalPorts, EnvVar[] envVars, PodLabels podLabels, PodAnnotations podAnnotations, VolumeMount[] volumes, ContainerAdditionals additionals) { Number = number; NameOverride = nameOverride; @@ -37,7 +37,7 @@ public PodLabels PodLabels { get; } public PodAnnotations PodAnnotations { get; } public VolumeMount[] Volumes { get; } - public object[] Additionals { get; } + public ContainerAdditionals Additionals { get; } public Port? GetPortByTag(string tag) { diff --git a/KubernetesWorkflow/ContainerRecipeFactory.cs b/KubernetesWorkflow/ContainerRecipeFactory.cs index d606598..6fb50a5 100644 --- a/KubernetesWorkflow/ContainerRecipeFactory.cs +++ b/KubernetesWorkflow/ContainerRecipeFactory.cs @@ -29,7 +29,7 @@ namespace KubernetesWorkflow podLabels.Clone(), podAnnotations.Clone(), volumeMounts.ToArray(), - additionals.ToArray()); + ContainerAdditionals.CreateFromUserData(additionals)); exposedPorts.Clear(); internalPorts.Clear(); diff --git a/MetricsPlugin/MetricsPlugin.cs b/MetricsPlugin/MetricsPlugin.cs index 431aea9..4e39ffc 100644 --- a/MetricsPlugin/MetricsPlugin.cs +++ b/MetricsPlugin/MetricsPlugin.cs @@ -38,7 +38,8 @@ namespace MetricsPlugin public IMetricsAccess WrapMetricsCollectorDeployment(RunningContainer runningContainer, IMetricsScrapeTarget target) { - return starter.CreateAccessForTarget(SerializeGate.Gate(runningContainer), target); + runningContainer = SerializeGate.Gate(runningContainer); + return starter.CreateAccessForTarget(runningContainer, target); } public LogFile? DownloadAllMetrics(IMetricsAccess metricsAccess, string targetName) From 09670e00e98474be445de087336f4e2b19de5888 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 10:51:47 +0200 Subject: [PATCH 40/51] Moves projects into folders --- .../ArgsUniform}/ArgsUniform.cs | 0 .../ArgsUniform}/ArgsUniform.csproj | 0 .../ArgsUniform}/ExampleUser.cs | 0 .../ArgsUniform}/UniformAttribute.cs | 0 {Core => Framework/Core}/Core.csproj | 0 {Core => Framework/Core}/CoreInterface.cs | 0 {Core => Framework/Core}/DownloadedLog.cs | 0 {Core => Framework/Core}/EntryPoint.cs | 0 {Core => Framework/Core}/Http.cs | 0 .../Core}/LogDownloadHandler.cs | 0 {Core => Framework/Core}/PluginFinder.cs | 0 {Core => Framework/Core}/PluginManager.cs | 0 {Core => Framework/Core}/PluginMetadata.cs | 0 {Core => Framework/Core}/PluginTools.cs | 0 {Core => Framework/Core}/SerializeGate.cs | 0 {Core => Framework/Core}/TimeSet.cs | 0 {Core => Framework/Core}/ToolsFactory.cs | 0 .../FileUtils}/FileManager.cs | 5 +- .../FileUtils}/FileUtils.csproj | 0 .../FileUtils}/TrackedFile.cs | 11 +- .../KubernetesWorkflow}/ByteSizeExtensions.cs | 0 .../KubernetesWorkflow}/CommandRunner.cs | 0 .../KubernetesWorkflow}/Configuration.cs | 0 .../ContainerAdditionals.cs | 0 .../KubernetesWorkflow}/ContainerRecipe.cs | 0 .../ContainerRecipeFactory.cs | 0 .../KubernetesWorkflow}/ContainerResources.cs | 0 .../KubernetesWorkflow}/CrashWatcher.cs | 0 .../KubernetesWorkflow}/K8sClient.cs | 0 .../KubernetesWorkflow}/K8sCluster.cs | 0 .../KubernetesWorkflow}/K8sController.cs | 0 .../KubernetesWorkflow}/K8sHooks.cs | 0 .../KubernetesWorkflow}/K8sNameUtils.cs | 0 .../KubernetesWorkflow}/KnownK8sPods.cs | 0 .../KubernetesWorkflow.csproj | 1 + .../KubernetesWorkflow}/Location.cs | 0 .../KubernetesWorkflow}/PodAnnotations.cs | 0 .../KubernetesWorkflow}/PodLabels.cs | 0 .../RecipeComponentFactory.cs | 0 .../RunnerLocationUtils.cs | 0 .../KubernetesWorkflow}/RunningContainers.cs | 0 .../KubernetesWorkflow}/RunningPod.cs | 0 .../KubernetesWorkflow}/StartupConfig.cs | 0 .../KubernetesWorkflow}/StartupWorkflow.cs | 0 .../KubernetesWorkflow}/WorkflowCreator.cs | 0 .../WorkflowNumberSource.cs | 0 .../Logging}/ApplicationIds.cs | 0 {Logging => Framework/Logging}/BaseLog.cs | 8 - {Logging => Framework/Logging}/LogConfig.cs | 0 {Logging => Framework/Logging}/LogFile.cs | 0 {Logging => Framework/Logging}/LogPrefixer.cs | 0 {Logging => Framework/Logging}/Logging.csproj | 6 - {Logging => Framework/Logging}/NullLog.cs | 4 +- {Logging => Framework/Logging}/Stopwatch.cs | 0 .../ConversionExtensions.cs | 0 .../NethereumInteraction.cs | 0 .../NethereumInteractionCreator.cs | 0 .../NethereumWorkflow.csproj | 0 {Utils => Framework/Utils}/Address.cs | 0 {Utils => Framework/Utils}/ByteSize.cs | 0 {Utils => Framework/Utils}/DebugStack.cs | 0 {Utils => Framework/Utils}/Formatter.cs | 0 Framework/Utils/FrameworkAssert.cs | 15 ++ {Utils => Framework/Utils}/NumberSource.cs | 0 {Utils => Framework/Utils}/ParseEnum.cs | 0 {Utils => Framework/Utils}/RandomUtils.cs | 0 {Utils => Framework/Utils}/Time.cs | 0 {Utils => Framework/Utils}/Utils.csproj | 0 .../CodexContractsAccess.cs | 0 .../CodexContractsContainerConfig.cs | 0 .../CodexContractsContainerRecipe.cs | 0 .../CodexContractsDeployment.cs | 0 .../CodexContractsPlugin.cs | 0 .../CodexContractsPlugin.csproj | 0 .../CodexContractsStarter.cs | 0 .../ContractInteractions.cs | 0 .../ContractsContainerInfoExtractor.cs | 0 .../CoreInterfaceExtensions.cs | 0 .../TestTokenExtensions.cs | 0 .../CodexPlugin}/CodexAccess.cs | 0 .../CodexPlugin}/CodexApiTypes.cs | 0 .../CodexPlugin}/CodexContainerRecipe.cs | 0 .../CodexPlugin}/CodexDeployment.cs | 0 .../CodexPlugin}/CodexLogLevel.cs | 0 .../CodexPlugin}/CodexNode.cs | 7 +- .../CodexPlugin}/CodexNodeFactory.cs | 0 .../CodexPlugin}/CodexNodeGroup.cs | 0 .../CodexPlugin}/CodexPlugin.cs | 0 .../CodexPlugin}/CodexPlugin.csproj | 0 .../CodexPlugin}/CodexSetup.cs | 0 .../CodexPlugin}/CodexStarter.cs | 0 .../CodexPlugin}/CodexStartupConfig.cs | 0 .../CodexPlugin}/CoreInterfaceExtensions.cs | 0 .../CodexPlugin}/MarketplaceAccess.cs | 7 +- .../CodexPlugin}/MarketplaceInitialConfig.cs | 0 .../CodexPlugin}/MarketplaceStartResults.cs | 0 .../CodexPlugin}/MarketplaceStarter.cs | 0 .../GethPlugin}/CoreInterfaceExtensions.cs | 0 .../GethPlugin}/EthAddress.cs | 0 .../GethPlugin}/EthTokenExtensions.cs | 0 .../GethPlugin}/GethAccount.cs | 0 .../GethPlugin}/GethContainerInfoExtractor.cs | 0 .../GethPlugin}/GethContainerRecipe.cs | 0 .../GethPlugin}/GethDeployment.cs | 0 .../GethPlugin}/GethNode.cs | 0 .../GethPlugin}/GethPlugin.cs | 0 .../GethPlugin}/GethPlugin.csproj | 0 .../GethPlugin}/GethStarter.cs | 0 .../GethPlugin}/GethStartupConfig.cs | 0 .../MetricsPlugin}/CoreInterfaceExtensions.cs | 0 .../MetricsPlugin}/MetricsAccess.cs | 0 .../MetricsPlugin}/MetricsDownloader.cs | 0 .../MetricsPlugin}/MetricsPlugin.cs | 0 .../MetricsPlugin}/MetricsPlugin.csproj | 0 .../MetricsPlugin}/MetricsQuery.cs | 0 .../MetricsPlugin}/MetricsScrapeTarget.cs | 0 .../PrometheusContainerRecipe.cs | 0 .../MetricsPlugin}/PrometheusStarter.cs | 0 .../MetricsPlugin}/PrometheusStartupConfig.cs | 0 .../MetricsPlugin}/dashboard.json | 0 .../CodexAccessFactory.cs | 0 .../CodexContinuousTests.csproj | 0 .../CodexContinuousTests}/Configuration.cs | 0 .../CodexContinuousTests}/ContinuousTest.cs | 0 .../ContinuousTestRunner.cs | 0 .../CodexContinuousTests}/K8sFactory.cs | 0 .../CodexContinuousTests}/NodeRunner.cs | 0 .../CodexContinuousTests}/Program.cs | 0 .../CodexContinuousTests}/SingleTestRun.cs | 0 .../CodexContinuousTests}/StartupChecker.cs | 0 .../CodexContinuousTests}/TaskFactory.cs | 0 .../CodexContinuousTests}/TestFactory.cs | 0 .../CodexContinuousTests}/TestHandle.cs | 0 .../CodexContinuousTests}/TestLoop.cs | 0 .../TestMomentAttribute.cs | 0 .../Tests/HoldMyBeerTest.cs | 0 .../Tests/MarketplaceTest.cs | 0 .../CodexContinuousTests}/Tests/PeersTest.cs | 0 .../Tests/PerformanceTests.cs | 0 .../Tests/ThresholdChecks.cs | 0 .../Tests/TransientNodeTest.cs | 0 .../Tests/TwoClientTest.cs | 0 .../reports/CodexTestNetReport-August2023.md | 0 .../reports/CodexTestNetReport-July2023.md | 0 .../CodexContinuousTests}/run.sh | 0 .../BasicTests/DownloadTests.cs | 0 .../BasicTests/LargeFileTests.cs | 0 .../BasicTests/TestInfraTests.cs | 0 .../CodexLongTests}/BasicTests/UploadTests.cs | 0 .../CodexLongTests/CodexTestsLong.csproj | 0 .../LongFullyConnectedDownloadTests.cs | 0 .../CodexLongTests}/Parallelism.cs | 0 .../{ => CodexTests}/AutoBootstrapDistTest.cs | 0 .../BasicTests/ContinuousSubstitute.cs | 0 .../BasicTests/ExampleTests.cs | 0 .../BasicTests/NetworkIsolationTest.cs | 0 .../BasicTests/OneClientTests.cs | 0 .../BasicTests/ThreeClientTest.cs | 0 .../BasicTests/TwoClientTests.cs | 0 Tests/{ => CodexTests}/CodexDistTest.cs | 0 .../CodexTests.csproj} | 0 .../FullyConnectedDownloadTests.cs | 0 .../Helpers/FullConnectivityHelper.cs | 0 .../Helpers/PeerConnectionTestHelpers.cs | 0 .../Helpers/PeerDownloadTestHelpers.cs | 0 .../MetricsAccessExtensions.cs | 0 Tests/{ => CodexTests}/Parallelism.cs | 0 .../LayeredDiscoveryTests.cs | 0 .../PeerDiscoveryTests/PeerDiscoveryTests.cs | 0 .../DistTestCore}/Configuration.cs | 0 .../DistTestCore}/DistTest.cs | 2 + .../DistTestCore}/DistTestCore.csproj | 0 .../DontDownloadLogsOnFailureAttribute.cs | 0 .../DistTestCore}/DownloadedLogExtensions.cs | 0 .../DistTestCore}/Helpers/AssertHelpers.cs | 0 .../DistTestCore/Logs}/BaseTestLog.cs | 12 +- .../DistTestCore/Logs}/FixtureLog.cs | 4 +- .../DistTestCore/Logs}/StatusLog.cs | 2 +- .../DistTestCore/Logs}/TestLog.cs | 2 +- .../DistTestCore}/LongTimeSet.cs | 0 .../LongTimeoutsTestAttribute.cs | 0 {Logging => Tests/DistTestCore}/NameUtils.cs | 5 +- .../DistTestCore}/TestLifecycle.cs | 2 +- .../CodexNetDeployer}/CodexNetDeployer.csproj | 0 .../CodexNetDeployer}/CodexNodeStarter.cs | 0 .../CodexNetDeployer}/Configuration.cs | 0 .../CodexNetDeployer}/Deployer.cs | 0 .../PeerConnectivityChecker.cs | 0 .../CodexNetDeployer}/Program.cs | 0 .../deploy-continuous-testnet.sh | 0 .../CodexNetDownloader.csproj | 0 .../CodexNetDownloader}/Configuration.cs | 0 .../CodexNetDownloader}/Program.cs | 0 cs-codex-dist-testing.sln | 193 ++++++++++-------- 194 files changed, 166 insertions(+), 120 deletions(-) rename {ArgsUniform => Framework/ArgsUniform}/ArgsUniform.cs (100%) rename {ArgsUniform => Framework/ArgsUniform}/ArgsUniform.csproj (100%) rename {ArgsUniform => Framework/ArgsUniform}/ExampleUser.cs (100%) rename {ArgsUniform => Framework/ArgsUniform}/UniformAttribute.cs (100%) rename {Core => Framework/Core}/Core.csproj (100%) rename {Core => Framework/Core}/CoreInterface.cs (100%) rename {Core => Framework/Core}/DownloadedLog.cs (100%) rename {Core => Framework/Core}/EntryPoint.cs (100%) rename {Core => Framework/Core}/Http.cs (100%) rename {Core => Framework/Core}/LogDownloadHandler.cs (100%) rename {Core => Framework/Core}/PluginFinder.cs (100%) rename {Core => Framework/Core}/PluginManager.cs (100%) rename {Core => Framework/Core}/PluginMetadata.cs (100%) rename {Core => Framework/Core}/PluginTools.cs (100%) rename {Core => Framework/Core}/SerializeGate.cs (100%) rename {Core => Framework/Core}/TimeSet.cs (100%) rename {Core => Framework/Core}/ToolsFactory.cs (100%) rename {FileUtils => Framework/FileUtils}/FileManager.cs (96%) rename {FileUtils => Framework/FileUtils}/FileUtils.csproj (100%) rename {FileUtils => Framework/FileUtils}/TrackedFile.cs (82%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/ByteSizeExtensions.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/CommandRunner.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/Configuration.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/ContainerAdditionals.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/ContainerRecipe.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/ContainerRecipeFactory.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/ContainerResources.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/CrashWatcher.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/K8sClient.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/K8sCluster.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/K8sController.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/K8sHooks.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/K8sNameUtils.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/KnownK8sPods.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/KubernetesWorkflow.csproj (88%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/Location.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/PodAnnotations.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/PodLabels.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/RecipeComponentFactory.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/RunnerLocationUtils.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/RunningContainers.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/RunningPod.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/StartupConfig.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/StartupWorkflow.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/WorkflowCreator.cs (100%) rename {KubernetesWorkflow => Framework/KubernetesWorkflow}/WorkflowNumberSource.cs (100%) rename {Logging => Framework/Logging}/ApplicationIds.cs (100%) rename {Logging => Framework/Logging}/BaseLog.cs (91%) rename {Logging => Framework/Logging}/LogConfig.cs (100%) rename {Logging => Framework/Logging}/LogFile.cs (100%) rename {Logging => Framework/Logging}/LogPrefixer.cs (100%) rename {Logging => Framework/Logging}/Logging.csproj (59%) rename {Logging => Framework/Logging}/NullLog.cs (88%) rename {Logging => Framework/Logging}/Stopwatch.cs (100%) rename {Nethereum => Framework/NethereumWorkflow}/ConversionExtensions.cs (100%) rename {Nethereum => Framework/NethereumWorkflow}/NethereumInteraction.cs (100%) rename {Nethereum => Framework/NethereumWorkflow}/NethereumInteractionCreator.cs (100%) rename {Nethereum => Framework/NethereumWorkflow}/NethereumWorkflow.csproj (100%) rename {Utils => Framework/Utils}/Address.cs (100%) rename {Utils => Framework/Utils}/ByteSize.cs (100%) rename {Utils => Framework/Utils}/DebugStack.cs (100%) rename {Utils => Framework/Utils}/Formatter.cs (100%) create mode 100644 Framework/Utils/FrameworkAssert.cs rename {Utils => Framework/Utils}/NumberSource.cs (100%) rename {Utils => Framework/Utils}/ParseEnum.cs (100%) rename {Utils => Framework/Utils}/RandomUtils.cs (100%) rename {Utils => Framework/Utils}/Time.cs (100%) rename {Utils => Framework/Utils}/Utils.csproj (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/CodexContractsAccess.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/CodexContractsContainerConfig.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/CodexContractsContainerRecipe.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/CodexContractsDeployment.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/CodexContractsPlugin.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/CodexContractsPlugin.csproj (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/CodexContractsStarter.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/ContractInteractions.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/ContractsContainerInfoExtractor.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/CoreInterfaceExtensions.cs (100%) rename {CodexContractsPlugin => ProjectPlugins/CodexContractsPlugin}/TestTokenExtensions.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexAccess.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexApiTypes.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexContainerRecipe.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexDeployment.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexLogLevel.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexNode.cs (95%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexNodeFactory.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexNodeGroup.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexPlugin.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexPlugin.csproj (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexSetup.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexStarter.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CodexStartupConfig.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/CoreInterfaceExtensions.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/MarketplaceAccess.cs (95%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/MarketplaceInitialConfig.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/MarketplaceStartResults.cs (100%) rename {CodexPlugin => ProjectPlugins/CodexPlugin}/MarketplaceStarter.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/CoreInterfaceExtensions.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/EthAddress.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/EthTokenExtensions.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethAccount.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethContainerInfoExtractor.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethContainerRecipe.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethDeployment.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethNode.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethPlugin.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethPlugin.csproj (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethStarter.cs (100%) rename {GethPlugin => ProjectPlugins/GethPlugin}/GethStartupConfig.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/CoreInterfaceExtensions.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/MetricsAccess.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/MetricsDownloader.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/MetricsPlugin.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/MetricsPlugin.csproj (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/MetricsQuery.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/MetricsScrapeTarget.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/PrometheusContainerRecipe.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/PrometheusStarter.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/PrometheusStartupConfig.cs (100%) rename {MetricsPlugin => ProjectPlugins/MetricsPlugin}/dashboard.json (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/CodexAccessFactory.cs (100%) rename ContinuousTests/ContinuousTests.csproj => Tests/CodexContinuousTests/CodexContinuousTests.csproj (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Configuration.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/ContinuousTest.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/ContinuousTestRunner.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/K8sFactory.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/NodeRunner.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Program.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/SingleTestRun.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/StartupChecker.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/TaskFactory.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/TestFactory.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/TestHandle.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/TestLoop.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/TestMomentAttribute.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Tests/HoldMyBeerTest.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Tests/MarketplaceTest.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Tests/PeersTest.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Tests/PerformanceTests.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Tests/ThresholdChecks.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Tests/TransientNodeTest.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/Tests/TwoClientTest.cs (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/reports/CodexTestNetReport-August2023.md (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/reports/CodexTestNetReport-July2023.md (100%) rename {ContinuousTests => Tests/CodexContinuousTests}/run.sh (100%) rename {LongTests => Tests/CodexLongTests}/BasicTests/DownloadTests.cs (100%) rename {LongTests => Tests/CodexLongTests}/BasicTests/LargeFileTests.cs (100%) rename {LongTests => Tests/CodexLongTests}/BasicTests/TestInfraTests.cs (100%) rename {LongTests => Tests/CodexLongTests}/BasicTests/UploadTests.cs (100%) rename LongTests/TestsLong.csproj => Tests/CodexLongTests/CodexTestsLong.csproj (100%) rename {LongTests => Tests/CodexLongTests}/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs (100%) rename {LongTests => Tests/CodexLongTests}/Parallelism.cs (100%) rename Tests/{ => CodexTests}/AutoBootstrapDistTest.cs (100%) rename Tests/{ => CodexTests}/BasicTests/ContinuousSubstitute.cs (100%) rename Tests/{ => CodexTests}/BasicTests/ExampleTests.cs (100%) rename Tests/{ => CodexTests}/BasicTests/NetworkIsolationTest.cs (100%) rename Tests/{ => CodexTests}/BasicTests/OneClientTests.cs (100%) rename Tests/{ => CodexTests}/BasicTests/ThreeClientTest.cs (100%) rename Tests/{ => CodexTests}/BasicTests/TwoClientTests.cs (100%) rename Tests/{ => CodexTests}/CodexDistTest.cs (100%) rename Tests/{Tests.csproj => CodexTests/CodexTests.csproj} (100%) rename Tests/{ => CodexTests}/DownloadConnectivityTests/FullyConnectedDownloadTests.cs (100%) rename Tests/{ => CodexTests}/Helpers/FullConnectivityHelper.cs (100%) rename Tests/{ => CodexTests}/Helpers/PeerConnectionTestHelpers.cs (100%) rename Tests/{ => CodexTests}/Helpers/PeerDownloadTestHelpers.cs (100%) rename Tests/{ => CodexTests}/MetricsAccessExtensions.cs (100%) rename Tests/{ => CodexTests}/Parallelism.cs (100%) rename Tests/{ => CodexTests}/PeerDiscoveryTests/LayeredDiscoveryTests.cs (100%) rename Tests/{ => CodexTests}/PeerDiscoveryTests/PeerDiscoveryTests.cs (100%) rename {DistTestCore => Tests/DistTestCore}/Configuration.cs (100%) rename {DistTestCore => Tests/DistTestCore}/DistTest.cs (99%) rename {DistTestCore => Tests/DistTestCore}/DistTestCore.csproj (100%) rename {DistTestCore => Tests/DistTestCore}/DontDownloadLogsOnFailureAttribute.cs (100%) rename {DistTestCore => Tests/DistTestCore}/DownloadedLogExtensions.cs (100%) rename {DistTestCore => Tests/DistTestCore}/Helpers/AssertHelpers.cs (100%) rename {Logging => Tests/DistTestCore/Logs}/BaseTestLog.cs (52%) rename {Logging => Tests/DistTestCore/Logs}/FixtureLog.cs (93%) rename {DistTestCore => Tests/DistTestCore/Logs}/StatusLog.cs (98%) rename {Logging => Tests/DistTestCore/Logs}/TestLog.cs (94%) rename {DistTestCore => Tests/DistTestCore}/LongTimeSet.cs (100%) rename {DistTestCore => Tests/DistTestCore}/LongTimeoutsTestAttribute.cs (100%) rename {Logging => Tests/DistTestCore}/NameUtils.cs (97%) rename {DistTestCore => Tests/DistTestCore}/TestLifecycle.cs (99%) rename {CodexNetDeployer => Tools/CodexNetDeployer}/CodexNetDeployer.csproj (100%) rename {CodexNetDeployer => Tools/CodexNetDeployer}/CodexNodeStarter.cs (100%) rename {CodexNetDeployer => Tools/CodexNetDeployer}/Configuration.cs (100%) rename {CodexNetDeployer => Tools/CodexNetDeployer}/Deployer.cs (100%) rename {CodexNetDeployer => Tools/CodexNetDeployer}/PeerConnectivityChecker.cs (100%) rename {CodexNetDeployer => Tools/CodexNetDeployer}/Program.cs (100%) rename {CodexNetDeployer => Tools/CodexNetDeployer}/deploy-continuous-testnet.sh (100%) rename {CodexNetDownloader => Tools/CodexNetDownloader}/CodexNetDownloader.csproj (100%) rename {CodexNetDownloader => Tools/CodexNetDownloader}/Configuration.cs (100%) rename {CodexNetDownloader => Tools/CodexNetDownloader}/Program.cs (100%) diff --git a/ArgsUniform/ArgsUniform.cs b/Framework/ArgsUniform/ArgsUniform.cs similarity index 100% rename from ArgsUniform/ArgsUniform.cs rename to Framework/ArgsUniform/ArgsUniform.cs diff --git a/ArgsUniform/ArgsUniform.csproj b/Framework/ArgsUniform/ArgsUniform.csproj similarity index 100% rename from ArgsUniform/ArgsUniform.csproj rename to Framework/ArgsUniform/ArgsUniform.csproj diff --git a/ArgsUniform/ExampleUser.cs b/Framework/ArgsUniform/ExampleUser.cs similarity index 100% rename from ArgsUniform/ExampleUser.cs rename to Framework/ArgsUniform/ExampleUser.cs diff --git a/ArgsUniform/UniformAttribute.cs b/Framework/ArgsUniform/UniformAttribute.cs similarity index 100% rename from ArgsUniform/UniformAttribute.cs rename to Framework/ArgsUniform/UniformAttribute.cs diff --git a/Core/Core.csproj b/Framework/Core/Core.csproj similarity index 100% rename from Core/Core.csproj rename to Framework/Core/Core.csproj diff --git a/Core/CoreInterface.cs b/Framework/Core/CoreInterface.cs similarity index 100% rename from Core/CoreInterface.cs rename to Framework/Core/CoreInterface.cs diff --git a/Core/DownloadedLog.cs b/Framework/Core/DownloadedLog.cs similarity index 100% rename from Core/DownloadedLog.cs rename to Framework/Core/DownloadedLog.cs diff --git a/Core/EntryPoint.cs b/Framework/Core/EntryPoint.cs similarity index 100% rename from Core/EntryPoint.cs rename to Framework/Core/EntryPoint.cs diff --git a/Core/Http.cs b/Framework/Core/Http.cs similarity index 100% rename from Core/Http.cs rename to Framework/Core/Http.cs diff --git a/Core/LogDownloadHandler.cs b/Framework/Core/LogDownloadHandler.cs similarity index 100% rename from Core/LogDownloadHandler.cs rename to Framework/Core/LogDownloadHandler.cs diff --git a/Core/PluginFinder.cs b/Framework/Core/PluginFinder.cs similarity index 100% rename from Core/PluginFinder.cs rename to Framework/Core/PluginFinder.cs diff --git a/Core/PluginManager.cs b/Framework/Core/PluginManager.cs similarity index 100% rename from Core/PluginManager.cs rename to Framework/Core/PluginManager.cs diff --git a/Core/PluginMetadata.cs b/Framework/Core/PluginMetadata.cs similarity index 100% rename from Core/PluginMetadata.cs rename to Framework/Core/PluginMetadata.cs diff --git a/Core/PluginTools.cs b/Framework/Core/PluginTools.cs similarity index 100% rename from Core/PluginTools.cs rename to Framework/Core/PluginTools.cs diff --git a/Core/SerializeGate.cs b/Framework/Core/SerializeGate.cs similarity index 100% rename from Core/SerializeGate.cs rename to Framework/Core/SerializeGate.cs diff --git a/Core/TimeSet.cs b/Framework/Core/TimeSet.cs similarity index 100% rename from Core/TimeSet.cs rename to Framework/Core/TimeSet.cs diff --git a/Core/ToolsFactory.cs b/Framework/Core/ToolsFactory.cs similarity index 100% rename from Core/ToolsFactory.cs rename to Framework/Core/ToolsFactory.cs diff --git a/FileUtils/FileManager.cs b/Framework/FileUtils/FileManager.cs similarity index 96% rename from FileUtils/FileManager.cs rename to Framework/FileUtils/FileManager.cs index 33641ab..7723ae6 100644 --- a/FileUtils/FileManager.cs +++ b/Framework/FileUtils/FileManager.cs @@ -1,5 +1,4 @@ using Logging; -using NUnit.Framework; using Utils; namespace FileUtils @@ -106,12 +105,12 @@ namespace FileUtils if (spaceAvailable < size.SizeInBytes) { - var msg = $"Inconclusive: Not enough disk space to perform test. " + + var msg = $"Not enough disk space. " + $"{Formatter.FormatByteSize(size.SizeInBytes)} required. " + $"{Formatter.FormatByteSize(spaceAvailable)} available."; log.Log(msg); - Assert.Inconclusive(msg); + throw new Exception(msg); } } diff --git a/FileUtils/FileUtils.csproj b/Framework/FileUtils/FileUtils.csproj similarity index 100% rename from FileUtils/FileUtils.csproj rename to Framework/FileUtils/FileUtils.csproj diff --git a/FileUtils/TrackedFile.cs b/Framework/FileUtils/TrackedFile.cs similarity index 82% rename from FileUtils/TrackedFile.cs rename to Framework/FileUtils/TrackedFile.cs index 3b6ccfb..5f9b04a 100644 --- a/FileUtils/TrackedFile.cs +++ b/Framework/FileUtils/TrackedFile.cs @@ -1,5 +1,4 @@ using Logging; -using NUnit.Framework; using Utils; namespace FileUtils @@ -40,10 +39,10 @@ namespace FileUtils private void AssertEqual(TrackedFile? actual) { - if (actual == null) Assert.Fail("TestFile is null."); - if (actual == this || actual!.Filename == Filename) Assert.Fail("TestFile is compared to itself."); + if (actual == null) FrameworkAssert.Fail("TestFile is null."); + if (actual == this || actual!.Filename == Filename) FrameworkAssert.Fail("TestFile is compared to itself."); - Assert.That(actual.GetFileSize(), Is.EqualTo(GetFileSize()), "Files are not of equal length."); + FrameworkAssert.That(actual.GetFileSize() == GetFileSize(), "Files are not of equal length."); using var streamExpected = new FileStream(Filename, FileMode.Open, FileAccess.Read); using var streamActual = new FileStream(actual.Filename, FileMode.Open, FileAccess.Read); @@ -65,11 +64,11 @@ namespace FileUtils return; } - Assert.That(readActual, Is.EqualTo(readExpected), "Unable to read buffers of equal length."); + FrameworkAssert.That(readActual == readExpected, "Unable to read buffers of equal length."); for (var i = 0; i < readActual; i++) { - if (bytesExpected[i] != bytesActual[i]) Assert.Fail("File contents not equal."); + if (bytesExpected[i] != bytesActual[i]) FrameworkAssert.Fail("File contents not equal."); } } } diff --git a/KubernetesWorkflow/ByteSizeExtensions.cs b/Framework/KubernetesWorkflow/ByteSizeExtensions.cs similarity index 100% rename from KubernetesWorkflow/ByteSizeExtensions.cs rename to Framework/KubernetesWorkflow/ByteSizeExtensions.cs diff --git a/KubernetesWorkflow/CommandRunner.cs b/Framework/KubernetesWorkflow/CommandRunner.cs similarity index 100% rename from KubernetesWorkflow/CommandRunner.cs rename to Framework/KubernetesWorkflow/CommandRunner.cs diff --git a/KubernetesWorkflow/Configuration.cs b/Framework/KubernetesWorkflow/Configuration.cs similarity index 100% rename from KubernetesWorkflow/Configuration.cs rename to Framework/KubernetesWorkflow/Configuration.cs diff --git a/KubernetesWorkflow/ContainerAdditionals.cs b/Framework/KubernetesWorkflow/ContainerAdditionals.cs similarity index 100% rename from KubernetesWorkflow/ContainerAdditionals.cs rename to Framework/KubernetesWorkflow/ContainerAdditionals.cs diff --git a/KubernetesWorkflow/ContainerRecipe.cs b/Framework/KubernetesWorkflow/ContainerRecipe.cs similarity index 100% rename from KubernetesWorkflow/ContainerRecipe.cs rename to Framework/KubernetesWorkflow/ContainerRecipe.cs diff --git a/KubernetesWorkflow/ContainerRecipeFactory.cs b/Framework/KubernetesWorkflow/ContainerRecipeFactory.cs similarity index 100% rename from KubernetesWorkflow/ContainerRecipeFactory.cs rename to Framework/KubernetesWorkflow/ContainerRecipeFactory.cs diff --git a/KubernetesWorkflow/ContainerResources.cs b/Framework/KubernetesWorkflow/ContainerResources.cs similarity index 100% rename from KubernetesWorkflow/ContainerResources.cs rename to Framework/KubernetesWorkflow/ContainerResources.cs diff --git a/KubernetesWorkflow/CrashWatcher.cs b/Framework/KubernetesWorkflow/CrashWatcher.cs similarity index 100% rename from KubernetesWorkflow/CrashWatcher.cs rename to Framework/KubernetesWorkflow/CrashWatcher.cs diff --git a/KubernetesWorkflow/K8sClient.cs b/Framework/KubernetesWorkflow/K8sClient.cs similarity index 100% rename from KubernetesWorkflow/K8sClient.cs rename to Framework/KubernetesWorkflow/K8sClient.cs diff --git a/KubernetesWorkflow/K8sCluster.cs b/Framework/KubernetesWorkflow/K8sCluster.cs similarity index 100% rename from KubernetesWorkflow/K8sCluster.cs rename to Framework/KubernetesWorkflow/K8sCluster.cs diff --git a/KubernetesWorkflow/K8sController.cs b/Framework/KubernetesWorkflow/K8sController.cs similarity index 100% rename from KubernetesWorkflow/K8sController.cs rename to Framework/KubernetesWorkflow/K8sController.cs diff --git a/KubernetesWorkflow/K8sHooks.cs b/Framework/KubernetesWorkflow/K8sHooks.cs similarity index 100% rename from KubernetesWorkflow/K8sHooks.cs rename to Framework/KubernetesWorkflow/K8sHooks.cs diff --git a/KubernetesWorkflow/K8sNameUtils.cs b/Framework/KubernetesWorkflow/K8sNameUtils.cs similarity index 100% rename from KubernetesWorkflow/K8sNameUtils.cs rename to Framework/KubernetesWorkflow/K8sNameUtils.cs diff --git a/KubernetesWorkflow/KnownK8sPods.cs b/Framework/KubernetesWorkflow/KnownK8sPods.cs similarity index 100% rename from KubernetesWorkflow/KnownK8sPods.cs rename to Framework/KubernetesWorkflow/KnownK8sPods.cs diff --git a/KubernetesWorkflow/KubernetesWorkflow.csproj b/Framework/KubernetesWorkflow/KubernetesWorkflow.csproj similarity index 88% rename from KubernetesWorkflow/KubernetesWorkflow.csproj rename to Framework/KubernetesWorkflow/KubernetesWorkflow.csproj index cf95d1d..c9201c3 100644 --- a/KubernetesWorkflow/KubernetesWorkflow.csproj +++ b/Framework/KubernetesWorkflow/KubernetesWorkflow.csproj @@ -9,6 +9,7 @@ + diff --git a/KubernetesWorkflow/Location.cs b/Framework/KubernetesWorkflow/Location.cs similarity index 100% rename from KubernetesWorkflow/Location.cs rename to Framework/KubernetesWorkflow/Location.cs diff --git a/KubernetesWorkflow/PodAnnotations.cs b/Framework/KubernetesWorkflow/PodAnnotations.cs similarity index 100% rename from KubernetesWorkflow/PodAnnotations.cs rename to Framework/KubernetesWorkflow/PodAnnotations.cs diff --git a/KubernetesWorkflow/PodLabels.cs b/Framework/KubernetesWorkflow/PodLabels.cs similarity index 100% rename from KubernetesWorkflow/PodLabels.cs rename to Framework/KubernetesWorkflow/PodLabels.cs diff --git a/KubernetesWorkflow/RecipeComponentFactory.cs b/Framework/KubernetesWorkflow/RecipeComponentFactory.cs similarity index 100% rename from KubernetesWorkflow/RecipeComponentFactory.cs rename to Framework/KubernetesWorkflow/RecipeComponentFactory.cs diff --git a/KubernetesWorkflow/RunnerLocationUtils.cs b/Framework/KubernetesWorkflow/RunnerLocationUtils.cs similarity index 100% rename from KubernetesWorkflow/RunnerLocationUtils.cs rename to Framework/KubernetesWorkflow/RunnerLocationUtils.cs diff --git a/KubernetesWorkflow/RunningContainers.cs b/Framework/KubernetesWorkflow/RunningContainers.cs similarity index 100% rename from KubernetesWorkflow/RunningContainers.cs rename to Framework/KubernetesWorkflow/RunningContainers.cs diff --git a/KubernetesWorkflow/RunningPod.cs b/Framework/KubernetesWorkflow/RunningPod.cs similarity index 100% rename from KubernetesWorkflow/RunningPod.cs rename to Framework/KubernetesWorkflow/RunningPod.cs diff --git a/KubernetesWorkflow/StartupConfig.cs b/Framework/KubernetesWorkflow/StartupConfig.cs similarity index 100% rename from KubernetesWorkflow/StartupConfig.cs rename to Framework/KubernetesWorkflow/StartupConfig.cs diff --git a/KubernetesWorkflow/StartupWorkflow.cs b/Framework/KubernetesWorkflow/StartupWorkflow.cs similarity index 100% rename from KubernetesWorkflow/StartupWorkflow.cs rename to Framework/KubernetesWorkflow/StartupWorkflow.cs diff --git a/KubernetesWorkflow/WorkflowCreator.cs b/Framework/KubernetesWorkflow/WorkflowCreator.cs similarity index 100% rename from KubernetesWorkflow/WorkflowCreator.cs rename to Framework/KubernetesWorkflow/WorkflowCreator.cs diff --git a/KubernetesWorkflow/WorkflowNumberSource.cs b/Framework/KubernetesWorkflow/WorkflowNumberSource.cs similarity index 100% rename from KubernetesWorkflow/WorkflowNumberSource.cs rename to Framework/KubernetesWorkflow/WorkflowNumberSource.cs diff --git a/Logging/ApplicationIds.cs b/Framework/Logging/ApplicationIds.cs similarity index 100% rename from Logging/ApplicationIds.cs rename to Framework/Logging/ApplicationIds.cs diff --git a/Logging/BaseLog.cs b/Framework/Logging/BaseLog.cs similarity index 91% rename from Logging/BaseLog.cs rename to Framework/Logging/BaseLog.cs index e8a94a4..26d9c1e 100644 --- a/Logging/BaseLog.cs +++ b/Framework/Logging/BaseLog.cs @@ -70,14 +70,6 @@ namespace Logging return new LogFile($"{GetFullName()}_{GetSubfileNumber()}", ext); } - public void WriteLogTag() - { - var runId = NameUtils.GetRunId(); - var category = NameUtils.GetCategoryName(); - var name = NameUtils.GetTestMethodName(); - LogFile.WriteRaw($"{runId} {category} {name}"); - } - private string ApplyReplacements(string str) { foreach (var replacement in replacements) diff --git a/Logging/LogConfig.cs b/Framework/Logging/LogConfig.cs similarity index 100% rename from Logging/LogConfig.cs rename to Framework/Logging/LogConfig.cs diff --git a/Logging/LogFile.cs b/Framework/Logging/LogFile.cs similarity index 100% rename from Logging/LogFile.cs rename to Framework/Logging/LogFile.cs diff --git a/Logging/LogPrefixer.cs b/Framework/Logging/LogPrefixer.cs similarity index 100% rename from Logging/LogPrefixer.cs rename to Framework/Logging/LogPrefixer.cs diff --git a/Logging/Logging.csproj b/Framework/Logging/Logging.csproj similarity index 59% rename from Logging/Logging.csproj rename to Framework/Logging/Logging.csproj index defbdcc..0757f27 100644 --- a/Logging/Logging.csproj +++ b/Framework/Logging/Logging.csproj @@ -7,12 +7,6 @@ enable - - - - - - diff --git a/Logging/NullLog.cs b/Framework/Logging/NullLog.cs similarity index 88% rename from Logging/NullLog.cs rename to Framework/Logging/NullLog.cs index c969b17..997e490 100644 --- a/Logging/NullLog.cs +++ b/Framework/Logging/NullLog.cs @@ -1,8 +1,8 @@ namespace Logging { - public class NullLog : TestLog + public class NullLog : BaseLog { - public NullLog() : base("NULL", false, "NULL") + public NullLog() : base(false) { } diff --git a/Logging/Stopwatch.cs b/Framework/Logging/Stopwatch.cs similarity index 100% rename from Logging/Stopwatch.cs rename to Framework/Logging/Stopwatch.cs diff --git a/Nethereum/ConversionExtensions.cs b/Framework/NethereumWorkflow/ConversionExtensions.cs similarity index 100% rename from Nethereum/ConversionExtensions.cs rename to Framework/NethereumWorkflow/ConversionExtensions.cs diff --git a/Nethereum/NethereumInteraction.cs b/Framework/NethereumWorkflow/NethereumInteraction.cs similarity index 100% rename from Nethereum/NethereumInteraction.cs rename to Framework/NethereumWorkflow/NethereumInteraction.cs diff --git a/Nethereum/NethereumInteractionCreator.cs b/Framework/NethereumWorkflow/NethereumInteractionCreator.cs similarity index 100% rename from Nethereum/NethereumInteractionCreator.cs rename to Framework/NethereumWorkflow/NethereumInteractionCreator.cs diff --git a/Nethereum/NethereumWorkflow.csproj b/Framework/NethereumWorkflow/NethereumWorkflow.csproj similarity index 100% rename from Nethereum/NethereumWorkflow.csproj rename to Framework/NethereumWorkflow/NethereumWorkflow.csproj diff --git a/Utils/Address.cs b/Framework/Utils/Address.cs similarity index 100% rename from Utils/Address.cs rename to Framework/Utils/Address.cs diff --git a/Utils/ByteSize.cs b/Framework/Utils/ByteSize.cs similarity index 100% rename from Utils/ByteSize.cs rename to Framework/Utils/ByteSize.cs diff --git a/Utils/DebugStack.cs b/Framework/Utils/DebugStack.cs similarity index 100% rename from Utils/DebugStack.cs rename to Framework/Utils/DebugStack.cs diff --git a/Utils/Formatter.cs b/Framework/Utils/Formatter.cs similarity index 100% rename from Utils/Formatter.cs rename to Framework/Utils/Formatter.cs diff --git a/Framework/Utils/FrameworkAssert.cs b/Framework/Utils/FrameworkAssert.cs new file mode 100644 index 0000000..325f7cf --- /dev/null +++ b/Framework/Utils/FrameworkAssert.cs @@ -0,0 +1,15 @@ +namespace Utils +{ + public static class FrameworkAssert + { + public static void That(bool condition, string message) + { + if (!condition) Fail(message); + } + + public static void Fail(string message) + { + throw new Exception(message); + } + } +} diff --git a/Utils/NumberSource.cs b/Framework/Utils/NumberSource.cs similarity index 100% rename from Utils/NumberSource.cs rename to Framework/Utils/NumberSource.cs diff --git a/Utils/ParseEnum.cs b/Framework/Utils/ParseEnum.cs similarity index 100% rename from Utils/ParseEnum.cs rename to Framework/Utils/ParseEnum.cs diff --git a/Utils/RandomUtils.cs b/Framework/Utils/RandomUtils.cs similarity index 100% rename from Utils/RandomUtils.cs rename to Framework/Utils/RandomUtils.cs diff --git a/Utils/Time.cs b/Framework/Utils/Time.cs similarity index 100% rename from Utils/Time.cs rename to Framework/Utils/Time.cs diff --git a/Utils/Utils.csproj b/Framework/Utils/Utils.csproj similarity index 100% rename from Utils/Utils.csproj rename to Framework/Utils/Utils.csproj diff --git a/CodexContractsPlugin/CodexContractsAccess.cs b/ProjectPlugins/CodexContractsPlugin/CodexContractsAccess.cs similarity index 100% rename from CodexContractsPlugin/CodexContractsAccess.cs rename to ProjectPlugins/CodexContractsPlugin/CodexContractsAccess.cs diff --git a/CodexContractsPlugin/CodexContractsContainerConfig.cs b/ProjectPlugins/CodexContractsPlugin/CodexContractsContainerConfig.cs similarity index 100% rename from CodexContractsPlugin/CodexContractsContainerConfig.cs rename to ProjectPlugins/CodexContractsPlugin/CodexContractsContainerConfig.cs diff --git a/CodexContractsPlugin/CodexContractsContainerRecipe.cs b/ProjectPlugins/CodexContractsPlugin/CodexContractsContainerRecipe.cs similarity index 100% rename from CodexContractsPlugin/CodexContractsContainerRecipe.cs rename to ProjectPlugins/CodexContractsPlugin/CodexContractsContainerRecipe.cs diff --git a/CodexContractsPlugin/CodexContractsDeployment.cs b/ProjectPlugins/CodexContractsPlugin/CodexContractsDeployment.cs similarity index 100% rename from CodexContractsPlugin/CodexContractsDeployment.cs rename to ProjectPlugins/CodexContractsPlugin/CodexContractsDeployment.cs diff --git a/CodexContractsPlugin/CodexContractsPlugin.cs b/ProjectPlugins/CodexContractsPlugin/CodexContractsPlugin.cs similarity index 100% rename from CodexContractsPlugin/CodexContractsPlugin.cs rename to ProjectPlugins/CodexContractsPlugin/CodexContractsPlugin.cs diff --git a/CodexContractsPlugin/CodexContractsPlugin.csproj b/ProjectPlugins/CodexContractsPlugin/CodexContractsPlugin.csproj similarity index 100% rename from CodexContractsPlugin/CodexContractsPlugin.csproj rename to ProjectPlugins/CodexContractsPlugin/CodexContractsPlugin.csproj diff --git a/CodexContractsPlugin/CodexContractsStarter.cs b/ProjectPlugins/CodexContractsPlugin/CodexContractsStarter.cs similarity index 100% rename from CodexContractsPlugin/CodexContractsStarter.cs rename to ProjectPlugins/CodexContractsPlugin/CodexContractsStarter.cs diff --git a/CodexContractsPlugin/ContractInteractions.cs b/ProjectPlugins/CodexContractsPlugin/ContractInteractions.cs similarity index 100% rename from CodexContractsPlugin/ContractInteractions.cs rename to ProjectPlugins/CodexContractsPlugin/ContractInteractions.cs diff --git a/CodexContractsPlugin/ContractsContainerInfoExtractor.cs b/ProjectPlugins/CodexContractsPlugin/ContractsContainerInfoExtractor.cs similarity index 100% rename from CodexContractsPlugin/ContractsContainerInfoExtractor.cs rename to ProjectPlugins/CodexContractsPlugin/ContractsContainerInfoExtractor.cs diff --git a/CodexContractsPlugin/CoreInterfaceExtensions.cs b/ProjectPlugins/CodexContractsPlugin/CoreInterfaceExtensions.cs similarity index 100% rename from CodexContractsPlugin/CoreInterfaceExtensions.cs rename to ProjectPlugins/CodexContractsPlugin/CoreInterfaceExtensions.cs diff --git a/CodexContractsPlugin/TestTokenExtensions.cs b/ProjectPlugins/CodexContractsPlugin/TestTokenExtensions.cs similarity index 100% rename from CodexContractsPlugin/TestTokenExtensions.cs rename to ProjectPlugins/CodexContractsPlugin/TestTokenExtensions.cs diff --git a/CodexPlugin/CodexAccess.cs b/ProjectPlugins/CodexPlugin/CodexAccess.cs similarity index 100% rename from CodexPlugin/CodexAccess.cs rename to ProjectPlugins/CodexPlugin/CodexAccess.cs diff --git a/CodexPlugin/CodexApiTypes.cs b/ProjectPlugins/CodexPlugin/CodexApiTypes.cs similarity index 100% rename from CodexPlugin/CodexApiTypes.cs rename to ProjectPlugins/CodexPlugin/CodexApiTypes.cs diff --git a/CodexPlugin/CodexContainerRecipe.cs b/ProjectPlugins/CodexPlugin/CodexContainerRecipe.cs similarity index 100% rename from CodexPlugin/CodexContainerRecipe.cs rename to ProjectPlugins/CodexPlugin/CodexContainerRecipe.cs diff --git a/CodexPlugin/CodexDeployment.cs b/ProjectPlugins/CodexPlugin/CodexDeployment.cs similarity index 100% rename from CodexPlugin/CodexDeployment.cs rename to ProjectPlugins/CodexPlugin/CodexDeployment.cs diff --git a/CodexPlugin/CodexLogLevel.cs b/ProjectPlugins/CodexPlugin/CodexLogLevel.cs similarity index 100% rename from CodexPlugin/CodexLogLevel.cs rename to ProjectPlugins/CodexPlugin/CodexLogLevel.cs diff --git a/CodexPlugin/CodexNode.cs b/ProjectPlugins/CodexPlugin/CodexNode.cs similarity index 95% rename from CodexPlugin/CodexNode.cs rename to ProjectPlugins/CodexPlugin/CodexNode.cs index c7560ff..a47f2b2 100644 --- a/CodexPlugin/CodexNode.cs +++ b/ProjectPlugins/CodexPlugin/CodexNode.cs @@ -4,7 +4,6 @@ using GethPlugin; using KubernetesWorkflow; using Logging; using MetricsPlugin; -using NUnit.Framework; using Utils; namespace CodexPlugin @@ -91,8 +90,8 @@ namespace CodexPlugin return CodexAccess.UploadFile(fileStream); }); - if (string.IsNullOrEmpty(response)) Assert.Fail("Received empty response."); - if (response.StartsWith(UploadFailedMessage)) Assert.Fail("Node failed to store block."); + if (string.IsNullOrEmpty(response)) FrameworkAssert.Fail("Received empty response."); + if (response.StartsWith(UploadFailedMessage)) FrameworkAssert.Fail("Node failed to store block."); Log($"Uploaded file. Received contentId: '{response}'."); return new ContentId(response); @@ -116,7 +115,7 @@ namespace CodexPlugin var peerInfo = node.GetDebugInfo(); var response = CodexAccess.ConnectToPeer(peerInfo.id, GetPeerMultiAddress(peer, peerInfo)); - Assert.That(response, Is.EqualTo(SuccessfullyConnectedMessage), "Unable to connect codex nodes."); + FrameworkAssert.That(response == SuccessfullyConnectedMessage, "Unable to connect codex nodes."); Log($"Successfully connected to peer {peer.GetName()}."); } diff --git a/CodexPlugin/CodexNodeFactory.cs b/ProjectPlugins/CodexPlugin/CodexNodeFactory.cs similarity index 100% rename from CodexPlugin/CodexNodeFactory.cs rename to ProjectPlugins/CodexPlugin/CodexNodeFactory.cs diff --git a/CodexPlugin/CodexNodeGroup.cs b/ProjectPlugins/CodexPlugin/CodexNodeGroup.cs similarity index 100% rename from CodexPlugin/CodexNodeGroup.cs rename to ProjectPlugins/CodexPlugin/CodexNodeGroup.cs diff --git a/CodexPlugin/CodexPlugin.cs b/ProjectPlugins/CodexPlugin/CodexPlugin.cs similarity index 100% rename from CodexPlugin/CodexPlugin.cs rename to ProjectPlugins/CodexPlugin/CodexPlugin.cs diff --git a/CodexPlugin/CodexPlugin.csproj b/ProjectPlugins/CodexPlugin/CodexPlugin.csproj similarity index 100% rename from CodexPlugin/CodexPlugin.csproj rename to ProjectPlugins/CodexPlugin/CodexPlugin.csproj diff --git a/CodexPlugin/CodexSetup.cs b/ProjectPlugins/CodexPlugin/CodexSetup.cs similarity index 100% rename from CodexPlugin/CodexSetup.cs rename to ProjectPlugins/CodexPlugin/CodexSetup.cs diff --git a/CodexPlugin/CodexStarter.cs b/ProjectPlugins/CodexPlugin/CodexStarter.cs similarity index 100% rename from CodexPlugin/CodexStarter.cs rename to ProjectPlugins/CodexPlugin/CodexStarter.cs diff --git a/CodexPlugin/CodexStartupConfig.cs b/ProjectPlugins/CodexPlugin/CodexStartupConfig.cs similarity index 100% rename from CodexPlugin/CodexStartupConfig.cs rename to ProjectPlugins/CodexPlugin/CodexStartupConfig.cs diff --git a/CodexPlugin/CoreInterfaceExtensions.cs b/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs similarity index 100% rename from CodexPlugin/CoreInterfaceExtensions.cs rename to ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs diff --git a/CodexPlugin/MarketplaceAccess.cs b/ProjectPlugins/CodexPlugin/MarketplaceAccess.cs similarity index 95% rename from CodexPlugin/MarketplaceAccess.cs rename to ProjectPlugins/CodexPlugin/MarketplaceAccess.cs index 2a0683a..5734c4f 100644 --- a/CodexPlugin/MarketplaceAccess.cs +++ b/ProjectPlugins/CodexPlugin/MarketplaceAccess.cs @@ -1,7 +1,6 @@ using CodexContractsPlugin; using Logging; using Newtonsoft.Json; -using NUnit.Framework; using Utils; using System.Numerics; @@ -113,7 +112,7 @@ namespace CodexPlugin private void Unavailable() { - Assert.Fail("Incorrect test setup: Marketplace was not enabled for this group of Codex nodes. Add 'EnableMarketplace(...)' after 'SetupCodexNodes()' to enable it."); + FrameworkAssert.Fail("Incorrect test setup: Marketplace was not enabled for this group of Codex nodes. Add 'EnableMarketplace(...)' after 'SetupCodexNodes()' to enable it."); throw new InvalidOperationException(); } } @@ -189,12 +188,12 @@ namespace CodexPlugin if (lastState == "errored") { - Assert.Fail("Contract errored: " + statusJson); + FrameworkAssert.Fail("Contract errored: " + statusJson); } if (DateTime.UtcNow - waitStart > timeout) { - Assert.Fail($"Contract did not reach '{desiredState}' within timeout. {statusJson}"); + FrameworkAssert.Fail($"Contract did not reach '{desiredState}' within timeout. {statusJson}"); } } log.Log($"Contract '{desiredState}'."); diff --git a/CodexPlugin/MarketplaceInitialConfig.cs b/ProjectPlugins/CodexPlugin/MarketplaceInitialConfig.cs similarity index 100% rename from CodexPlugin/MarketplaceInitialConfig.cs rename to ProjectPlugins/CodexPlugin/MarketplaceInitialConfig.cs diff --git a/CodexPlugin/MarketplaceStartResults.cs b/ProjectPlugins/CodexPlugin/MarketplaceStartResults.cs similarity index 100% rename from CodexPlugin/MarketplaceStartResults.cs rename to ProjectPlugins/CodexPlugin/MarketplaceStartResults.cs diff --git a/CodexPlugin/MarketplaceStarter.cs b/ProjectPlugins/CodexPlugin/MarketplaceStarter.cs similarity index 100% rename from CodexPlugin/MarketplaceStarter.cs rename to ProjectPlugins/CodexPlugin/MarketplaceStarter.cs diff --git a/GethPlugin/CoreInterfaceExtensions.cs b/ProjectPlugins/GethPlugin/CoreInterfaceExtensions.cs similarity index 100% rename from GethPlugin/CoreInterfaceExtensions.cs rename to ProjectPlugins/GethPlugin/CoreInterfaceExtensions.cs diff --git a/GethPlugin/EthAddress.cs b/ProjectPlugins/GethPlugin/EthAddress.cs similarity index 100% rename from GethPlugin/EthAddress.cs rename to ProjectPlugins/GethPlugin/EthAddress.cs diff --git a/GethPlugin/EthTokenExtensions.cs b/ProjectPlugins/GethPlugin/EthTokenExtensions.cs similarity index 100% rename from GethPlugin/EthTokenExtensions.cs rename to ProjectPlugins/GethPlugin/EthTokenExtensions.cs diff --git a/GethPlugin/GethAccount.cs b/ProjectPlugins/GethPlugin/GethAccount.cs similarity index 100% rename from GethPlugin/GethAccount.cs rename to ProjectPlugins/GethPlugin/GethAccount.cs diff --git a/GethPlugin/GethContainerInfoExtractor.cs b/ProjectPlugins/GethPlugin/GethContainerInfoExtractor.cs similarity index 100% rename from GethPlugin/GethContainerInfoExtractor.cs rename to ProjectPlugins/GethPlugin/GethContainerInfoExtractor.cs diff --git a/GethPlugin/GethContainerRecipe.cs b/ProjectPlugins/GethPlugin/GethContainerRecipe.cs similarity index 100% rename from GethPlugin/GethContainerRecipe.cs rename to ProjectPlugins/GethPlugin/GethContainerRecipe.cs diff --git a/GethPlugin/GethDeployment.cs b/ProjectPlugins/GethPlugin/GethDeployment.cs similarity index 100% rename from GethPlugin/GethDeployment.cs rename to ProjectPlugins/GethPlugin/GethDeployment.cs diff --git a/GethPlugin/GethNode.cs b/ProjectPlugins/GethPlugin/GethNode.cs similarity index 100% rename from GethPlugin/GethNode.cs rename to ProjectPlugins/GethPlugin/GethNode.cs diff --git a/GethPlugin/GethPlugin.cs b/ProjectPlugins/GethPlugin/GethPlugin.cs similarity index 100% rename from GethPlugin/GethPlugin.cs rename to ProjectPlugins/GethPlugin/GethPlugin.cs diff --git a/GethPlugin/GethPlugin.csproj b/ProjectPlugins/GethPlugin/GethPlugin.csproj similarity index 100% rename from GethPlugin/GethPlugin.csproj rename to ProjectPlugins/GethPlugin/GethPlugin.csproj diff --git a/GethPlugin/GethStarter.cs b/ProjectPlugins/GethPlugin/GethStarter.cs similarity index 100% rename from GethPlugin/GethStarter.cs rename to ProjectPlugins/GethPlugin/GethStarter.cs diff --git a/GethPlugin/GethStartupConfig.cs b/ProjectPlugins/GethPlugin/GethStartupConfig.cs similarity index 100% rename from GethPlugin/GethStartupConfig.cs rename to ProjectPlugins/GethPlugin/GethStartupConfig.cs diff --git a/MetricsPlugin/CoreInterfaceExtensions.cs b/ProjectPlugins/MetricsPlugin/CoreInterfaceExtensions.cs similarity index 100% rename from MetricsPlugin/CoreInterfaceExtensions.cs rename to ProjectPlugins/MetricsPlugin/CoreInterfaceExtensions.cs diff --git a/MetricsPlugin/MetricsAccess.cs b/ProjectPlugins/MetricsPlugin/MetricsAccess.cs similarity index 100% rename from MetricsPlugin/MetricsAccess.cs rename to ProjectPlugins/MetricsPlugin/MetricsAccess.cs diff --git a/MetricsPlugin/MetricsDownloader.cs b/ProjectPlugins/MetricsPlugin/MetricsDownloader.cs similarity index 100% rename from MetricsPlugin/MetricsDownloader.cs rename to ProjectPlugins/MetricsPlugin/MetricsDownloader.cs diff --git a/MetricsPlugin/MetricsPlugin.cs b/ProjectPlugins/MetricsPlugin/MetricsPlugin.cs similarity index 100% rename from MetricsPlugin/MetricsPlugin.cs rename to ProjectPlugins/MetricsPlugin/MetricsPlugin.cs diff --git a/MetricsPlugin/MetricsPlugin.csproj b/ProjectPlugins/MetricsPlugin/MetricsPlugin.csproj similarity index 100% rename from MetricsPlugin/MetricsPlugin.csproj rename to ProjectPlugins/MetricsPlugin/MetricsPlugin.csproj diff --git a/MetricsPlugin/MetricsQuery.cs b/ProjectPlugins/MetricsPlugin/MetricsQuery.cs similarity index 100% rename from MetricsPlugin/MetricsQuery.cs rename to ProjectPlugins/MetricsPlugin/MetricsQuery.cs diff --git a/MetricsPlugin/MetricsScrapeTarget.cs b/ProjectPlugins/MetricsPlugin/MetricsScrapeTarget.cs similarity index 100% rename from MetricsPlugin/MetricsScrapeTarget.cs rename to ProjectPlugins/MetricsPlugin/MetricsScrapeTarget.cs diff --git a/MetricsPlugin/PrometheusContainerRecipe.cs b/ProjectPlugins/MetricsPlugin/PrometheusContainerRecipe.cs similarity index 100% rename from MetricsPlugin/PrometheusContainerRecipe.cs rename to ProjectPlugins/MetricsPlugin/PrometheusContainerRecipe.cs diff --git a/MetricsPlugin/PrometheusStarter.cs b/ProjectPlugins/MetricsPlugin/PrometheusStarter.cs similarity index 100% rename from MetricsPlugin/PrometheusStarter.cs rename to ProjectPlugins/MetricsPlugin/PrometheusStarter.cs diff --git a/MetricsPlugin/PrometheusStartupConfig.cs b/ProjectPlugins/MetricsPlugin/PrometheusStartupConfig.cs similarity index 100% rename from MetricsPlugin/PrometheusStartupConfig.cs rename to ProjectPlugins/MetricsPlugin/PrometheusStartupConfig.cs diff --git a/MetricsPlugin/dashboard.json b/ProjectPlugins/MetricsPlugin/dashboard.json similarity index 100% rename from MetricsPlugin/dashboard.json rename to ProjectPlugins/MetricsPlugin/dashboard.json diff --git a/ContinuousTests/CodexAccessFactory.cs b/Tests/CodexContinuousTests/CodexAccessFactory.cs similarity index 100% rename from ContinuousTests/CodexAccessFactory.cs rename to Tests/CodexContinuousTests/CodexAccessFactory.cs diff --git a/ContinuousTests/ContinuousTests.csproj b/Tests/CodexContinuousTests/CodexContinuousTests.csproj similarity index 100% rename from ContinuousTests/ContinuousTests.csproj rename to Tests/CodexContinuousTests/CodexContinuousTests.csproj diff --git a/ContinuousTests/Configuration.cs b/Tests/CodexContinuousTests/Configuration.cs similarity index 100% rename from ContinuousTests/Configuration.cs rename to Tests/CodexContinuousTests/Configuration.cs diff --git a/ContinuousTests/ContinuousTest.cs b/Tests/CodexContinuousTests/ContinuousTest.cs similarity index 100% rename from ContinuousTests/ContinuousTest.cs rename to Tests/CodexContinuousTests/ContinuousTest.cs diff --git a/ContinuousTests/ContinuousTestRunner.cs b/Tests/CodexContinuousTests/ContinuousTestRunner.cs similarity index 100% rename from ContinuousTests/ContinuousTestRunner.cs rename to Tests/CodexContinuousTests/ContinuousTestRunner.cs diff --git a/ContinuousTests/K8sFactory.cs b/Tests/CodexContinuousTests/K8sFactory.cs similarity index 100% rename from ContinuousTests/K8sFactory.cs rename to Tests/CodexContinuousTests/K8sFactory.cs diff --git a/ContinuousTests/NodeRunner.cs b/Tests/CodexContinuousTests/NodeRunner.cs similarity index 100% rename from ContinuousTests/NodeRunner.cs rename to Tests/CodexContinuousTests/NodeRunner.cs diff --git a/ContinuousTests/Program.cs b/Tests/CodexContinuousTests/Program.cs similarity index 100% rename from ContinuousTests/Program.cs rename to Tests/CodexContinuousTests/Program.cs diff --git a/ContinuousTests/SingleTestRun.cs b/Tests/CodexContinuousTests/SingleTestRun.cs similarity index 100% rename from ContinuousTests/SingleTestRun.cs rename to Tests/CodexContinuousTests/SingleTestRun.cs diff --git a/ContinuousTests/StartupChecker.cs b/Tests/CodexContinuousTests/StartupChecker.cs similarity index 100% rename from ContinuousTests/StartupChecker.cs rename to Tests/CodexContinuousTests/StartupChecker.cs diff --git a/ContinuousTests/TaskFactory.cs b/Tests/CodexContinuousTests/TaskFactory.cs similarity index 100% rename from ContinuousTests/TaskFactory.cs rename to Tests/CodexContinuousTests/TaskFactory.cs diff --git a/ContinuousTests/TestFactory.cs b/Tests/CodexContinuousTests/TestFactory.cs similarity index 100% rename from ContinuousTests/TestFactory.cs rename to Tests/CodexContinuousTests/TestFactory.cs diff --git a/ContinuousTests/TestHandle.cs b/Tests/CodexContinuousTests/TestHandle.cs similarity index 100% rename from ContinuousTests/TestHandle.cs rename to Tests/CodexContinuousTests/TestHandle.cs diff --git a/ContinuousTests/TestLoop.cs b/Tests/CodexContinuousTests/TestLoop.cs similarity index 100% rename from ContinuousTests/TestLoop.cs rename to Tests/CodexContinuousTests/TestLoop.cs diff --git a/ContinuousTests/TestMomentAttribute.cs b/Tests/CodexContinuousTests/TestMomentAttribute.cs similarity index 100% rename from ContinuousTests/TestMomentAttribute.cs rename to Tests/CodexContinuousTests/TestMomentAttribute.cs diff --git a/ContinuousTests/Tests/HoldMyBeerTest.cs b/Tests/CodexContinuousTests/Tests/HoldMyBeerTest.cs similarity index 100% rename from ContinuousTests/Tests/HoldMyBeerTest.cs rename to Tests/CodexContinuousTests/Tests/HoldMyBeerTest.cs diff --git a/ContinuousTests/Tests/MarketplaceTest.cs b/Tests/CodexContinuousTests/Tests/MarketplaceTest.cs similarity index 100% rename from ContinuousTests/Tests/MarketplaceTest.cs rename to Tests/CodexContinuousTests/Tests/MarketplaceTest.cs diff --git a/ContinuousTests/Tests/PeersTest.cs b/Tests/CodexContinuousTests/Tests/PeersTest.cs similarity index 100% rename from ContinuousTests/Tests/PeersTest.cs rename to Tests/CodexContinuousTests/Tests/PeersTest.cs diff --git a/ContinuousTests/Tests/PerformanceTests.cs b/Tests/CodexContinuousTests/Tests/PerformanceTests.cs similarity index 100% rename from ContinuousTests/Tests/PerformanceTests.cs rename to Tests/CodexContinuousTests/Tests/PerformanceTests.cs diff --git a/ContinuousTests/Tests/ThresholdChecks.cs b/Tests/CodexContinuousTests/Tests/ThresholdChecks.cs similarity index 100% rename from ContinuousTests/Tests/ThresholdChecks.cs rename to Tests/CodexContinuousTests/Tests/ThresholdChecks.cs diff --git a/ContinuousTests/Tests/TransientNodeTest.cs b/Tests/CodexContinuousTests/Tests/TransientNodeTest.cs similarity index 100% rename from ContinuousTests/Tests/TransientNodeTest.cs rename to Tests/CodexContinuousTests/Tests/TransientNodeTest.cs diff --git a/ContinuousTests/Tests/TwoClientTest.cs b/Tests/CodexContinuousTests/Tests/TwoClientTest.cs similarity index 100% rename from ContinuousTests/Tests/TwoClientTest.cs rename to Tests/CodexContinuousTests/Tests/TwoClientTest.cs diff --git a/ContinuousTests/reports/CodexTestNetReport-August2023.md b/Tests/CodexContinuousTests/reports/CodexTestNetReport-August2023.md similarity index 100% rename from ContinuousTests/reports/CodexTestNetReport-August2023.md rename to Tests/CodexContinuousTests/reports/CodexTestNetReport-August2023.md diff --git a/ContinuousTests/reports/CodexTestNetReport-July2023.md b/Tests/CodexContinuousTests/reports/CodexTestNetReport-July2023.md similarity index 100% rename from ContinuousTests/reports/CodexTestNetReport-July2023.md rename to Tests/CodexContinuousTests/reports/CodexTestNetReport-July2023.md diff --git a/ContinuousTests/run.sh b/Tests/CodexContinuousTests/run.sh similarity index 100% rename from ContinuousTests/run.sh rename to Tests/CodexContinuousTests/run.sh diff --git a/LongTests/BasicTests/DownloadTests.cs b/Tests/CodexLongTests/BasicTests/DownloadTests.cs similarity index 100% rename from LongTests/BasicTests/DownloadTests.cs rename to Tests/CodexLongTests/BasicTests/DownloadTests.cs diff --git a/LongTests/BasicTests/LargeFileTests.cs b/Tests/CodexLongTests/BasicTests/LargeFileTests.cs similarity index 100% rename from LongTests/BasicTests/LargeFileTests.cs rename to Tests/CodexLongTests/BasicTests/LargeFileTests.cs diff --git a/LongTests/BasicTests/TestInfraTests.cs b/Tests/CodexLongTests/BasicTests/TestInfraTests.cs similarity index 100% rename from LongTests/BasicTests/TestInfraTests.cs rename to Tests/CodexLongTests/BasicTests/TestInfraTests.cs diff --git a/LongTests/BasicTests/UploadTests.cs b/Tests/CodexLongTests/BasicTests/UploadTests.cs similarity index 100% rename from LongTests/BasicTests/UploadTests.cs rename to Tests/CodexLongTests/BasicTests/UploadTests.cs diff --git a/LongTests/TestsLong.csproj b/Tests/CodexLongTests/CodexTestsLong.csproj similarity index 100% rename from LongTests/TestsLong.csproj rename to Tests/CodexLongTests/CodexTestsLong.csproj diff --git a/LongTests/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs b/Tests/CodexLongTests/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs similarity index 100% rename from LongTests/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs rename to Tests/CodexLongTests/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs diff --git a/LongTests/Parallelism.cs b/Tests/CodexLongTests/Parallelism.cs similarity index 100% rename from LongTests/Parallelism.cs rename to Tests/CodexLongTests/Parallelism.cs diff --git a/Tests/AutoBootstrapDistTest.cs b/Tests/CodexTests/AutoBootstrapDistTest.cs similarity index 100% rename from Tests/AutoBootstrapDistTest.cs rename to Tests/CodexTests/AutoBootstrapDistTest.cs diff --git a/Tests/BasicTests/ContinuousSubstitute.cs b/Tests/CodexTests/BasicTests/ContinuousSubstitute.cs similarity index 100% rename from Tests/BasicTests/ContinuousSubstitute.cs rename to Tests/CodexTests/BasicTests/ContinuousSubstitute.cs diff --git a/Tests/BasicTests/ExampleTests.cs b/Tests/CodexTests/BasicTests/ExampleTests.cs similarity index 100% rename from Tests/BasicTests/ExampleTests.cs rename to Tests/CodexTests/BasicTests/ExampleTests.cs diff --git a/Tests/BasicTests/NetworkIsolationTest.cs b/Tests/CodexTests/BasicTests/NetworkIsolationTest.cs similarity index 100% rename from Tests/BasicTests/NetworkIsolationTest.cs rename to Tests/CodexTests/BasicTests/NetworkIsolationTest.cs diff --git a/Tests/BasicTests/OneClientTests.cs b/Tests/CodexTests/BasicTests/OneClientTests.cs similarity index 100% rename from Tests/BasicTests/OneClientTests.cs rename to Tests/CodexTests/BasicTests/OneClientTests.cs diff --git a/Tests/BasicTests/ThreeClientTest.cs b/Tests/CodexTests/BasicTests/ThreeClientTest.cs similarity index 100% rename from Tests/BasicTests/ThreeClientTest.cs rename to Tests/CodexTests/BasicTests/ThreeClientTest.cs diff --git a/Tests/BasicTests/TwoClientTests.cs b/Tests/CodexTests/BasicTests/TwoClientTests.cs similarity index 100% rename from Tests/BasicTests/TwoClientTests.cs rename to Tests/CodexTests/BasicTests/TwoClientTests.cs diff --git a/Tests/CodexDistTest.cs b/Tests/CodexTests/CodexDistTest.cs similarity index 100% rename from Tests/CodexDistTest.cs rename to Tests/CodexTests/CodexDistTest.cs diff --git a/Tests/Tests.csproj b/Tests/CodexTests/CodexTests.csproj similarity index 100% rename from Tests/Tests.csproj rename to Tests/CodexTests/CodexTests.csproj diff --git a/Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs b/Tests/CodexTests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs similarity index 100% rename from Tests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs rename to Tests/CodexTests/DownloadConnectivityTests/FullyConnectedDownloadTests.cs diff --git a/Tests/Helpers/FullConnectivityHelper.cs b/Tests/CodexTests/Helpers/FullConnectivityHelper.cs similarity index 100% rename from Tests/Helpers/FullConnectivityHelper.cs rename to Tests/CodexTests/Helpers/FullConnectivityHelper.cs diff --git a/Tests/Helpers/PeerConnectionTestHelpers.cs b/Tests/CodexTests/Helpers/PeerConnectionTestHelpers.cs similarity index 100% rename from Tests/Helpers/PeerConnectionTestHelpers.cs rename to Tests/CodexTests/Helpers/PeerConnectionTestHelpers.cs diff --git a/Tests/Helpers/PeerDownloadTestHelpers.cs b/Tests/CodexTests/Helpers/PeerDownloadTestHelpers.cs similarity index 100% rename from Tests/Helpers/PeerDownloadTestHelpers.cs rename to Tests/CodexTests/Helpers/PeerDownloadTestHelpers.cs diff --git a/Tests/MetricsAccessExtensions.cs b/Tests/CodexTests/MetricsAccessExtensions.cs similarity index 100% rename from Tests/MetricsAccessExtensions.cs rename to Tests/CodexTests/MetricsAccessExtensions.cs diff --git a/Tests/Parallelism.cs b/Tests/CodexTests/Parallelism.cs similarity index 100% rename from Tests/Parallelism.cs rename to Tests/CodexTests/Parallelism.cs diff --git a/Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs b/Tests/CodexTests/PeerDiscoveryTests/LayeredDiscoveryTests.cs similarity index 100% rename from Tests/PeerDiscoveryTests/LayeredDiscoveryTests.cs rename to Tests/CodexTests/PeerDiscoveryTests/LayeredDiscoveryTests.cs diff --git a/Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs b/Tests/CodexTests/PeerDiscoveryTests/PeerDiscoveryTests.cs similarity index 100% rename from Tests/PeerDiscoveryTests/PeerDiscoveryTests.cs rename to Tests/CodexTests/PeerDiscoveryTests/PeerDiscoveryTests.cs diff --git a/DistTestCore/Configuration.cs b/Tests/DistTestCore/Configuration.cs similarity index 100% rename from DistTestCore/Configuration.cs rename to Tests/DistTestCore/Configuration.cs diff --git a/DistTestCore/DistTest.cs b/Tests/DistTestCore/DistTest.cs similarity index 99% rename from DistTestCore/DistTest.cs rename to Tests/DistTestCore/DistTest.cs index 5555ed8..a1a4ef5 100644 --- a/DistTestCore/DistTest.cs +++ b/Tests/DistTestCore/DistTest.cs @@ -1,9 +1,11 @@ using Core; +using DistTestCore.Logs; using FileUtils; using Logging; using NUnit.Framework; using System.Reflection; using Utils; +using Assert = NUnit.Framework.Assert; namespace DistTestCore { diff --git a/DistTestCore/DistTestCore.csproj b/Tests/DistTestCore/DistTestCore.csproj similarity index 100% rename from DistTestCore/DistTestCore.csproj rename to Tests/DistTestCore/DistTestCore.csproj diff --git a/DistTestCore/DontDownloadLogsOnFailureAttribute.cs b/Tests/DistTestCore/DontDownloadLogsOnFailureAttribute.cs similarity index 100% rename from DistTestCore/DontDownloadLogsOnFailureAttribute.cs rename to Tests/DistTestCore/DontDownloadLogsOnFailureAttribute.cs diff --git a/DistTestCore/DownloadedLogExtensions.cs b/Tests/DistTestCore/DownloadedLogExtensions.cs similarity index 100% rename from DistTestCore/DownloadedLogExtensions.cs rename to Tests/DistTestCore/DownloadedLogExtensions.cs diff --git a/DistTestCore/Helpers/AssertHelpers.cs b/Tests/DistTestCore/Helpers/AssertHelpers.cs similarity index 100% rename from DistTestCore/Helpers/AssertHelpers.cs rename to Tests/DistTestCore/Helpers/AssertHelpers.cs diff --git a/Logging/BaseTestLog.cs b/Tests/DistTestCore/Logs/BaseTestLog.cs similarity index 52% rename from Logging/BaseTestLog.cs rename to Tests/DistTestCore/Logs/BaseTestLog.cs index 99f1eb1..99cd0f1 100644 --- a/Logging/BaseTestLog.cs +++ b/Tests/DistTestCore/Logs/BaseTestLog.cs @@ -1,4 +1,6 @@ -namespace Logging +using Logging; + +namespace DistTestCore.Logs { public abstract class BaseTestLog : BaseLog { @@ -9,6 +11,14 @@ { } + public void WriteLogTag() + { + var runId = NameUtils.GetRunId(); + var category = NameUtils.GetCategoryName(); + var name = NameUtils.GetTestMethodName(); + LogFile.WriteRaw($"{runId} {category} {name}"); + } + public void MarkAsFailed() { if (hasFailed) return; diff --git a/Logging/FixtureLog.cs b/Tests/DistTestCore/Logs/FixtureLog.cs similarity index 93% rename from Logging/FixtureLog.cs rename to Tests/DistTestCore/Logs/FixtureLog.cs index 306b40a..c32ce87 100644 --- a/Logging/FixtureLog.cs +++ b/Tests/DistTestCore/Logs/FixtureLog.cs @@ -1,4 +1,6 @@ -namespace Logging +using Logging; + +namespace DistTestCore.Logs { public class FixtureLog : BaseTestLog { diff --git a/DistTestCore/StatusLog.cs b/Tests/DistTestCore/Logs/StatusLog.cs similarity index 98% rename from DistTestCore/StatusLog.cs rename to Tests/DistTestCore/Logs/StatusLog.cs index e937469..b7ce891 100644 --- a/DistTestCore/StatusLog.cs +++ b/Tests/DistTestCore/Logs/StatusLog.cs @@ -1,7 +1,7 @@ using Logging; using Newtonsoft.Json; -namespace DistTestCore +namespace DistTestCore.Logs { public class StatusLog { diff --git a/Logging/TestLog.cs b/Tests/DistTestCore/Logs/TestLog.cs similarity index 94% rename from Logging/TestLog.cs rename to Tests/DistTestCore/Logs/TestLog.cs index 217a755..0f8831b 100644 --- a/Logging/TestLog.cs +++ b/Tests/DistTestCore/Logs/TestLog.cs @@ -1,4 +1,4 @@ -namespace Logging +namespace DistTestCore.Logs { public class TestLog : BaseTestLog { diff --git a/DistTestCore/LongTimeSet.cs b/Tests/DistTestCore/LongTimeSet.cs similarity index 100% rename from DistTestCore/LongTimeSet.cs rename to Tests/DistTestCore/LongTimeSet.cs diff --git a/DistTestCore/LongTimeoutsTestAttribute.cs b/Tests/DistTestCore/LongTimeoutsTestAttribute.cs similarity index 100% rename from DistTestCore/LongTimeoutsTestAttribute.cs rename to Tests/DistTestCore/LongTimeoutsTestAttribute.cs diff --git a/Logging/NameUtils.cs b/Tests/DistTestCore/NameUtils.cs similarity index 97% rename from Logging/NameUtils.cs rename to Tests/DistTestCore/NameUtils.cs index 2ca47e2..fea549d 100644 --- a/Logging/NameUtils.cs +++ b/Tests/DistTestCore/NameUtils.cs @@ -1,6 +1,7 @@ -using NUnit.Framework; +using Logging; +using NUnit.Framework; -namespace Logging +namespace DistTestCore { public static class NameUtils { diff --git a/DistTestCore/TestLifecycle.cs b/Tests/DistTestCore/TestLifecycle.cs similarity index 99% rename from DistTestCore/TestLifecycle.cs rename to Tests/DistTestCore/TestLifecycle.cs index 1ceea34..1f5bf17 100644 --- a/DistTestCore/TestLifecycle.cs +++ b/Tests/DistTestCore/TestLifecycle.cs @@ -1,7 +1,7 @@ using Core; +using DistTestCore.Logs; using FileUtils; using KubernetesWorkflow; -using Logging; using Utils; namespace DistTestCore diff --git a/CodexNetDeployer/CodexNetDeployer.csproj b/Tools/CodexNetDeployer/CodexNetDeployer.csproj similarity index 100% rename from CodexNetDeployer/CodexNetDeployer.csproj rename to Tools/CodexNetDeployer/CodexNetDeployer.csproj diff --git a/CodexNetDeployer/CodexNodeStarter.cs b/Tools/CodexNetDeployer/CodexNodeStarter.cs similarity index 100% rename from CodexNetDeployer/CodexNodeStarter.cs rename to Tools/CodexNetDeployer/CodexNodeStarter.cs diff --git a/CodexNetDeployer/Configuration.cs b/Tools/CodexNetDeployer/Configuration.cs similarity index 100% rename from CodexNetDeployer/Configuration.cs rename to Tools/CodexNetDeployer/Configuration.cs diff --git a/CodexNetDeployer/Deployer.cs b/Tools/CodexNetDeployer/Deployer.cs similarity index 100% rename from CodexNetDeployer/Deployer.cs rename to Tools/CodexNetDeployer/Deployer.cs diff --git a/CodexNetDeployer/PeerConnectivityChecker.cs b/Tools/CodexNetDeployer/PeerConnectivityChecker.cs similarity index 100% rename from CodexNetDeployer/PeerConnectivityChecker.cs rename to Tools/CodexNetDeployer/PeerConnectivityChecker.cs diff --git a/CodexNetDeployer/Program.cs b/Tools/CodexNetDeployer/Program.cs similarity index 100% rename from CodexNetDeployer/Program.cs rename to Tools/CodexNetDeployer/Program.cs diff --git a/CodexNetDeployer/deploy-continuous-testnet.sh b/Tools/CodexNetDeployer/deploy-continuous-testnet.sh similarity index 100% rename from CodexNetDeployer/deploy-continuous-testnet.sh rename to Tools/CodexNetDeployer/deploy-continuous-testnet.sh diff --git a/CodexNetDownloader/CodexNetDownloader.csproj b/Tools/CodexNetDownloader/CodexNetDownloader.csproj similarity index 100% rename from CodexNetDownloader/CodexNetDownloader.csproj rename to Tools/CodexNetDownloader/CodexNetDownloader.csproj diff --git a/CodexNetDownloader/Configuration.cs b/Tools/CodexNetDownloader/Configuration.cs similarity index 100% rename from CodexNetDownloader/Configuration.cs rename to Tools/CodexNetDownloader/Configuration.cs diff --git a/CodexNetDownloader/Program.cs b/Tools/CodexNetDownloader/Program.cs similarity index 100% rename from CodexNetDownloader/Program.cs rename to Tools/CodexNetDownloader/Program.cs diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index 92e1c82..12dea0f 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -3,37 +3,47 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.4.33213.308 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{57F57B85-A537-4D3A-B7AE-B72C66B74AAB}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{88C2A621-8A98-4D07-8625-7900FC8EF89E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistTestCore", "DistTestCore\DistTestCore.csproj", "{47F31305-6E68-4827-8E39-7B41DAA1CE7A}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KubernetesWorkflow", "KubernetesWorkflow\KubernetesWorkflow.csproj", "{359123AA-3D9B-4442-80F4-19E32E3EC9EA}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Framework", "Framework", "{81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Utils", "Utils\Utils.csproj", "{957DE3B8-9571-450A-8609-B267DCA8727C}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ProjectPlugins", "ProjectPlugins", "{8F1F1C2A-E313-4E0C-BE40-58FB0BA91124}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logging", "Logging\Logging.csproj", "{8481A4A6-4BDD-41B0-A3EB-EF53F7BD40D1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArgsUniform", "Framework\ArgsUniform\ArgsUniform.csproj", "{9922732F-01B3-4DBB-ADEC-E5451AB90CEE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NethereumWorkflow", "Nethereum\NethereumWorkflow.csproj", "{D6C3555E-D52D-4993-A87B-71AB650398FD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "Framework\Core\Core.csproj", "{D5E952BA-DCB1-4EA8-A038-6E2E0FCD64D9}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ContinuousTests", "ContinuousTests\ContinuousTests.csproj", "{025B7074-0A09-4FCC-9BB9-03AE2A961EA1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileUtils", "Framework\FileUtils\FileUtils.csproj", "{D10125E6-FF03-4292-A22C-9D622B2ACEDE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDeployer", "CodexNetDeployer\CodexNetDeployer.csproj", "{871CAF12-14BE-4509-BC6E-20FDF0B1083A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "KubernetesWorkflow", "Framework\KubernetesWorkflow\KubernetesWorkflow.csproj", "{98198410-71F9-4498-8550-E6F08B1FC4FA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ArgsUniform", "ArgsUniform\ArgsUniform.csproj", "{634324B1-E359-42B4-A269-BDC429936B3C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logging", "Framework\Logging\Logging.csproj", "{4FB7FC96-CB01-4905-9E40-3768692EDC0A}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDownloader", "CodexNetDownloader\CodexNetDownloader.csproj", "{6CDF35D2-906A-4285-8E1F-4794588B948B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NethereumWorkflow", "Framework\NethereumWorkflow\NethereumWorkflow.csproj", "{70CFFF7A-FA63-48DB-B304-8C859998F339}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileUtils", "FileUtils\FileUtils.csproj", "{ECC954DA-8D4E-49EE-83AD-80085A43DEEB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Utils", "Framework\Utils\Utils.csproj", "{8D264872-5361-4AC5-8A99-908137E13A22}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexPlugin", "CodexPlugin\CodexPlugin.csproj", "{DE4E802C-288C-45C4-84B6-8A5A6A96EF49}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexContractsPlugin", "ProjectPlugins\CodexContractsPlugin\CodexContractsPlugin.csproj", "{65D97CC1-E566-423E-9BD8-A1FA936CAAFA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "Core\Core.csproj", "{F2BF34B3-C660-43EF-BD42-BC5C60237FC4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexPlugin", "ProjectPlugins\CodexPlugin\CodexPlugin.csproj", "{F36DFCB1-C33F-426B-851B-FB1DEE7F028E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MetricsPlugin", "MetricsPlugin\MetricsPlugin.csproj", "{FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GethPlugin", "ProjectPlugins\GethPlugin\GethPlugin.csproj", "{8B39F251-F948-40AE-8922-3D8C4E529A86}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GethPlugin", "GethPlugin\GethPlugin.csproj", "{5A1EF1DD-9E81-4501-B44C-493C72D2B166}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MetricsPlugin", "ProjectPlugins\MetricsPlugin\MetricsPlugin.csproj", "{8DE8FF65-23CB-4FB3-8BE5-6C0BEC4BAA97}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodexContractsPlugin", "CodexContractsPlugin\CodexContractsPlugin.csproj", "{F315AEB1-C254-45FD-A0D2-5CEF401E0442}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexContinuousTests", "Tests\CodexContinuousTests\CodexContinuousTests.csproj", "{ADEC06CF-6F3A-44C5-AA57-EAB94124AC82}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexTestsLong", "Tests\CodexLongTests\CodexTestsLong.csproj", "{0C2D067F-053C-45A8-AE0D-4EB388E77C89}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexTests", "Tests\CodexTests\CodexTests.csproj", "{562EC700-6984-4C9A-83BF-3BF4E3EB1A64}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistTestCore", "Tests\DistTestCore\DistTestCore.csproj", "{E849B7BA-FDCC-4CFF-998F-845ED2F1BF40}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDeployer", "Tools\CodexNetDeployer\CodexNetDeployer.csproj", "{3417D508-E2F4-4974-8988-BB124046D9E2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDownloader", "Tools\CodexNetDownloader\CodexNetDownloader.csproj", "{8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -41,74 +51,97 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {57F57B85-A537-4D3A-B7AE-B72C66B74AAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {57F57B85-A537-4D3A-B7AE-B72C66B74AAB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {57F57B85-A537-4D3A-B7AE-B72C66B74AAB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {57F57B85-A537-4D3A-B7AE-B72C66B74AAB}.Release|Any CPU.Build.0 = Release|Any CPU - {47F31305-6E68-4827-8E39-7B41DAA1CE7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {47F31305-6E68-4827-8E39-7B41DAA1CE7A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {47F31305-6E68-4827-8E39-7B41DAA1CE7A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {47F31305-6E68-4827-8E39-7B41DAA1CE7A}.Release|Any CPU.Build.0 = Release|Any CPU - {359123AA-3D9B-4442-80F4-19E32E3EC9EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {359123AA-3D9B-4442-80F4-19E32E3EC9EA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {359123AA-3D9B-4442-80F4-19E32E3EC9EA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {359123AA-3D9B-4442-80F4-19E32E3EC9EA}.Release|Any CPU.Build.0 = Release|Any CPU - {957DE3B8-9571-450A-8609-B267DCA8727C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {957DE3B8-9571-450A-8609-B267DCA8727C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {957DE3B8-9571-450A-8609-B267DCA8727C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {957DE3B8-9571-450A-8609-B267DCA8727C}.Release|Any CPU.Build.0 = Release|Any CPU - {8481A4A6-4BDD-41B0-A3EB-EF53F7BD40D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8481A4A6-4BDD-41B0-A3EB-EF53F7BD40D1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8481A4A6-4BDD-41B0-A3EB-EF53F7BD40D1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8481A4A6-4BDD-41B0-A3EB-EF53F7BD40D1}.Release|Any CPU.Build.0 = Release|Any CPU - {D6C3555E-D52D-4993-A87B-71AB650398FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D6C3555E-D52D-4993-A87B-71AB650398FD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D6C3555E-D52D-4993-A87B-71AB650398FD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D6C3555E-D52D-4993-A87B-71AB650398FD}.Release|Any CPU.Build.0 = Release|Any CPU - {025B7074-0A09-4FCC-9BB9-03AE2A961EA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {025B7074-0A09-4FCC-9BB9-03AE2A961EA1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {025B7074-0A09-4FCC-9BB9-03AE2A961EA1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {025B7074-0A09-4FCC-9BB9-03AE2A961EA1}.Release|Any CPU.Build.0 = Release|Any CPU - {871CAF12-14BE-4509-BC6E-20FDF0B1083A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {871CAF12-14BE-4509-BC6E-20FDF0B1083A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {871CAF12-14BE-4509-BC6E-20FDF0B1083A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {871CAF12-14BE-4509-BC6E-20FDF0B1083A}.Release|Any CPU.Build.0 = Release|Any CPU - {634324B1-E359-42B4-A269-BDC429936B3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {634324B1-E359-42B4-A269-BDC429936B3C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {634324B1-E359-42B4-A269-BDC429936B3C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {634324B1-E359-42B4-A269-BDC429936B3C}.Release|Any CPU.Build.0 = Release|Any CPU - {6CDF35D2-906A-4285-8E1F-4794588B948B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6CDF35D2-906A-4285-8E1F-4794588B948B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6CDF35D2-906A-4285-8E1F-4794588B948B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6CDF35D2-906A-4285-8E1F-4794588B948B}.Release|Any CPU.Build.0 = Release|Any CPU - {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ECC954DA-8D4E-49EE-83AD-80085A43DEEB}.Release|Any CPU.Build.0 = Release|Any CPU - {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DE4E802C-288C-45C4-84B6-8A5A6A96EF49}.Release|Any CPU.Build.0 = Release|Any CPU - {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F2BF34B3-C660-43EF-BD42-BC5C60237FC4}.Release|Any CPU.Build.0 = Release|Any CPU - {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FCC74AF1-463D-4E5A-9FE7-B4A13F7C8820}.Release|Any CPU.Build.0 = Release|Any CPU - {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5A1EF1DD-9E81-4501-B44C-493C72D2B166}.Release|Any CPU.Build.0 = Release|Any CPU - {F315AEB1-C254-45FD-A0D2-5CEF401E0442}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F315AEB1-C254-45FD-A0D2-5CEF401E0442}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F315AEB1-C254-45FD-A0D2-5CEF401E0442}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F315AEB1-C254-45FD-A0D2-5CEF401E0442}.Release|Any CPU.Build.0 = Release|Any CPU + {9922732F-01B3-4DBB-ADEC-E5451AB90CEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9922732F-01B3-4DBB-ADEC-E5451AB90CEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9922732F-01B3-4DBB-ADEC-E5451AB90CEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9922732F-01B3-4DBB-ADEC-E5451AB90CEE}.Release|Any CPU.Build.0 = Release|Any CPU + {D5E952BA-DCB1-4EA8-A038-6E2E0FCD64D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5E952BA-DCB1-4EA8-A038-6E2E0FCD64D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5E952BA-DCB1-4EA8-A038-6E2E0FCD64D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5E952BA-DCB1-4EA8-A038-6E2E0FCD64D9}.Release|Any CPU.Build.0 = Release|Any CPU + {D10125E6-FF03-4292-A22C-9D622B2ACEDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D10125E6-FF03-4292-A22C-9D622B2ACEDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D10125E6-FF03-4292-A22C-9D622B2ACEDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D10125E6-FF03-4292-A22C-9D622B2ACEDE}.Release|Any CPU.Build.0 = Release|Any CPU + {98198410-71F9-4498-8550-E6F08B1FC4FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98198410-71F9-4498-8550-E6F08B1FC4FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98198410-71F9-4498-8550-E6F08B1FC4FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98198410-71F9-4498-8550-E6F08B1FC4FA}.Release|Any CPU.Build.0 = Release|Any CPU + {4FB7FC96-CB01-4905-9E40-3768692EDC0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FB7FC96-CB01-4905-9E40-3768692EDC0A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FB7FC96-CB01-4905-9E40-3768692EDC0A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FB7FC96-CB01-4905-9E40-3768692EDC0A}.Release|Any CPU.Build.0 = Release|Any CPU + {70CFFF7A-FA63-48DB-B304-8C859998F339}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70CFFF7A-FA63-48DB-B304-8C859998F339}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70CFFF7A-FA63-48DB-B304-8C859998F339}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70CFFF7A-FA63-48DB-B304-8C859998F339}.Release|Any CPU.Build.0 = Release|Any CPU + {8D264872-5361-4AC5-8A99-908137E13A22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8D264872-5361-4AC5-8A99-908137E13A22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8D264872-5361-4AC5-8A99-908137E13A22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8D264872-5361-4AC5-8A99-908137E13A22}.Release|Any CPU.Build.0 = Release|Any CPU + {65D97CC1-E566-423E-9BD8-A1FA936CAAFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65D97CC1-E566-423E-9BD8-A1FA936CAAFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65D97CC1-E566-423E-9BD8-A1FA936CAAFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65D97CC1-E566-423E-9BD8-A1FA936CAAFA}.Release|Any CPU.Build.0 = Release|Any CPU + {F36DFCB1-C33F-426B-851B-FB1DEE7F028E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F36DFCB1-C33F-426B-851B-FB1DEE7F028E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F36DFCB1-C33F-426B-851B-FB1DEE7F028E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F36DFCB1-C33F-426B-851B-FB1DEE7F028E}.Release|Any CPU.Build.0 = Release|Any CPU + {8B39F251-F948-40AE-8922-3D8C4E529A86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B39F251-F948-40AE-8922-3D8C4E529A86}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B39F251-F948-40AE-8922-3D8C4E529A86}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B39F251-F948-40AE-8922-3D8C4E529A86}.Release|Any CPU.Build.0 = Release|Any CPU + {8DE8FF65-23CB-4FB3-8BE5-6C0BEC4BAA97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8DE8FF65-23CB-4FB3-8BE5-6C0BEC4BAA97}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DE8FF65-23CB-4FB3-8BE5-6C0BEC4BAA97}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8DE8FF65-23CB-4FB3-8BE5-6C0BEC4BAA97}.Release|Any CPU.Build.0 = Release|Any CPU + {ADEC06CF-6F3A-44C5-AA57-EAB94124AC82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADEC06CF-6F3A-44C5-AA57-EAB94124AC82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADEC06CF-6F3A-44C5-AA57-EAB94124AC82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADEC06CF-6F3A-44C5-AA57-EAB94124AC82}.Release|Any CPU.Build.0 = Release|Any CPU + {0C2D067F-053C-45A8-AE0D-4EB388E77C89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C2D067F-053C-45A8-AE0D-4EB388E77C89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C2D067F-053C-45A8-AE0D-4EB388E77C89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C2D067F-053C-45A8-AE0D-4EB388E77C89}.Release|Any CPU.Build.0 = Release|Any CPU + {562EC700-6984-4C9A-83BF-3BF4E3EB1A64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {562EC700-6984-4C9A-83BF-3BF4E3EB1A64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {562EC700-6984-4C9A-83BF-3BF4E3EB1A64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {562EC700-6984-4C9A-83BF-3BF4E3EB1A64}.Release|Any CPU.Build.0 = Release|Any CPU + {E849B7BA-FDCC-4CFF-998F-845ED2F1BF40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E849B7BA-FDCC-4CFF-998F-845ED2F1BF40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E849B7BA-FDCC-4CFF-998F-845ED2F1BF40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E849B7BA-FDCC-4CFF-998F-845ED2F1BF40}.Release|Any CPU.Build.0 = Release|Any CPU + {3417D508-E2F4-4974-8988-BB124046D9E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3417D508-E2F4-4974-8988-BB124046D9E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3417D508-E2F4-4974-8988-BB124046D9E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3417D508-E2F4-4974-8988-BB124046D9E2}.Release|Any CPU.Build.0 = Release|Any CPU + {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9922732F-01B3-4DBB-ADEC-E5451AB90CEE} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} + {D5E952BA-DCB1-4EA8-A038-6E2E0FCD64D9} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} + {D10125E6-FF03-4292-A22C-9D622B2ACEDE} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} + {98198410-71F9-4498-8550-E6F08B1FC4FA} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} + {4FB7FC96-CB01-4905-9E40-3768692EDC0A} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} + {70CFFF7A-FA63-48DB-B304-8C859998F339} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} + {8D264872-5361-4AC5-8A99-908137E13A22} = {81AE04BC-CBFA-4E6F-B039-8208E9AFAAE7} + {65D97CC1-E566-423E-9BD8-A1FA936CAAFA} = {8F1F1C2A-E313-4E0C-BE40-58FB0BA91124} + {F36DFCB1-C33F-426B-851B-FB1DEE7F028E} = {8F1F1C2A-E313-4E0C-BE40-58FB0BA91124} + {8B39F251-F948-40AE-8922-3D8C4E529A86} = {8F1F1C2A-E313-4E0C-BE40-58FB0BA91124} + {8DE8FF65-23CB-4FB3-8BE5-6C0BEC4BAA97} = {8F1F1C2A-E313-4E0C-BE40-58FB0BA91124} + {ADEC06CF-6F3A-44C5-AA57-EAB94124AC82} = {88C2A621-8A98-4D07-8625-7900FC8EF89E} + {0C2D067F-053C-45A8-AE0D-4EB388E77C89} = {88C2A621-8A98-4D07-8625-7900FC8EF89E} + {562EC700-6984-4C9A-83BF-3BF4E3EB1A64} = {88C2A621-8A98-4D07-8625-7900FC8EF89E} + {E849B7BA-FDCC-4CFF-998F-845ED2F1BF40} = {88C2A621-8A98-4D07-8625-7900FC8EF89E} + {3417D508-E2F4-4974-8988-BB124046D9E2} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3} + {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {237BF0AA-9EC4-4659-AD9A-65DEB974250C} EndGlobalSection From bf4c8d80265dfa24278bcd5da6dedb86de59005b Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 10:59:52 +0200 Subject: [PATCH 41/51] Restores project references. --- .../CodexContractsPlugin.csproj | 2 +- ProjectPlugins/CodexPlugin/CodexPlugin.csproj | 5 ++--- ProjectPlugins/GethPlugin/GethPlugin.csproj | 6 +++--- ProjectPlugins/MetricsPlugin/MetricsPlugin.csproj | 2 +- Tests/CodexLongTests/BasicTests/DownloadTests.cs | 11 ++++++----- Tests/CodexLongTests/BasicTests/LargeFileTests.cs | 11 ++++++----- Tests/CodexLongTests/BasicTests/TestInfraTests.cs | 9 +++++---- Tests/CodexLongTests/BasicTests/UploadTests.cs | 14 ++++++++------ Tests/CodexLongTests/CodexTestsLong.csproj | 2 ++ .../LongFullyConnectedDownloadTests.cs | 5 +++-- Tests/CodexLongTests/Parallelism.cs | 2 +- Tests/CodexTests/CodexTests.csproj | 8 ++++---- Tests/DistTestCore/DistTestCore.csproj | 7 ++----- 13 files changed, 44 insertions(+), 40 deletions(-) diff --git a/ProjectPlugins/CodexContractsPlugin/CodexContractsPlugin.csproj b/ProjectPlugins/CodexContractsPlugin/CodexContractsPlugin.csproj index f7a4e57..648b846 100644 --- a/ProjectPlugins/CodexContractsPlugin/CodexContractsPlugin.csproj +++ b/ProjectPlugins/CodexContractsPlugin/CodexContractsPlugin.csproj @@ -7,7 +7,7 @@ - + diff --git a/ProjectPlugins/CodexPlugin/CodexPlugin.csproj b/ProjectPlugins/CodexPlugin/CodexPlugin.csproj index aac490c..19c3b60 100644 --- a/ProjectPlugins/CodexPlugin/CodexPlugin.csproj +++ b/ProjectPlugins/CodexPlugin/CodexPlugin.csproj @@ -11,12 +11,11 @@ + + - - - diff --git a/ProjectPlugins/GethPlugin/GethPlugin.csproj b/ProjectPlugins/GethPlugin/GethPlugin.csproj index 3b52869..73aae77 100644 --- a/ProjectPlugins/GethPlugin/GethPlugin.csproj +++ b/ProjectPlugins/GethPlugin/GethPlugin.csproj @@ -7,9 +7,9 @@ - - - + + + diff --git a/ProjectPlugins/MetricsPlugin/MetricsPlugin.csproj b/ProjectPlugins/MetricsPlugin/MetricsPlugin.csproj index ec0e3ef..f921934 100644 --- a/ProjectPlugins/MetricsPlugin/MetricsPlugin.csproj +++ b/ProjectPlugins/MetricsPlugin/MetricsPlugin.csproj @@ -17,7 +17,7 @@ - + diff --git a/Tests/CodexLongTests/BasicTests/DownloadTests.cs b/Tests/CodexLongTests/BasicTests/DownloadTests.cs index 0cf97e9..68de3b1 100644 --- a/Tests/CodexLongTests/BasicTests/DownloadTests.cs +++ b/Tests/CodexLongTests/BasicTests/DownloadTests.cs @@ -1,12 +1,13 @@ using DistTestCore; using FileUtils; using NUnit.Framework; +using Tests; using Utils; -namespace TestsLong.BasicTests +namespace CodexLongTests.BasicTests { [TestFixture] - public class DownloadTests : DistTest + public class DownloadTests : CodexDistTest { [TestCase(3, 500)] [TestCase(5, 100)] @@ -14,8 +15,8 @@ namespace TestsLong.BasicTests [UseLongTimeouts] public void ParallelDownload(int numberOfNodes, int filesizeMb) { - var group = SetupCodexNodes(numberOfNodes); - var host = SetupCodexNode(); + var group = AddCodex(numberOfNodes); + var host = AddCodex(); foreach (var node in group) { @@ -24,7 +25,7 @@ namespace TestsLong.BasicTests var testFile = GenerateTestFile(filesizeMb.MB()); var contentId = host.UploadFile(testFile); - var list = new List>(); + var list = new List>(); foreach (var node in group) { diff --git a/Tests/CodexLongTests/BasicTests/LargeFileTests.cs b/Tests/CodexLongTests/BasicTests/LargeFileTests.cs index 2d834e5..68eb1a7 100644 --- a/Tests/CodexLongTests/BasicTests/LargeFileTests.cs +++ b/Tests/CodexLongTests/BasicTests/LargeFileTests.cs @@ -1,13 +1,14 @@ -using DistTestCore; -using DistTestCore.Codex; +using CodexPlugin; +using DistTestCore; using NUnit.Framework; using NUnit.Framework.Interfaces; +using Tests; using Utils; -namespace TestsLong.BasicTests +namespace CodexLongTests.BasicTests { [TestFixture] - public class LargeFileTests : DistTest + public class LargeFileTests : CodexDistTest { #region Abort test run after first failure @@ -47,7 +48,7 @@ namespace TestsLong.BasicTests var expectedFile = GenerateTestFile(sizeMB); - var node = SetupCodexNode(s => s.WithStorageQuota((size + 10).MB())); + var node = AddCodex(s => s.WithStorageQuota((size + 10).MB())); var uploadStart = DateTime.UtcNow; var cid = node.UploadFile(expectedFile); diff --git a/Tests/CodexLongTests/BasicTests/TestInfraTests.cs b/Tests/CodexLongTests/BasicTests/TestInfraTests.cs index a6f68e3..caad570 100644 --- a/Tests/CodexLongTests/BasicTests/TestInfraTests.cs +++ b/Tests/CodexLongTests/BasicTests/TestInfraTests.cs @@ -1,14 +1,15 @@ using DistTestCore; using NUnit.Framework; +using Tests; -namespace TestsLong.BasicTests +namespace CodexLongTests.BasicTests { - public class TestInfraTests : DistTest + public class TestInfraTests : CodexDistTest { [Test, UseLongTimeouts] public void TestInfraShouldHave1000AddressSpacesPerPod() { - var group = SetupCodexNodes(1000, s => s.EnableMetrics()); // Increases use of port address space per node. + var group = AddCodex(1000, s => s.EnableMetrics()); var nodeIds = group.Select(n => n.GetDebugInfo().id).ToArray(); @@ -21,7 +22,7 @@ namespace TestsLong.BasicTests { for (var i = 0; i < 20; i++) { - var n = SetupCodexNode(); + var n = AddCodex(); Assert.That(!string.IsNullOrEmpty(n.GetDebugInfo().id)); } diff --git a/Tests/CodexLongTests/BasicTests/UploadTests.cs b/Tests/CodexLongTests/BasicTests/UploadTests.cs index 516586e..b2cadc0 100644 --- a/Tests/CodexLongTests/BasicTests/UploadTests.cs +++ b/Tests/CodexLongTests/BasicTests/UploadTests.cs @@ -1,12 +1,14 @@ +using CodexPlugin; using DistTestCore; using FileUtils; using NUnit.Framework; +using Tests; using Utils; -namespace TestsLong.BasicTests +namespace CodexLongTests.BasicTests { [TestFixture] - public class UploadTests : DistTest + public class UploadTests : CodexDistTest { [TestCase(3, 50)] [TestCase(5, 75)] @@ -14,15 +16,15 @@ namespace TestsLong.BasicTests [UseLongTimeouts] public void ParallelUpload(int numberOfNodes, int filesizeMb) { - var group = SetupCodexNodes(numberOfNodes); - var host = SetupCodexNode(); + var group = AddCodex(numberOfNodes); + var host = AddCodex(); foreach (var node in group) { host.ConnectToPeer(node); } - var testfiles = new List(); + var testfiles = new List(); var contentIds = new List>(); for (int i = 0; i < group.Count(); i++) @@ -31,7 +33,7 @@ namespace TestsLong.BasicTests var n = i; contentIds.Add(Task.Run(() => { return host.UploadFile(testfiles[n]); })); } - var downloads = new List>(); + var downloads = new List>(); for (int i = 0; i < group.Count(); i++) { var n = i; diff --git a/Tests/CodexLongTests/CodexTestsLong.csproj b/Tests/CodexLongTests/CodexTestsLong.csproj index 90f1cd6..71c6756 100644 --- a/Tests/CodexLongTests/CodexTestsLong.csproj +++ b/Tests/CodexLongTests/CodexTestsLong.csproj @@ -13,6 +13,8 @@ + + diff --git a/Tests/CodexLongTests/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs b/Tests/CodexLongTests/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs index 6de5c38..5858d53 100644 --- a/Tests/CodexLongTests/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs +++ b/Tests/CodexLongTests/DownloadConnectivityTests/LongFullyConnectedDownloadTests.cs @@ -1,8 +1,9 @@ using DistTestCore; using NUnit.Framework; +using Tests; using Utils; -namespace TestsLong.DownloadConnectivityTests +namespace CodexLongTests.DownloadConnectivityTests { [TestFixture] public class LongFullyConnectedDownloadTests : AutoBootstrapDistTest @@ -14,7 +15,7 @@ namespace TestsLong.DownloadConnectivityTests [Values(10, 15, 20)] int numberOfNodes, [Values(10, 100)] int sizeMBs) { - for (var i = 0; i < numberOfNodes; i++) SetupCodexNode(); + for (var i = 0; i < numberOfNodes; i++) AddCodex(); CreatePeerDownloadTestHelpers().AssertFullDownloadInterconnectivity(GetAllOnlineCodexNodes(), sizeMBs.MB()); } diff --git a/Tests/CodexLongTests/Parallelism.cs b/Tests/CodexLongTests/Parallelism.cs index f45d8f2..51709b8 100644 --- a/Tests/CodexLongTests/Parallelism.cs +++ b/Tests/CodexLongTests/Parallelism.cs @@ -1,6 +1,6 @@ using NUnit.Framework; [assembly: LevelOfParallelism(1)] -namespace Tests +namespace CodexLongTests { } diff --git a/Tests/CodexTests/CodexTests.csproj b/Tests/CodexTests/CodexTests.csproj index e6faada..2c04d03 100644 --- a/Tests/CodexTests/CodexTests.csproj +++ b/Tests/CodexTests/CodexTests.csproj @@ -13,11 +13,11 @@ - - + + + + - - diff --git a/Tests/DistTestCore/DistTestCore.csproj b/Tests/DistTestCore/DistTestCore.csproj index 69da090..6a77277 100644 --- a/Tests/DistTestCore/DistTestCore.csproj +++ b/Tests/DistTestCore/DistTestCore.csproj @@ -19,10 +19,7 @@ - - - - - + + From 75369d68f724194762924133cd5272927acd303c Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 12:02:32 +0200 Subject: [PATCH 42/51] Restores CodexNetDeployer --- ProjectPlugins/CodexPlugin/CodexDeployment.cs | 11 +- ProjectPlugins/CodexPlugin/CodexPlugin.cs | 2 +- .../CodexPlugin/CoreInterfaceExtensions.cs | 20 ++-- .../CodexPlugin/MarketplaceAccess.cs | 2 +- Tests/CodexTests/BasicTests/ExampleTests.cs | 2 +- .../BasicTests/NetworkIsolationTest.cs | 4 +- Tests/CodexTests/BasicTests/OneClientTests.cs | 6 +- Tests/CodexTests/BasicTests/TwoClientTests.cs | 6 +- Tests/CodexTests/CodexDistTest.cs | 2 +- .../LayeredDiscoveryTests.cs | 22 ++-- .../CodexNetDeployer/CodexNetDeployer.csproj | 6 +- Tools/CodexNetDeployer/CodexNodeStarter.cs | 107 +++++++---------- Tools/CodexNetDeployer/Configuration.cs | 7 +- Tools/CodexNetDeployer/Deployer.cs | 110 +++++++++--------- .../PeerConnectivityChecker.cs | 4 +- Tools/CodexNetDeployer/Program.cs | 12 +- .../deploy-continuous-testnet.sh | 2 +- 17 files changed, 146 insertions(+), 179 deletions(-) diff --git a/ProjectPlugins/CodexPlugin/CodexDeployment.cs b/ProjectPlugins/CodexPlugin/CodexDeployment.cs index 3bca60b..6dbf961 100644 --- a/ProjectPlugins/CodexPlugin/CodexDeployment.cs +++ b/ProjectPlugins/CodexPlugin/CodexDeployment.cs @@ -1,22 +1,21 @@ -using KubernetesWorkflow; +using GethPlugin; +using KubernetesWorkflow; namespace CodexPlugin { public class CodexDeployment { - public CodexDeployment(/*GethStartResult gethStartResult,*/ RunningContainer[] codexContainers, RunningContainer? prometheusContainer, /*GrafanaStartInfo? grafanaStartInfo,*/ DeploymentMetadata metadata) + public CodexDeployment(RunningContainer[] codexContainers, GethDeployment gethDeployment, RunningContainer? prometheusContainer, DeploymentMetadata metadata) { - //GethStartResult = gethStartResult; CodexContainers = codexContainers; + GethDeployment = gethDeployment; PrometheusContainer = prometheusContainer; - //GrafanaStartInfo = grafanaStartInfo; Metadata = metadata; } - //public GethStartResult GethStartResult { get; } public RunningContainer[] CodexContainers { get; } + public GethDeployment GethDeployment { get; } public RunningContainer? PrometheusContainer { get; } - //public GrafanaStartInfo? GrafanaStartInfo { get; } public DeploymentMetadata Metadata { get; } } diff --git a/ProjectPlugins/CodexPlugin/CodexPlugin.cs b/ProjectPlugins/CodexPlugin/CodexPlugin.cs index 2cfb882..2f670de 100644 --- a/ProjectPlugins/CodexPlugin/CodexPlugin.cs +++ b/ProjectPlugins/CodexPlugin/CodexPlugin.cs @@ -31,7 +31,7 @@ namespace CodexPlugin { } - public RunningContainers[] StartCodexNodes(int numberOfNodes, Action setup) + public RunningContainers[] DeployCodexNodes(int numberOfNodes, Action setup) { var codexSetup = GetSetup(numberOfNodes, setup); return codexStarter.BringOnline(codexSetup); diff --git a/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs b/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs index c9cd6a4..a79615e 100644 --- a/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs +++ b/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs @@ -5,9 +5,9 @@ namespace CodexPlugin { public static class CoreInterfaceExtensions { - public static RunningContainers[] StartCodexNodes(this CoreInterface ci, int number, Action setup) + public static RunningContainers[] DeployCodexNodes(this CoreInterface ci, int number, Action setup) { - return Plugin(ci).StartCodexNodes(number, setup); + return Plugin(ci).DeployCodexNodes(number, setup); } public static ICodexNodeGroup WrapCodexContainers(this CoreInterface ci, RunningContainers[] containers) @@ -15,27 +15,27 @@ namespace CodexPlugin return Plugin(ci).WrapCodexContainers(ci, containers); } - public static ICodexNode SetupCodexNode(this CoreInterface ci) + public static ICodexNode StartCodexNode(this CoreInterface ci) { - return ci.SetupCodexNodes(1)[0]; + return ci.StartCodexNodes(1)[0]; } - public static ICodexNode SetupCodexNode(this CoreInterface ci, Action setup) + public static ICodexNode StartCodexNode(this CoreInterface ci, Action setup) { - return ci.SetupCodexNodes(1, setup)[0]; + return ci.StartCodexNodes(1, setup)[0]; } - public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number, Action setup) + public static ICodexNodeGroup StartCodexNodes(this CoreInterface ci, int number, Action setup) { - var rc = ci.StartCodexNodes(number, setup); + var rc = ci.DeployCodexNodes(number, setup); var result = ci.WrapCodexContainers(rc); Plugin(ci).WireUpMarketplace(result, setup); return result; } - public static ICodexNodeGroup SetupCodexNodes(this CoreInterface ci, int number) + public static ICodexNodeGroup StartCodexNodes(this CoreInterface ci, int number) { - return ci.SetupCodexNodes(number, s => { }); + return ci.StartCodexNodes(number, s => { }); } private static CodexPlugin Plugin(CoreInterface ci) diff --git a/ProjectPlugins/CodexPlugin/MarketplaceAccess.cs b/ProjectPlugins/CodexPlugin/MarketplaceAccess.cs index 5734c4f..e331b66 100644 --- a/ProjectPlugins/CodexPlugin/MarketplaceAccess.cs +++ b/ProjectPlugins/CodexPlugin/MarketplaceAccess.cs @@ -8,7 +8,7 @@ namespace CodexPlugin { public interface IMarketplaceAccess { - string MakeStorageAvailable(ByteSize size, TestToken minPricePerBytePerSecond, TestToken maxCollateral, TimeSpan maxDuration); + string MakeStorageAvailable(ByteSize size, TestToken minPriceForTotalSpace, TestToken maxCollateral, TimeSpan maxDuration); StoragePurchaseContract RequestStorage(ContentId contentId, TestToken pricePerSlotPerSecond, TestToken requiredCollateral, uint minRequiredNumberOfNodes, int proofProbability, TimeSpan duration); } diff --git a/Tests/CodexTests/BasicTests/ExampleTests.cs b/Tests/CodexTests/BasicTests/ExampleTests.cs index 2858c6e..39814a5 100644 --- a/Tests/CodexTests/BasicTests/ExampleTests.cs +++ b/Tests/CodexTests/BasicTests/ExampleTests.cs @@ -61,7 +61,7 @@ namespace Tests.BasicTests AssertBalance(geth, contracts, seller, Is.EqualTo(sellerInitialBalance)); seller.Marketplace.MakeStorageAvailable( size: 10.GB(), - minPricePerBytePerSecond: 1.TestTokens(), + minPriceForTotalSpace: 1.TestTokens(), maxCollateral: 20.TestTokens(), maxDuration: TimeSpan.FromMinutes(3)); diff --git a/Tests/CodexTests/BasicTests/NetworkIsolationTest.cs b/Tests/CodexTests/BasicTests/NetworkIsolationTest.cs index c6e5491..7388e4a 100644 --- a/Tests/CodexTests/BasicTests/NetworkIsolationTest.cs +++ b/Tests/CodexTests/BasicTests/NetworkIsolationTest.cs @@ -17,7 +17,7 @@ namespace Tests.BasicTests [Test] public void SetUpANodeAndWait() { - node = Ci.SetupCodexNode(); + node = Ci.StartCodexNode(); Time.WaitUntil(() => node == null, TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(5)); } @@ -25,7 +25,7 @@ namespace Tests.BasicTests [Test] public void ForeignNodeConnects() { - var myNode = Ci.SetupCodexNode(); + var myNode = Ci.StartCodexNode(); Time.WaitUntil(() => node != null, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); diff --git a/Tests/CodexTests/BasicTests/OneClientTests.cs b/Tests/CodexTests/BasicTests/OneClientTests.cs index e878e14..22fe0dd 100644 --- a/Tests/CodexTests/BasicTests/OneClientTests.cs +++ b/Tests/CodexTests/BasicTests/OneClientTests.cs @@ -11,7 +11,7 @@ namespace Tests.BasicTests [Test] public void OneClientTest() { - var primary = Ci.SetupCodexNode(); + var primary = Ci.StartCodexNode(); PerformOneClientTest(primary); } @@ -19,11 +19,11 @@ namespace Tests.BasicTests [Test] public void RestartTest() { - var primary = Ci.SetupCodexNode(); + var primary = Ci.StartCodexNode(); primary.Stop(); - primary = Ci.SetupCodexNode(); + primary = Ci.StartCodexNode(); PerformOneClientTest(primary); } diff --git a/Tests/CodexTests/BasicTests/TwoClientTests.cs b/Tests/CodexTests/BasicTests/TwoClientTests.cs index 5f0db78..6908494 100644 --- a/Tests/CodexTests/BasicTests/TwoClientTests.cs +++ b/Tests/CodexTests/BasicTests/TwoClientTests.cs @@ -12,7 +12,7 @@ namespace Tests.BasicTests [Test] public void TwoClientTest() { - var group = Ci.SetupCodexNodes(2); + var group = Ci.StartCodexNodes(2); var primary = group[0]; var secondary = group[1]; @@ -23,8 +23,8 @@ namespace Tests.BasicTests [Test] public void TwoClientsTwoLocationsTest() { - var primary = Ci.SetupCodexNode(s => s.At(Location.One)); - var secondary = Ci.SetupCodexNode(s => s.At(Location.Two)); + var primary = Ci.StartCodexNode(s => s.At(Location.One)); + var secondary = Ci.StartCodexNode(s => s.At(Location.Two)); PerformTwoClientTest(primary, secondary); } diff --git a/Tests/CodexTests/CodexDistTest.cs b/Tests/CodexTests/CodexDistTest.cs index 6c33676..2af737d 100644 --- a/Tests/CodexTests/CodexDistTest.cs +++ b/Tests/CodexTests/CodexDistTest.cs @@ -29,7 +29,7 @@ namespace Tests public ICodexNodeGroup AddCodex(int numberOfNodes, Action setup) { - var group = Ci.SetupCodexNodes(numberOfNodes, s => + var group = Ci.StartCodexNodes(numberOfNodes, s => { setup(s); OnCodexSetup(s); diff --git a/Tests/CodexTests/PeerDiscoveryTests/LayeredDiscoveryTests.cs b/Tests/CodexTests/PeerDiscoveryTests/LayeredDiscoveryTests.cs index 088fbcb..97675af 100644 --- a/Tests/CodexTests/PeerDiscoveryTests/LayeredDiscoveryTests.cs +++ b/Tests/CodexTests/PeerDiscoveryTests/LayeredDiscoveryTests.cs @@ -9,10 +9,10 @@ namespace Tests.PeerDiscoveryTests [Test] public void TwoLayersTest() { - var root = Ci.SetupCodexNode(); - var l1Source = Ci.SetupCodexNode(s => s.WithBootstrapNode(root)); - var l1Node = Ci.SetupCodexNode(s => s.WithBootstrapNode(root)); - var l2Target = Ci.SetupCodexNode(s => s.WithBootstrapNode(l1Node)); + var root = Ci.StartCodexNode(); + var l1Source = Ci.StartCodexNode(s => s.WithBootstrapNode(root)); + var l1Node = Ci.StartCodexNode(s => s.WithBootstrapNode(root)); + var l2Target = Ci.StartCodexNode(s => s.WithBootstrapNode(l1Node)); AssertAllNodesConnected(); } @@ -20,11 +20,11 @@ namespace Tests.PeerDiscoveryTests [Test] public void ThreeLayersTest() { - var root = Ci.SetupCodexNode(); - var l1Source = Ci.SetupCodexNode(s => s.WithBootstrapNode(root)); - var l1Node = Ci.SetupCodexNode(s => s.WithBootstrapNode(root)); - var l2Node = Ci.SetupCodexNode(s => s.WithBootstrapNode(l1Node)); - var l3Target = Ci.SetupCodexNode(s => s.WithBootstrapNode(l2Node)); + var root = Ci.StartCodexNode(); + var l1Source = Ci.StartCodexNode(s => s.WithBootstrapNode(root)); + var l1Node = Ci.StartCodexNode(s => s.WithBootstrapNode(root)); + var l2Node = Ci.StartCodexNode(s => s.WithBootstrapNode(l1Node)); + var l3Target = Ci.StartCodexNode(s => s.WithBootstrapNode(l2Node)); AssertAllNodesConnected(); } @@ -35,10 +35,10 @@ namespace Tests.PeerDiscoveryTests [TestCase(20)] public void NodeChainTest(int chainLength) { - var node = Ci.SetupCodexNode(); + var node = Ci.StartCodexNode(); for (var i = 1; i < chainLength; i++) { - node = Ci.SetupCodexNode(s => s.WithBootstrapNode(node)); + node = Ci.StartCodexNode(s => s.WithBootstrapNode(node)); } AssertAllNodesConnected(); diff --git a/Tools/CodexNetDeployer/CodexNetDeployer.csproj b/Tools/CodexNetDeployer/CodexNetDeployer.csproj index 48bca3a..9168ae7 100644 --- a/Tools/CodexNetDeployer/CodexNetDeployer.csproj +++ b/Tools/CodexNetDeployer/CodexNetDeployer.csproj @@ -8,8 +8,10 @@ - - + + + + diff --git a/Tools/CodexNetDeployer/CodexNodeStarter.cs b/Tools/CodexNetDeployer/CodexNodeStarter.cs index c6b3850..f7cdd94 100644 --- a/Tools/CodexNetDeployer/CodexNodeStarter.cs +++ b/Tools/CodexNetDeployer/CodexNodeStarter.cs @@ -1,7 +1,7 @@ -using DistTestCore; -using DistTestCore.Codex; -using DistTestCore.Marketplace; -using KubernetesWorkflow; +using CodexContractsPlugin; +using CodexPlugin; +using Core; +using GethPlugin; using Utils; namespace CodexNetDeployer @@ -9,49 +9,49 @@ namespace CodexNetDeployer public class CodexNodeStarter { private readonly Configuration config; - private readonly TestLifecycle lifecycle; - private readonly GethStartResult gethResult; + private readonly CoreInterface ci; + private readonly IGethNode gethNode; + private readonly ICodexContracts contracts; private string bootstrapSpr = ""; private int validatorsLeft; - public CodexNodeStarter(Configuration config, TestLifecycle lifecycle, GethStartResult gethResult, int numberOfValidators) + public CodexNodeStarter(Configuration config, CoreInterface ci, IGethNode gethNode, ICodexContracts contracts, int numberOfValidators) { this.config = config; - this.lifecycle = lifecycle; - this.gethResult = gethResult; + this.ci = ci; + this.gethNode = gethNode; + this.contracts = contracts; validatorsLeft = numberOfValidators; } public CodexNodeStartResult? Start(int i) { - Console.Write($" - {i} = "); - var workflow = lifecycle.WorkflowCreator.CreateWorkflow(); - var workflowStartup = new StartupConfig(); - workflowStartup.Add(gethResult); - workflowStartup.Add(CreateCodexStartupConfig(bootstrapSpr, i, validatorsLeft)); - workflowStartup.NameOverride = GetCodexContainerName(i); - - var containers = workflow.Start(1, Location.Unspecified, new CodexContainerRecipe(), workflowStartup); - - var container = containers.Containers.First(); - var codexAccess = new CodexAccess(lifecycle.Log, container, lifecycle.TimeSet, lifecycle.Configuration.GetAddress(container)); - var account = gethResult.MarketplaceNetwork.Bootstrap.AllAccounts.Accounts[i]; - var tokenAddress = gethResult.MarketplaceNetwork.Marketplace.TokenAddress; - var marketAccess = new MarketplaceAccess(lifecycle, gethResult.MarketplaceNetwork, account, codexAccess); + var name = GetCodexContainerName(i); + Console.Write($" - {i} ({name}) = "); + ICodexNode? codexNode = null; try { - var debugInfo = codexAccess.GetDebugInfo(); + codexNode = ci.StartCodexNode(s => + { + s.WithName(name); + s.WithLogLevel(config.CodexLogLevel); + s.WithStorageQuota(config.StorageQuota!.Value.MB()); + s.EnableMarketplace(gethNode, contracts, 100.Eth(), config.InitialTestTokens.TestTokens(), validatorsLeft > 0); + s.EnableMetrics(); + + if (config.BlockTTL != Configuration.SecondsIn1Day) s.WithBlockTTL(TimeSpan.FromSeconds(config.BlockTTL)); + if (config.BlockMI != Configuration.TenMinutes) s.WithBlockMaintenanceInterval(TimeSpan.FromSeconds(config.BlockMI)); + if (config.BlockMN != 1000) s.WithBlockMaintenanceNumber(config.BlockMN); + }); + + var debugInfo = codexNode.GetDebugInfo(); if (!string.IsNullOrWhiteSpace(debugInfo.spr)) { Console.Write("Online\t"); - var interaction = gethResult.MarketplaceNetwork.Bootstrap.StartInteraction(lifecycle); - interaction.MintTestTokens(new[] { account.Account }, config.InitialTestTokens, tokenAddress); - Console.Write("Tokens minted\t"); - - var response = marketAccess.MakeStorageAvailable( - totalSpace: config.StorageSell!.Value.MB(), + var response = codexNode.Marketplace.MakeStorageAvailable( + size: config.StorageSell!.Value.MB(), minPriceForTotalSpace: config.MinPrice.TestTokens(), maxCollateral: config.MaxCollateral.TestTokens(), maxDuration: TimeSpan.FromSeconds(config.MaxDuration)); @@ -62,7 +62,7 @@ namespace CodexNetDeployer if (string.IsNullOrEmpty(bootstrapSpr)) bootstrapSpr = debugInfo.spr; validatorsLeft--; - return new CodexNodeStartResult(workflow, container, codexAccess); + return new CodexNodeStartResult(codexNode); } } } @@ -71,8 +71,12 @@ namespace CodexNetDeployer Console.WriteLine("Exception:" + ex.ToString()); } - Console.Write("Unknown failure. Downloading container log." + Environment.NewLine); - lifecycle.DownloadLog(container); + Console.WriteLine("Unknown failure."); + if (codexNode != null) + { + Console.WriteLine("Downloading container log."); + ci.DownloadLog(codexNode); + } return null; } @@ -82,46 +86,15 @@ namespace CodexNetDeployer if (i == 0) return "BOOTSTRAP"; return "CODEX" + i; } - - private CodexStartupConfig CreateCodexStartupConfig(string bootstrapSpr, int i, int validatorsLeft) - { - var codexStart = new CodexStartupConfig(config.CodexLogLevel); - - if (!string.IsNullOrEmpty(bootstrapSpr)) codexStart.BootstrapSpr = bootstrapSpr; - codexStart.StorageQuota = config.StorageQuota!.Value.MB(); - var marketplaceConfig = new MarketplaceInitialConfig(100000.Eth(), 0.TestTokens(), validatorsLeft > 0); - marketplaceConfig.AccountIndexOverride = i; - codexStart.MarketplaceConfig = marketplaceConfig; - codexStart.MetricsMode = config.Metrics; - - if (config.BlockTTL != Configuration.SecondsIn1Day) - { - codexStart.BlockTTL = config.BlockTTL; - } - if (config.BlockMI != Configuration.TenMinutes) - { - codexStart.BlockMaintenanceInterval = TimeSpan.FromSeconds(config.BlockMI); - } - if (config.BlockMN != 1000) - { - codexStart.BlockMaintenanceNumber = config.BlockMN; - } - - return codexStart; - } } public class CodexNodeStartResult { - public CodexNodeStartResult(StartupWorkflow workflow, RunningContainer container, CodexAccess access) + public CodexNodeStartResult(ICodexNode codexNode) { - Workflow = workflow; - Container = container; - Access = access; + CodexNode = codexNode; } - public StartupWorkflow Workflow { get; } - public RunningContainer Container { get; } - public CodexAccess Access { get; } + public ICodexNode CodexNode { get; } } } diff --git a/Tools/CodexNetDeployer/Configuration.cs b/Tools/CodexNetDeployer/Configuration.cs index 7ad241a..9eb107f 100644 --- a/Tools/CodexNetDeployer/Configuration.cs +++ b/Tools/CodexNetDeployer/Configuration.cs @@ -1,6 +1,5 @@ using ArgsUniform; -using DistTestCore.Codex; -using DistTestCore.Metrics; +using CodexPlugin; namespace CodexNetDeployer { @@ -51,8 +50,8 @@ namespace CodexNetDeployer [Uniform("block-mn", "bmn", "BLOCKMN", false, "Number of blocks maintained per interval. Default is 1000 blocks.")] public int BlockMN { get; set; } = 1000; - [Uniform("metrics", "m", "METRICS", false, "[None*, Record, Dashboard]. Determines if metrics will be recorded and if a dashboard service will be created.")] - public MetricsMode Metrics { get; set; } = MetricsMode.None; + [Uniform("metrics", "m", "METRICS", false, "[true, false]. Determines if metrics will be recorded. Default is false.")] + public bool Metrics { get; set; } = false; [Uniform("teststype-podlabel", "ttpl", "TESTSTYPE-PODLABEL", false, "Each kubernetes pod will be created with a label 'teststype' with value 'continuous'. " + "set this option to override the label value.")] diff --git a/Tools/CodexNetDeployer/Deployer.cs b/Tools/CodexNetDeployer/Deployer.cs index eb329fc..94b878d 100644 --- a/Tools/CodexNetDeployer/Deployer.cs +++ b/Tools/CodexNetDeployer/Deployer.cs @@ -1,51 +1,58 @@ -using DistTestCore; -using DistTestCore.Codex; +using CodexContractsPlugin; +using CodexPlugin; +using Core; +using GethPlugin; using KubernetesWorkflow; using Logging; -using Utils; +using MetricsPlugin; namespace CodexNetDeployer { public class Deployer { private readonly Configuration config; - private readonly DefaultTimeSet timeset; private readonly PeerConnectivityChecker peerConnectivityChecker; + private readonly EntryPoint entryPoint; public Deployer(Configuration config) { this.config = config; - timeset = new DefaultTimeSet(); peerConnectivityChecker = new PeerConnectivityChecker(); + + entryPoint = CreateEntryPoint(new NullLog()); + } + + public void AnnouncePlugins() + { + var ep = CreateEntryPoint(new ConsoleLog()); + + Log("Using plugins:" + Environment.NewLine); + ep.Announce(); + Log(""); + var metadata = ep.GetPluginMetadata(); + foreach (var entry in metadata) + { + Log($"{entry.Key} = {entry.Value}"); + } + Log(""); } public CodexDeployment Deploy() { Log("Initializing..."); - var lifecycle = CreateTestLifecycle(); + var ci = entryPoint.CreateInterface(); - Log("Preparing configuration..."); - // We trick the Geth companion node into unlocking all of its accounts, by saying we want to start 999 codex nodes. - var setup = new CodexSetup(999, config.CodexLogLevel); - setup.WithStorageQuota(config.StorageQuota!.Value.MB()).EnableMarketplace(0.TestTokens()); - setup.MetricsMode = config.Metrics; + Log("Deploying Geth instance..."); + var gethDeployment = ci.DeployGeth(s => s.IsMiner()); + var gethNode = ci.WrapGethDeployment(gethDeployment); - Log("Creating Geth instance and deploying contracts..."); - var gethStarter = new GethStarter(lifecycle); - var gethResults = gethStarter.BringOnlineMarketplaceFor(setup); - - Log("Geth started. Codex contracts deployed."); - Log("Warning: It can take up to 45 minutes for the Geth node to finish unlocking all if its 1000 preconfigured accounts."); - - // It takes a second for the geth node to unlock a single account. Let's assume 3. - // We can't start the codex nodes until their accounts are definitely unlocked. So - // We wait: - Thread.Sleep(TimeSpan.FromSeconds(3.0 * config.NumberOfCodexNodes!.Value)); + Log("Geth started. Deploying Codex contracts..."); + var contractsDeployment = ci.DeployCodexContracts(gethNode); + var contracts = ci.WrapCodexContractsDeployment(contractsDeployment); + Log("Codex contracts deployed."); Log("Starting Codex nodes..."); - - // Each node must have its own IP, so it needs it own pod. Start them 1 at a time. - var codexStarter = new CodexNodeStarter(config, lifecycle, gethResults, config.NumberOfValidators!.Value); + var codexStarter = new CodexNodeStarter(config, ci, gethNode, contracts, config.NumberOfValidators!.Value); var startResults = new List(); for (var i = 0; i < config.NumberOfCodexNodes; i++) { @@ -53,47 +60,40 @@ namespace CodexNetDeployer if (result != null) startResults.Add(result); } - var (prometheusContainer, grafanaStartInfo) = StartMetricsService(lifecycle, setup, startResults.Select(r => r.Container)); + Log("Codex nodes started."); + var metricsService = StartMetricsService(ci, startResults); CheckPeerConnectivity(startResults); CheckContainerRestarts(startResults); - return new CodexDeployment(gethResults, startResults.Select(r => r.Container).ToArray(), prometheusContainer, grafanaStartInfo, CreateMetadata()); + var codexContainers = startResults.Select(s => s.CodexNode.Container).ToArray(); + return new CodexDeployment(codexContainers, gethDeployment, metricsService, CreateMetadata()); } - private TestLifecycle CreateTestLifecycle() + private EntryPoint CreateEntryPoint(ILog log) { var kubeConfig = GetKubeConfig(config.KubeConfigFile); - var lifecycleConfig = new DistTestCore.Configuration - ( - kubeConfigFile: kubeConfig, - logPath: "null", - logDebug: false, - dataFilesPath: "notUsed", - codexLogLevel: config.CodexLogLevel, - k8sNamespacePrefix: config.KubeNamespace - ); + var configuration = new KubernetesWorkflow.Configuration( + kubeConfig, + operationTimeout: TimeSpan.FromSeconds(30), + retryDelay: TimeSpan.FromSeconds(10), + kubernetesNamespace: config.KubeNamespace); - var lifecycle = new TestLifecycle(new NullLog(), lifecycleConfig, timeset, string.Empty); - DefaultContainerRecipe.TestsType = config.TestsTypePodLabel; - DefaultContainerRecipe.ApplicationIds = lifecycle.GetApplicationIds(); - return lifecycle; + return new EntryPoint(log, configuration, string.Empty); } - private (RunningContainer?, GrafanaStartInfo?) StartMetricsService(TestLifecycle lifecycle, CodexSetup setup, IEnumerable codexContainers) + private RunningContainer? StartMetricsService(CoreInterface ci, List startResults) { - if (setup.MetricsMode == DistTestCore.Metrics.MetricsMode.None) return (null, null); + if (!config.Metrics) return null; Log("Starting metrics service..."); - var runningContainers = new[] { new RunningContainers(null!, null!, codexContainers.ToArray()) }; - var prometheusContainer = lifecycle.PrometheusStarter.CollectMetricsFor(runningContainers).Containers.Single(); - if (setup.MetricsMode == DistTestCore.Metrics.MetricsMode.Record) return (prometheusContainer, null); + var runningContainer = ci.DeployMetricsCollector(startResults.Select(r => r.CodexNode).ToArray()); - Log("Starting dashboard service..."); - var grafanaStartInfo = lifecycle.GrafanaStarter.StartDashboard(prometheusContainer, setup); - return (prometheusContainer, grafanaStartInfo); + Log("Metrics service started."); + + return runningContainer; } private string? GetKubeConfig(string kubeConfigFile) @@ -106,7 +106,7 @@ namespace CodexNetDeployer { if (!config.CheckPeerConnection) return; - Log("Starting peer-connectivity check for deployed nodes..."); + Log("Starting peer connectivity check for deployed nodes..."); peerConnectivityChecker.CheckConnectivity(codexContainers); Log("Check passed."); } @@ -114,19 +114,21 @@ namespace CodexNetDeployer private void CheckContainerRestarts(List startResults) { var crashes = new List(); + Log("Starting container crash check..."); foreach (var startResult in startResults) { - var watcher = startResult.Workflow.CreateCrashWatcher(startResult.Container); - if (watcher.HasContainerCrashed()) crashes.Add(startResult.Container); + var watcher = startResult.CodexNode.Container.CrashWatcher; + if (watcher == null) throw new Exception("Expected each CodexNode container to be created with a crash-watcher."); + if (watcher.HasContainerCrashed()) crashes.Add(startResult.CodexNode.Container); } if (!crashes.Any()) { - Log("Container restart check passed."); + Log("Check passed."); } else { - Log($"Deployment failed. The following containers have crashed: {string.Join(",", crashes.Select(c => c.Name))}"); + Log($"Check failed. The following containers have crashed: {string.Join(",", crashes.Select(c => c.Name))}"); throw new Exception("Deployment failed: One or more containers crashed."); } } diff --git a/Tools/CodexNetDeployer/PeerConnectivityChecker.cs b/Tools/CodexNetDeployer/PeerConnectivityChecker.cs index 6e7eddb..8e20a4a 100644 --- a/Tools/CodexNetDeployer/PeerConnectivityChecker.cs +++ b/Tools/CodexNetDeployer/PeerConnectivityChecker.cs @@ -9,9 +9,9 @@ namespace CodexNetDeployer { var log = new ConsoleLog(); var checker = new PeerConnectionTestHelpers(log); - var access = startResults.Select(r => r.Access); + var nodes = startResults.Select(r => r.CodexNode); - checker.AssertFullyConnected(access); + checker.AssertFullyConnected(nodes); } } diff --git a/Tools/CodexNetDeployer/Program.cs b/Tools/CodexNetDeployer/Program.cs index 8ddf527..73fef7f 100644 --- a/Tools/CodexNetDeployer/Program.cs +++ b/Tools/CodexNetDeployer/Program.cs @@ -1,8 +1,5 @@ using ArgsUniform; using CodexNetDeployer; -using DistTestCore.Codex; -using DistTestCore.Marketplace; -using DistTestCore.Metrics; using Newtonsoft.Json; using Configuration = CodexNetDeployer.Configuration; @@ -26,12 +23,8 @@ public class Program return; } - Console.WriteLine("Using images:" + nl + - $"\tCodex image: '{new CodexContainerRecipe().Image}'" + nl + - $"\tCodexContracts image: '{new CodexContractsContainerRecipe().Image}'" + nl + - $"\tPrometheus image: '{new PrometheusContainerRecipe().Image}'" + nl + - $"\tGeth image: '{new GethContainerRecipe().Image}'" + nl + - $"\tGrafana image: '{new GrafanaContainerRecipe().Image}'" + nl); + var deployer = new Deployer(config); + deployer.AnnouncePlugins(); if (!args.Any(a => a == "-y")) { @@ -40,7 +33,6 @@ public class Program Console.WriteLine("I think so too."); } - var deployer = new Deployer(config); var deployment = deployer.Deploy(); Console.WriteLine("Writing codex-deployment.json..."); diff --git a/Tools/CodexNetDeployer/deploy-continuous-testnet.sh b/Tools/CodexNetDeployer/deploy-continuous-testnet.sh index afcfc44..e4ad70a 100644 --- a/Tools/CodexNetDeployer/deploy-continuous-testnet.sh +++ b/Tools/CodexNetDeployer/deploy-continuous-testnet.sh @@ -12,5 +12,5 @@ dotnet run \ --block-ttl=180 \ --block-mi=120 \ --block-mn=10000 \ - --metrics=Dashboard \ + --metrics=true \ --check-connect=1 From 3b6fdaa3bcebcad052ab7f356581a1837b5fa533 Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 12:55:09 +0200 Subject: [PATCH 43/51] Restores CodexNetDeployer --- .../KubernetesWorkflow/RunningContainers.cs | 3 --- Framework/KubernetesWorkflow/StartupConfig.cs | 1 - Framework/KubernetesWorkflow/StartupWorkflow.cs | 16 ++++++---------- ProjectPlugins/CodexPlugin/CodexAccess.cs | 6 ++++-- ProjectPlugins/CodexPlugin/CodexNode.cs | 2 ++ ProjectPlugins/CodexPlugin/CodexNodeFactory.cs | 7 +++++++ ProjectPlugins/CodexPlugin/CodexNodeGroup.cs | 3 ++- ProjectPlugins/CodexPlugin/CodexStarter.cs | 9 ++++----- Tools/CodexNetDeployer/CodexNodeStarter.cs | 7 ++++--- Tools/CodexNetDeployer/Configuration.cs | 2 +- Tools/CodexNetDeployer/Deployer.cs | 2 +- .../deploy-continuous-testnet.sh | 2 +- 12 files changed, 32 insertions(+), 28 deletions(-) diff --git a/Framework/KubernetesWorkflow/RunningContainers.cs b/Framework/KubernetesWorkflow/RunningContainers.cs index 4b36f95..cfcae4d 100644 --- a/Framework/KubernetesWorkflow/RunningContainers.cs +++ b/Framework/KubernetesWorkflow/RunningContainers.cs @@ -41,9 +41,6 @@ namespace KubernetesWorkflow public Address ClusterExternalAddress { get; } public Address ClusterInternalAddress { get; } - [JsonIgnore] - public CrashWatcher? CrashWatcher { get; set; } - [JsonIgnore] public Address Address { diff --git a/Framework/KubernetesWorkflow/StartupConfig.cs b/Framework/KubernetesWorkflow/StartupConfig.cs index 6aa7268..76a96b6 100644 --- a/Framework/KubernetesWorkflow/StartupConfig.cs +++ b/Framework/KubernetesWorkflow/StartupConfig.cs @@ -5,7 +5,6 @@ private readonly List configs = new List(); public string? NameOverride { get; set; } - public bool CreateCrashWatcher { get; set; } public void Add(object config) { diff --git a/Framework/KubernetesWorkflow/StartupWorkflow.cs b/Framework/KubernetesWorkflow/StartupWorkflow.cs index d8221d1..58f5273 100644 --- a/Framework/KubernetesWorkflow/StartupWorkflow.cs +++ b/Framework/KubernetesWorkflow/StartupWorkflow.cs @@ -6,6 +6,7 @@ namespace KubernetesWorkflow public interface IStartupWorkflow { RunningContainers Start(int numberOfContainers, Location location, ContainerRecipeFactory recipeFactory, StartupConfig startupConfig); + CrashWatcher CreateCrashWatcher(RunningContainer container); void Stop(RunningContainers runningContainers); void DownloadContainerLog(RunningContainer container, ILogHandler logHandler, int? tailLines = null); string ExecuteCommand(RunningContainer container, string command, params string[] args); @@ -39,14 +40,17 @@ namespace KubernetesWorkflow var runningPod = controller.BringOnline(recipes, location); var containers = CreateContainers(runningPod, recipes, startupConfig); - if (startupConfig.CreateCrashWatcher) CreateCrashWatchers(controller, containers); - var rc = new RunningContainers(startupConfig, runningPod, containers); cluster.Configuration.Hooks.OnContainersStarted(rc); return rc; }); } + public CrashWatcher CreateCrashWatcher(RunningContainer container) + { + return K8s(c => c.CreateCrashWatcher(container)); + } + public void Stop(RunningContainers runningContainers) { K8s(controller => @@ -88,14 +92,6 @@ namespace KubernetesWorkflow }); } - private void CreateCrashWatchers(K8sController controller, RunningContainer[] runningContainers) - { - foreach (var container in runningContainers) - { - container.CrashWatcher = controller.CreateCrashWatcher(container); - } - } - private RunningContainer[] CreateContainers(RunningPod runningPod, ContainerRecipe[] recipes, StartupConfig startupConfig) { log.Debug(); diff --git a/ProjectPlugins/CodexPlugin/CodexAccess.cs b/ProjectPlugins/CodexPlugin/CodexAccess.cs index fa9c00a..4cb9390 100644 --- a/ProjectPlugins/CodexPlugin/CodexAccess.cs +++ b/ProjectPlugins/CodexPlugin/CodexAccess.cs @@ -8,16 +8,18 @@ namespace CodexPlugin private readonly IPluginTools tools; private bool hasContainerCrashed; - public CodexAccess(IPluginTools tools, RunningContainer container) + public CodexAccess(IPluginTools tools, RunningContainer container, CrashWatcher crashWatcher) { this.tools = tools; Container = container; + CrashWatcher = crashWatcher; hasContainerCrashed = false; - if (container.CrashWatcher != null) container.CrashWatcher.Start(this); + CrashWatcher.Start(this); } public RunningContainer Container { get; } + public CrashWatcher CrashWatcher { get; } public CodexDebugResponse GetDebugInfo() { diff --git a/ProjectPlugins/CodexPlugin/CodexNode.cs b/ProjectPlugins/CodexPlugin/CodexNode.cs index a47f2b2..f4ff802 100644 --- a/ProjectPlugins/CodexPlugin/CodexNode.cs +++ b/ProjectPlugins/CodexPlugin/CodexNode.cs @@ -18,6 +18,7 @@ namespace CodexPlugin void ConnectToPeer(ICodexNode node); CodexDebugVersionResponse Version { get; } IMarketplaceAccess Marketplace { get; } + CrashWatcher CrashWatcher { get; } void Stop(); } @@ -40,6 +41,7 @@ namespace CodexPlugin public RunningContainer Container { get { return CodexAccess.Container; } } public CodexAccess CodexAccess { get; } + public CrashWatcher CrashWatcher { get => CodexAccess.CrashWatcher; } public CodexNodeGroup Group { get; } public IMarketplaceAccess Marketplace { get; } public CodexDebugVersionResponse Version { get; private set; } diff --git a/ProjectPlugins/CodexPlugin/CodexNodeFactory.cs b/ProjectPlugins/CodexPlugin/CodexNodeFactory.cs index 29daf0a..4b9dfdd 100644 --- a/ProjectPlugins/CodexPlugin/CodexNodeFactory.cs +++ b/ProjectPlugins/CodexPlugin/CodexNodeFactory.cs @@ -1,11 +1,13 @@ using Core; using GethPlugin; +using KubernetesWorkflow; namespace CodexPlugin { public interface ICodexNodeFactory { CodexNode CreateOnlineCodexNode(CodexAccess access, CodexNodeGroup group); + CrashWatcher CreateCrashWatcher(RunningContainer c); } public class CodexNodeFactory : ICodexNodeFactory @@ -36,5 +38,10 @@ namespace CodexPlugin if (mStart == null) return null; return mStart.EthAddress; } + + public CrashWatcher CreateCrashWatcher(RunningContainer c) + { + return tools.CreateWorkflow().CreateCrashWatcher(c); + } } } diff --git a/ProjectPlugins/CodexPlugin/CodexNodeGroup.cs b/ProjectPlugins/CodexPlugin/CodexNodeGroup.cs index cff4f8c..01c5661 100644 --- a/ProjectPlugins/CodexPlugin/CodexNodeGroup.cs +++ b/ProjectPlugins/CodexPlugin/CodexNodeGroup.cs @@ -76,7 +76,8 @@ namespace CodexPlugin private CodexNode CreateOnlineCodexNode(RunningContainer c, IPluginTools tools, ICodexNodeFactory factory) { - var access = new CodexAccess(tools, c); + var watcher = factory.CreateCrashWatcher(c); + var access = new CodexAccess(tools, c, watcher); return factory.CreateOnlineCodexNode(access, this); } } diff --git a/ProjectPlugins/CodexPlugin/CodexStarter.cs b/ProjectPlugins/CodexPlugin/CodexStarter.cs index 3c987da..855fb14 100644 --- a/ProjectPlugins/CodexPlugin/CodexStarter.cs +++ b/ProjectPlugins/CodexPlugin/CodexStarter.cs @@ -46,10 +46,10 @@ namespace CodexPlugin public void BringOffline(CodexNodeGroup group) { Log($"Stopping {group.Describe()}..."); + StopCrashWatcher(group); var workflow = pluginTools.CreateWorkflow(); foreach (var c in group.Containers) { - StopCrashWatcher(c); workflow.Stop(c); } Log("Stopped."); @@ -65,7 +65,6 @@ namespace CodexPlugin { var startupConfig = new StartupConfig(); startupConfig.NameOverride = codexSetup.NameOverride; - startupConfig.CreateCrashWatcher = true; startupConfig.Add(codexSetup); return startupConfig; } @@ -114,11 +113,11 @@ namespace CodexPlugin pluginTools.GetLog().Log(message); } - private void StopCrashWatcher(RunningContainers containers) + private void StopCrashWatcher(CodexNodeGroup group) { - foreach (var c in containers.Containers) + foreach (var node in group) { - c.CrashWatcher?.Stop(); + node.CrashWatcher.Stop(); } } } diff --git a/Tools/CodexNetDeployer/CodexNodeStarter.cs b/Tools/CodexNetDeployer/CodexNodeStarter.cs index f7cdd94..b89acf2 100644 --- a/Tools/CodexNetDeployer/CodexNodeStarter.cs +++ b/Tools/CodexNetDeployer/CodexNodeStarter.cs @@ -12,7 +12,7 @@ namespace CodexNetDeployer private readonly CoreInterface ci; private readonly IGethNode gethNode; private readonly ICodexContracts contracts; - private string bootstrapSpr = ""; + private ICodexNode? bootstrapNode = null; private int validatorsLeft; public CodexNodeStarter(Configuration config, CoreInterface ci, IGethNode gethNode, ICodexContracts contracts, int numberOfValidators) @@ -27,7 +27,7 @@ namespace CodexNetDeployer public CodexNodeStartResult? Start(int i) { var name = GetCodexContainerName(i); - Console.Write($" - {i} ({name}) = "); + Console.Write($" - {i} ({name}) \t= "); ICodexNode? codexNode = null; try @@ -40,6 +40,7 @@ namespace CodexNetDeployer s.EnableMarketplace(gethNode, contracts, 100.Eth(), config.InitialTestTokens.TestTokens(), validatorsLeft > 0); s.EnableMetrics(); + if (bootstrapNode != null) s.WithBootstrapNode(bootstrapNode); if (config.BlockTTL != Configuration.SecondsIn1Day) s.WithBlockTTL(TimeSpan.FromSeconds(config.BlockTTL)); if (config.BlockMI != Configuration.TenMinutes) s.WithBlockMaintenanceInterval(TimeSpan.FromSeconds(config.BlockMI)); if (config.BlockMN != 1000) s.WithBlockMaintenanceNumber(config.BlockMN); @@ -60,8 +61,8 @@ namespace CodexNetDeployer { Console.Write("Storage available\tOK" + Environment.NewLine); - if (string.IsNullOrEmpty(bootstrapSpr)) bootstrapSpr = debugInfo.spr; validatorsLeft--; + if (bootstrapNode == null) bootstrapNode = codexNode; return new CodexNodeStartResult(codexNode); } } diff --git a/Tools/CodexNetDeployer/Configuration.cs b/Tools/CodexNetDeployer/Configuration.cs index 9eb107f..3ff1020 100644 --- a/Tools/CodexNetDeployer/Configuration.cs +++ b/Tools/CodexNetDeployer/Configuration.cs @@ -57,7 +57,7 @@ namespace CodexNetDeployer "set this option to override the label value.")] public string TestsTypePodLabel { get; set; } = "continuous-tests"; - [Uniform("check-connect", "cc", "CHECKCONNECT", false, "If true, deployer check ensure peer-connectivity between all deployed nodes after deployment.")] + [Uniform("check-connect", "cc", "CHECKCONNECT", false, "If true, deployer check ensure peer-connectivity between all deployed nodes after deployment. Default is false.")] public bool CheckPeerConnection { get; set; } = false; public List Validate() diff --git a/Tools/CodexNetDeployer/Deployer.cs b/Tools/CodexNetDeployer/Deployer.cs index 94b878d..ee9ff9a 100644 --- a/Tools/CodexNetDeployer/Deployer.cs +++ b/Tools/CodexNetDeployer/Deployer.cs @@ -117,7 +117,7 @@ namespace CodexNetDeployer Log("Starting container crash check..."); foreach (var startResult in startResults) { - var watcher = startResult.CodexNode.Container.CrashWatcher; + var watcher = startResult.CodexNode.CrashWatcher; if (watcher == null) throw new Exception("Expected each CodexNode container to be created with a crash-watcher."); if (watcher.HasContainerCrashed()) crashes.Add(startResult.CodexNode.Container); } diff --git a/Tools/CodexNetDeployer/deploy-continuous-testnet.sh b/Tools/CodexNetDeployer/deploy-continuous-testnet.sh index e4ad70a..6a3bbe8 100644 --- a/Tools/CodexNetDeployer/deploy-continuous-testnet.sh +++ b/Tools/CodexNetDeployer/deploy-continuous-testnet.sh @@ -12,5 +12,5 @@ dotnet run \ --block-ttl=180 \ --block-mi=120 \ --block-mn=10000 \ - --metrics=true \ + --metrics=1 \ --check-connect=1 From 48da92c73773a986159c3b33f77edd612f74b32f Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 12:56:02 +0200 Subject: [PATCH 44/51] Removes codex net downloader --- .../CodexNetDownloader.csproj | 16 ------- Tools/CodexNetDownloader/Configuration.cs | 19 -------- Tools/CodexNetDownloader/Program.cs | 48 ------------------- 3 files changed, 83 deletions(-) delete mode 100644 Tools/CodexNetDownloader/CodexNetDownloader.csproj delete mode 100644 Tools/CodexNetDownloader/Configuration.cs delete mode 100644 Tools/CodexNetDownloader/Program.cs diff --git a/Tools/CodexNetDownloader/CodexNetDownloader.csproj b/Tools/CodexNetDownloader/CodexNetDownloader.csproj deleted file mode 100644 index 0df1223..0000000 --- a/Tools/CodexNetDownloader/CodexNetDownloader.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - Exe - net7.0 - enable - enable - - - - - - - - - diff --git a/Tools/CodexNetDownloader/Configuration.cs b/Tools/CodexNetDownloader/Configuration.cs deleted file mode 100644 index a42136a..0000000 --- a/Tools/CodexNetDownloader/Configuration.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ArgsUniform; -using DistTestCore.Codex; - -namespace CodexNetDownloader -{ - public class Configuration - { - [Uniform("output-path", "o", "OUTPUT", true, "Path where files will be written.")] - public string OutputPath { get; set; } = "output"; - - [Uniform("codex-deployment", "c", "CODEXDEPLOYMENT", true, "Path to codex-deployment JSON file.")] - public string CodexDeploymentJson { get; set; } = string.Empty; - - [Uniform("kube-config", "kc", "KUBECONFIG", true, "Path to Kubeconfig file. Use 'null' (default) to use local cluster.")] - public string KubeConfigFile { get; set; } = "null"; - - public CodexDeployment CodexDeployment { get; set; } = null!; - } -} diff --git a/Tools/CodexNetDownloader/Program.cs b/Tools/CodexNetDownloader/Program.cs deleted file mode 100644 index 5744e71..0000000 --- a/Tools/CodexNetDownloader/Program.cs +++ /dev/null @@ -1,48 +0,0 @@ -using ArgsUniform; -using ContinuousTests; -using DistTestCore; -using DistTestCore.Codex; -using Logging; -using Newtonsoft.Json; - -public class Program -{ - public static void Main(string[] args) - { - var nl = Environment.NewLine; - Console.WriteLine("CodexNetDownloader" + nl); - - var uniformArgs = new ArgsUniform(PrintHelp, args); - var config = uniformArgs.Parse(true); - - config.CodexDeployment = ParseCodexDeploymentJson(config.CodexDeploymentJson); - - if (!Directory.Exists(config.OutputPath)) Directory.CreateDirectory(config.OutputPath); - - var k8sFactory = new K8sFactory(); - var lifecycle = k8sFactory.CreateTestLifecycle(config.KubeConfigFile, config.OutputPath, "dataPath", config.CodexDeployment.Metadata.KubeNamespace, new DefaultTimeSet(), new NullLog()); - - foreach (var container in config.CodexDeployment.CodexContainers) - { - lifecycle.DownloadLog(container); - } - - Console.WriteLine("Done!"); - } - - private static CodexDeployment ParseCodexDeploymentJson(string filename) - { - var d = JsonConvert.DeserializeObject(File.ReadAllText(filename))!; - if (d == null) throw new Exception("Unable to parse " + filename); - return d; - } - - private static void PrintHelp() - { - var nl = Environment.NewLine; - Console.WriteLine("CodexNetDownloader lets you download all container logs given a codex-deployment.json file." + nl); - - Console.WriteLine("CodexNetDownloader assumes you are running this tool from *inside* the Kubernetes cluster. " + - "If you are not running this from a container inside the cluster, add the argument '--external'." + nl); - } -} From 8cde69a48338a87389883c0e82d92cd192e8c43f Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 13:33:58 +0200 Subject: [PATCH 45/51] Restores continuous test runner --- Framework/Logging/BaseLog.cs | 1 + Framework/Logging/LogPrefixer.cs | 5 ++ ProjectPlugins/CodexPlugin/CodexAccess.cs | 2 + .../CodexPlugin/CoreInterfaceExtensions.cs | 7 ++ .../CodexAccessFactory.cs | 20 ----- .../CodexContinuousTests.csproj | 6 +- Tests/CodexContinuousTests/Configuration.cs | 8 +- Tests/CodexContinuousTests/ContinuousTest.cs | 64 ++----------- .../ContinuousTestRunner.cs | 20 +++-- .../CodexContinuousTests/EntryPointFactory.cs | 29 ++++++ Tests/CodexContinuousTests/K8sFactory.cs | 34 ------- Tests/CodexContinuousTests/NodeRunner.cs | 90 ++++++------------- Tests/CodexContinuousTests/SingleTestRun.cs | 36 ++++---- Tests/CodexContinuousTests/StartupChecker.cs | 39 +++----- Tests/CodexContinuousTests/TestLoop.cs | 9 +- .../Tests/HoldMyBeerTest.cs | 10 +-- Tests/CodexContinuousTests/Tests/PeersTest.cs | 4 +- .../Tests/ThresholdChecks.cs | 60 ------------- .../Tests/TwoClientTest.cs | 10 +-- cs-codex-dist-testing.sln | 7 -- 20 files changed, 135 insertions(+), 326 deletions(-) delete mode 100644 Tests/CodexContinuousTests/CodexAccessFactory.cs create mode 100644 Tests/CodexContinuousTests/EntryPointFactory.cs delete mode 100644 Tests/CodexContinuousTests/K8sFactory.cs delete mode 100644 Tests/CodexContinuousTests/Tests/ThresholdChecks.cs diff --git a/Framework/Logging/BaseLog.cs b/Framework/Logging/BaseLog.cs index 26d9c1e..b80b007 100644 --- a/Framework/Logging/BaseLog.cs +++ b/Framework/Logging/BaseLog.cs @@ -7,6 +7,7 @@ namespace Logging void Log(string message); void Debug(string message = "", int skipFrames = 0); void Error(string message); + void AddStringReplace(string from, string to); LogFile CreateSubfile(string ext = "log"); } diff --git a/Framework/Logging/LogPrefixer.cs b/Framework/Logging/LogPrefixer.cs index de05730..a3d2f9f 100644 --- a/Framework/Logging/LogPrefixer.cs +++ b/Framework/Logging/LogPrefixer.cs @@ -30,5 +30,10 @@ { backingLog.Log(prefix + message); } + + public void AddStringReplace(string from, string to) + { + backingLog.AddStringReplace(from, to); + } } } diff --git a/ProjectPlugins/CodexPlugin/CodexAccess.cs b/ProjectPlugins/CodexPlugin/CodexAccess.cs index 4cb9390..8e0ac40 100644 --- a/ProjectPlugins/CodexPlugin/CodexAccess.cs +++ b/ProjectPlugins/CodexPlugin/CodexAccess.cs @@ -51,6 +51,8 @@ namespace CodexPlugin public string UploadFile(FileStream fileStream) { + // private const string UploadFailedMessage = "Unable to store block"; + return Http().HttpPostStream("upload", fileStream); } diff --git a/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs b/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs index a79615e..bce5fe5 100644 --- a/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs +++ b/ProjectPlugins/CodexPlugin/CoreInterfaceExtensions.cs @@ -10,6 +10,13 @@ namespace CodexPlugin return Plugin(ci).DeployCodexNodes(number, setup); } + public static ICodexNodeGroup WrapCodexContainers(this CoreInterface ci, RunningContainer[] containers) + { + // ew, clean this up. + var rcs = new RunningContainers(null!, containers.First().Pod, containers); + return WrapCodexContainers(ci, new[] { rcs }); + } + public static ICodexNodeGroup WrapCodexContainers(this CoreInterface ci, RunningContainers[] containers) { return Plugin(ci).WrapCodexContainers(ci, containers); diff --git a/Tests/CodexContinuousTests/CodexAccessFactory.cs b/Tests/CodexContinuousTests/CodexAccessFactory.cs deleted file mode 100644 index 78e9069..0000000 --- a/Tests/CodexContinuousTests/CodexAccessFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -using DistTestCore; -using DistTestCore.Codex; -using KubernetesWorkflow; -using Logging; - -namespace ContinuousTests -{ - public class CodexAccessFactory - { - public CodexAccess[] Create(Configuration config, RunningContainer[] containers, BaseLog log, ITimeSet timeSet) - { - return containers.Select(container => - { - var address = container.ClusterExternalAddress; - if (config.RunnerLocation == RunnerLocation.InternalToCluster) address = container.ClusterInternalAddress; - return new CodexAccess(log, container, timeSet, address); - }).ToArray(); - } - } -} diff --git a/Tests/CodexContinuousTests/CodexContinuousTests.csproj b/Tests/CodexContinuousTests/CodexContinuousTests.csproj index 5543d01..de07fe4 100644 --- a/Tests/CodexContinuousTests/CodexContinuousTests.csproj +++ b/Tests/CodexContinuousTests/CodexContinuousTests.csproj @@ -12,10 +12,10 @@ - + + + - - diff --git a/Tests/CodexContinuousTests/Configuration.cs b/Tests/CodexContinuousTests/Configuration.cs index 04491a9..50ef578 100644 --- a/Tests/CodexContinuousTests/Configuration.cs +++ b/Tests/CodexContinuousTests/Configuration.cs @@ -1,6 +1,5 @@ using ArgsUniform; -using DistTestCore; -using DistTestCore.Codex; +using CodexPlugin; using Newtonsoft.Json; namespace ContinuousTests @@ -29,8 +28,6 @@ namespace ContinuousTests public bool DownloadContainerLogs { get; set; } = false; public CodexDeployment CodexDeployment { get; set; } = null!; - - public RunnerLocation RunnerLocation { get; set; } } public class ConfigLoader @@ -40,10 +37,7 @@ namespace ContinuousTests var uniformArgs = new ArgsUniform(PrintHelp, args); var result = uniformArgs.Parse(true); - result.CodexDeployment = ParseCodexDeploymentJson(result.CodexDeploymentJson); - result.RunnerLocation = RunnerLocationUtils.DetermineRunnerLocation(result.CodexDeployment.CodexContainers.First()); - return result; } diff --git a/Tests/CodexContinuousTests/ContinuousTest.cs b/Tests/CodexContinuousTests/ContinuousTest.cs index edfc05b..e4122fc 100644 --- a/Tests/CodexContinuousTests/ContinuousTest.cs +++ b/Tests/CodexContinuousTests/ContinuousTest.cs @@ -1,8 +1,7 @@ -using DistTestCore; -using DistTestCore.Codex; -using DistTestCore.Logs; +using CodexPlugin; +using Core; +using DistTestCore; using FileUtils; -using KubernetesWorkflow; using Logging; namespace ContinuousTests @@ -22,9 +21,7 @@ namespace ContinuousTests protected const int DayOne = HourOne * 24; protected const int DayThree = DayOne * 3; - private const string UploadFailedMessage = "Unable to store block"; - - public void Initialize(CodexAccess[] nodes, BaseLog log, FileManager fileManager, Configuration configuration, CancellationToken cancelToken) + public void Initialize(ICodexNode[] nodes, ILog log, IFileManager fileManager, Configuration configuration, CancellationToken cancelToken) { Nodes = nodes; Log = log; @@ -34,7 +31,7 @@ namespace ContinuousTests if (nodes != null) { - NodeRunner = new NodeRunner(Nodes, configuration, TimeSet, Log, CustomK8sNamespace, EthereumAccountIndex); + NodeRunner = new NodeRunner(Nodes, configuration, Log, CustomK8sNamespace); } else { @@ -42,8 +39,8 @@ namespace ContinuousTests } } - public CodexAccess[] Nodes { get; private set; } = null!; - public BaseLog Log { get; private set; } = null!; + public ICodexNode[] Nodes { get; private set; } = null!; + public ILog Log { get; private set; } = null!; public IFileManager FileManager { get; private set; } = null!; public Configuration Configuration { get; private set; } = null!; public virtual ITimeSet TimeSet { get { return new DefaultTimeSet(); } } @@ -53,7 +50,6 @@ namespace ContinuousTests public abstract int RequiredNumberOfNodes { get; } public abstract TimeSpan RunTestEvery { get; } public abstract TestFailMode TestFailMode { get; } - public virtual int EthereumAccountIndex { get { return -1; } } public virtual string CustomK8sNamespace { get { return string.Empty; } } public string Name @@ -64,52 +60,6 @@ namespace ContinuousTests } } - public ContentId? UploadFile(CodexAccess node, TestFile file) - { - using var fileStream = File.OpenRead(file.Filename); - - var logMessage = $"Uploading file {file.Describe()}..."; - var response = Stopwatch.Measure(Log, logMessage, () => - { - return node.UploadFile(fileStream); - }); - - if (string.IsNullOrEmpty(response)) return null; - if (response.StartsWith(UploadFailedMessage)) return null; - - Log.Log($"Uploaded file. Received contentId: '{response}'."); - return new ContentId(response); - } - - public TestFile DownloadFile(CodexAccess node, ContentId contentId, string fileLabel = "") - { - var logMessage = $"Downloading for contentId: '{contentId.Id}'..."; - var file = FileManager.CreateEmptyTestFile(fileLabel); - Stopwatch.Measure(Log, logMessage, () => DownloadToFile(node, contentId.Id, file)); - Log.Log($"Downloaded file {file.Describe()} to '{file.Filename}'."); - return file; - } - - public IDownloadedLog DownloadContainerLog(RunningContainer container, int? tailLines = null) - { - var nodeRunner = new NodeRunner(Nodes, Configuration, TimeSet, Log, Configuration.CodexDeployment.Metadata.KubeNamespace, EthereumAccountIndex); - return nodeRunner.DownloadLog(container, tailLines); - } - - private void DownloadToFile(CodexAccess node, string contentId, TestFile file) - { - using var fileStream = File.OpenWrite(file.Filename); - try - { - using var downloadStream = node.DownloadFile(contentId); - downloadStream.CopyTo(fileStream); - } - catch - { - Log.Log($"Failed to download file '{contentId}'."); - throw; - } - } } public enum TestFailMode diff --git a/Tests/CodexContinuousTests/ContinuousTestRunner.cs b/Tests/CodexContinuousTests/ContinuousTestRunner.cs index cf40a8f..470dbbb 100644 --- a/Tests/CodexContinuousTests/ContinuousTestRunner.cs +++ b/Tests/CodexContinuousTests/ContinuousTestRunner.cs @@ -1,36 +1,39 @@ -using DistTestCore; +using DistTestCore.Logs; using Logging; namespace ContinuousTests { public class ContinuousTestRunner { - private readonly K8sFactory k8SFactory = new K8sFactory(); + private readonly EntryPointFactory entryPointFactory = new EntryPointFactory(); private readonly ConfigLoader configLoader = new ConfigLoader(); private readonly TestFactory testFactory = new TestFactory(); private readonly Configuration config; - private readonly StartupChecker startupChecker; private readonly CancellationToken cancelToken; public ContinuousTestRunner(string[] args, CancellationToken cancelToken) { config = configLoader.Load(args); - startupChecker = new StartupChecker(config, cancelToken); this.cancelToken = cancelToken; } public void Run() { + var overviewLog = new FixtureLog(new LogConfig(config.LogPath, false), DateTime.UtcNow, "Overview"); + + var entryPoint = entryPointFactory.CreateEntryPoint(config.KubeConfigFile, config.DataPath, config.CodexDeployment.Metadata.KubeNamespace, overviewLog); + entryPoint.Announce(); + + var startupChecker = new StartupChecker(entryPoint, config, cancelToken); startupChecker.Check(); var taskFactory = new TaskFactory(); - var overviewLog = new FixtureLog(new LogConfig(config.LogPath, false), DateTime.UtcNow, "Overview"); overviewLog.Log("Continuous tests starting..."); var allTests = testFactory.CreateTests(); ClearAllCustomNamespaces(allTests, overviewLog); - var testLoops = allTests.Select(t => new TestLoop(taskFactory, config, overviewLog, t.GetType(), t.RunTestEvery, startupChecker, cancelToken)).ToArray(); + var testLoops = allTests.Select(t => new TestLoop(entryPoint, taskFactory, config, overviewLog, t.GetType(), t.RunTestEvery, startupChecker, cancelToken)).ToArray(); foreach (var testLoop in testLoops) { @@ -58,8 +61,9 @@ namespace ContinuousTests if (string.IsNullOrEmpty(test.CustomK8sNamespace)) return; log.Log($"Clearing namespace '{test.CustomK8sNamespace}'..."); - var lifecycle = k8SFactory.CreateTestLifecycle(config.KubeConfigFile, config.LogPath, config.DataPath, test.CustomK8sNamespace, new DefaultTimeSet(), log); - lifecycle.WorkflowCreator.CreateWorkflow().DeleteNamespacesStartingWith(); + + var entryPoint = entryPointFactory.CreateEntryPoint(config.KubeConfigFile, config.DataPath, test.CustomK8sNamespace, log); + entryPoint.Tools.CreateWorkflow().DeleteNamespacesStartingWith(test.CustomK8sNamespace); } } } diff --git a/Tests/CodexContinuousTests/EntryPointFactory.cs b/Tests/CodexContinuousTests/EntryPointFactory.cs new file mode 100644 index 0000000..f61d22e --- /dev/null +++ b/Tests/CodexContinuousTests/EntryPointFactory.cs @@ -0,0 +1,29 @@ +using Logging; +using Core; + +namespace ContinuousTests +{ + public class EntryPointFactory + { + public EntryPoint CreateEntryPoint(string kubeConfigFile, string dataFilePath, string customNamespace, ILog log) + { + var kubeConfig = GetKubeConfig(kubeConfigFile); + var lifecycleConfig = new KubernetesWorkflow.Configuration + ( + kubeConfigFile: kubeConfig, + operationTimeout: TimeSpan.FromSeconds(30), + retryDelay: TimeSpan.FromSeconds(10), + kubernetesNamespace: customNamespace + ); + + return new EntryPoint(log, lifecycleConfig, dataFilePath); + //DefaultContainerRecipe.TestsType = "continuous-tests"; + } + + private static string? GetKubeConfig(string kubeConfigFile) + { + if (string.IsNullOrEmpty(kubeConfigFile) || kubeConfigFile.ToLowerInvariant() == "null") return null; + return kubeConfigFile; + } + } +} diff --git a/Tests/CodexContinuousTests/K8sFactory.cs b/Tests/CodexContinuousTests/K8sFactory.cs deleted file mode 100644 index 5ae8b1b..0000000 --- a/Tests/CodexContinuousTests/K8sFactory.cs +++ /dev/null @@ -1,34 +0,0 @@ -using DistTestCore.Codex; -using DistTestCore; -using Logging; - -namespace ContinuousTests -{ - public class K8sFactory - { - public TestLifecycle CreateTestLifecycle(string kubeConfigFile, string logPath, string dataFilePath, string customNamespace, ITimeSet timeSet, BaseLog log) - { - var kubeConfig = GetKubeConfig(kubeConfigFile); - var lifecycleConfig = new DistTestCore.Configuration - ( - kubeConfigFile: kubeConfig, - logPath: logPath, - logDebug: false, - dataFilesPath: dataFilePath, - codexLogLevel: CodexLogLevel.Debug, - k8sNamespacePrefix: customNamespace - ); - - var lifecycle = new TestLifecycle(log, lifecycleConfig, timeSet, string.Empty); - DefaultContainerRecipe.TestsType = "continuous-tests"; - DefaultContainerRecipe.ApplicationIds = lifecycle.GetApplicationIds(); - return lifecycle; - } - - private static string? GetKubeConfig(string kubeConfigFile) - { - if (string.IsNullOrEmpty(kubeConfigFile) || kubeConfigFile.ToLowerInvariant() == "null") return null; - return kubeConfigFile; - } - } -} diff --git a/Tests/CodexContinuousTests/NodeRunner.cs b/Tests/CodexContinuousTests/NodeRunner.cs index b8bb415..85f01b2 100644 --- a/Tests/CodexContinuousTests/NodeRunner.cs +++ b/Tests/CodexContinuousTests/NodeRunner.cs @@ -1,113 +1,73 @@ -using DistTestCore.Codex; -using DistTestCore.Marketplace; -using DistTestCore; -using KubernetesWorkflow; +using KubernetesWorkflow; using NUnit.Framework; using Logging; using Utils; -using DistTestCore.Logs; +using Core; +using CodexPlugin; namespace ContinuousTests { public class NodeRunner { - private readonly K8sFactory k8SFactory = new K8sFactory(); - private readonly CodexAccess[] nodes; + private readonly EntryPointFactory entryPointFactory = new EntryPointFactory(); + private readonly ICodexNode[] nodes; private readonly Configuration config; - private readonly ITimeSet timeSet; - private readonly BaseLog log; + private readonly ILog log; private readonly string customNamespace; - private readonly int ethereumAccountIndex; - public NodeRunner(CodexAccess[] nodes, Configuration config, ITimeSet timeSet, BaseLog log, string customNamespace, int ethereumAccountIndex) + public NodeRunner(ICodexNode[] nodes, Configuration config, ILog log, string customNamespace) { this.nodes = nodes; this.config = config; - this.timeSet = timeSet; this.log = log; this.customNamespace = customNamespace; - this.ethereumAccountIndex = ethereumAccountIndex; - } - - public void RunNode(Action operation) - { - RunNode(nodes.ToList().PickOneRandom(), operation, 0.TestTokens()); - } - - public void RunNode(CodexAccess bootstrapNode, Action operation) - { - RunNode(bootstrapNode, operation, 0.TestTokens()); } public IDownloadedLog DownloadLog(RunningContainer container, int? tailLines = null) { - var subFile = log.CreateSubfile(); - var description = container.Name; - var handler = new LogDownloadHandler(container, description, subFile); - - log.Log($"Downloading logs for {description} to file '{subFile.FullFilename}'"); - - var lifecycle = CreateTestLifecycle(); - var flow = lifecycle.WorkflowCreator.CreateWorkflow(); - flow.DownloadContainerLog(container, handler, tailLines); - - return new DownloadedLog(subFile, description); + var entryPoint = CreateEntryPoint(); + return entryPoint.CreateInterface().DownloadLog(container, tailLines); } - public void RunNode(CodexAccess bootstrapNode, Action operation, TestToken mintTestTokens) + public void RunNode(Action setup, Action operation) { - var lifecycle = CreateTestLifecycle(); - var flow = lifecycle.WorkflowCreator.CreateWorkflow(); + RunNode(nodes.ToList().PickOneRandom(), setup, operation); + } + + public void RunNode(ICodexNode bootstrapNode, Action setup, Action operation) + { + var entryPoint = CreateEntryPoint(); try { var debugInfo = bootstrapNode.GetDebugInfo(); Assert.That(!string.IsNullOrEmpty(debugInfo.spr)); - var startupConfig = new StartupConfig(); - startupConfig.NameOverride = "TransientNode"; - var codexStartConfig = new CodexStartupConfig(CodexLogLevel.Trace); - codexStartConfig.MarketplaceConfig = new MarketplaceInitialConfig(0.Eth(), 0.TestTokens(), false); - codexStartConfig.MarketplaceConfig.AccountIndexOverride = ethereumAccountIndex; - codexStartConfig.BootstrapSpr = debugInfo.spr; - startupConfig.Add(codexStartConfig); - startupConfig.Add(config.CodexDeployment.GethStartResult); - var rc = flow.Start(1, Location.Unspecified, new CodexContainerRecipe(), startupConfig); - - var account = config.CodexDeployment.GethStartResult.CompanionNode.Accounts[ethereumAccountIndex]; - - var marketplaceNetwork = config.CodexDeployment.GethStartResult.MarketplaceNetwork; - if (mintTestTokens.Amount > 0) + var node = entryPoint.CreateInterface().StartCodexNode(s => { - var tokenAddress = marketplaceNetwork.Marketplace.TokenAddress; - var interaction = marketplaceNetwork.Bootstrap.StartInteraction(lifecycle); - interaction.MintTestTokens(new[] { account.Account }, mintTestTokens.Amount, tokenAddress); - } - - var container = rc.Containers[0]; - var address = lifecycle.Configuration.GetAddress(container); - var codexAccess = new CodexAccess(log, container, lifecycle.TimeSet, address); - var marketAccess = new MarketplaceAccess(lifecycle, marketplaceNetwork, account, codexAccess); + setup(s); + s.WithBootstrapNode(bootstrapNode); + }); try { - operation(codexAccess, marketAccess, lifecycle); + operation(node); } catch { - lifecycle.DownloadLog(container); + DownloadLog(node.Container); throw; } } finally { - flow.DeleteNamespacesStartingWith(); + entryPoint.Tools.CreateWorkflow().DeleteNamespace(); } } - private TestLifecycle CreateTestLifecycle() + private EntryPoint CreateEntryPoint() { - return k8SFactory.CreateTestLifecycle(config.KubeConfigFile, config.LogPath, config.DataPath, customNamespace, timeSet, log); + return entryPointFactory.CreateEntryPoint(config.KubeConfigFile, config.DataPath, customNamespace, log); } } } diff --git a/Tests/CodexContinuousTests/SingleTestRun.cs b/Tests/CodexContinuousTests/SingleTestRun.cs index ef95fb9..319318d 100644 --- a/Tests/CodexContinuousTests/SingleTestRun.cs +++ b/Tests/CodexContinuousTests/SingleTestRun.cs @@ -1,33 +1,34 @@ -using DistTestCore.Codex; -using DistTestCore; -using Logging; +using Logging; using Utils; using KubernetesWorkflow; using NUnit.Framework.Internal; using System.Reflection; using static Program; using FileUtils; +using CodexPlugin; +using DistTestCore.Logs; +using Core; namespace ContinuousTests { public class SingleTestRun { - private readonly CodexAccessFactory codexNodeFactory = new CodexAccessFactory(); private readonly List exceptions = new List(); + private readonly EntryPoint entryPoint; private readonly TaskFactory taskFactory; private readonly Configuration config; private readonly BaseLog overviewLog; private readonly TestHandle handle; private readonly CancellationToken cancelToken; - private readonly CodexAccess[] nodes; - private readonly FileManager fileManager; + private readonly ICodexNode[] nodes; private readonly FixtureLog fixtureLog; private readonly string testName; private readonly string dataFolder; private static int failureCount = 0; - public SingleTestRun(TaskFactory taskFactory, Configuration config, BaseLog overviewLog, TestHandle handle, StartupChecker startupChecker, CancellationToken cancelToken) + public SingleTestRun(EntryPoint entryPoint, TaskFactory taskFactory, Configuration config, BaseLog overviewLog, TestHandle handle, StartupChecker startupChecker, CancellationToken cancelToken) { + this.entryPoint = entryPoint; this.taskFactory = taskFactory; this.config = config; this.overviewLog = overviewLog; @@ -39,7 +40,6 @@ namespace ContinuousTests nodes = CreateRandomNodes(); dataFolder = config.DataPath + "-" + Guid.NewGuid(); - fileManager = new FileManager(fixtureLog, CreateFileManagerConfiguration().GetFileManagerFolder()); } public void Run(EventWaitHandle runFinishedHandle) @@ -49,7 +49,7 @@ namespace ContinuousTests try { RunTest(); - fileManager.DeleteAllTestFiles(); + entryPoint.Tools.GetFileManager().DeleteAllFiles(); Directory.Delete(dataFolder, true); runFinishedHandle.Set(); } @@ -142,14 +142,14 @@ namespace ContinuousTests private void DownloadClusterLogs() { - var k8sFactory = new K8sFactory(); + var entryPointFactory = new EntryPointFactory(); var log = new NullLog(); log.FullFilename = Path.Combine(config.LogPath, "NODE"); - var lifecycle = k8sFactory.CreateTestLifecycle(config.KubeConfigFile, config.LogPath, "dataPath", config.CodexDeployment.Metadata.KubeNamespace, new DefaultTimeSet(), log); + var entryPoint = entryPointFactory.CreateEntryPoint(config.KubeConfigFile, config.DataPath, config.CodexDeployment.Metadata.KubeNamespace, log); foreach (var container in config.CodexDeployment.CodexContainers) { - lifecycle.DownloadLog(container); + entryPoint.CreateInterface().DownloadLog(container); } } @@ -197,7 +197,7 @@ namespace ContinuousTests private void InitializeTest(string name) { Log($" > Running TestMoment '{name}'"); - handle.Test.Initialize(nodes, fixtureLog, fileManager, config, cancelToken); + handle.Test.Initialize(nodes, fixtureLog, entryPoint.Tools.GetFileManager(), config, cancelToken); } private void DecommissionTest() @@ -223,11 +223,11 @@ namespace ContinuousTests return $"({string.Join(",", nodes.Select(n => n.Container.Name))})"; } - private CodexAccess[] CreateRandomNodes() + private ICodexNode[] CreateRandomNodes() { var containers = SelectRandomContainers(); fixtureLog.Log("Selected nodes: " + string.Join(",", containers.Select(c => c.Name))); - return codexNodeFactory.Create(config, containers, fixtureLog, handle.Test.TimeSet); + return entryPoint.CreateInterface().WrapCodexContainers(containers).ToArray(); } private RunningContainer[] SelectRandomContainers() @@ -243,11 +243,5 @@ namespace ContinuousTests } return result; } - - private DistTestCore.Configuration CreateFileManagerConfiguration() - { - return new DistTestCore.Configuration(null, string.Empty, false, dataFolder, - CodexLogLevel.Error, string.Empty); - } } } diff --git a/Tests/CodexContinuousTests/StartupChecker.cs b/Tests/CodexContinuousTests/StartupChecker.cs index 2fee5eb..e90f93d 100644 --- a/Tests/CodexContinuousTests/StartupChecker.cs +++ b/Tests/CodexContinuousTests/StartupChecker.cs @@ -1,5 +1,6 @@ -using DistTestCore.Codex; -using DistTestCore; +using CodexPlugin; +using Core; +using DistTestCore.Logs; using Logging; namespace ContinuousTests @@ -7,12 +8,13 @@ namespace ContinuousTests public class StartupChecker { private readonly TestFactory testFactory = new TestFactory(); - private readonly CodexAccessFactory codexNodeFactory = new CodexAccessFactory(); + private readonly EntryPoint entryPoint; private readonly Configuration config; private readonly CancellationToken cancelToken; - public StartupChecker(Configuration config, CancellationToken cancelToken) + public StartupChecker(EntryPoint entryPoint, Configuration config, CancellationToken cancelToken) { + this.entryPoint = entryPoint; this.config = config; this.cancelToken = cancelToken; LogReplacements = new List(); @@ -61,13 +63,13 @@ namespace ContinuousTests private void CheckCodexNodes(BaseLog log, Configuration config) { - var nodes = codexNodeFactory.Create(config, config.CodexDeployment.CodexContainers, log, new DefaultTimeSet()); + var nodes = entryPoint.CreateInterface().WrapCodexContainers(config.CodexDeployment.CodexContainers); var pass = true; foreach (var n in nodes) { cancelToken.ThrowIfCancellationRequested(); - log.Log($"Checking {n.Container.Name} @ '{n.Address.Host}:{n.Address.Port}'..."); + log.Log($"Checking {n.Container.Name} @ '{n.Container.Address.Host}:{n.Container.Address.Port}'..."); if (EnsureOnline(log, n)) { @@ -75,7 +77,7 @@ namespace ContinuousTests } else { - log.Error($"No response from '{n.Address.Host}'."); + log.Error($"No response from '{n.Container.Address.Host}'."); pass = false; } } @@ -85,7 +87,7 @@ namespace ContinuousTests } } - private bool EnsureOnline(BaseLog log, CodexAccess n) + private bool EnsureOnline(BaseLog log, ICodexNode n) { try { @@ -107,30 +109,9 @@ namespace ContinuousTests var errors = new List(); CheckRequiredNumberOfNodes(tests, errors); CheckCustomNamespaceClashes(tests, errors); - CheckEthereumIndexClashes(tests, errors); return errors; } - private void CheckEthereumIndexClashes(ContinuousTest[] tests, List errors) - { - var offLimits = config.CodexDeployment.CodexContainers.Length; - foreach (var test in tests) - { - if (test.EthereumAccountIndex != -1) - { - if (test.EthereumAccountIndex <= offLimits) - { - errors.Add($"Test '{test.Name}' has selected 'EthereumAccountIndex' = {test.EthereumAccountIndex}. All accounts up to and including {offLimits} are being used by the targetted Codex net. Select a different 'EthereumAccountIndex'."); - } - } - } - - DuplicatesCheck(tests, errors, - considerCondition: t => t.EthereumAccountIndex != -1, - getValue: t => t.EthereumAccountIndex, - propertyName: nameof(ContinuousTest.EthereumAccountIndex)); - } - private void CheckCustomNamespaceClashes(ContinuousTest[] tests, List errors) { DuplicatesCheck(tests, errors, diff --git a/Tests/CodexContinuousTests/TestLoop.cs b/Tests/CodexContinuousTests/TestLoop.cs index 79ae999..2861481 100644 --- a/Tests/CodexContinuousTests/TestLoop.cs +++ b/Tests/CodexContinuousTests/TestLoop.cs @@ -1,9 +1,11 @@ -using Logging; +using Core; +using Logging; namespace ContinuousTests { public class TestLoop { + private readonly EntryPoint entryPoint; private readonly TaskFactory taskFactory; private readonly Configuration config; private readonly BaseLog overviewLog; @@ -13,8 +15,9 @@ namespace ContinuousTests private readonly CancellationToken cancelToken; private readonly EventWaitHandle runFinishedHandle = new EventWaitHandle(true, EventResetMode.ManualReset); - public TestLoop(TaskFactory taskFactory, Configuration config, BaseLog overviewLog, Type testType, TimeSpan runsEvery, StartupChecker startupChecker, CancellationToken cancelToken) + public TestLoop(Core.EntryPoint entryPoint, TaskFactory taskFactory, Configuration config, BaseLog overviewLog, Type testType, TimeSpan runsEvery, StartupChecker startupChecker, CancellationToken cancelToken) { + this.entryPoint = entryPoint; this.taskFactory = taskFactory; this.config = config; this.overviewLog = overviewLog; @@ -60,7 +63,7 @@ namespace ContinuousTests { var test = (ContinuousTest)Activator.CreateInstance(testType)!; var handle = new TestHandle(test); - var run = new SingleTestRun(taskFactory, config, overviewLog, handle, startupChecker, cancelToken); + var run = new SingleTestRun(entryPoint, taskFactory, config, overviewLog, handle, startupChecker, cancelToken); runFinishedHandle.Reset(); run.Run(runFinishedHandle); diff --git a/Tests/CodexContinuousTests/Tests/HoldMyBeerTest.cs b/Tests/CodexContinuousTests/Tests/HoldMyBeerTest.cs index b2dd5d3..76457e2 100644 --- a/Tests/CodexContinuousTests/Tests/HoldMyBeerTest.cs +++ b/Tests/CodexContinuousTests/Tests/HoldMyBeerTest.cs @@ -1,4 +1,4 @@ -using DistTestCore; +using CodexPlugin; using FileUtils; using NUnit.Framework; using Utils; @@ -12,19 +12,19 @@ namespace ContinuousTests.Tests public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure; private ContentId? cid; - private TestFile file = null!; + private TrackedFile file = null!; [TestMoment(t: Zero)] public void UploadTestFile() { var filesize = 80.MB(); - file = FileManager.GenerateTestFile(filesize); + file = FileManager.GenerateFile(filesize); - cid = UploadFile(Nodes[0], file); + cid = Nodes[0].UploadFile(file); Assert.That(cid, Is.Not.Null); - var dl = DownloadFile(Nodes[0], cid!); + var dl = Nodes[0].DownloadContent(cid); file.AssertIsEqual(dl); } } diff --git a/Tests/CodexContinuousTests/Tests/PeersTest.cs b/Tests/CodexContinuousTests/Tests/PeersTest.cs index 1cf6802..8d5d08b 100644 --- a/Tests/CodexContinuousTests/Tests/PeersTest.cs +++ b/Tests/CodexContinuousTests/Tests/PeersTest.cs @@ -1,4 +1,4 @@ -using DistTestCore.Codex; +using CodexPlugin; using DistTestCore.Helpers; using NUnit.Framework; @@ -37,7 +37,7 @@ namespace ContinuousTests.Tests } } - private string AreAllPresent(CodexAccess n, string[] allIds) + private string AreAllPresent(ICodexNode n, string[] allIds) { var info = n.GetDebugInfo(); var known = info.table.nodes.Select(n => n.nodeId).ToArray(); diff --git a/Tests/CodexContinuousTests/Tests/ThresholdChecks.cs b/Tests/CodexContinuousTests/Tests/ThresholdChecks.cs deleted file mode 100644 index 6ed6ea2..0000000 --- a/Tests/CodexContinuousTests/Tests/ThresholdChecks.cs +++ /dev/null @@ -1,60 +0,0 @@ -using DistTestCore; -using DistTestCore.Codex; -using NUnit.Framework; - -namespace ContinuousTests.Tests -{ - public class ThresholdChecks : ContinuousTest - { - public override int RequiredNumberOfNodes => 1; - public override TimeSpan RunTestEvery => TimeSpan.FromSeconds(30); - public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure; - - private static readonly List previousBreaches = new List(); - - [TestMoment(t: 0)] - public void CheckAllThresholds() - { - var allNodes = CreateAccessToAllNodes(); - foreach (var n in allNodes) CheckThresholds(n); - } - - private void CheckThresholds(CodexAccess n) - { - var breaches = n.GetDebugThresholdBreaches(); - if (breaches.breaches.Any()) - { - var newBreaches = new List(); - foreach (var b in breaches.breaches) - { - if (!previousBreaches.Contains(b)) - { - newBreaches.Add(b); - previousBreaches.Add(b); - } - } - - if (newBreaches.Any()) - { - Assert.Fail(string.Join(",", newBreaches.Select(b => FormatBreach(n, b)))); - - Program.Cancellation.Cts.Cancel(); - } - } - } - - private string FormatBreach(CodexAccess n, string breach) - { - return $"{n.Container.Name} = '{breach}'"; - } - - private CodexAccess[] CreateAccessToAllNodes() - { - // Normally, a continuous test accesses only a subset of the nodes in the deployment. - // This time, we want to check all of them. - var factory = new CodexAccessFactory(); - var allContainers = Configuration.CodexDeployment.CodexContainers; - return factory.Create(Configuration, allContainers, Log, new DefaultTimeSet()); - } - } -} diff --git a/Tests/CodexContinuousTests/Tests/TwoClientTest.cs b/Tests/CodexContinuousTests/Tests/TwoClientTest.cs index 5e2b841..53cff4b 100644 --- a/Tests/CodexContinuousTests/Tests/TwoClientTest.cs +++ b/Tests/CodexContinuousTests/Tests/TwoClientTest.cs @@ -1,4 +1,4 @@ -using DistTestCore; +using CodexPlugin; using FileUtils; using NUnit.Framework; using Utils; @@ -12,21 +12,21 @@ namespace ContinuousTests.Tests public override TestFailMode TestFailMode => TestFailMode.StopAfterFirstFailure; private ContentId? cid; - private TestFile file = null!; + private TrackedFile file = null!; [TestMoment(t: Zero)] public void UploadTestFile() { - file = FileManager.GenerateTestFile(80.MB()); + file = FileManager.GenerateFile(80.MB()); - cid = UploadFile(Nodes[0], file); + cid = Nodes[0].UploadFile(file); Assert.That(cid, Is.Not.Null); } [TestMoment(t: 10)] public void DownloadTestFile() { - var dl = DownloadFile(Nodes[1], cid!); + var dl = Nodes[1].DownloadContent(cid!); file.AssertIsEqual(dl); } diff --git a/cs-codex-dist-testing.sln b/cs-codex-dist-testing.sln index 12dea0f..7d3dda9 100644 --- a/cs-codex-dist-testing.sln +++ b/cs-codex-dist-testing.sln @@ -43,8 +43,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DistTestCore", "Tests\DistT EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDeployer", "Tools\CodexNetDeployer\CodexNetDeployer.csproj", "{3417D508-E2F4-4974-8988-BB124046D9E2}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodexNetDownloader", "Tools\CodexNetDownloader\CodexNetDownloader.csproj", "{8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,10 +113,6 @@ Global {3417D508-E2F4-4974-8988-BB124046D9E2}.Debug|Any CPU.Build.0 = Debug|Any CPU {3417D508-E2F4-4974-8988-BB124046D9E2}.Release|Any CPU.ActiveCfg = Release|Any CPU {3417D508-E2F4-4974-8988-BB124046D9E2}.Release|Any CPU.Build.0 = Release|Any CPU - {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -140,7 +134,6 @@ Global {562EC700-6984-4C9A-83BF-3BF4E3EB1A64} = {88C2A621-8A98-4D07-8625-7900FC8EF89E} {E849B7BA-FDCC-4CFF-998F-845ED2F1BF40} = {88C2A621-8A98-4D07-8625-7900FC8EF89E} {3417D508-E2F4-4974-8988-BB124046D9E2} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3} - {8BB4E60B-2381-436C-BDA9-72D2A31F8DFA} = {7591C5B3-D86E-4AE4-8ED2-B272D17FE7E3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {237BF0AA-9EC4-4659-AD9A-65DEB974250C} From 5b2557b3f40e642136bacd7ab3621631ff12a8fb Mon Sep 17 00:00:00 2001 From: benbierens Date: Wed, 20 Sep 2023 13:56:01 +0200 Subject: [PATCH 46/51] Set up loading of plugins. --- Framework/Core/PluginManager.cs | 16 --------- Framework/Core/ProjectPlugin.cs | 34 +++++++++++++++++++ .../CodexContinuousTests/EntryPointFactory.cs | 8 +++++ Tests/CodexTests/CodexDistTest.cs | 10 +++++- Tools/CodexNetDeployer/Deployer.cs | 8 +++-- 5 files changed, 56 insertions(+), 20 deletions(-) create mode 100644 Framework/Core/ProjectPlugin.cs diff --git a/Framework/Core/PluginManager.cs b/Framework/Core/PluginManager.cs index 69ee4bb..7de080f 100644 --- a/Framework/Core/PluginManager.cs +++ b/Framework/Core/PluginManager.cs @@ -59,20 +59,4 @@ } } } - - public interface IProjectPlugin - { - void Announce(); - void Decommission(); - } - - public interface IHasLogPrefix - { - string LogPrefix { get; } - } - - public interface IHasMetadata - { - void AddMetadata(IAddMetadata metadata); - } } diff --git a/Framework/Core/ProjectPlugin.cs b/Framework/Core/ProjectPlugin.cs new file mode 100644 index 0000000..72f3c16 --- /dev/null +++ b/Framework/Core/ProjectPlugin.cs @@ -0,0 +1,34 @@ +using Utils; + +namespace Core +{ + public interface IProjectPlugin + { + void Announce(); + void Decommission(); + } + + public interface IHasLogPrefix + { + string LogPrefix { get; } + } + + public interface IHasMetadata + { + void AddMetadata(IAddMetadata metadata); + } + + public static class ProjectPlugin + { + /// + /// On some platforms and in some cases, not all required plugin assemblies are automatically loaded into the app domain. + /// In this case, the runtime needs a slight push to load it before the EntryPoint class is instantiated. + /// Used ProjectPlugin.Load<>() before you create an EntryPoint to ensure all plugins you want to use are loaded. + /// + public static void Load() where T : IProjectPlugin + { + var type = typeof(T); + FrameworkAssert.That(type != null, $"Unable to load plugin."); + } + } +} diff --git a/Tests/CodexContinuousTests/EntryPointFactory.cs b/Tests/CodexContinuousTests/EntryPointFactory.cs index f61d22e..90b92b4 100644 --- a/Tests/CodexContinuousTests/EntryPointFactory.cs +++ b/Tests/CodexContinuousTests/EntryPointFactory.cs @@ -5,6 +5,14 @@ namespace ContinuousTests { public class EntryPointFactory { + public EntryPointFactory() + { + ProjectPlugin.Load(); + ProjectPlugin.Load(); + ProjectPlugin.Load(); + ProjectPlugin.Load(); + } + public EntryPoint CreateEntryPoint(string kubeConfigFile, string dataFilePath, string customNamespace, ILog log) { var kubeConfig = GetKubeConfig(kubeConfigFile); diff --git a/Tests/CodexTests/CodexDistTest.cs b/Tests/CodexTests/CodexDistTest.cs index 2af737d..32c6ec2 100644 --- a/Tests/CodexTests/CodexDistTest.cs +++ b/Tests/CodexTests/CodexDistTest.cs @@ -1,10 +1,10 @@ using CodexContractsPlugin; using CodexPlugin; +using Core; using DistTestCore; using DistTestCore.Helpers; using GethPlugin; using NUnit.Framework.Constraints; -using Utils; namespace Tests { @@ -12,6 +12,14 @@ namespace Tests { private readonly List onlineCodexNodes = new List(); + public CodexDistTest() + { + ProjectPlugin.Load(); + ProjectPlugin.Load(); + ProjectPlugin.Load(); + ProjectPlugin.Load(); + } + public ICodexNode AddCodex() { return AddCodex(s => { }); diff --git a/Tools/CodexNetDeployer/Deployer.cs b/Tools/CodexNetDeployer/Deployer.cs index ee9ff9a..f06e91c 100644 --- a/Tools/CodexNetDeployer/Deployer.cs +++ b/Tools/CodexNetDeployer/Deployer.cs @@ -18,7 +18,11 @@ namespace CodexNetDeployer { this.config = config; peerConnectivityChecker = new PeerConnectivityChecker(); - + + ProjectPlugin.Load(); + ProjectPlugin.Load(); + ProjectPlugin.Load(); + ProjectPlugin.Load(); entryPoint = CreateEntryPoint(new NullLog()); } @@ -27,8 +31,6 @@ namespace CodexNetDeployer var ep = CreateEntryPoint(new ConsoleLog()); Log("Using plugins:" + Environment.NewLine); - ep.Announce(); - Log(""); var metadata = ep.GetPluginMetadata(); foreach (var entry in metadata) { From b5e0c9bfe045d3f55f5b57d95d2bf5035013b0a3 Mon Sep 17 00:00:00 2001 From: benbierens Date: Thu, 21 Sep 2023 08:49:09 +0200 Subject: [PATCH 47/51] Console output alignment is important. --- Tools/CodexNetDeployer/CodexNodeStarter.cs | 3 ++- Tools/CodexNetDeployer/Deployer.cs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Tools/CodexNetDeployer/CodexNodeStarter.cs b/Tools/CodexNetDeployer/CodexNodeStarter.cs index b89acf2..f1cfed4 100644 --- a/Tools/CodexNetDeployer/CodexNodeStarter.cs +++ b/Tools/CodexNetDeployer/CodexNodeStarter.cs @@ -27,7 +27,8 @@ namespace CodexNetDeployer public CodexNodeStartResult? Start(int i) { var name = GetCodexContainerName(i); - Console.Write($" - {i} ({name}) \t= "); + Console.Write($" - {i} ({name})"); + Console.CursorLeft = 30; ICodexNode? codexNode = null; try diff --git a/Tools/CodexNetDeployer/Deployer.cs b/Tools/CodexNetDeployer/Deployer.cs index f06e91c..ec370b2 100644 --- a/Tools/CodexNetDeployer/Deployer.cs +++ b/Tools/CodexNetDeployer/Deployer.cs @@ -32,9 +32,12 @@ namespace CodexNetDeployer Log("Using plugins:" + Environment.NewLine); var metadata = ep.GetPluginMetadata(); + var longestKey = metadata.Keys.Max(k => k.Length); foreach (var entry in metadata) { - Log($"{entry.Key} = {entry.Value}"); + Console.Write(entry.Key); + Console.CursorLeft = longestKey + 5; + Console.WriteLine($"= {entry.Value}"); } Log(""); } From dbf0ed714cdcc121ea0b94ba2e5d282b225b3cb5 Mon Sep 17 00:00:00 2001 From: benbierens Date: Thu, 21 Sep 2023 10:33:09 +0200 Subject: [PATCH 48/51] Restores continuous test runner. --- Framework/Core/EntryPoint.cs | 5 ++- Framework/Core/PluginManager.cs | 34 +++++++++++---- Framework/Core/PluginTools.cs | 7 ++++ Framework/FileUtils/FileManager.cs | 20 +++++---- Framework/Logging/ConsoleLog.cs | 19 +++++++++ Framework/Logging/LogSplitter.cs | 42 +++++++++++++++++++ Tests/CodexContinuousTests/ContinuousTest.cs | 1 - .../ContinuousTestRunner.cs | 18 +++++--- Tests/CodexContinuousTests/Program.cs | 21 +++++----- Tests/CodexContinuousTests/SingleTestRun.cs | 17 ++++---- Tests/CodexContinuousTests/TestLoop.cs | 13 +++--- Tests/DistTestCore/DistTest.cs | 6 ++- Tests/DistTestCore/TestLifecycle.cs | 7 ++-- .../PeerConnectivityChecker.cs | 17 -------- 14 files changed, 153 insertions(+), 74 deletions(-) create mode 100644 Framework/Logging/ConsoleLog.cs create mode 100644 Framework/Logging/LogSplitter.cs diff --git a/Framework/Core/EntryPoint.cs b/Framework/Core/EntryPoint.cs index 5ce9af1..7977eb3 100644 --- a/Framework/Core/EntryPoint.cs +++ b/Framework/Core/EntryPoint.cs @@ -38,9 +38,10 @@ namespace Core return new CoreInterface(this); } - public void Decommission() + public void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles) { - manager.DecommissionPlugins(); + manager.DecommissionPlugins(deleteKubernetesResources, deleteTrackedFiles); + Tools.Decommission(deleteKubernetesResources, deleteTrackedFiles); } internal T GetPlugin() where T : IProjectPlugin diff --git a/Framework/Core/PluginManager.cs b/Framework/Core/PluginManager.cs index 7de080f..e2b2a5c 100644 --- a/Framework/Core/PluginManager.cs +++ b/Framework/Core/PluginManager.cs @@ -2,11 +2,11 @@ { internal class PluginManager { - private readonly List projectPlugins = new List(); + private readonly List pairs = new List(); internal void InstantiatePlugins(Type[] pluginTypes, IToolsFactory provider) { - projectPlugins.Clear(); + pairs.Clear(); foreach (var pluginType in pluginTypes) { var tools = provider.CreateTools(); @@ -18,15 +18,15 @@ internal void AnnouncePlugins() { - foreach (var plugin in projectPlugins) plugin.Announce(); + foreach (var pair in pairs) pair.Plugin.Announce(); } internal PluginMetadata GatherPluginMetadata() { var metadata = new PluginMetadata(); - foreach (var plugin in projectPlugins) + foreach (var pair in pairs) { - if (plugin is IHasMetadata m) + if (pair.Plugin is IHasMetadata m) { m.AddMetadata(metadata); } @@ -34,20 +34,24 @@ return metadata; } - internal void DecommissionPlugins() + internal void DecommissionPlugins(bool deleteKubernetesResources, bool deleteTrackedFiles) { - foreach (var plugin in projectPlugins) plugin.Decommission(); + foreach (var pair in pairs) + { + pair.Plugin.Decommission(); + pair.Tools.Decommission(deleteKubernetesResources, deleteTrackedFiles); + } } internal T GetPlugin() where T : IProjectPlugin { - return (T)projectPlugins.Single(p => p.GetType() == typeof(T)); + return (T)pairs.Single(p => p.Plugin.GetType() == typeof(T)).Plugin; } private IProjectPlugin InstantiatePlugins(Type pluginType, PluginTools tools) { var plugin = (IProjectPlugin)Activator.CreateInstance(pluginType, args: tools)!; - projectPlugins.Add(plugin); + pairs.Add(new PluginToolsPair(plugin, tools)); return plugin; } @@ -58,5 +62,17 @@ tools.ApplyLogPrefix(hasLogPrefix.LogPrefix); } } + + private class PluginToolsPair + { + public PluginToolsPair(IProjectPlugin plugin, IPluginTools tools) + { + Plugin = plugin; + Tools = tools; + } + + public IProjectPlugin Plugin { get; } + public IPluginTools Tools { get; } + } } } diff --git a/Framework/Core/PluginTools.cs b/Framework/Core/PluginTools.cs index dd0360c..5fd0ab8 100644 --- a/Framework/Core/PluginTools.cs +++ b/Framework/Core/PluginTools.cs @@ -7,6 +7,7 @@ namespace Core { public interface IPluginTools : IWorkflowTool, ILogTool, IHttpFactoryTool, IFileTool { + void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles); } public interface IWorkflowTool @@ -65,6 +66,12 @@ namespace Core return workflowCreator.CreateWorkflow(namespaceOverride); } + public void Decommission(bool deleteKubernetesResources, bool deleteTrackedFiles) + { + if (deleteKubernetesResources) CreateWorkflow().DeleteNamespace(); + if (deleteTrackedFiles) fileManager.DeleteAllFiles(); + } + public IFileManager GetFileManager() { return fileManager; diff --git a/Framework/FileUtils/FileManager.cs b/Framework/FileUtils/FileManager.cs index 7723ae6..9b25c56 100644 --- a/Framework/FileUtils/FileManager.cs +++ b/Framework/FileUtils/FileManager.cs @@ -18,6 +18,7 @@ namespace FileUtils private static NumberSource folderNumberSource = new NumberSource(0); private readonly Random random = new Random(); private readonly ILog log; + private readonly string rootFolder; private readonly string folder; private readonly List> fileSetStack = new List>(); @@ -25,13 +26,15 @@ namespace FileUtils { folder = Path.Combine(rootFolder, folderNumberSource.GetNextNumber().ToString("D5")); - EnsureDirectory(); this.log = log; + this.rootFolder = rootFolder; } public TrackedFile CreateEmptyFile(string label = "") { - var path = Path.Combine(folder, Guid.NewGuid().ToString() + "_test.bin"); + var path = Path.Combine(folder, Guid.NewGuid().ToString() + ".bin"); + EnsureDirectory(); + var result = new TrackedFile(log, path, label); File.Create(result.Filename).Close(); if (fileSetStack.Any()) fileSetStack.Last().Add(result); @@ -79,12 +82,11 @@ namespace FileUtils foreach (var file in pop) { - try - { - File.Delete(file.Filename); - } - catch { } + File.Delete(file.Filename); } + + // If the folder is now empty, delete it too. + if (!Directory.GetFiles(folder).Any()) DeleteDirectory(); } private TrackedFile GenerateRandomFile(ByteSize size, string label) @@ -144,12 +146,12 @@ namespace FileUtils private void EnsureDirectory() { - if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); + Directory.CreateDirectory(folder); } private void DeleteDirectory() { - Directory.Delete(folder, true); + if (Directory.Exists(folder)) Directory.Delete(folder, true); } } } diff --git a/Framework/Logging/ConsoleLog.cs b/Framework/Logging/ConsoleLog.cs new file mode 100644 index 0000000..61e6115 --- /dev/null +++ b/Framework/Logging/ConsoleLog.cs @@ -0,0 +1,19 @@ +namespace Logging +{ + public class ConsoleLog : BaseLog + { + public ConsoleLog() : base(false) + { + } + + protected override string GetFullName() + { + return "CONSOLE"; + } + + public override void Log(string message) + { + Console.WriteLine(message); + } + } +} diff --git a/Framework/Logging/LogSplitter.cs b/Framework/Logging/LogSplitter.cs new file mode 100644 index 0000000..6d24d5b --- /dev/null +++ b/Framework/Logging/LogSplitter.cs @@ -0,0 +1,42 @@ +namespace Logging +{ + public class LogSplitter : ILog + { + private readonly ILog[] targetLogs; + + public LogSplitter(params ILog[] targetLogs) + { + this.targetLogs = targetLogs; + } + + public void AddStringReplace(string from, string to) + { + OnAll(l => l.AddStringReplace(from, to)); + } + + public LogFile CreateSubfile(string ext = "log") + { + return targetLogs.First().CreateSubfile(ext); + } + + public void Debug(string message = "", int skipFrames = 0) + { + OnAll(l => l.Debug(message, skipFrames + 2)); + } + + public void Error(string message) + { + OnAll(l => l.Error(message)); + } + + public void Log(string message) + { + OnAll(l => l.Log(message)); + } + + private void OnAll(Action action) + { + foreach (var t in targetLogs) action(t); + } + } +} diff --git a/Tests/CodexContinuousTests/ContinuousTest.cs b/Tests/CodexContinuousTests/ContinuousTest.cs index e4122fc..2bef6db 100644 --- a/Tests/CodexContinuousTests/ContinuousTest.cs +++ b/Tests/CodexContinuousTests/ContinuousTest.cs @@ -59,7 +59,6 @@ namespace ContinuousTests return GetType().Name; } } - } public enum TestFailMode diff --git a/Tests/CodexContinuousTests/ContinuousTestRunner.cs b/Tests/CodexContinuousTests/ContinuousTestRunner.cs index 470dbbb..a0589b2 100644 --- a/Tests/CodexContinuousTests/ContinuousTestRunner.cs +++ b/Tests/CodexContinuousTests/ContinuousTestRunner.cs @@ -19,21 +19,29 @@ namespace ContinuousTests public void Run() { - var overviewLog = new FixtureLog(new LogConfig(config.LogPath, false), DateTime.UtcNow, "Overview"); + var overviewLog = new LogSplitter( + new FixtureLog(new LogConfig(config.LogPath, false), DateTime.UtcNow, "Overview"), + new ConsoleLog() + ); + + overviewLog.Log("Initializing..."); var entryPoint = entryPointFactory.CreateEntryPoint(config.KubeConfigFile, config.DataPath, config.CodexDeployment.Metadata.KubeNamespace, overviewLog); entryPoint.Announce(); + overviewLog.Log("Initialized. Performing startup checks..."); + var startupChecker = new StartupChecker(entryPoint, config, cancelToken); startupChecker.Check(); var taskFactory = new TaskFactory(); - overviewLog.Log("Continuous tests starting..."); + overviewLog.Log("Startup checks passed. Continuous tests starting..."); + overviewLog.Log(""); var allTests = testFactory.CreateTests(); ClearAllCustomNamespaces(allTests, overviewLog); - var testLoops = allTests.Select(t => new TestLoop(entryPoint, taskFactory, config, overviewLog, t.GetType(), t.RunTestEvery, startupChecker, cancelToken)).ToArray(); + var testLoops = allTests.Select(t => new TestLoop(entryPointFactory, taskFactory, config, overviewLog, t.GetType(), t.RunTestEvery, startupChecker, cancelToken)).ToArray(); foreach (var testLoop in testLoops) { @@ -51,12 +59,12 @@ namespace ContinuousTests overviewLog.Log("All tasks cancelled."); } - private void ClearAllCustomNamespaces(ContinuousTest[] allTests, FixtureLog log) + private void ClearAllCustomNamespaces(ContinuousTest[] allTests, ILog log) { foreach (var test in allTests) ClearAllCustomNamespaces(test, log); } - private void ClearAllCustomNamespaces(ContinuousTest test, FixtureLog log) + private void ClearAllCustomNamespaces(ContinuousTest test, ILog log) { if (string.IsNullOrEmpty(test.CustomK8sNamespace)) return; diff --git a/Tests/CodexContinuousTests/Program.cs b/Tests/CodexContinuousTests/Program.cs index 273f13a..3db3b15 100644 --- a/Tests/CodexContinuousTests/Program.cs +++ b/Tests/CodexContinuousTests/Program.cs @@ -5,7 +5,6 @@ public class Program public static void Main(string[] args) { Console.WriteLine("Codex Continous-Test-Runner."); - Console.WriteLine("Running..."); var runner = new ContinuousTestRunner(args, Cancellation.Cts.Token); @@ -20,14 +19,14 @@ public class Program runner.Run(); Console.WriteLine("Done."); } - - public static class Cancellation - { - static Cancellation() - { - Cts = new CancellationTokenSource(); - } - - public static CancellationTokenSource Cts { get; } - } +} + +public static class Cancellation +{ + static Cancellation() + { + Cts = new CancellationTokenSource(); + } + + public static CancellationTokenSource Cts { get; } } diff --git a/Tests/CodexContinuousTests/SingleTestRun.cs b/Tests/CodexContinuousTests/SingleTestRun.cs index 319318d..7a233db 100644 --- a/Tests/CodexContinuousTests/SingleTestRun.cs +++ b/Tests/CodexContinuousTests/SingleTestRun.cs @@ -3,8 +3,6 @@ using Utils; using KubernetesWorkflow; using NUnit.Framework.Internal; using System.Reflection; -using static Program; -using FileUtils; using CodexPlugin; using DistTestCore.Logs; using Core; @@ -17,18 +15,16 @@ namespace ContinuousTests private readonly EntryPoint entryPoint; private readonly TaskFactory taskFactory; private readonly Configuration config; - private readonly BaseLog overviewLog; + private readonly ILog overviewLog; private readonly TestHandle handle; private readonly CancellationToken cancelToken; private readonly ICodexNode[] nodes; private readonly FixtureLog fixtureLog; private readonly string testName; - private readonly string dataFolder; private static int failureCount = 0; - public SingleTestRun(EntryPoint entryPoint, TaskFactory taskFactory, Configuration config, BaseLog overviewLog, TestHandle handle, StartupChecker startupChecker, CancellationToken cancelToken) + public SingleTestRun(EntryPointFactory entryPointFactory, TaskFactory taskFactory, Configuration config, ILog overviewLog, TestHandle handle, StartupChecker startupChecker, CancellationToken cancelToken) { - this.entryPoint = entryPoint; this.taskFactory = taskFactory; this.config = config; this.overviewLog = overviewLog; @@ -36,10 +32,10 @@ namespace ContinuousTests this.cancelToken = cancelToken; testName = handle.Test.GetType().Name; fixtureLog = new FixtureLog(new LogConfig(config.LogPath, true), DateTime.UtcNow, testName); + entryPoint = entryPointFactory.CreateEntryPoint(config.KubeConfigFile, config.DataPath, config.CodexDeployment.Metadata.KubeNamespace, fixtureLog); ApplyLogReplacements(fixtureLog, startupChecker); nodes = CreateRandomNodes(); - dataFolder = config.DataPath + "-" + Guid.NewGuid(); } public void Run(EventWaitHandle runFinishedHandle) @@ -49,8 +45,11 @@ namespace ContinuousTests try { RunTest(); - entryPoint.Tools.GetFileManager().DeleteAllFiles(); - Directory.Delete(dataFolder, true); + + entryPoint.Decommission( + deleteKubernetesResources: false, // This would delete the continuous test net. + deleteTrackedFiles: true + ); runFinishedHandle.Set(); } catch (Exception ex) diff --git a/Tests/CodexContinuousTests/TestLoop.cs b/Tests/CodexContinuousTests/TestLoop.cs index 2861481..48f66ca 100644 --- a/Tests/CodexContinuousTests/TestLoop.cs +++ b/Tests/CodexContinuousTests/TestLoop.cs @@ -1,23 +1,22 @@ -using Core; -using Logging; +using Logging; namespace ContinuousTests { public class TestLoop { - private readonly EntryPoint entryPoint; + private readonly EntryPointFactory entryPointFactory; private readonly TaskFactory taskFactory; private readonly Configuration config; - private readonly BaseLog overviewLog; + private readonly ILog overviewLog; private readonly Type testType; private readonly TimeSpan runsEvery; private readonly StartupChecker startupChecker; private readonly CancellationToken cancelToken; private readonly EventWaitHandle runFinishedHandle = new EventWaitHandle(true, EventResetMode.ManualReset); - public TestLoop(Core.EntryPoint entryPoint, TaskFactory taskFactory, Configuration config, BaseLog overviewLog, Type testType, TimeSpan runsEvery, StartupChecker startupChecker, CancellationToken cancelToken) + public TestLoop(EntryPointFactory entryPointFactory, TaskFactory taskFactory, Configuration config, ILog overviewLog, Type testType, TimeSpan runsEvery, StartupChecker startupChecker, CancellationToken cancelToken) { - this.entryPoint = entryPoint; + this.entryPointFactory = entryPointFactory; this.taskFactory = taskFactory; this.config = config; this.overviewLog = overviewLog; @@ -63,7 +62,7 @@ namespace ContinuousTests { var test = (ContinuousTest)Activator.CreateInstance(testType)!; var handle = new TestHandle(test); - var run = new SingleTestRun(entryPoint, taskFactory, config, overviewLog, handle, startupChecker, cancelToken); + var run = new SingleTestRun(entryPointFactory, taskFactory, config, overviewLog, handle, startupChecker, cancelToken); runFinishedHandle.Reset(); run.Run(runFinishedHandle); diff --git a/Tests/DistTestCore/DistTest.cs b/Tests/DistTestCore/DistTest.cs index a1a4ef5..8e9932a 100644 --- a/Tests/DistTestCore/DistTest.cs +++ b/Tests/DistTestCore/DistTest.cs @@ -62,7 +62,11 @@ namespace DistTestCore [OneTimeTearDown] public void GlobalTearDown() { - globalEntryPoint.Decommission(); + globalEntryPoint.Decommission( + // There shouldn't be any of either, but clean everything up regardless. + deleteKubernetesResources: true, + deleteTrackedFiles: true + ); } [SetUp] diff --git a/Tests/DistTestCore/TestLifecycle.cs b/Tests/DistTestCore/TestLifecycle.cs index 1f5bf17..c0904c1 100644 --- a/Tests/DistTestCore/TestLifecycle.cs +++ b/Tests/DistTestCore/TestLifecycle.cs @@ -33,9 +33,10 @@ namespace DistTestCore public void DeleteAllResources() { - entryPoint.Tools.CreateWorkflow().DeleteNamespace(); - entryPoint.Tools.GetFileManager().DeleteAllFiles(); - entryPoint.Decommission(); + entryPoint.Decommission( + deleteKubernetesResources: true, + deleteTrackedFiles: true + ); } public TrackedFile GenerateTestFile(ByteSize size, string label = "") diff --git a/Tools/CodexNetDeployer/PeerConnectivityChecker.cs b/Tools/CodexNetDeployer/PeerConnectivityChecker.cs index 8e20a4a..87bdc31 100644 --- a/Tools/CodexNetDeployer/PeerConnectivityChecker.cs +++ b/Tools/CodexNetDeployer/PeerConnectivityChecker.cs @@ -14,21 +14,4 @@ namespace CodexNetDeployer checker.AssertFullyConnected(nodes); } } - - public class ConsoleLog : BaseLog - { - public ConsoleLog() : base(false) - { - } - - protected override string GetFullName() - { - return "CONSOLE"; - } - - public override void Log(string message) - { - Console.WriteLine(message); - } - } } From e4716b5471e8fb672dad0a4b12f59c68e88dd45f Mon Sep 17 00:00:00 2001 From: benbierens Date: Thu, 21 Sep 2023 10:56:48 +0200 Subject: [PATCH 49/51] Adds IHasContainer to geth objects. --- .../CodexContractsContainerRecipe.cs | 2 +- ProjectPlugins/CodexPlugin/CodexContainerRecipe.cs | 2 +- ProjectPlugins/GethPlugin/GethDeployment.cs | 9 +++++---- ProjectPlugins/GethPlugin/GethNode.cs | 9 ++++++--- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/ProjectPlugins/CodexContractsPlugin/CodexContractsContainerRecipe.cs b/ProjectPlugins/CodexContractsPlugin/CodexContractsContainerRecipe.cs index d9ec1f8..6b6aabe 100644 --- a/ProjectPlugins/CodexContractsPlugin/CodexContractsContainerRecipe.cs +++ b/ProjectPlugins/CodexContractsPlugin/CodexContractsContainerRecipe.cs @@ -16,7 +16,7 @@ namespace CodexContractsPlugin { var config = startupConfig.Get(); - var ip = config.GethNode.StartResult.RunningContainer.Pod.PodInfo.Ip; + var ip = config.GethNode.StartResult.Container.Pod.PodInfo.Ip; var port = config.GethNode.StartResult.HttpPort.Number; AddEnvVar("DISTTEST_NETWORK_URL", $"http://{ip}:{port}"); diff --git a/ProjectPlugins/CodexPlugin/CodexContainerRecipe.cs b/ProjectPlugins/CodexPlugin/CodexContainerRecipe.cs index 986a1d0..15e9ee1 100644 --- a/ProjectPlugins/CodexPlugin/CodexContainerRecipe.cs +++ b/ProjectPlugins/CodexPlugin/CodexContainerRecipe.cs @@ -81,7 +81,7 @@ namespace CodexPlugin { var mconfig = config.MarketplaceConfig; var gethStart = mconfig.GethNode.StartResult; - var ip = gethStart.RunningContainer.Pod.PodInfo.Ip; + var ip = gethStart.Container.Pod.PodInfo.Ip; var port = gethStart.WsPort.Number; var marketplaceAddress = mconfig.CodexContracts.Deployment.MarketplaceAddress; diff --git a/ProjectPlugins/GethPlugin/GethDeployment.cs b/ProjectPlugins/GethPlugin/GethDeployment.cs index 8b98259..52eea48 100644 --- a/ProjectPlugins/GethPlugin/GethDeployment.cs +++ b/ProjectPlugins/GethPlugin/GethDeployment.cs @@ -1,12 +1,13 @@ -using KubernetesWorkflow; +using Core; +using KubernetesWorkflow; namespace GethPlugin { - public class GethDeployment + public class GethDeployment : IHasContainer { public GethDeployment(RunningContainer runningContainer, Port discoveryPort, Port httpPort, Port wsPort, AllGethAccounts allAccounts, string pubKey) { - RunningContainer = runningContainer; + Container = runningContainer; DiscoveryPort = discoveryPort; HttpPort = httpPort; WsPort = wsPort; @@ -14,7 +15,7 @@ namespace GethPlugin PubKey = pubKey; } - public RunningContainer RunningContainer { get; } + public RunningContainer Container { get; } public Port DiscoveryPort { get; } public Port HttpPort { get; } public Port WsPort { get; } diff --git a/ProjectPlugins/GethPlugin/GethNode.cs b/ProjectPlugins/GethPlugin/GethNode.cs index b4cdb57..978a852 100644 --- a/ProjectPlugins/GethPlugin/GethNode.cs +++ b/ProjectPlugins/GethPlugin/GethNode.cs @@ -1,10 +1,12 @@ -using Logging; +using Core; +using KubernetesWorkflow; +using Logging; using Nethereum.Contracts; using NethereumWorkflow; namespace GethPlugin { - public interface IGethNode + public interface IGethNode : IHasContainer { GethDeployment StartResult { get; } @@ -32,6 +34,7 @@ namespace GethPlugin public GethDeployment StartResult { get; } public GethAccount Account { get; } + public RunningContainer Container => StartResult.Container; public Ether GetEthBalance() { @@ -70,7 +73,7 @@ namespace GethPlugin private NethereumInteraction StartInteraction() { - var address = StartResult.RunningContainer.Address; + var address = StartResult.Container.Address; var account = Account; var creator = new NethereumInteractionCreator(log, address.Host, address.Port, account.PrivateKey); From 418daf1e3fc00774bd544a1278d23cdca80a8915 Mon Sep 17 00:00:00 2001 From: benbierens Date: Thu, 21 Sep 2023 14:39:41 +0200 Subject: [PATCH 50/51] Updates the docs --- CONTRIBUTINGPLUGINS.MD | 86 ++++++++++++++++++ CONTRIBUTINGTESTS.MD | 54 +++++++---- ProjectPlugins/GethPlugin/GethDeployment.cs | 4 +- README.md | 29 +++--- Tests/CodexTests/BasicTests/ExampleTests.cs | 14 ++- ...cture.png => CodexTestNetArchitecture.png} | Bin docs/FrameworkArchitecture.png | Bin 0 -> 462237 bytes docs/LOCALSETUP.md | 12 +-- 8 files changed, 146 insertions(+), 53 deletions(-) create mode 100644 CONTRIBUTINGPLUGINS.MD rename docs/{Architecture.png => CodexTestNetArchitecture.png} (100%) create mode 100644 docs/FrameworkArchitecture.png diff --git a/CONTRIBUTINGPLUGINS.MD b/CONTRIBUTINGPLUGINS.MD new file mode 100644 index 0000000..71619fb --- /dev/null +++ b/CONTRIBUTINGPLUGINS.MD @@ -0,0 +1,86 @@ +# Distributed System Tests for Nim-Codex + +## Contributing plugins +The testing framework was created for testing Codex. However, it's been designed such that other distributed/containerized projects can 'easily' be added. In order to add your project to the framework you must: +1. Create a library assembly in the project plugins folder. +1. It must contain a type that implements the `IProjectPlugin` interface from the `Core` assembly. +1. If your plugin wants to expose any specific methods or objects to the code using the framework (the tests and tools), it must implement extensions for the `CoreInterface` type. + +## Constructors & Tools +Your implementation of `IProjectPlugin` must have a public constructor with a single argument of type `IPluginTools`, for example: +```C# + public class MyPlugin : IProjectPlugin + { + public MyPlugin(IPluginTools tools) + { + ... + } + + ... + } +``` + +`IPluginTools` provides your plugin access to all framework functionality, such as logging, tracked file management, container lifecycle management, and a means to create HTTP clients for containers. (Without having to figure out addresses manually.) + +## Plugin Interfaces +The `IProjectPlugin` interface requires the implementation of two methods. +1. `Announce` - It is considered polite to use the logging functionality provided by the `IPluginTools` to announce that your plugin has been loaded. You may also want to log some manner of version information at this time if applicable. +1. `Decommission` - Should your plugin have any active system resources, free them in this method. + +There are a few optional interfaces your plugin may choose to implement. The framework will automatically use these interfaces. +1. `IHasLogPrefix` - Implementing this interface allows you to provide a string with will be prepended to all log statements made by your plugin. +1. `IHasMetadata` - This allows you to provide metadata in the form of key/value pairs. This metadata can be accessed by code that uses your plugin. + +## Core Interface +Any functionality your plugin wants to expose to code which uses the framework will have to be added on to the `CoreInterface` type. You can accomplish this by using C# extension methods. The framework provides a `GetPlugin` method to access your plugin instance from the `CoreInterface` type: +```C# + public static class CoreInterfaceExtensions + { + public static MyPluginReturnType DoSomethingCool(this CoreInterface ci, string someArgument) + { + return Plugin(ci).SomethingCool(someArgument); + } + + private static MyPlugin Plugin(CoreInterface ci) + { + return ci.GetPlugin(); + } + } +``` + +While technically you can build whatever you like on top of the `CoreInterface` and your own plugin types, I recommend that you follow the approach explained below. + +## Deploying, Wrapping, and Starting +When building a plugin, it is important to make as few assumptions as possible about how it will be used by whoever is going to use the framework. For this reason, I recommend you expose three kinds of methods using your `CoreInterface` extensions: +1. Deploy - This kind of method should deploy your project, creating and configuring containers as needed and returning containers as a result. If your project requires additional information, you can create a new class type to contain both it and the containers created. +1. Wrap - This kind of method should, when given the previously mentioned container information, create some kind of convenient accessor or interactor object. This object should abstract away for example details of a REST API of your project, allowing users of your plugin to write their code using a set of methods and types that nicely model your project's domain. +1. Start - This kind of method does both, simply calling a Deploy method first, then a Wrap method, and returns the result. + +Here's an example: +```C# +public static class CoreInterfaceExtensions + { + public static RunningContainers DeployMyProject(this CoreInterface ci, string someArgument) + { + // `RunningContainers` is a framework type. It contains all necessary information about a deployed container. It is serializable. + // Should you need to return any additional information, create a new type that contains it as well as the container information. Make sure it is serializable. + return Plugin(ci).DeployMyProjectContainer(someArgument); // <-- This method should use the `PluginTools.CreateWorkflow()` tool to deploy a container with a configuration that matches someArguments. + } + + public static IMyProjectNode WrapMyProjectContainer(this CoreInterface ci, RunningContainers container) + { + return Plugin(ci).WrapMyContainerProject(container); // <-- This method probably will use the 'PluginTools.CreateHttp()` tool to create an HTTP client for the container, then wrap it in an object that + // represents the API of your project. + } + + public static IMyProjectNode StartMyProject(this CoreInterface ci, string someArgument) + { + // Start is now nothing more than a convenience method, combining the previous two. + var rc = ci.DeployMyProject(someArgument); + return WrapMyProjectContainer(ci, rc); + } + } +``` + +The primary reason to decouple deploying and wrapping functionalities is that some use cases require these steps to be performed by separate applications, and different moments in time. For this reason, whatever is returned by the deploy methods should be serializable. After deserialization at some later time, it should then be valid input for the wrap method. The Codex continuous tests system is a clear example of this use case: The `CodexNetDeployer` tool uses deploy methods to create Codex nodes. Then it writes the returned objects to a JSON file. Some time later, the `CodexContinousTests` application uses this JSON file to reconstruct the objects created by the deploy methods. It then uses the wrap methods to create accessors and interactors, which are used for testing. + diff --git a/CONTRIBUTINGTESTS.MD b/CONTRIBUTINGTESTS.MD index e2e3ac4..070e192 100644 --- a/CONTRIBUTINGTESTS.MD +++ b/CONTRIBUTINGTESTS.MD @@ -1,24 +1,40 @@ # Distributed System Tests for Nim-Codex ## Contributing tests -Do you want to write some tests for Codex using this distributed test setup? Great! Here's what you do. +Do you want to write some tests using this distributed test setup? Great! Here's what you do. 1. Create a branch. Name it something descriptive, but start it with `tests/` please. [Example: `tests/data-redundancy`.] -1. Checkout your branch, and decide if your tests will be 'short' tests (minutes to hours), or 'long' tests (hours to days), or both! Create a folder for your tests in the matching folders (`/Tests`, `/LongTests`) and don't worry: You can always move your tests later if you like. [Example, short: `/Tests/DataRedundancy/`, long: `/LongTests/DataRedundancy/`] -1. Create one or more code files in your new folder, and write some tests! Here are some tips to help you get started. You can always take a look at the example tests found in [`/Tests/BasicTests/ExampleTests.cs`](/Tests/BasicTests/ExampleTests.cs) - 1. Set up a standard NUnit test fixture. - 1. Inherrit from `DistTest` or `AutoBootstrapDistTest`. - 1. When using `DistTest`: - 1. You must start your own Codex bootstrap node. You can use `SetupCodexBootstrapNode(...)` for this. - 1. When you start other Codex nodes with `SetupCodexNodes(...)` you can pass the bootstrap node by adding the `.WithBootstrapNode(...)` option. - 1. When using `AutoBootstrapDistTest`: - 1. The test-infra creates the bootstrap node for you, and automatically passes it to each Codex node you create in your tests. Handy for keeping your tests clean and to-the-point. - 1. When using the auto-bootstrap, you have no control over the bootstrap node from your tests. You can't (for example) shut it down during the course of the test. If you need this level of control for your scenario, use the `DistTest` instead. - 1. You can generate files of random test data by calling `GenerateTestFile(...)`. - 1. If your test needs a long time to run, add the `[UseLongTimeouts]` function attribute. This will greatly increase maximum time-out values for operations like for example uploading and downloading files. - 1. You can enable access to the Codex node metrics by adding the option `.EnableMetrics()`. Enabling metrics will make the test-infra download and save all Codex metrics in case of a test failure. (The metrics are stored as CSV, in the same location as the test log file.) - 1. You can enable access to the blockchain marketplace by adding the option `.EnableMarketplace(...)`. - 1. Enabling metrics and/or enabling the marketplace takes extra resources from the test-infra and increases the time needed during Codex node setup. Please don't enable these features unless your tests need them. - 1. Tip: Codex nodes can be named. Use the option `WithName(...)` and make reading your test logs a little nicer! - 1. Tip: Commit often. -1. Once you're happy with your tests, please create a pull-request and ask (another) Codex core contributor to review your changes. +1. Checkout your branch. +1. Create a new assembly in the `/Tests` folder. This can be an NUnit test assembly or simply a console app. +1. Add Project references to `Core`, as well as any project plugin you'll be using. +1. Write tests! Use existing tests for inspiration. + +## Tips for writing tests for Codex +### Transient tests +1. Add new code files to `Tests/CodexTests` +1. Inherrit from `CodexDistTest` or `AutoBootstrapDistTest`. +1. When using `CodexDistTest`: + 1. You must start your own Codex bootstrap node. You can use `AddCodex(...)` for this. + 1. When you start other Codex nodes with `AddCodex(...)` you can pass the bootstrap node by adding the `.WithBootstrapNode(...)` option. +1. When using `AutoBootstrapDistTest`: + 1. The test-infra creates the bootstrap node for you, and automatically passes it to each Codex node you create in your tests. Handy for keeping your tests clean and to-the-point. + 1. When using the auto-bootstrap, you have no control over the bootstrap node from your tests. You can't (for example) shut it down during the course of the test. If you need this level of control for your scenario, use the `CodexDistTest` instead. +1. If your test needs a long time to run, add the `[UseLongTimeouts]` function attribute. This will greatly increase maximum time-out values for operations like for example uploading and downloading files. +### Continuous tests +1. Add new code files to `Tests/CodexContinousTests/Tests` +1. Inherrit from `ContinuousTest` +1. Define one or more methods and decorate them with the `[TestMoment(...)]` attribute. +1. The TestMoment takes a number of seconds as argument. Each moment will be executed by the continuous test runner applying the given seconds as delay. (Non-cumulative. So two moments at T:10 will be executed one after another without delay, in this case the order of execution should not be depended upon.) +1. Continuous tests automatically receive access to the Codex nodes that the tests are being run against. +1. Additionally, Continuous tests can start their own transient Codex nodes and bootstrap them against the persistent nodes. + +### Tips for either type of test +1. You can generate files of random test data by calling `GenerateTestFile(...)`. +1. You can enable access to the Codex node metrics by adding the option `.EnableMetrics()`. Enabling metrics will make the test-infra download and save all Codex metrics in case of a test failure. (The metrics are stored as CSV, in the same location as the test log file.) +1. You can enable access to the blockchain marketplace by adding the option `.EnableMarketplace(...)`. +1. Enabling metrics and/or enabling the marketplace takes extra resources from the test-infra and increases the time needed during Codex node setup. Please don't enable these features unless your tests need them. +1. Tip: Codex nodes can be named. Use the option `WithName(...)` and make reading your test logs a little nicer! +1. Tip: Commit often. + +## Don't forget +1. Once you're happy with your tests, please create a pull-request and ask a Codex core contributor to review your changes. diff --git a/ProjectPlugins/GethPlugin/GethDeployment.cs b/ProjectPlugins/GethPlugin/GethDeployment.cs index 52eea48..b8d9644 100644 --- a/ProjectPlugins/GethPlugin/GethDeployment.cs +++ b/ProjectPlugins/GethPlugin/GethDeployment.cs @@ -5,9 +5,9 @@ namespace GethPlugin { public class GethDeployment : IHasContainer { - public GethDeployment(RunningContainer runningContainer, Port discoveryPort, Port httpPort, Port wsPort, AllGethAccounts allAccounts, string pubKey) + public GethDeployment(RunningContainer container, Port discoveryPort, Port httpPort, Port wsPort, AllGethAccounts allAccounts, string pubKey) { - Container = runningContainer; + Container = container; DiscoveryPort = discoveryPort; HttpPort = httpPort; WsPort = wsPort; diff --git a/README.md b/README.md index 5141e9c..ee26ed4 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,30 @@ # Distributed System Tests for Nim-Codex - Using a common dotnet unit-test framework and a few other libraries, this project allows you to write tests that use multiple Codex node instances in various configurations to test the distributed system in a controlled, reproducible environment. Nim-Codex: https://github.com/codex-storage/nim-codex -Dotnet: v6.0 +Dotnet: v7.0 Kubernetes: v1.25.4 Dotnet-kubernetes SDK: v10.1.4 https://github.com/kubernetes-client/csharp Nethereum: v4.14.0 -## Tests -Tests are devided into two assemblies: `/Tests` and `/LongTests`. -`/Tests` is to be used for tests that take several minutes to hours to execute. -`/LongTests` is to be used for tests that take hours to days to execute. +## Tests/CodexTests and Tests/CodexLongTests +These are test assemblies that use NUnit3 to perform tests against transient Codex nodes. -TODO: All tests will eventually be running as part of a dedicated CI pipeline and kubernetes cluster. Currently, we're developing these tests and the infra-code to support it by running the whole thing locally. +## Tests/ContinousTests +A console application that runs tests in an endless loop against a persistent deployment of Codex nodes. -## Configuration -Test executing can be configured using the following environment variables. -| Variable | Description | Default | -|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------| -| KUBECONFIG | Optional path (abs or rel) to kubeconfig YAML file. When null, uses system default (docker-desktop) kubeconfig if available. | (null) | -| LOGPATH | Path (abs or rel) where log files will be saved. | "CodexTestLogs" | -| LOGDEBUG | When "true", enables additional test-runner debug log output. | "false" | -| DATAFILEPATH | Path (abs or rel) where temporary test data files will be saved. | "TestDataFiles" | -| LOGLEVEL | Codex log-level. (case-insensitive) | "Trace" | -| RUNNERLOCATION | Use "ExternalToCluster" when test app is running outside of the k8s cluster. Use "InternalToCluster" when tests are run from inside a pod/container. | "ExternalToCluster" | +## Tools/CodexNetDeployer +A console application that can deploy Codex nodes. ## Test logs Because tests potentially take a long time to run, logging is in place to help you investigate failures afterwards. Should a test fail, all Codex terminal output (as well as metrics if they have been enabled) will be downloaded and stored along with a detailed, step-by-step log of the test. If something's gone wrong and you're here to discover the details, head for the logs. +## How to contribute a plugin +If you want to add support for your project to the testing framework, follow the steps [HERE](/CONTRIBUTINGPLUGINS.MD) + ## How to contribute tests -An important goal of the test infra is to provide a simple, accessible way for developers to write their tests. If you want to contribute tests for Codex, please follow the steps [HERE](/CONTRIBUTINGTESTS.md). +If you want to contribute tests, please follow the steps [HERE](/CONTRIBUTINGTESTS.md). ## Run the tests on your machine Creating tests is much easier when you can debug them on your local system. This is possible, but requires some set-up. If you want to be able to run the tests on your local system, follow the steps [HERE](/docs/LOCALSETUP.md). Please note that tests which require explicit node locations cannot be executed locally. (Well, you could comment out the location statements and then it would probably work. But that might impact the validity/usefulness of the test.) diff --git a/Tests/CodexTests/BasicTests/ExampleTests.cs b/Tests/CodexTests/BasicTests/ExampleTests.cs index 39814a5..f35be56 100644 --- a/Tests/CodexTests/BasicTests/ExampleTests.cs +++ b/Tests/CodexTests/BasicTests/ExampleTests.cs @@ -45,7 +45,10 @@ namespace Tests.BasicTests } [Test] - public void MarketplaceExample() + [Combinatorial] + public void MarketplaceExample( + [Values(true, false)] bool isValidator, + [Values(true, false)] bool simulateProofFailure) { var sellerInitialBalance = 234.TestTokens(); var buyerInitialBalance = 1000.TestTokens(); @@ -54,9 +57,12 @@ namespace Tests.BasicTests var geth = Ci.StartGethNode(s => s.IsMiner().WithName("disttest-geth")); var contracts = Ci.StartCodexContracts(geth); - var seller = AddCodex(s => s - .WithStorageQuota(11.GB()) - .EnableMarketplace(geth, contracts, initialEth: 10.Eth(), initialTokens: sellerInitialBalance)); + var seller = AddCodex(s => + { + s.WithStorageQuota(11.GB()); + s.EnableMarketplace(geth, contracts, initialEth: 10.Eth(), initialTokens: sellerInitialBalance, isValidator); + if (simulateProofFailure) s.WithSimulateProofFailures(3); + }); AssertBalance(geth, contracts, seller, Is.EqualTo(sellerInitialBalance)); seller.Marketplace.MakeStorageAvailable( diff --git a/docs/Architecture.png b/docs/CodexTestNetArchitecture.png similarity index 100% rename from docs/Architecture.png rename to docs/CodexTestNetArchitecture.png diff --git a/docs/FrameworkArchitecture.png b/docs/FrameworkArchitecture.png new file mode 100644 index 0000000000000000000000000000000000000000..f3bcb21a2dcec6b56cde8eca6048f887839dfed8 GIT binary patch literal 462237 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∨oq^7$*=%%f%;w^rY#G4hoV3icuZ4^9Lqtfkh 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

@1^VLcm-6-28$w(P6SCO93? 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 z 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

67v6n>S54OhDP#E+mD5588?9V2y)mPYP9DkpD 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


myuo-akFN(#= zxWCDbgiPV1FBbh!)>i`5tufBee#y&rjV7$3ZGfz#{kP3ntL23eYn=^%6~2uAbqyA} z6$DL*NWs*OcXmo#DR}?Idchx`6$ZSQtTEl}uO_YX;RLYu5s(gmPAe_^#%HbXq^ukX zNG={=M*bZ#?dOA5CxTsMuEsB=wQf*)^3eBfU(N}14*@4uxPIj*0u23o+(^h=JiTg? zz|>UwC7i4=&d))jGk=XHxYPiSPpolMYb|5#W~|lni<{Ou8_hfa=K8+?IlF+*mxvUw zQoidySugnDv%-M)k~N-4{;Nq_RIjEl5^oH;ZvU+Hh5orKChAfH{!NnoJ7mDKMnPXm zt0v`q8h&=I8=P>RMO(LgcG|p%DgK5LdP|lAUQEA zl&;voj8!(BrTgIF1^D?~c+f;=9=>;Ow1xX2LdJW3W;hi*NaxcFD}l|q;-K>2%YS^T z#(&?96`lCa{{(YUlNH9LYfE01)7?e9=HE6vtx+WCv6hFm;h>U1>-*X|_`b1GTsy&{ zV~8l~ZQ!+4BG6|Q7Qrvr1+rS%#p$TeQ-aA^$-_Qs-Y+Fp5)jj|=inp_s_i%gu|T_1 z%eCPFI>qs_^9HR`Tx9l?f(GHNAz0& literal 0 HcmV?d00001 diff --git a/docs/LOCALSETUP.md b/docs/LOCALSETUP.md index 862995c..ad65c0e 100644 --- a/docs/LOCALSETUP.md +++ b/docs/LOCALSETUP.md @@ -4,18 +4,10 @@ These steps will help you set up everything you need to run and debug the tests on your local system. ### Installing the requirements. -1. Install dotnet v6.0 or newer. (If you install a newer version, consider updating the .csproj files by replacing all mention of `net6.0` with your version.) +1. Install dotnet v7.0 or newer. (If you install a newer version, consider updating the .csproj files by replacing all mention of `net7.0` with your version.) 1. Set up a nice C# IDE or plugin for your current IDE. 1. Install docker desktop. 1. In the docker-desktop settings, enable kubernetes. (This might take a few minutes.) -### Configure to taste. -The tests should run as-is. You can change the configuration. The items below explain the what and how. -1. Open `DistTestCore/Configuration.cs`. -1. `k8sNamespace` defines the Kubernetes namespace the tests will use. All Kubernetes resources used during the test will be created in it. At the beginning of a test run and at the end of each test, the namespace and all resources in it will be deleted. -1. `kubeConfigFile`. If you are using the Kubernetes cluster created in docker desktop, this field should be set to null. If you wish to use a different cluster, set this field to the path (absolute or relative) of your KubeConfig file. -1. `LogConfig(path, debugEnabled)`. Path defines the path (absolute or relative) where the tests logs will be saved. The debug flag allows you to enable additional logging. This is mostly useful when something's wrong with the test infra. -1. `FileManagerFolder` defines the path (absolute or relative) where the test infra will generate and store test data files. This folder will be deleted at the end of every test run. - ### Running the tests -Most IDEs will let you run individual tests or test fixtures straight from the code file. If you want to run all the tests, you can use `dotnet test`. You can control which tests to run by specifying which folder of tests to run. `dotnet test Tests` will run only the tests in `/Tests` and exclude the long tests. +Most IDEs will let you run individual tests or test fixtures straight from the code file. If you want to run all the tests, you can use `dotnet test`. You can control which tests to run by specifying which folder of tests to run. `dotnet test Tests/CodexTests` will run only the tests in `/Tests/CodexTests` and exclude the long tests. From 385fab498d032f3570a980b315ea8f30ece9a3c6 Mon Sep 17 00:00:00 2001 From: benbierens Date: Thu, 21 Sep 2023 14:42:45 +0200 Subject: [PATCH 51/51] updates ci command --- .github/workflows/dist-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dist-tests.yaml b/.github/workflows/dist-tests.yaml index 2fbf048..1453f00 100644 --- a/.github/workflows/dist-tests.yaml +++ b/.github/workflows/dist-tests.yaml @@ -41,7 +41,7 @@ env: SOURCE: ${{ format('{0}/{1}', github.server_url, github.repository) }} NAMEPREFIX: codex-dist-tests NAMESPACE: default - COMMAND: dotnet test Tests + COMMAND: dotnet test Tests/CodexTests JOB_MANIFEST: docker/job.yaml KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }} KUBE_VERSION: v1.26.1