diff --git a/.editorconfig b/.editorconfig index a121307e..0b234c24 100644 --- a/.editorconfig +++ b/.editorconfig @@ -223,6 +223,8 @@ dotnet_diagnostic.SA1502.severity = none dotnet_diagnostic.SA1508.severity = none # SA1516 Elements should be separated by blank line (but reports false positives) dotnet_diagnostic.SA1516.severity = none +# SA1201 An element within a C# code file is out of order in relation to the other elements in the code. +dotnet_diagnostic.SA1201.severity = none # TODO TBD diff --git a/.github/actions/setup-runner/action.yml b/.github/actions/setup-runner/action.yml index 3a67909d..d14dbb63 100644 --- a/.github/actions/setup-runner/action.yml +++ b/.github/actions/setup-runner/action.yml @@ -19,3 +19,7 @@ runs: - name: Restore .NET tools shell: bash run: dotnet tool restore + + - name: Install Containerlab + shell: bash + run: bash -c "$(curl -sL https://get.containerlab.dev)" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dbf0b9b8..706dc6da 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,7 +25,7 @@ jobs: test: name: Test runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 env: # https://docs.github.com/en/actions/how-tos/monitor-workflows/enable-debug-logging ACTIONS_RUNNER_DEBUG: false diff --git a/.github/workflows/prerelease.yaml b/.github/workflows/prerelease.yaml index d164d396..a85d46b4 100644 --- a/.github/workflows/prerelease.yaml +++ b/.github/workflows/prerelease.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # Required by Release target - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a9429e27..e4e18bb9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # Required by Release target - timeout-minutes: 5 + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index d3ed220a..80a95396 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ obj/ #*.idea .idea/.idea.Drift/.idea/watcherTasks.xml .idea/.idea.Drift/.idea/encodings.xml +.idea/.idea.Drift.Build/.idea/encodings.xml *.DotSettings *.received.* artifacts/ @@ -14,4 +15,5 @@ TestResults/ build.binlog build.binlog-warnings-only.log publish.binlog -publish.binlog-warnings-only.log \ No newline at end of file +publish.binlog-warnings-only.log +containerlab/*/ \ No newline at end of file diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 40e55bc6..f3aa6165 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -40,6 +40,7 @@ "ReleaseContainer", "Restore", "Test", + "TestClab", "TestE2E", "TestLocal", "TestSelf", @@ -117,6 +118,10 @@ "allOf": [ { "properties": { + "ClabTopology": { + "type": "string", + "description": "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified" + }, "Commit": { "type": "string", "description": "Commit - e.g. '4c16978aa41a3b435c0b2e34590f1759c1dc0763'" @@ -143,10 +148,18 @@ "description": "GitHubToken - GitHub token used to create releases", "default": "Secrets must be entered via 'nuke :secrets [profile]'" }, + "KeepClabRunning": { + "type": "boolean", + "description": "Keep Containerlab topology running after tests" + }, "MsBuildVerbosity": { "type": "string", "description": "MsBuildVerbosity - Console output verbosity - Default is 'normal'" }, + "SkipClabDeploy": { + "type": "boolean", + "description": "Skip Containerlab deployment (useful for debugging when topology is already running)" + }, "Solution": { "type": "string", "description": "Path to a solution file that is automatically loaded" diff --git a/Directory.Packages.props b/Directory.Packages.props index a58f0300..ee81c680 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,12 @@ true + + + + + + @@ -13,6 +19,7 @@ + @@ -35,6 +42,7 @@ + diff --git a/Drift.Build.slnx b/Drift.Build.slnx index 4923d451..e6781204 100644 --- a/Drift.Build.slnx +++ b/Drift.Build.slnx @@ -2,14 +2,16 @@ + + - - - + + + \ No newline at end of file diff --git a/Drift.sln b/Drift.sln index d29c2422..55808dea 100644 --- a/Drift.sln +++ b/Drift.sln @@ -73,6 +73,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli.Settings.SchemaGenerato EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Common.Schemas\Common.Schemas.csproj", "{BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Grpc", "src\Networking.PeerStreaming.Grpc\Networking.PeerStreaming.Grpc.csproj", "{8ED3FF22-90D2-4F08-A079-55FE7127D1C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Cluster", "src\Networking.Cluster\Networking.Cluster.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core", "src\Networking.PeerStreaming.Core\Networking.PeerStreaming.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Hosting", "src\Agent.Hosting\Agent.Hosting.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Server", "src\Networking.PeerStreaming.Server\Networking.PeerStreaming.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Client", "src\Networking.PeerStreaming.Client\Networking.PeerStreaming.Client.csproj", "{E69772D3-8A07-414F-8F9A-30370D81A972}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Networking", "Networking", "{75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core.Abstractions", "src\Networking.PeerStreaming.Core.Abstractions\Networking.PeerStreaming.Core.Abstractions.csproj", "{ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Tests", "src\Networking.PeerStreaming.Tests\Networking.PeerStreaming.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol", "src\Agent.PeerProtocol\Agent.PeerProtocol.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol.Tests", "src\Agent.PeerProtocol.Tests\Agent.PeerProtocol.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -177,11 +199,58 @@ Global {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Debug|Any CPU.Build.0 = Debug|Any CPU {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Release|Any CPU.ActiveCfg = Release|Any CPU {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Release|Any CPU.Build.0 = Release|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Release|Any CPU.Build.0 = Release|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Debug|Any CPU.Build.0 = Debug|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Release|Any CPU.ActiveCfg = Release|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Release|Any CPU.Build.0 = Release|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Release|Any CPU.Build.0 = Release|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Release|Any CPU.Build.0 = Release|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Release|Any CPU.Build.0 = Release|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Release|Any CPU.Build.0 = Release|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Release|Any CPU.Build.0 = Release|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Release|Any CPU.Build.0 = Release|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Release|Any CPU.Build.0 = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {FEA2FBBE-785F-4187-8242-FD348F9E78AF} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {272166CF-E425-45F8-984F-FAFD3CE953C9} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {DD70FBC7-8367-45B3-8D3D-757F1CDF6531} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} + {091D3DCE-F062-4D40-A8F6-5B6F123ED713} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {80445644-7342-4C6D-88E5-BF27126FE9A2} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {A26B4527-6EBF-4A20-8E75-945CCD59016B} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {E69772D3-8A07-414F-8F9A-30370D81A972} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {9DFDD692-22F8-4F9A-8808-94E318863D23} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} EndGlobalSection EndGlobal diff --git a/README_dev.md b/README_dev.md index 7a89c5ed..f58d70a3 100644 --- a/README_dev.md +++ b/README_dev.md @@ -35,6 +35,10 @@ One or more addresses (MAC, IPv4, IPv6, and/or hostname) that together serve as a unique identifier for a network device. +- **Agent** + A running instance of Drift in agent mode that reports network state to other Drift peers. + Agents help ensure full network visibility by uncovering state that's only observable when scanning from specific subnets. + ## Concepts ### Device ID diff --git a/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj b/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj index 2a3d630e..56d8698c 100644 --- a/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj +++ b/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj @@ -1,11 +1,11 @@  - + - + diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index dfdbc08c..72e8cf9f 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -17,7 +17,7 @@ sealed partial class NukeBuild { Target Test => _ => _ - .DependsOn( TestSelf, TestUnit, TestE2E ); + .DependsOn( TestSelf, TestUnit, TestE2E, TestClab ); Target TestSelf => _ => _ .Before( BuildInfo ) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs new file mode 100644 index 00000000..e20b5482 --- /dev/null +++ b/build/NukeBuild.TestContainerlab.cs @@ -0,0 +1,351 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Drift.Build.Utilities; +using Nuke.Common; +using Nuke.Common.IO; +using Nuke.Common.Tooling; +using Serilog; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + [Parameter( "Skip Containerlab deployment (useful for debugging when topology is already running)" )] + readonly bool SkipClabDeploy = false; + + [Parameter( "Keep Containerlab topology running after tests" )] + readonly bool KeepClabRunning = false; + + [Parameter( "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified." )] + readonly string ClabTopology = null; + + /// + /// Defines all Containerlab integration test cases. + /// Each test case specifies a topology, its spec file, the CLI container name, + /// and assertions to validate the scan output. + /// + private static readonly ContainerlabTestCase[] TestCases = [ + new( + Name: "simple-test", + TopologyFile: "simple-test.clab.yaml", + SpecFile: "simple-test-spec.yaml", + CliContainer: "clab-drift-simple-test-cli", + Assertions: [ + new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), + new ScanAssertion( "Both scans successful (local + agent)", + output => output.Contains( "2/2 scan operations successful" ) ), + new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + ] + ), + new( + Name: "cooperation-test", + TopologyFile: "cooperation-test.clab.yaml", + SpecFile: "cooperation-test-spec.yaml", + CliContainer: "clab-drift-cooperation-test-cli", + Assertions: [ + new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), + new ScanAssertion( "All 4 scans successful (local + 3 agents)", + output => output.Contains( "4/4 scan operations successful" ) ), + new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + ] + ), + new( + Name: "subnet-isolation-test", + TopologyFile: "subnet-isolation-test.clab.yaml", + SpecFile: "subnet-isolation-test-spec.yaml", + CliContainer: "clab-drift-subnet-isolation-test-cli", + Assertions: [ + new ScanAssertion( "Subnet-A scanned", output => output.Contains( "192.168.10.0/24" ) ), + new ScanAssertion( "Subnet-B scanned", output => output.Contains( "192.168.20.0/24" ) ), + new ScanAssertion( "All scan operations successful", + output => output.Contains( "7/7 scan operations successful" ) ), + new ScanAssertion( "Scan completed successfully", output => output.Contains( "Distributed scan completed" ) ), + ] + ), + ]; + + Target TestClab => _ => _ + .DependsOn( PublishContainer ) + .After( TestUnit ) + .Executes( async () => { + using var _ = new OperationTimer( nameof(TestClab) ); + + var imageRef = _driftImageRef ?? throw new ArgumentNullException( nameof(_driftImageRef) ); + Log.Information( "Using image {ImageRef} for Containerlab tests", imageRef ); + + if ( !RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + Log.Warning( "Containerlab tests require Linux. Skipping." ); + return; + } + + if ( !await IsContainerlabAvailableAsync() ) { + throw new Exception( + "Containerlab does not appear to be installed or in PATH. " + + "See https://containerlab.dev/install/ for installation instructions." + ); + } + + var casesToRun = SelectTestCases(); + + Log.Information( + "Running {Count} Containerlab test case(s): {Names}", + casesToRun.Length, + string.Join( ", ", casesToRun.Select( tc => tc.Name ) ) + ); + + var total = casesToRun.Length; + var passed = 0; + var failed = 0; + + foreach ( var testCase in casesToRun ) { + var run = passed + failed + 1; + Log.Information( "━━━ Test case: {Name} ({Run}/{Total}) ━━━", testCase.Name, run, total ); + + if ( await RunTestCaseAsync( testCase ) ) { + passed++; + Log.Information( "PASS: {Name}", testCase.Name ); + } + else { + failed++; + Log.Error( "FAIL: {Name}", testCase.Name ); + } + } + + Log.Information( "Containerlab integration tests: {Passed} passed, {Failed} failed", passed, failed ); + + if ( failed > 0 ) { + throw new Exception( $"{failed} Containerlab test case(s) failed" ); + } + } + ); + + private ContainerlabTestCase[] SelectTestCases() { + if ( ClabTopology == null ) { + return TestCases; + } + + var selected = TestCases.Where( tc => tc.Name == ClabTopology ).ToArray(); + if ( !selected.Any() ) { + throw new Exception( + $"No test case found matching topology '{ClabTopology}'. " + + $"Valid names: {string.Join( ", ", TestCases.Select( tc => tc.Name ) )}" + ); + } + + return selected; + } + + private async Task RunTestCaseAsync( ContainerlabTestCase testCase ) { + var topoFile = Paths.ContainerlabsDirectory / testCase.TopologyFile; + var specFile = Paths.ContainerlabsDirectory / testCase.SpecFile; + + if ( !File.Exists( topoFile ) ) { + Log.Error( "Topology file not found: {File}", topoFile ); + return false; + } + + if ( !File.Exists( specFile ) ) { + Log.Error( "Spec file not found: {File}", specFile ); + return false; + } + + try { + if ( SkipClabDeploy ) { + Log.Information( "Skipping deployment (--skip-clab-deploy)" ); + } + else { + await DeployTopologyAsync( testCase.TopologyFile ); + } + + await RunScanAndAssertAsync( specFile, testCase ); + return true; + } + catch ( Exception ex ) { + Log.Error( "Test case '{Name}' failed: {Error}", testCase.Name, ex.Message ); + return false; + } + finally { + if ( KeepClabRunning ) { + Log.Information( "Keeping topology running (--keep-clab-running)" ); + } + else { + await DestroyTopologyAsync( testCase.TopologyFile ); + } + } + } + + private static async Task IsContainerlabAvailableAsync() { + try { + var versionOutput = await CommandRunner.RunAsync( "containerlab", "version" ); + Log.Debug( "\n{Version}", versionOutput ); + return true; + } + catch { + return false; + } + } + + private static async Task DeployTopologyAsync( string topologyFile ) { + Log.Information( "Deploying topology: {File}", topologyFile ); + + DestroyTopologyIfExists( topologyFile ); + EnsureClabManagementNetwork(); + + Clab( + $"deploy --topo {topologyFile}", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 5 ) + ).AssertZeroExitCode(); + + Log.Information( "Waiting for containers to be ready..." ); + await Task.Delay( TimeSpan.FromSeconds( 10 ) ); + } + + private static void DestroyTopologyIfExists( string topologyFile ) { + try { + Clab( + $"destroy --topo {topologyFile} --cleanup", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 2 ), + logOutput: false + ).AssertZeroExitCode(); + } + catch { + Log.Debug( "No existing topology to destroy (or destroy failed — continuing)" ); + } + } + + /// + /// Pre-creates the 'clab' management network before deploying. + /// + /// Rootless Podman with pasta networking does NOT create kernel bridge interfaces. + /// Containerlab always tries `ip link show br-<network-id>` immediately after + /// creating a new network, which fatally fails ("Link not found") because no + /// kernel bridge was created. However, when the network already exists, + /// Containerlab skips the creation step and reuses it — avoiding the fatal lookup. + /// + /// Strategy: try to remove any stale 'clab' network (ignore failure — may be in + /// use by another running topology), then create it. Ignore "already exists" errors + /// from create — the important thing is the network is present before deploy. + /// + private static void EnsureClabManagementNetwork() { + Log.Debug( "Pre-creating Containerlab management network..." ); + + // Ignore failure — network may not exist yet, or may still be in use by another topology + var rm = ProcessTasks.StartProcess( "docker", "network rm clab", logOutput: false ); + rm.WaitForExit(); + + // Ignore failure — "network already exists" is acceptable; we just need it to be present + var create = ProcessTasks.StartProcess( + "docker", "network create --subnet 172.20.20.0/24 --ipv6 --subnet 3fff:172:20:20::/64 clab", + logOutput: false + ); + create.WaitForExit(); + + Log.Debug( "Management network 'clab' ready" ); + } + + private static async Task DestroyTopologyAsync( string topologyFile ) { + Log.Information( "Destroying topology: {File}", topologyFile ); + try { + Clab( + $"destroy --topo {topologyFile} --cleanup", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 2 ) + ).AssertZeroExitCode(); + } + catch ( Exception ex ) { + Log.Warning( "Failed to destroy topology: {Error}", ex.Message ); + } + } + + private static async Task RunScanAndAssertAsync( AbsolutePath specFile, ContainerlabTestCase testCase ) { + Log.Information( "Running scan for test case: {Name}", testCase.Name ); + + // Give agent(s) a moment to finish starting up + await Task.Delay( TimeSpan.FromSeconds( 5 ) ); + + Log.Debug( "Copying spec to CLI container {Container}...", testCase.CliContainer ); + Docker( $"cp {specFile} {testCase.CliContainer}:/tmp/spec.yaml" ).AssertZeroExitCode(); + + Log.Information( "Running scan in {Container}...", testCase.CliContainer ); + var scanResult = Docker( + $"exec {testCase.CliContainer} /app/drift scan /tmp/spec.yaml", + timeout: TimeSpan.FromMinutes( 5 ) + ); + + foreach ( var line in scanResult.Output ) { + Log.Debug( "[scan:{Name}] {Line}", testCase.Name, line.Text ); + } + + scanResult.AssertZeroExitCode(); + + AssertScanOutput( testCase, scanResult.Output.Select( o => o.Text ) ); + } + + private static void AssertScanOutput( ContainerlabTestCase testCase, IEnumerable outputLines ) { + var output = string.Join( "\n", outputLines ); + var failures = new List(); + + foreach ( var assertion in testCase.Assertions ) { + if ( assertion.Check( output ) ) { + Log.Debug( "Assertion passed: {Description}", assertion.Description ); + } + else { + failures.Add( assertion.Description ); + Log.Error( "Assertion failed: {Description}", assertion.Description ); + } + } + + if ( failures.Count > 0 ) { + Log.Error( "Scan output was:\n{Output}", output ); + var failList = string.Join( "\n", failures.Select( f => $" FAIL: {f}" ) ); + throw new Exception( $"Scan assertions failed for '{testCase.Name}':\n{failList}" ); + } + + Log.Information( "All {Count} assertions passed for '{Name}'", testCase.Assertions.Length, testCase.Name ); + } + + // ── Process helpers ──────────────────────────────────────────────────────── + + /// + /// Containerlab writes all its output to stderr by design. + /// Use a custom logger that routes both stdout and stderr to Debug + /// to avoid GH Actions annotating every Containerlab log line as an error. + /// + private static void ClabLogger( OutputType type, string text ) => Log.Debug( text ); + + private static IProcess Clab( string args, AbsolutePath workDir = null, TimeSpan? timeout = null, + bool logOutput = true ) => + ProcessTasks.StartProcess( + "containerlab", args, + workingDirectory: workDir, + timeout: (int?) timeout?.TotalMilliseconds, + logOutput: logOutput, + logger: logOutput ? ClabLogger : null + ); + + private static IProcess Docker( string args, AbsolutePath workDir = null, TimeSpan? timeout = null ) => + ProcessTasks.StartProcess( + "docker", args, + workingDirectory: workDir, + timeout: (int?) timeout?.TotalMilliseconds + ); +} + +/// A Containerlab integration test case. +sealed record ContainerlabTestCase( + string Name, + string TopologyFile, + string SpecFile, + string CliContainer, + ScanAssertion[] Assertions +); + +/// A named assertion over scan output text. +sealed record ScanAssertion( string Description, Func Check ); \ No newline at end of file diff --git a/build/NukeBuild.cs b/build/NukeBuild.cs index 9b2ea201..a12009f7 100644 --- a/build/NukeBuild.cs +++ b/build/NukeBuild.cs @@ -107,6 +107,8 @@ private static class Paths { internal static AbsolutePath PublishDirectoryForRuntime( DotNetRuntimeIdentifier id ) => PublishDirectory / id.ToString(); + + internal static AbsolutePath ContainerlabsDirectory => RootDirectory / "containerlab"; } Target BuildInfo => _ => _ diff --git a/build/_build.csproj b/build/_build.csproj index 6b7e5a43..fc36a1e0 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -13,14 +13,15 @@ + + + + + - - - - diff --git a/containerlab/README.md b/containerlab/README.md new file mode 100644 index 00000000..6450a9b1 --- /dev/null +++ b/containerlab/README.md @@ -0,0 +1,124 @@ +# Containerlab Integration Testing + +This directory contains containerlab topologies for testing Drift's distributed network scanning capabilities. + +## Prerequisites + +- [Containerlab](https://containerlab.dev/) installed +- Docker with sufficient resources (at least 4GB RAM, 2 CPUs) +- Drift Docker image: `localhost:5000/drift:dev` + +## Quick Start + +The easiest way to run containerlab integration tests is via Nuke: + +```bash +# Run all tests including containerlab integration +dotnet nuke Test + +# Run only containerlab tests +dotnet nuke TestContainerlab --skip test + +# Run a single topology for debugging +dotnet nuke TestContainerlab --skip test --clab-topology simple-test + +# Keep containers running after tests (for debugging) +dotnet nuke TestContainerlab --skip test --keep-clab-running +``` + +## Topologies + +### `simple-test.clab.yaml` + +Minimal topology: 1 agent, 1 CLI, 1 target on the management network. + +``` + CLI Agent1 Target1 +(172.20.20.x) (172.20.20.x) (172.20.20.x) + | | | + +--------------------+---------------------+ + 172.20.20.0/24 +``` + +**Assertions:** +- `172.20.20.0/24` is scanned +- 2/2 scan operations successful (local + 1 agent) +- Scan completes successfully + +### `cooperation-test.clab.yaml` + +Multi-agent cooperation topology: 3 agents, 1 CLI, 5 targets on a flat management network. +Tests multi-agent coordination and result merging. + +``` +CLI + Agent1 + Agent2 + Agent3 + Target1..5 + | + 172.20.20.0/24 +``` + +**Assertions:** +- `172.20.20.0/24` is scanned +- 4/4 scan operations successful (local + 3 agents) +- Scan completes successfully + +### `subnet-isolation-test.clab.yaml` + +Subnet isolation topology: 2 agents, 1 CLI, 4 targets. Each agent is connected to its own +isolated subnet via veth links and an in-container bridge. Tests that each agent only reports +devices reachable on its own subnet. + +``` +CLI ─── mgmt (172.20.20.0/24) ─── Agent1 ─── subnet-a (192.168.10.0/24) ─── Target-A1, Target-A2 + └─ Agent2 ─── subnet-b (192.168.20.0/24) ─── Target-B1, Target-B2 +``` + +**Assertions:** +- `192.168.10.0/24` is scanned (by Agent1) +- `192.168.20.0/24` is scanned (by Agent2) +- 7/7 scan operations successful (mgmt×3 + subnet-a×2 + subnet-b×2) +- Scan completes successfully + +## Agent Identity + +Agents in these topologies use the `--id` flag to set a fixed, predictable agent ID: + +```yaml +agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_test1 +``` + +The `--id` flag is hidden from the help output and logs a warning when used — it is only for testing. + +In production, agents generate and persist their own ID at `/root/.config/drift/agent/agent-identity.json`. + +## Nuke Target Parameters + +| Parameter | Description | +|---|---| +| `--clab-topology ` | Run only the named topology (e.g. `simple-test`). Runs all if omitted. | +| `--skip-clab-deploy` | Skip deployment — useful when topology is already running | +| `--keep-clab-running` | Keep containers running after tests for debugging | + +## Troubleshooting + +**Deploy fails with "Link not found"** — This is a known issue with rootless Podman + pasta networking. The Nuke target works around it by pre-creating the `clab` management network before deploying. If you are deploying manually, run: +```bash +docker network rm clab 2>/dev/null; docker network create --subnet 172.20.20.0/24 --ipv6 --subnet 3fff:172:20:20::/64 clab +containerlab deploy --topo simple-test.clab.yaml +``` + +**Agents not starting** — Check container logs: +```bash +docker logs clab-drift-simple-test-agent1 +docker logs clab-drift-cooperation-test-agent1 +docker logs clab-drift-subnet-isolation-test-agent1 +``` + +**Cannot reach agents** — Verify containers are on the management network: +```bash +docker network inspect clab +``` + +**Connection refused on first scan attempt** — Normal. The agent starts slowly and the client retries automatically. diff --git a/containerlab/cooperation-test-spec.yaml b/containerlab/cooperation-test-spec.yaml new file mode 100644 index 00000000..00994efd --- /dev/null +++ b/containerlab/cooperation-test-spec.yaml @@ -0,0 +1,21 @@ +version: "v1-preview" + +network: + subnets: [] + devices: [] + +agents: + - id: agentid_coop_agent1 + address: http://clab-drift-cooperation-test-agent1:5000 + authentication: + type: none + + - id: agentid_coop_agent2 + address: http://clab-drift-cooperation-test-agent2:5000 + authentication: + type: none + + - id: agentid_coop_agent3 + address: http://clab-drift-cooperation-test-agent3:5000 + authentication: + type: none diff --git a/containerlab/cooperation-test.clab.yaml b/containerlab/cooperation-test.clab.yaml new file mode 100644 index 00000000..e7859935 --- /dev/null +++ b/containerlab/cooperation-test.clab.yaml @@ -0,0 +1,70 @@ +name: drift-cooperation-test + +# Multi-agent cooperation topology +# +# All nodes share a single flat management network (172.20.20.0/24). +# Three agents cooperate to scan the same subnet, testing: +# - Multi-agent coordination (3 agents) +# - Result merging from multiple agents +# - Correct handling of overlapping scan results +# +# Targets: +# - 5 Alpine containers as scan targets + +topology: + nodes: + # Controller/CLI node + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Agent 1 + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_coop_agent1 + + # Agent 2 + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_coop_agent2 + + # Agent 3 + agent3: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_coop_agent3 + + # Target devices + target1: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target2: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target3: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target4: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target5: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" diff --git a/containerlab/simple-test-spec.yaml b/containerlab/simple-test-spec.yaml new file mode 100644 index 00000000..8075f51a --- /dev/null +++ b/containerlab/simple-test-spec.yaml @@ -0,0 +1,11 @@ +version: "v1-preview" + +network: + subnets: [] + devices: [] + +agents: + - id: agentid_test1 + address: http://clab-drift-simple-test-agent1:5000 + authentication: + type: none diff --git a/containerlab/simple-test.clab.yaml b/containerlab/simple-test.clab.yaml new file mode 100644 index 00000000..6c8bb507 --- /dev/null +++ b/containerlab/simple-test.clab.yaml @@ -0,0 +1,26 @@ +name: drift-simple-test + +# Simplified topology for initial testing +topology: + nodes: + # Controller/CLI node + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Single agent for basic test + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_test1 + ports: + - "5001:5000" + + # Single target device + target1: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" diff --git a/containerlab/subnet-isolation-test-spec.yaml b/containerlab/subnet-isolation-test-spec.yaml new file mode 100644 index 00000000..c3fc35c6 --- /dev/null +++ b/containerlab/subnet-isolation-test-spec.yaml @@ -0,0 +1,18 @@ +version: "v1-preview" + +network: + subnets: + - address: 192.168.10.0/24 + - address: 192.168.20.0/24 + devices: [] + +agents: + - id: agentid_subnet_agent1 + address: http://clab-drift-subnet-isolation-test-agent1:5000 + authentication: + type: none + + - id: agentid_subnet_agent2 + address: http://clab-drift-subnet-isolation-test-agent2:5000 + authentication: + type: none diff --git a/containerlab/subnet-isolation-test.clab.yaml b/containerlab/subnet-isolation-test.clab.yaml new file mode 100644 index 00000000..bad5dfe0 --- /dev/null +++ b/containerlab/subnet-isolation-test.clab.yaml @@ -0,0 +1,112 @@ +name: drift-subnet-isolation-test + +# Subnet isolation topology +# +# Tests that each agent only scans targets reachable on its own isolated subnet. +# +# Networks: +# - mgmt (172.20.20.0/24): CLI + Agent1 + Agent2 [management/control plane] +# - subnet-a (veth + bridge): Agent1 + Target-A1 + Target-A2 [192.168.10.0/24] +# - subnet-b (veth + bridge): Agent2 + Target-B1 + Target-B2 [192.168.20.0/24] +# +# Targets use network-mode: none — they have no management interface and are +# only reachable via the veth link to their respective agent. +# +# Inside each agent, a Linux bridge (br0) is created to bridge the two veth +# links together on a single /24 subnet — allowing the agent to reach both +# targets with a single IP address. +# +# Scan spec explicitly declares the two isolated subnets. +# Agent1 can reach 192.168.10.0/24; Agent2 can reach 192.168.20.0/24. +# Neither agent can reach the other's subnet. + +topology: + nodes: + # Controller/CLI node (mgmt only) + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Agent 1 — scans subnet-a (192.168.10.0/24) + # Creates br0 bridging eth1+eth2, assigns 192.168.10.1/24 to the bridge + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_subnet_agent1 + exec: + - ip link add br0 type bridge + - ip link set eth1 master br0 + - ip link set eth2 master br0 + - ip link set eth1 up + - ip link set eth2 up + - ip link set br0 up + - ip addr add 192.168.10.1/24 dev br0 + + # Agent 2 — scans subnet-b (192.168.20.0/24) + # Creates br0 bridging eth1+eth2, assigns 192.168.20.1/24 to the bridge + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_subnet_agent2 + exec: + - ip link add br0 type bridge + - ip link set eth1 master br0 + - ip link set eth2 master br0 + - ip link set eth1 up + - ip link set eth2 up + - ip link set br0 up + - ip addr add 192.168.20.1/24 dev br0 + + # Subnet-A targets (only reachable by agent1) + # network-mode: none means no management interface; eth0 is the veth link + target-a1: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.10.101/24 dev eth0 + - ip link set eth0 up + + target-a2: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.10.102/24 dev eth0 + - ip link set eth0 up + + # Subnet-B targets (only reachable by agent2) + target-b1: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.20.101/24 dev eth0 + - ip link set eth0 up + + target-b2: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.20.102/24 dev eth0 + - ip link set eth0 up + + links: + # Subnet-A: agent1 <-> target-a1 and agent1 <-> target-a2 + - endpoints: ["agent1:eth1", "target-a1:eth0"] + - endpoints: ["agent1:eth2", "target-a2:eth0"] + + # Subnet-B: agent2 <-> target-b1 and agent2 <-> target-b2 + - endpoints: ["agent2:eth1", "target-b1:eth0"] + - endpoints: ["agent2:eth2", "target-b2:eth0"] diff --git a/src/Agent.Hosting/Agent.Hosting.csproj b/src/Agent.Hosting/Agent.Hosting.csproj new file mode 100644 index 00000000..93719ce4 --- /dev/null +++ b/src/Agent.Hosting/Agent.Hosting.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Agent.Hosting/AgentHost.cs b/src/Agent.Hosting/AgentHost.cs new file mode 100644 index 00000000..fe8e4960 --- /dev/null +++ b/src/Agent.Hosting/AgentHost.cs @@ -0,0 +1,70 @@ +using Drift.Agent.PeerProtocol.Subnets; +using Drift.Networking.PeerStreaming.Client; +using Drift.Networking.PeerStreaming.Core; +using Drift.Networking.PeerStreaming.Server; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Hosting; + +public static class AgentHost { + public static Task Run( + ushort port, + ILogger logger, + Action? configureServices, + CancellationToken cancellationToken, + TaskCompletionSource? ready = null + ) { + var app = Build( port, logger, configureServices, ready ); + return app.RunAsync( cancellationToken ); + } + + private static WebApplication Build( + ushort port, + ILogger logger, + Action? configureServices = null, + TaskCompletionSource? ready = null + ) { + var builder = WebApplication.CreateSlimBuilder(); + + builder.Logging.ClearProviders(); + builder.Services.AddSingleton( logger ); + builder.Services.AddPeerStreamingServer( options => { + options.EnableDetailedErrors = true; + } ); + builder.Services.AddPeerStreamingClient(); + var peerStreamingOptions = new PeerStreamingOptions { MessageAssembly = typeof(SubnetsRequest).Assembly }; + builder.Services.AddPeerStreamingCore( peerStreamingOptions ); + configureServices?.Invoke( builder.Services ); + + builder.WebHost.ConfigureKestrel( options => { + options.ListenAnyIP( port, o => { + o.Protocols = HttpProtocols.Http2; // Allow HTTP/2 over plain HTTP i.e., non-HTTPS + } ); + } ); + + var app = builder.Build(); + + peerStreamingOptions.StoppingToken = app.Lifetime.ApplicationStopping; + + app.MapPeerStreamingServerEndpoints(); + + app.Lifetime.ApplicationStarted.Register( () => { + logger.LogInformation( "Listening for incoming connections on port {Port}", port ); + logger.LogInformation( "Agent started" ); + ready?.TrySetResult(); + } ); + app.Lifetime.ApplicationStopping.Register( () => { + logger.LogInformation( "Agent stopping..." ); + } ); + app.Lifetime.ApplicationStopped.Register( () => { + logger.LogInformation( "Agent stopped" ); + } ); + + return app; + } +} \ No newline at end of file diff --git a/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs b/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs new file mode 100644 index 00000000..a462b561 --- /dev/null +++ b/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Drift.Domain; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Hosting.Identity; + +public partial class AgentIdentity { + private IAgentIdentityLocationProvider? _loadLocation; + + public static AgentIdentity Load( ILogger? logger = null, IAgentIdentityLocationProvider? location = null ) { + try { + location ??= new DefaultAgentIdentityLocationProvider(); + + logger?.LogTrace( "Loading agent identity from {Path}", location.GetFile() ); + + if ( !File.Exists( location.GetFile() ) ) { + logger?.LogDebug( "Agent identity file not found. Generating new identity." ); + var newIdentity = CreateNew(); + newIdentity._loadLocation = location; + return newIdentity; + } + + var json = File.ReadAllText( location.GetFile() ); + var identity = JsonSerializer.Deserialize( json, AgentIdentityJsonContext.Default.AgentIdentity ); + + logger?.LogTrace( "Loaded agent identity: {AgentId}", identity?.Id ); + + if ( identity == null ) { + logger?.LogWarning( "Deserialized identity is null. Generating new identity." ); + var newIdentity = CreateNew(); + newIdentity._loadLocation = location; + return newIdentity; + } + + identity._loadLocation = location; + + return identity; + } + catch ( Exception e ) { + logger?.LogError( e, "Error loading agent identity" ); + var newIdentity = CreateNew(); + newIdentity._loadLocation = location; + return newIdentity; + } + } + + public void Save( ILogger logger, IAgentIdentityLocationProvider? location = null ) { + location ??= new DefaultAgentIdentityLocationProvider(); + + logger.LogTrace( "Saving agent identity to {Path}", location.GetFile() ); + + if ( !Directory.Exists( location.GetDirectory() ) ) { + Directory.CreateDirectory( location.GetDirectory() ); + } + + if ( !File.Exists( location.GetFile() ) ) { + logger.LogInformation( "Creating new agent identity file at {Path}", location.GetFile() ); + } + else if ( _loadLocation == null || + !_loadLocation.GetFile().Equals( location.GetFile(), StringComparison.Ordinal ) // Casing matters on Linux + ) { + throw new InvalidOperationException( "Prevented overwriting an existing file, which had not first been loaded." ); + } + + var json = JsonSerializer.Serialize( this, AgentIdentityJsonContext.Default.AgentIdentity ); + File.WriteAllText( location.GetFile(), json ); + + // logger.LogDebug( "Agent identity saved: {AgentId}", Id ); + } + + private static AgentIdentity CreateNew() { + return new AgentIdentity { + Id = AgentId.New(), + CreatedAt = DateTime.UtcNow + }; + } +} diff --git a/src/Agent.Hosting/Identity/AgentIdentity.cs b/src/Agent.Hosting/Identity/AgentIdentity.cs new file mode 100644 index 00000000..30f82cff --- /dev/null +++ b/src/Agent.Hosting/Identity/AgentIdentity.cs @@ -0,0 +1,10 @@ +using Drift.Domain; + +namespace Drift.Agent.Hosting.Identity; + +public partial class AgentIdentity { + public required AgentId Id { get; init; } + public required DateTime CreatedAt { get; init; } + public string? ClusterId { get; init; } + public DateTime? EnrolledAt { get; init; } +} diff --git a/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs b/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs new file mode 100644 index 00000000..2d1be19f --- /dev/null +++ b/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Drift.Agent.Hosting.Identity; + +[JsonSourceGenerationOptions( + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + RespectRequiredConstructorParameters = true, + RespectNullableAnnotations = true +)] +[JsonSerializable( typeof(AgentIdentity) )] +internal sealed partial class AgentIdentityJsonContext : JsonSerializerContext { +} diff --git a/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs b/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs new file mode 100644 index 00000000..9a05bab7 --- /dev/null +++ b/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs @@ -0,0 +1,24 @@ +using System.Runtime.InteropServices; + +namespace Drift.Agent.Hosting.Identity; + +public sealed class DefaultAgentIdentityLocationProvider : IAgentIdentityLocationProvider { + public string GetDirectory() { + if ( RuntimeInformation.IsOSPlatform( OSPlatform.Windows ) ) { + return Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ), "Drift", "agent" ); + } + + if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + // https://specifications.freedesktop.org/basedir-spec/latest/ + var xdgConfigHome = Environment.GetEnvironmentVariable( "XDG_CONFIG_HOME" ); + + var baseDir = string.IsNullOrEmpty( xdgConfigHome ) + ? Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), ".config" ) + : xdgConfigHome; + + return Path.Combine( baseDir, "drift", "agent" ); + } + + throw new PlatformNotSupportedException(); + } +} diff --git a/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs b/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs new file mode 100644 index 00000000..356686f7 --- /dev/null +++ b/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs @@ -0,0 +1,9 @@ +namespace Drift.Agent.Hosting.Identity; + +public interface IAgentIdentityLocationProvider { + string GetDirectory(); + + string GetFile() { + return Path.Combine( GetDirectory(), "agent-identity.json" ); + } +} diff --git a/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj b/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj new file mode 100644 index 00000000..e00ea9c0 --- /dev/null +++ b/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs b/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..5493e66a --- /dev/null +++ b/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Category( "Unit" )] \ No newline at end of file diff --git a/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs b/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs new file mode 100644 index 00000000..eac8dbc8 --- /dev/null +++ b/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Agent.PeerProtocol.Tests; + +internal sealed class PeerMessageHandlerTests { + private static readonly Assembly ProtocolAssembly = typeof(PeerProtocolAssemblyMarker).Assembly; + private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IPeerRequest<>) ); + private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IPeerResponse) ); + private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); + + [Test] + public void FindMessagesAndHandlersAndMessages() { + using ( Assert.EnterMultipleScope() ) { + Assert.That( RequestTypes.ToList(), Has.Count.GreaterThan( 1 ), "No request messages found via reflection" ); + Assert.That( ResponseTypes.ToList(), Has.Count.GreaterThan( 1 ), "No response messages found via reflection" ); + Assert.That( HandlerTypes.ToList(), Has.Count.GreaterThan( 1 ), "No handlers found via reflection" ); + } + } + + [Explicit( "Disabled until interface has settled" )] + [TestCaseSource( nameof(RequestTypes) )] + [TestCaseSource( nameof(ResponseTypes) )] + public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { + var messageTypeValue = type + .GetProperty( nameof(IPeerMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! + .GetValue( null ) as string; + + Assert.That( messageTypeValue, Is.Not.Null.And.Not.Empty ); + + var jsonInfoValue = type + .GetProperty( nameof(IPeerMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! + .GetValue( null ); + + Assert.That( jsonInfoValue, Is.Not.Null ); + } + + private static List GetAllConcreteMessageTypes( Type baseType ) { + return ProtocolAssembly + .GetTypes() + .Concat( [typeof(Empty)] ) + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => + // Generic base type + t.GetInterfaces().Any( i => i.IsGenericType && i.GetGenericTypeDefinition() == baseType ) || + // Non-generic base type + baseType.IsAssignableFrom( t ) + ) + .ToList(); + } + + private static List GetAllConcreteHandlerTypes() { + return ProtocolAssembly + .GetTypes() + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => typeof(IPeerMessageHandler).IsAssignableFrom( t ) ) + .ToList(); + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs new file mode 100644 index 00000000..c586ed34 --- /dev/null +++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs @@ -0,0 +1,24 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.PeerProtocol.Adopt; + +internal sealed class AdoptRequestHandler : IPeerMessageHandler { + // private readonly ILogger _logger; // Example: inject what you need + + public string MessageType => AdoptRequestPayload.MessageType; + + public Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ) { + // var message = converter.FromEnvelope( envelope ); + // _logger.LogInformation( "[AdoptRequest] Controller: {ControllerId}", message.ControllerId ); + + // This handler doesn't send a response (Empty response pattern) + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs new file mode 100644 index 00000000..2ad48099 --- /dev/null +++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization.Metadata; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Agent.PeerProtocol.Adopt; + +internal sealed class AdoptRequestPayload : IPeerRequest { + public static string MessageType => "adopt-request"; + + public required string Jwt { + get; + set; + } + + public required string ControllerId { + get; + set; + } + + public static JsonTypeInfo JsonInfo { + get; + } = null!; +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs b/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs new file mode 100644 index 00000000..67fa75dc --- /dev/null +++ b/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs @@ -0,0 +1,18 @@ +namespace Drift.Agent.PeerProtocol.Adopt; + +internal sealed class AdoptResponsePayload { + public required string Status { + get; + set; + } // "accepted" or "rejected" + + public required string AgentId { + get; + set; + } // Only with "accepted" + + public required string Reason { + get; + set; + } // Only with "rejected" +} diff --git a/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj new file mode 100644 index 00000000..09269a20 --- /dev/null +++ b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs b/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs new file mode 100644 index 00000000..1907e215 --- /dev/null +++ b/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs @@ -0,0 +1,7 @@ +namespace Drift.Agent.PeerProtocol; + +// Justification: marker class +#pragma warning disable S2094 +public class PeerProtocolAssemblyMarker { +} +#pragma warning restore S2094 \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs new file mode 100644 index 00000000..e330d4b2 --- /dev/null +++ b/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Domain.Scan; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Serialization.Converters; + +namespace Drift.Agent.PeerProtocol.Scan; + +public sealed class ScanSubnetCompleteResponse : IPeerResponse { + public static string MessageType => "scan-complete"; + + public required SubnetScanResult Result { + get; + init; + } + + public static JsonTypeInfo JsonInfo => ScanSubnetCompleteResponseJsonContext.Default.ScanSubnetCompleteResponse; +} + +[JsonSourceGenerationOptions( + Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter), typeof(DeviceAddressConverter), typeof(IpV4AddressSetConverter)], + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable( typeof(ScanSubnetCompleteResponse) )] +internal sealed partial class ScanSubnetCompleteResponseJsonContext : JsonSerializerContext; diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs new file mode 100644 index 00000000..596703fe --- /dev/null +++ b/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Agent.PeerProtocol.Scan; + +public sealed class ScanSubnetProgressUpdate : IPeerMessage { + public static string MessageType => "scan-progress-update"; + + public required byte ProgressPercentage { + get; + init; + } + + public required int DevicesFound { + get; + init; + } + + public string Status { + get; + init; + } = string.Empty; + + public static JsonTypeInfo JsonInfo => ScanSubnetProgressUpdateJsonContext.Default.ScanSubnetProgressUpdate; +} + +[JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase )] +[JsonSerializable( typeof(ScanSubnetProgressUpdate) )] +internal sealed partial class ScanSubnetProgressUpdateJsonContext : JsonSerializerContext; diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs new file mode 100644 index 00000000..4da42d37 --- /dev/null +++ b/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Domain; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Serialization.Converters; + +namespace Drift.Agent.PeerProtocol.Scan; + +public sealed class ScanSubnetRequest : IPeerRequest { + public static string MessageType => "scan-subnet-request"; + + public required CidrBlock Cidr { + get; + init; + } + + public uint PingsPerSecond { + get; + init; + } = 50; + + public static JsonTypeInfo JsonInfo => ScanSubnetRequestJsonContext.Default.ScanSubnetRequest; +} + +[JsonSourceGenerationOptions( + Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter)], + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable( typeof(ScanSubnetRequest) )] +internal sealed partial class ScanSubnetRequestJsonContext : JsonSerializerContext; diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs b/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs new file mode 100644 index 00000000..27c2db90 --- /dev/null +++ b/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs @@ -0,0 +1,112 @@ +using Drift.Domain; +using Drift.Domain.Scan; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Scanning.Scanners; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.PeerProtocol.Scan; + +internal sealed class ScanSubnetRequestHandler( + ISubnetScannerFactory subnetScannerFactory, + ILogger logger +) : IPeerMessageHandler { + public string MessageType => ScanSubnetRequest.MessageType; + + public async Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ) { + logger.LogInformation( "Handling scan subnet request" ); + + var request = converter.FromEnvelope( envelope ); + var options = new SubnetScanOptions { Cidr = request.Cidr, PingsPerSecond = request.PingsPerSecond }; + + logger.LogInformation( "Starting scan of {Cidr}", request.Cidr ); + + var scanner = subnetScannerFactory.Get( request.Cidr ); + var policy = new ProgressUpdatePolicy( stream, converter, envelope, request.Cidr, logger ); + + scanner.ResultUpdated += policy.Handle; + + try { + var result = await scanner.ScanAsync( options, logger, cancellationToken ); + + logger.LogInformation( + "Scan complete for {Cidr}: {DeviceCount} devices found", + request.Cidr, + result.DiscoveredDevices.Count + ); + + var completeResponse = new ScanSubnetCompleteResponse { Result = result }; + await stream.SendResponseAsync( converter, completeResponse, envelope.CorrelationId ); + } + finally { + scanner.ResultUpdated -= policy.Handle; + } + } + + private sealed class ProgressUpdatePolicy { + private readonly IPeerStream _stream; + private readonly IPeerMessageEnvelopeConverter _converter; + private readonly PeerMessage _envelope; + private readonly CidrBlock _cidr; + private readonly ILogger _logger; + + private byte _lastProgressPercentage; + private uint _lastDeviceCount; + private DateTime _lastSentAt = DateTime.UtcNow; + + public EventHandler Handle { + get; + } + + public ProgressUpdatePolicy( + IPeerStream stream, + IPeerMessageEnvelopeConverter converter, + PeerMessage envelope, + CidrBlock cidr, + ILogger logger + ) { + _stream = stream; + _converter = converter; + _envelope = envelope; + _cidr = cidr; + _logger = logger; + Handle = OnResultUpdated; + } + + private void OnResultUpdated( object? sender, SubnetScanResult result ) { + var progressPercentage = result.Progress.Value; + var deviceCount = result.DiscoveredDevices.Count; + var now = DateTime.UtcNow; + + bool progressThresholdReached = progressPercentage >= _lastProgressPercentage + 5; + bool isFirstCompletion = progressPercentage == 100 && _lastProgressPercentage < 100; + bool heartbeatDue = now - _lastSentAt > TimeSpan.FromSeconds( 10 ); + bool firstDeviceDiscovered = deviceCount > 0 && _lastDeviceCount == 0; + + if ( !( progressThresholdReached || isFirstCompletion || heartbeatDue || firstDeviceDiscovered ) ) { + return; + } + + _lastProgressPercentage = progressPercentage; + _lastDeviceCount = (uint) deviceCount; + _lastSentAt = now; + + var progressUpdate = new ScanSubnetProgressUpdate { + ProgressPercentage = progressPercentage, DevicesFound = deviceCount, Status = result.Status.ToString() + }; + + _stream.SendResponseFireAndForget( _converter, progressUpdate, _envelope.CorrelationId ); + + _logger.LogDebug( + "Sent progress update: {Progress}% for {Cidr}", + progressPercentage, + _cidr + ); + } + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..693ba852 --- /dev/null +++ b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Drift.Agent.PeerProtocol.Scan; +using Drift.Agent.PeerProtocol.Subnets; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Agent.PeerProtocol; + +public static class ServiceCollectionExtensions { + extension( IServiceCollection services ) { + public void AddPeerProtocol() { + services.AddScoped(); + services.AddScoped(); + } + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs b/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs new file mode 100644 index 00000000..7f0323f7 --- /dev/null +++ b/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Agent.PeerProtocol.Subnets; + +public sealed class SubnetsRequest : IPeerRequest { + public static string MessageType => "subnets-request"; + + public static JsonTypeInfo JsonInfo => SubnetsRequestJsonContext.Default.SubnetsRequest; +} + +[JsonSerializable( typeof(SubnetsRequest) )] +internal sealed partial class SubnetsRequestJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs b/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs new file mode 100644 index 00000000..a8d3eee5 --- /dev/null +++ b/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs @@ -0,0 +1,29 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.PeerProtocol.Subnets; + +internal sealed class SubnetsRequestHandler( + IInterfaceSubnetProvider interfaceSubnetProvider, + ILogger logger +) : IPeerMessageHandler { + public string MessageType => SubnetsRequest.MessageType; + + public async Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ) { + logger.LogInformation( "Handling subnet request" ); + + var subnets = ( await interfaceSubnetProvider.GetAsync() ).Select( s => s.Cidr ).ToList(); + + logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) ); + + var response = new SubnetsResponse { Subnets = subnets }; + await stream.SendResponseAsync( converter, response, envelope.CorrelationId ); + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs b/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs new file mode 100644 index 00000000..f2402b6c --- /dev/null +++ b/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Domain; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Serialization.Converters; + +namespace Drift.Agent.PeerProtocol.Subnets; + +public sealed class SubnetsResponse : IPeerResponse { + public static string MessageType => "subnets-response"; + + public required IReadOnlyList Subnets { + get; + init; + } + + public static JsonTypeInfo JsonInfo => SubnetsResponseJsonContext.Default.SubnetsResponse; +} + +[JsonSourceGenerationOptions( + Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter)], + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable( typeof(SubnetsResponse) )] +internal sealed partial class SubnetsResponseJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/ArchTests/SanityTests.cs b/src/ArchTests/SanityTests.cs index 3bac161e..b3268160 100644 --- a/src/ArchTests/SanityTests.cs +++ b/src/ArchTests/SanityTests.cs @@ -3,8 +3,8 @@ namespace Drift.ArchTests; internal sealed class SanityTests : DriftArchitectureFixture { - private const uint ExpectedAssemblyCount = 20; - private const uint ExpectedAssemblyCountTolerance = 3; + private const uint ExpectedAssemblyCount = 30; + private const uint ExpectedAssemblyCountTolerance = 10; [Test] public void FindManyAssemblies() { diff --git a/src/Cli.E2ETests/Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt b/src/Cli.E2ETests/Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt index 8523aa12..cd3c69c5 100644 --- a/src/Cli.E2ETests/Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt +++ b/src/Cli.E2ETests/Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt @@ -19,6 +19,7 @@ + diff --git a/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt new file mode 100644 index 00000000..9b0bccae --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt @@ -0,0 +1 @@ +✗ Either --adoptable or --join must be specified. diff --git a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt new file mode 100644 index 00000000..27ac3915 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt @@ -0,0 +1,10 @@ +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Agent starting... +Agent cluster enrollment method is Adoption +Listening for incoming connections on port 51515 +Agent started +Agent stopping... +Agent stopped diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs new file mode 100644 index 00000000..18b2a018 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -0,0 +1,51 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; + +namespace Drift.Cli.Tests.Commands; + +internal sealed class AgentCommandTests { + [CancelAfter( 3000 )] + [Test] + public async Task RespectsCancellationToken() { + using var tcs = new CancellationTokenSource( TimeSpan.FromMilliseconds( 2000 ) ); + + var (exitCode, output, _) = await DriftTestCli.InvokeAsync( + "agent start --adoptable", + cancellationToken: tcs.Token + ); + + Console.WriteLine( output ); + + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task SuccessfulStartup() { + using var tcs = new CancellationTokenSource(); + + var runningCommand = await DriftTestCli.StartAgentAsync( + "--adoptable", + cancellationToken: tcs.Token + ); + + await tcs.CancelAsync(); + + var (exitCode, output, error) = await runningCommand.Completion; + + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( output.ToString() ); + Assert.That( error.ToString(), Is.Empty ); + } + } + + [Test] + public async Task MissingOption() { + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "agent start" ); + + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + await Verify( output.ToString() + error ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/InitCommandTests.cs b/src/Cli.Tests/Commands/InitCommandTests.cs index 3a9a27bb..b502d1a1 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.cs +++ b/src/Cli.Tests/Commands/InitCommandTests.cs @@ -78,7 +78,7 @@ public void TearDown() { [Test] public async Task MissingNameOption() { // Arrange / Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "init --overwrite" ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "init --overwrite" ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -95,7 +95,7 @@ public async Task CancellationIsRespected() { try { // Act - var (exitCode, _, _) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, _, _) = await DriftTestCli.InvokeAsync( "init", cancellationToken: cancellationTokenSource.Token ); @@ -126,7 +126,7 @@ public async Task GenerateSpecWithDiscoverySuccess( }; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"init {SpecNameWithDiscovery} --discover {outputFormat} {verbose}", serviceConfig ); @@ -155,7 +155,7 @@ public async Task GenerateSpecWithoutDiscoverySuccess() { }; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"init {SpecNameWithoutDiscovery}", serviceConfig ); diff --git a/src/Cli.Tests/Commands/LintCommandTests.cs b/src/Cli.Tests/Commands/LintCommandTests.cs index 96bd313e..c57fda8f 100644 --- a/src/Cli.Tests/Commands/LintCommandTests.cs +++ b/src/Cli.Tests/Commands/LintCommandTests.cs @@ -14,7 +14,7 @@ public async Task LintValidSpec( var outputOption = string.IsNullOrWhiteSpace( outputFormat ) ? string.Empty : $" -o {outputFormat}"; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"lint ../../../../Spec.Tests/resources/{specName}.yaml" + outputOption ); @@ -36,7 +36,7 @@ public async Task LintInvalidSpec( var outputOption = string.IsNullOrWhiteSpace( outputFormat ) ? string.Empty : $" -o {outputFormat}"; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"lint ../../../../Spec.Tests/resources/{specName}.yaml" + outputOption ); @@ -51,7 +51,7 @@ await Verify( output.ToString() + error ) [Test] public async Task LintMissingSpec() { // Arrange / Act - var (exitCode, _, _) = await DriftTestCli.InvokeFromTestAsync( "lint" ); + var (exitCode, _, _) = await DriftTestCli.InvokeAsync( "lint" ); // Assert Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt new file mode 100644 index 00000000..538f05f0 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt @@ -0,0 +1,18 @@ +Requesting subnets from agent local1 +Received subnet(s) from agent local1: 192.168.10.0/24 +Requesting subnets from agent local2 +Received subnet(s) from agent local2: 192.168.20.0/24 +Scanning 3 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device) + +192.168.10.0/24 (1 devices) +└── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) +└── 192.168.10.101 21-21-21-21-21-21 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device) \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs new file mode 100644 index 00000000..af1356c6 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs @@ -0,0 +1,348 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Scan; +using Drift.Scanning.Scanners; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class ScanCommandTests { + [Test] + public async Task RemoteScan() { + // Arrange + var scanConfig = ConfigureServices( + new CidrBlock( "192.168.0.0/24" ), + [[new IpV4Address( "192.168.0.100" ), new MacAddress( "11:11:11:11:11:11" )]], + new Inventory { + Network = new Network(), + Agents = [ + new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, + new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } + ] + } + ); + + using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); + + Console.WriteLine( "Starting agents..." ); + RunningCliCommand[] agents = [ + await DriftTestCli.StartAgentAsync( + "--adoptable", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), + discoveredDevices: [ + [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )], + [new IpV4Address( "192.168.10.101" ), new MacAddress( "21:21:21:21:21:21" )] + ] + ) + ), + await DriftTestCli.StartAgentAsync( + "--adoptable --port 51516", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.20.0/24" ), + discoveredDevices: [[new IpV4Address( "192.168.20.100" ), new MacAddress( "33:33:33:33:33:33" )]] + ) + ) + ]; + + // Act + Console.WriteLine( "Starting scan..." ); + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan unittest", + scanConfig, + cancellationToken: tcs.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + Console.WriteLine( "Signalling agent cancellation..." ); + await tcs.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + foreach ( var agent in agents ) { + var (agentExitCode, agentOutput, agentError) = await agent.Completion; + + Console.WriteLine( "\nAgent finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( agentOutput.ToString() + agentError ); + Console.WriteLine( "----------------\n" ); + + Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert + Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( scanOutput.ToString() + scanError ); + } + + [Test] + public async Task RemoteScan_OverlappingSubnets() { + // Arrange - Both agents can see the same subnet (192.168.10.0/24) + var scanConfig = ConfigureServices( + new CidrBlock( "192.168.0.0/24" ), + [[new IpV4Address( "192.168.0.100" ), new MacAddress( "11:11:11:11:11:11" )]], + new Inventory { + Network = new Network(), + Agents = [ + new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, + new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } + ] + } + ); + + using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); + + Console.WriteLine( "Starting agents with overlapping subnet visibility..." ); + RunningCliCommand[] agents = [ + await DriftTestCli.StartAgentAsync( + "--adoptable", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), + discoveredDevices: [ + [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )], + [new IpV4Address( "192.168.10.101" ), new MacAddress( "21:21:21:21:21:21" )] + ] + ) + ), + await DriftTestCli.StartAgentAsync( + "--adoptable --port 51516", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), // Same subnet as agent1 + discoveredDevices: [ + [new IpV4Address( "192.168.10.102" ), new MacAddress( "44:44:44:44:44:44" )], + [new IpV4Address( "192.168.10.103" ), new MacAddress( "55:55:55:55:55:55" )] + ] + ) + ) + ]; + + // Act + Console.WriteLine( "Starting scan..." ); + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan unittest", + scanConfig, + cancellationToken: tcs.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + Console.WriteLine( "Signalling agent cancellation..." ); + await tcs.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + foreach ( var agent in agents ) { + var (agentExitCode, agentOutput, agentError) = await agent.Completion; + + Console.WriteLine( "\nAgent finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( agentOutput.ToString() + agentError ); + Console.WriteLine( "----------------\n" ); + + Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert + Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); + + // Verify the subnet was only scanned once (not twice) + var outputStr = scanOutput.ToString(); + Console.WriteLine( "Checking output for duplicate scans..." ); + + // The output should show "192.168.10.0/24" being scanned, but only once + // We expect to see results from only ONE agent (the first one to claim it) + await Verify( outputStr + scanError ); + } + + [Test] + public async Task RemoteScan_EmptyResults() { + // Arrange - Agents report subnets but no devices are found + var scanConfig = ConfigureServices( + new CidrBlock( "192.168.0.0/24" ), + [], // No local devices + new Inventory { + Network = new Network(), + Agents = [ + new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" } + ] + } + ); + + using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); + + Console.WriteLine( "Starting agent with empty scan results..." ); + RunningCliCommand[] agents = [ + await DriftTestCli.StartAgentAsync( + "--adoptable", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), + discoveredDevices: [] // No devices found + ) + ) + ]; + + // Act + Console.WriteLine( "Starting scan..." ); + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan unittest", + scanConfig, + cancellationToken: tcs.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + Console.WriteLine( "Signalling agent cancellation..." ); + await tcs.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + foreach ( var agent in agents ) { + var (agentExitCode, _, _) = await agent.Completion; + Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert + Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( scanOutput.ToString() + scanError ); + } + + [Test] + public async Task RemoteScan_AgentsOnly_NoLocalInterfaces() { + // Arrange - CLI has no local interfaces, all scanning delegated to agents + var scanConfig = ConfigureServices( + interfaces: (CidrBlock?) null, // No local interfaces - explicitly cast to resolve ambiguity + discoveredDevices: new List>(), + inventory: new Inventory { + Network = new Network(), + Agents = [ + new Domain.Agent { Id = "agentid_local1", Address = "http://localhost:51515" }, + new Domain.Agent { Id = "agentid_local2", Address = "http://localhost:51516" } + ] + } + ); + + using var tcs = new CancellationTokenSource( TimeSpan.FromMinutes( 1 ) ); + + Console.WriteLine( "Starting agents for agents-only scan..." ); + RunningCliCommand[] agents = [ + await DriftTestCli.StartAgentAsync( + "--adoptable", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.10.0/24" ), + discoveredDevices: [ + [new IpV4Address( "192.168.10.100" ), new MacAddress( "22:22:22:22:22:22" )] + ] + ) + ), + await DriftTestCli.StartAgentAsync( + "--adoptable --port 51516", + tcs.Token, + ConfigureServices( + interfaces: new CidrBlock( "192.168.20.0/24" ), + discoveredDevices: [ + [new IpV4Address( "192.168.20.100" ), new MacAddress( "33:33:33:33:33:33" )] + ] + ) + ) + ]; + + // Act + Console.WriteLine( "Starting scan with no local interfaces..." ); + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan unittest", + scanConfig, + cancellationToken: tcs.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + Console.WriteLine( "Signalling agent cancellation..." ); + await tcs.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + foreach ( var agent in agents ) { + var (agentExitCode, _, _) = await agent.Completion; + Assert.That( agentExitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert + Assert.That( scanExitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( scanOutput.ToString() + scanError ); + } + + /// + /// Mock subnet scanner that returns predefined results for testing. + /// + private sealed class MockSubnetScanner( SubnetScanResult result ) : ISubnetScanner { + public event EventHandler? ResultUpdated; + + public Task ScanAsync( + SubnetScanOptions options, + ILogger logger, + CancellationToken cancellationToken = default + ) { + // Simulate progress updates + var progressResult = new SubnetScanResult { + CidrBlock = result.CidrBlock, + DiscoveredDevices = result.DiscoveredDevices, + Metadata = result.Metadata, + Status = result.Status, + DiscoveryAttempts = result.DiscoveryAttempts, + Progress = new Percentage( 50 ) + }; + ResultUpdated?.Invoke( this, progressResult ); + + var finalResult = new SubnetScanResult { + CidrBlock = result.CidrBlock, + DiscoveredDevices = result.DiscoveredDevices, + Metadata = result.Metadata, + Status = result.Status, + DiscoveryAttempts = result.DiscoveryAttempts, + Progress = new Percentage( 100 ) + }; + ResultUpdated?.Invoke( this, finalResult ); + + return Task.FromResult( finalResult ); + } + } + + /// + /// Mock factory that creates scanners with predefined results based on CIDR. + /// + private sealed class MockSubnetScannerFactory( + Dictionary resultsByCidr + ) : ISubnetScannerFactory { + public ISubnetScanner Get( CidrBlock cidr ) { + if ( resultsByCidr.TryGetValue( cidr, out var result ) ) { + return new MockSubnetScanner( result ); + } + + // Return empty result for unknown CIDRs + return new MockSubnetScanner( new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = [], + Metadata = new Metadata { StartedAt = default, EndedAt = default }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = System.Collections.Immutable.ImmutableHashSet.Empty + } ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt new file mode 100644 index 00000000..b86a1ad8 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt @@ -0,0 +1,24 @@ +Requesting subnets from agent agentid_local1 +Received subnet(s) from agent agentid_local1: 192.168.10.0/24 +Requesting subnets from agent agentid_local2 +Received subnet(s) from agent agentid_local2: 192.168.20.0/24 +Scanning 3 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Distributed scan completed: 3/3 scan operations successful, 3 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device) + +192.168.10.0/24 (2 devices) +├── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) +└── 192.168.10.101 21-21-21-21-21-21 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt new file mode 100644 index 00000000..2765abc7 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt @@ -0,0 +1,19 @@ +Requesting subnets from agent agentid_local1 +Received subnet(s) from agent agentid_local1: 192.168.10.0/24 +Requesting subnets from agent agentid_local2 +Received subnet(s) from agent agentid_local2: 192.168.20.0/24 +Scanning 2 subnets + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Distributed scan completed: 2/2 scan operations successful, 2 unique subnets + +192.168.10.0/24 (1 devices) +└── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt new file mode 100644 index 00000000..21d98e9b --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt @@ -0,0 +1,15 @@ +Requesting subnets from agent agentid_local1 +Received subnet(s) from agent agentid_local1: 192.168.10.0/24 +Scanning 2 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Distributed scan completed: 2/2 scan operations successful, 2 unique subnets + +192.168.0.0/24 (0 devices) + +192.168.10.0/24 (0 devices) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt new file mode 100644 index 00000000..aa5468ac --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt @@ -0,0 +1,23 @@ +Requesting subnets from agent agentid_local1 +Received subnet(s) from agent agentid_local1: 192.168.10.0/24 +Requesting subnets from agent agentid_local2 +Received subnet(s) from agent agentid_local2: 192.168.10.0/24 +Scanning 2 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1, agentid_local2 +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +Agent adoption (--adoptable / --join) is not yet implemented and has no effect. +Merged 4 unique devices from 2 scans of 192.168.10.0/24 +Distributed scan completed: 3/3 scan operations successful, 2 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device) + +192.168.10.0/24 (4 devices) +├── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) +├── 192.168.10.101 21-21-21-21-21-21 Online (unknown device) +├── 192.168.10.102 44-44-44-44-44-44 Online (unknown device) +└── 192.168.10.103 55-55-55-55-55-55 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.cs b/src/Cli.Tests/Commands/ScanCommandTests.cs index 5e77b568..c245dabb 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.cs @@ -11,14 +11,18 @@ using Drift.Domain.Device.Discovered; using Drift.Domain.Extensions; using Drift.Domain.Scan; +using Drift.Scanning.Scanners; using Drift.Scanning.Subnets.Interface; using Drift.Scanning.Tests.Utils; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using NetworkInterface = Drift.Scanning.Subnets.Interface.NetworkInterface; namespace Drift.Cli.Tests.Commands; -internal sealed class ScanCommandTests { +internal sealed partial class ScanCommandTests { + private const string SpecName = "unittest"; + private static readonly INetworkInterface DefaultInterface = new NetworkInterface { Description = "eth0", OperationalStatus = OperationalStatus.Up, UnicastAddress = new CidrBlock( "192.168.0.0/24" ) }; @@ -170,7 +174,7 @@ List interfaces var serviceConfig = ConfigureServices( interfaces, discoveredDevices ); // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan", serviceConfig ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "scan", serviceConfig ); // var exitCode = await config.InvokeAsync( $"scan -o {outputFormat}" ); // Assert @@ -202,7 +206,7 @@ List discoveredDevices ); // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan unittest", serviceConfig ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"scan {SpecName}", serviceConfig ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -216,7 +220,7 @@ await Verify( output.ToString() + error ) [Test] public async Task NonExistingSpecOption() { // Arrange / Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "scan blah_spec.yaml" ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "scan blah_spec.yaml" ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -225,19 +229,56 @@ public async Task NonExistingSpecOption() { } } + private static Action ConfigureServices( + CidrBlock? interfaces, + List> discoveredDevices, + Inventory? inventory = null + ) { + var interfaceList = interfaces.HasValue + ? [ + new NetworkInterface { + Description = "eth1", OperationalStatus = OperationalStatus.Up, UnicastAddress = interfaces.Value + } + ] + : new List(); + + return ConfigureServices( + interfaceList, + discoveredDevices.Select( deviceAddresses => new DiscoveredDevice { Addresses = deviceAddresses } ).ToList(), + inventory + ); + } + + private static Action ConfigureServices( + CidrBlock interfaces, + List> discoveredDevices, + Inventory? inventory = null + ) { + return ConfigureServices( + [ + new NetworkInterface { + Description = "eth1", OperationalStatus = OperationalStatus.Up, UnicastAddress = interfaces + } + ], + discoveredDevices.Select( deviceAddresses => new DiscoveredDevice { Addresses = deviceAddresses } ).ToList(), + inventory + ); + } + private static Action ConfigureServices( List interfaces, List discoveredDevices, Inventory? inventory = null ) { return services => { - services.AddScoped( _ => - new PredefinedInterfaceSubnetProvider( interfaces ) + services.Replace( ServiceDescriptor.Scoped( _ => + new PredefinedInterfaceSubnetProvider( interfaces ) + ) ); if ( inventory != null ) { services.AddScoped( _ => - new PredefinedSpecProvider( new Dictionary { { "unittest", inventory } } ) + new PredefinedSpecProvider( new Dictionary { { SpecName, inventory } } ) ); } @@ -245,22 +286,41 @@ private static Action ConfigureServices( new NetworkScanResult { Metadata = new Metadata { StartedAt = default, EndedAt = default }, Status = ScanResultStatus.Success, - Subnets = [ - new SubnetScanResult { - CidrBlock = DefaultInterface.UnicastAddress!.Value, - DiscoveredDevices = discoveredDevices, - Metadata = new Metadata { StartedAt = default, EndedAt = default }, - Status = ScanResultStatus.Success, - // TODO could/should also include ip's of non-discovered devices? - DiscoveryAttempts = discoveredDevices.Select( d => - new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) ) - ) - .ToImmutableHashSet() - } - ] + Subnets = interfaces.Count > 0 + ? [ + new SubnetScanResult { + CidrBlock = DefaultInterface.UnicastAddress!.Value, + DiscoveredDevices = discoveredDevices, + Metadata = new Metadata { StartedAt = default, EndedAt = default }, + Status = ScanResultStatus.Success, + // TODO could/should also include ip's of non-discovered devices? + DiscoveryAttempts = discoveredDevices.Select( d => + new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) ) + ) + .ToImmutableHashSet() + } + ] + : [] } ) ); + + // Register ISubnetScannerFactory for agent tests + // Build a map of CIDR -> SubnetScanResult based on interfaces + var resultsByCidr = interfaces.ToDictionary( + iface => iface.UnicastAddress!.Value, + iface => new SubnetScanResult { + CidrBlock = iface.UnicastAddress!.Value, + DiscoveredDevices = discoveredDevices, + Metadata = new Metadata { StartedAt = default, EndedAt = default }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = discoveredDevices.Select( d => + new IpV4Address( d.Get( AddressType.IpV4 ) ?? throw new Exception( "Device had no IPv4" ) ) + ) + .ToImmutableHashSet() + } + ); + services.AddScoped( _ => new MockSubnetScannerFactory( resultsByCidr ) ); }; } } \ No newline at end of file diff --git a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt index 1756febb..bf9563ca 100644 --- a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt +++ b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt @@ -18,6 +18,7 @@ Commands: init Create a network spec scan Scan the network and detect drift lint Validate a network spec + agent Manage the local Drift agent Required command was not provided. Unrecognized command or argument 'nonexisting'. diff --git a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt index f1d8cd7a..7bb72412 100644 --- a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt +++ b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt @@ -1,6 +1,8 @@ -✗ This exception was thrown from ExceptionCommandHandler - at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DefaultParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 104 - at Drift.Cli.Commands.Common.CommandBase`2.<>c__DisplayClass0_0.<.ctor>b__0(ParseResult parseResult, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/Commands/Common/CommandBase.cs:line 25 - at System.CommandLine.Invocation.AnonymousAsynchronousCommandLineAction.InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) +✗ This exception was thrown from ExceptionCommandHandler + at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DummyParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 105 + at Drift.Cli.Commands.Common.Commands.CommandBase`2.<>c__DisplayClass0_0.<<-ctor>b__0>d.MoveNext() in {SolutionDirectory}src/Cli/Commands/Common/Commands/CommandBase.cs:line 35 +--- End of stack trace from previous location --- + at Drift.Cli.Commands.Common.Commands.CommandBase`2.<>c__DisplayClass0_0.<<-ctor>b__0>d.MoveNext() in {SolutionDirectory}src/Cli/Commands/Common/Commands/CommandBase.cs:line 35 +--- End of stack trace from previous location --- at System.CommandLine.Invocation.InvocationPipeline.InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) - at Drift.Cli.DriftCli.InvokeAsync(String[] args, Boolean toConsole, Boolean plainConsole, Action`1 configureServices, CommandRegistration[] customCommands, Action`1 configureInvocation, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/DriftCli.cs:line 41 + at Drift.Cli.DriftCli.InvokeAsync(String[] args, Boolean toConsole, Boolean plainConsole, Action`1 configureServices, CommandRegistration[] customCommands, Action`1 configureInvocation, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/DriftCli.cs:line 40 diff --git a/src/Cli.Tests/ExitCodeTests.cs b/src/Cli.Tests/ExitCodeTests.cs index 3d588492..40d5fd6e 100644 --- a/src/Cli.Tests/ExitCodeTests.cs +++ b/src/Cli.Tests/ExitCodeTests.cs @@ -1,7 +1,8 @@ using System.CommandLine; using System.Text.RegularExpressions; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; using Drift.Cli.Infrastructure; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Tests.Utils; @@ -25,7 +26,7 @@ public async Task ExitCodeIsReturnedFromCommandHandlerTest() { // Act var (exitCode, output, error) = - await DriftTestCli.InvokeFromTestAsync( ExitCodeCommand, customCommands: customCommands ); + await DriftTestCli.InvokeAsync( ExitCodeCommand, customCommands: customCommands ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -37,7 +38,7 @@ public async Task ExitCodeIsReturnedFromCommandHandlerTest() { [Test] public async Task NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest() { // Arrange - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( NonExistingCommand ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( NonExistingCommand ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -61,7 +62,7 @@ public async Task UnhandledExceptionReturnsUnknownErrorTest() { // Act var (exitCode, output, error) = - await DriftTestCli.InvokeFromTestAsync( ExceptionThrowingCommand, customCommands: customCommands ); + await DriftTestCli.InvokeAsync( ExceptionThrowingCommand, customCommands: customCommands ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -71,37 +72,42 @@ public async Task UnhandledExceptionReturnsUnknownErrorTest() { } private sealed class ExitCodeTestCommand( IServiceProvider provider ) - : CommandBase( + : CommandBase( ExitCodeCommand, "Command that returns a specific exit code", provider ) { - protected override DefaultParameters CreateParameters( ParseResult result ) { - return new DefaultParameters( result ); + protected override DummyParameters CreateParameters( ParseResult result ) { + return new DummyParameters( result ); } } - private sealed class ExitCodeCommandHandler( IOutputManager output ) : ICommandHandler { - public Task Invoke( DefaultParameters parameters, CancellationToken cancellationToken ) { + private sealed class ExitCodeCommandHandler( IOutputManager output ) : ICommandHandler { + public Task Invoke( DummyParameters parameters, CancellationToken cancellationToken ) { output.Normal.Write( $"Output from command '{ExitCodeCommand}'" ); return Task.FromResult( ExitCodeCommandExitCode ); } } private sealed class ExceptionTestCommand( IServiceProvider provider ) - : CommandBase( + : CommandBase( ExceptionThrowingCommand, "Command that throws an exception", provider ) { - protected override DefaultParameters CreateParameters( ParseResult result ) { - return new DefaultParameters( result ); + protected override DummyParameters CreateParameters( ParseResult result ) { + return new DummyParameters( result ); } } - private sealed class ExceptionCommandHandler : ICommandHandler { - public Task Invoke( DefaultParameters parameters, CancellationToken cancellationToken ) { + private sealed class ExceptionCommandHandler : ICommandHandler { + public Task Invoke( DummyParameters parameters, CancellationToken cancellationToken ) { throw new Exception( $"This exception was thrown from {nameof(ExceptionCommandHandler)}" ); } } + + private sealed record DummyParameters : BaseParameters { + public DummyParameters( ParseResult parseResult ) : base( parseResult ) { + } + } } \ No newline at end of file diff --git a/src/Cli.Tests/FeatureFlagTest.cs b/src/Cli.Tests/FeatureFlagTest.cs new file mode 100644 index 00000000..cfba9f51 --- /dev/null +++ b/src/Cli.Tests/FeatureFlagTest.cs @@ -0,0 +1,80 @@ +using System.CommandLine; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Infrastructure; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.Tests; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.FeatureFlags; +using Drift.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Cli.Tests; + +internal sealed class FeatureFlagTest { + private const string DummyCodeCommand = "dummy"; + private const int DummyCommandExitCode = 1337; + private static readonly FeatureFlag MyFeature = new("myFeature"); + private static readonly ISettingsLocationProvider SettingsLocationProvider = new TemporarySettingsLocationProvider(); + + [Test] + public async Task SettingsControlFlag( [Values( false, true, null )] bool? featureEnabled ) { + // Arrange + if ( Directory.Exists( SettingsLocationProvider.GetDirectory() ) ) { + Directory.Delete( SettingsLocationProvider.GetDirectory(), true ); + } + + var settings = new CliSettings(); + if ( featureEnabled != null ) { + settings.Features = [new FeatureFlagSetting( MyFeature, featureEnabled.Value )]; + } + + settings.Save( logger: NullLogger.Instance, location: SettingsLocationProvider ); + + RootCommandFactory.CommandRegistration[] customCommands = [ + new(typeof(DummyTestCommandHandler), sp => new DummyTestCommand( sp )) + ]; + + // Act + var result = await DriftTestCli.InvokeAsync( $"{DummyCodeCommand}", customCommands: customCommands ); + + // Assert + using ( Assert.EnterMultipleScope() ) { + Assert.That( result.ExitCode, Is.EqualTo( DummyCommandExitCode ) ); + var expectedOutput = featureEnabled == true ? "Feature is enabled" : "Feature is disabled"; + Assert.That( result.Output.ToString(), Is.EqualTo( expectedOutput ) ); + Assert.That( result.Error.ToString(), Is.Empty ); + } + + Console.WriteLine( result.Output.ToString() ); + } + + private sealed class DummyTestCommand( IServiceProvider provider ) + : CommandBase( + DummyCodeCommand, + "Command that switches behavior using a feature flag", + provider + ) { + protected override DummyTestParameters CreateParameters( ParseResult result ) { + return new DummyTestParameters( result ); + } + } + + private sealed class DummyTestCommandHandler( IOutputManager output ) : ICommandHandler { + public Task Invoke( DummyTestParameters parameters, CancellationToken cancellationToken ) { + output.Normal.Write( + CliSettings.Load( location: SettingsLocationProvider ).IsFeatureEnabled( MyFeature ) + ? "Feature is enabled" + : "Feature is disabled" + ); + + return Task.FromResult( DummyCommandExitCode ); + } + } + + private sealed record DummyTestParameters : BaseParameters { + public DummyTestParameters( ParseResult parseResult ) : base( parseResult ) { + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/SpecFilePathResolverTests.cs b/src/Cli.Tests/SpecFilePathResolverTests.cs index 6f3aebc6..0d23bb41 100644 --- a/src/Cli.Tests/SpecFilePathResolverTests.cs +++ b/src/Cli.Tests/SpecFilePathResolverTests.cs @@ -18,7 +18,7 @@ internal sealed class SpecFilePathResolverTests { [OneTimeSetUp] public void SetupHomeDir() { - _tempHome = Path.Combine( Path.GetTempPath(), "fake-home-" + Guid.NewGuid().ToString() ); + _tempHome = Path.Combine( Path.GetTempPath(), "fake-home-" + Guid.NewGuid() ); Directory.CreateDirectory( _tempHome ); _originalHome = Environment.GetEnvironmentVariable( HomeEnvVar ); diff --git a/src/Cli.Tests/Utils/CliCommandResult.cs b/src/Cli.Tests/Utils/CliCommandResult.cs new file mode 100644 index 00000000..9665fafc --- /dev/null +++ b/src/Cli.Tests/Utils/CliCommandResult.cs @@ -0,0 +1,24 @@ +namespace Drift.Cli.Tests.Utils; + +internal sealed class CliCommandResult { + internal required int ExitCode { + get; + init; + } + + internal required TextWriter Output { + get; + init; + } + + internal required TextWriter Error { + get; + init; + } + + public void Deconstruct( out int exitCode, out TextWriter output, out TextWriter error ) { + exitCode = ExitCode; + output = Output; + error = Error; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/DriftTestCli.cs b/src/Cli.Tests/Utils/DriftTestCli.cs index 0ab879ce..d33141d8 100644 --- a/src/Cli.Tests/Utils/DriftTestCli.cs +++ b/src/Cli.Tests/Utils/DriftTestCli.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.CommandLine.Parsing; +using Drift.Cli.Commands.Agent.Subcommands; using Drift.Cli.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -8,7 +9,7 @@ namespace Drift.Cli.Tests.Utils; internal static class DriftTestCli { private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds( 7 ); - internal static async Task<(int ExitCode, TextWriter Output, TextWriter Error )> InvokeFromTestAsync( + internal static async Task InvokeAsync( string args, Action? configureServices = null, RootCommandFactory.CommandRegistration[]? customCommands = null, @@ -31,22 +32,66 @@ void ConfigureInvocation( InvocationConfiguration config ) { } try { - return ( - await DriftCli.InvokeAsync( - CommandLineParser.SplitCommandLine( args ).ToArray(), - false, - true, - configureServices, - customCommands, - ConfigureInvocation, - token - ), - output, - error + var exitCode = await DriftCli.InvokeAsync( + CommandLineParser.SplitCommandLine( args ).ToArray(), + false, + true, + configureServices, + customCommands, + ConfigureInvocation, + token ); + + return new CliCommandResult { ExitCode = exitCode, Output = output, Error = error }; } finally { cancellationTokenSource?.Dispose(); } } + + internal static RunningCliCommand StartAsync( + string args, + Action configureServices, + CancellationToken cancellationToken + ) { + var cts = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken ); + + var task = InvokeAsync( + args, + configureServices, + cancellationToken: cts.Token + ); + + return new RunningCliCommand( task, cts ); + } + + /// + /// Starts a new agent asynchronously and returns tasks that complete when it has started. + /// + internal static async Task StartAgentAsync( + string args, + CancellationToken cancellationToken, + Action? configureServices = null + ) { + var readyTcs = new AgentLifetime(); + + var command = StartAsync( + "agent start " + args, + services => { + services.AddSingleton( readyTcs ); + configureServices?.Invoke( services ); + }, + cancellationToken + ); + + // Wait for either readiness or command exit + var completed = await Task.WhenAny( readyTcs.Ready.Task, command.Completion ); + + if ( completed == command.Completion ) { + var com = await command.Completion; + throw new InvalidOperationException( "Command exited before agent was started. Details:\n" + com.Error ); + } + + return command; + } } \ No newline at end of file diff --git a/src/Cli.Tests/Utils/RunningCliCommand.cs b/src/Cli.Tests/Utils/RunningCliCommand.cs new file mode 100644 index 00000000..00fa3eba --- /dev/null +++ b/src/Cli.Tests/Utils/RunningCliCommand.cs @@ -0,0 +1,20 @@ +namespace Drift.Cli.Tests.Utils; + +internal sealed class RunningCliCommand : IAsyncDisposable { + private readonly CancellationTokenSource _cts; + + internal RunningCliCommand( Task task, CancellationTokenSource cts ) { + Completion = task; + _cts = cts; + } + + public Task Completion { + get; + } + + public async ValueTask DisposeAsync() { + await _cts.CancelAsync(); + await Completion; + _cts.Dispose(); + } +} \ No newline at end of file diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj index 4c2454ff..7e18921b 100644 --- a/src/Cli/Cli.csproj +++ b/src/Cli/Cli.csproj @@ -1,4 +1,4 @@ - + linux-x64;linux-musl-x64;linux-arm64;linux-arm @@ -14,14 +14,19 @@ + + + + + @@ -32,13 +37,7 @@ - - all - - - - - + diff --git a/src/Cli/Commands/Agent/AgentCommand.cs b/src/Cli/Commands/Agent/AgentCommand.cs new file mode 100644 index 00000000..b7d42b85 --- /dev/null +++ b/src/Cli/Commands/Agent/AgentCommand.cs @@ -0,0 +1,24 @@ +using Drift.Cli.Commands.Agent.Subcommands.Start; +using Drift.Cli.Commands.Common.Commands; + +namespace Drift.Cli.Commands.Agent; + +internal class AgentCommand : ContainerCommandBase { + internal AgentCommand( IServiceProvider provider ) : base( "agent", "Manage the local Drift agent" ) { + Subcommands.Add( new AgentStartCommand( provider ) ); + // Subcommands.Add( new AgentServiceCommand( provider ) ); + + /*// Support other init systems in the future + var installCmd = new Command( "install", "Create agent systemd service file" ); + installCmd.Options.Add( new Option( "--join" ) { + Description = "Join the distributed agent network using a JWT" + } ); + Subcommands.Add( installCmd ); + + var uninstallCmd = new Command( "uninstall", "Remove agent systemd service file" ); + Subcommands.Add( uninstallCmd ); + + var statusCmd = new Command( "status", "Show agent status" ); + Subcommands.Add( statusCmd );*/ + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs b/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs new file mode 100644 index 00000000..416ee801 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs @@ -0,0 +1,7 @@ +namespace Drift.Cli.Commands.Agent.Subcommands; + +internal sealed class AgentLifetime { + public TaskCompletionSource Ready { + get; + } = new(); +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs new file mode 100644 index 00000000..72316f43 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs @@ -0,0 +1,115 @@ +using System.CommandLine; +using Drift.Agent.Hosting; +using Drift.Agent.Hosting.Identity; +using Drift.Agent.PeerProtocol; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Infrastructure; +using Drift.Cli.Presentation.Console.Logging; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Domain; +using Drift.Networking.Cluster; + +namespace Drift.Cli.Commands.Agent.Subcommands.Start; + +internal class AgentStartCommand : CommandBase { + internal AgentStartCommand( IServiceProvider provider ) : base( "start", "Start a local Drift agent", provider ) { + Options.Add( AgentStartParameters.Options.Port ); + Options.Add( AgentStartParameters.Options.Adoptable ); + Options.Add( AgentStartParameters.Options.Join ); + Options.Add( AgentStartParameters.Options.Id ); + } + + protected override AgentStartParameters CreateParameters( ParseResult result ) { + return new AgentStartParameters( result ); + } +} + +internal class AgentStartCommandHandler( + IOutputManager output, + AgentLifetime? agentLifetime = null, + Action? configureServicesOverride = null +) + : ICommandHandler { + public async Task Invoke( AgentStartParameters parameters, CancellationToken cancellationToken ) { + output.Log.LogDebug( "Running 'agent start' command" ); + + output.WarnAgentPreview(); + + var logger = output.GetLogger(); + + logger.LogInformation( "Agent starting..." ); + + _ = LoadAgentIdentity( parameters.Id ); + + // Check if agent has cluster membership info + var agentIdentity = AgentIdentity.Load( logger ); + var isEnrolled = agentIdentity.ClusterId != null; + + if ( !isEnrolled ) { + logger.LogDebug( "Agent is not enrolled" ); + + var enrollmentRequest = new EnrollmentRequest( parameters.Adoptable, parameters.Join ); + logger.LogInformation( "Agent cluster enrollment method is {EnrollmentMethod}", enrollmentRequest.Method ); + } + else { + logger.LogDebug( "Agent is enrolled into cluster '{ClusterId}'", agentIdentity.ClusterId ); + logger.LogInformation( "Attempting to re-join cluster '{ClusterId}'...", agentIdentity.ClusterId ); + } + + /*Inventory? inventory; + + try { + inventory = await specProvider.GetDeserializedAsync( parameters.SpecFile ); + } + catch ( FileNotFoundException ) { + return ExitCodes.GeneralError; + }*/ + + output.Log.LogDebug( "Starting agent..." ); + + await AgentHost.Run( parameters.Port, logger, ConfigureServices, cancellationToken, agentLifetime?.Ready ); + + output.Log.LogDebug( "Completed 'agent start' command" ); + + return ExitCodes.Success; + + void ConfigureServices( IServiceCollection services ) { + // Configure core agent services (scanning, subnet discovery, execution environment) + RootCommandFactory.ConfigureAgentCoreServices( services ); + + // Add peer protocol message handlers + services.AddPeerProtocol(); + + // Allow test overrides + configureServicesOverride?.Invoke( services ); + } + } + + private AgentId LoadAgentIdentity( string? idOverride ) { + var logger = output.GetLogger(); + IAgentIdentityLocationProvider locationProvider = new DefaultAgentIdentityLocationProvider(); + var identityFilePath = locationProvider.GetFile(); + + // If an ID override is provided, use it directly without loading/saving + if ( !string.IsNullOrWhiteSpace( idOverride ) ) { + logger.LogWarning( "Agent started with --id flag. This should only be used for testing purposes." ); + logger.LogInformation( "Using provided agent ID: {AgentId}", idOverride ); + return new AgentId( idOverride ); + } + + // Load existing identity or create new one + var identity = AgentIdentity.Load( logger, locationProvider ); + + // Save immediately if it's new to persist it + if ( !File.Exists( identityFilePath ) ) { + identity.Save( logger, locationProvider ); + logger.LogInformation( "Generated and saved new agent identity: {AgentId}", identity.Id ); + } + else { + logger.LogDebug( "Loaded existing agent identity: {AgentId}", identity.Id ); + } + + return identity.Id; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs new file mode 100644 index 00000000..b4c07c02 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -0,0 +1,64 @@ +using System.CommandLine; +using Drift.Cli.Commands.Common.Parameters; + +namespace Drift.Cli.Commands.Agent.Subcommands.Start; + +internal record AgentStartParameters : BaseParameters { + internal static class Options { + internal static readonly Option Adoptable = new("--adoptable") { + DefaultValueFactory = _ => false, + Description = "Allow this agent to be adopted by another peer in the agent cluster" + }; + + // support @ for supplying local file + internal static readonly Option Join = new("--join") { Description = "Join the agent cluster using a JWT" }; + + internal static readonly Option Daemon = new("--daemon", "-d") { + Description = "Run the agent as a background daemon" + }; + + internal static readonly Option Port = new("--port", "-p") { + DefaultValueFactory = _ => 51515, Description = "Set the port used for both adoption and communication" + }; + + internal static readonly Option Id = new("--id") { + Description = "Set a specific agent ID (for testing purposes)", + Hidden = true + }; + } + + internal AgentStartParameters( ParseResult parseResult ) : base( parseResult ) { + Port = parseResult.GetValue( Options.Port ); + Adoptable = parseResult.GetValue( Options.Adoptable ); + Join = parseResult.GetValue( Options.Join ); + Id = parseResult.GetValue( Options.Id ); + + if ( !Adoptable && string.IsNullOrWhiteSpace( Join ) ) { + throw new ArgumentException( "Either --adoptable or --join must be specified." ); + } + + if ( Adoptable && !string.IsNullOrWhiteSpace( Join ) ) { + throw new ArgumentException( "Cannot specify both --adoptable and --join." ); + } + } + + public string? Join { + get; + set; + } + + public bool Adoptable { + get; + set; + } + + public ushort Port { + get; + set; + } + + public string? Id { + get; + set; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/CommandBase.cs b/src/Cli/Commands/Common/Commands/CommandBase.cs similarity index 53% rename from src/Cli/Commands/Common/CommandBase.cs rename to src/Cli/Commands/Common/Commands/CommandBase.cs index 111412df..5bbc133d 100644 --- a/src/Cli/Commands/Common/CommandBase.cs +++ b/src/Cli/Commands/Common/Commands/CommandBase.cs @@ -1,10 +1,12 @@ using System.CommandLine; -using Microsoft.Extensions.DependencyInjection; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Presentation.Console.Managers.Abstractions; -namespace Drift.Cli.Commands.Common; +namespace Drift.Cli.Commands.Common.Commands; internal abstract class CommandBase : Command - where TParameters : DefaultParameters + where TParameters : BaseParameters where THandler : ICommandHandler { protected CommandBase( string name, string description, IServiceProvider provider ) : base( name, description ) { Add( CommonParameters.Options.Verbose ); @@ -13,16 +15,24 @@ protected CommandBase( string name, string description, IServiceProvider provide Add( CommonParameters.Options.OutputFormat ); Add( CommonParameters.Arguments.Spec ); - SetAction( ( parseResult, cancellationToken ) => { - using var scope = provider.CreateScope(); + SetAction( async ( parseResult, cancellationToken ) => { + await using var scope = provider.CreateAsyncScope(); var serviceProvider = scope.ServiceProvider; serviceProvider.GetRequiredService().ParseResult = parseResult; var handler = serviceProvider.GetRequiredService(); - var parameters = CreateParameters( parseResult ); - return handler.Invoke( parameters, cancellationToken ); + TParameters parameters; + try { + parameters = CreateParameters( parseResult ); + } + catch ( ArgumentException e ) { + serviceProvider.GetRequiredService().Normal.WriteLineError( $"✗ {e.Message}" ); + return ExitCodes.GeneralError; + } + + return await handler.Invoke( parameters, cancellationToken ); } ); } diff --git a/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs b/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs new file mode 100644 index 00000000..146b4c3c --- /dev/null +++ b/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs @@ -0,0 +1,8 @@ +using System.CommandLine; + +namespace Drift.Cli.Commands.Common.Commands; + +internal class ContainerCommandBase : Command { + public ContainerCommandBase( string name, string description ) : base( name, description ) { + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/ICommandHandler.cs b/src/Cli/Commands/Common/Commands/ICommandHandler.cs similarity index 56% rename from src/Cli/Commands/Common/ICommandHandler.cs rename to src/Cli/Commands/Common/Commands/ICommandHandler.cs index e303b028..07ea9ef0 100644 --- a/src/Cli/Commands/Common/ICommandHandler.cs +++ b/src/Cli/Commands/Common/Commands/ICommandHandler.cs @@ -1,5 +1,7 @@ -namespace Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; -internal interface ICommandHandler where TParameters : DefaultParameters { +namespace Drift.Cli.Commands.Common.Commands; + +internal interface ICommandHandler where TParameters : BaseParameters { Task Invoke( TParameters parameters, CancellationToken cancellationToken ); } \ No newline at end of file diff --git a/src/Cli/Commands/Common/CommonParameters.cs b/src/Cli/Commands/Common/CommonParameters.cs index d574e9f6..488b3183 100644 --- a/src/Cli/Commands/Common/CommonParameters.cs +++ b/src/Cli/Commands/Common/CommonParameters.cs @@ -1,7 +1,6 @@ using System.CommandLine; using Drift.Cli.Presentation.Console; using Drift.Cli.Settings.V1_preview; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Common; diff --git a/src/Cli/Commands/Common/DefaultParameters.cs b/src/Cli/Commands/Common/DefaultParameters.cs deleted file mode 100644 index 8e8eb2db..00000000 --- a/src/Cli/Commands/Common/DefaultParameters.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.CommandLine; -using Drift.Cli.Presentation.Console; - -namespace Drift.Cli.Commands.Common; - -internal record DefaultParameters { - internal DefaultParameters( ParseResult parseResult ) { - OutputFormat = parseResult.GetValue( CommonParameters.Options.OutputFormat ); - SpecFile = parseResult.GetValue( CommonParameters.Arguments.Spec ); - } - - internal OutputFormat OutputFormat { - get; - } - - internal FileInfo? SpecFile { - get; - } -} \ No newline at end of file diff --git a/src/Cli/Commands/Common/Parameters/BaseParameters.cs b/src/Cli/Commands/Common/Parameters/BaseParameters.cs new file mode 100644 index 00000000..07e4338e --- /dev/null +++ b/src/Cli/Commands/Common/Parameters/BaseParameters.cs @@ -0,0 +1,14 @@ +using System.CommandLine; +using Drift.Cli.Presentation.Console; + +namespace Drift.Cli.Commands.Common.Parameters; + +internal abstract record BaseParameters { + protected BaseParameters( ParseResult parseResult ) { + OutputFormat = parseResult.GetValue( CommonParameters.Options.OutputFormat ); + } + + internal OutputFormat OutputFormat { + get; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/Parameters/SpecParameters.cs b/src/Cli/Commands/Common/Parameters/SpecParameters.cs new file mode 100644 index 00000000..3d55c247 --- /dev/null +++ b/src/Cli/Commands/Common/Parameters/SpecParameters.cs @@ -0,0 +1,13 @@ +using System.CommandLine; + +namespace Drift.Cli.Commands.Common.Parameters; + +internal abstract record SpecParameters : BaseParameters { + protected SpecParameters( ParseResult parseResult ) : base( parseResult ) { + SpecFile = parseResult.GetValue( CommonParameters.Arguments.Spec ); + } + + internal FileInfo? SpecFile { + get; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/ParseResultHolder.cs b/src/Cli/Commands/Common/ParseResultHolder.cs index a05afe98..ee3b7751 100644 --- a/src/Cli/Commands/Common/ParseResultHolder.cs +++ b/src/Cli/Commands/Common/ParseResultHolder.cs @@ -8,7 +8,7 @@ internal class ParseResultHolder { public ParseResult ParseResult { get => _parseResult ?? throw new InvalidOperationException( - $"{nameof(ParseResult)} is null. This should have been set via dependency injection." + $"{nameof(ParseResult)} is null. This should have been set during dependency injection." ); set => _parseResult = value; } diff --git a/src/Cli/Commands/Init/InitCommand.cs b/src/Cli/Commands/Init/InitCommand.cs index a49ceb7a..24eec1a0 100644 --- a/src/Cli/Commands/Init/InitCommand.cs +++ b/src/Cli/Commands/Init/InitCommand.cs @@ -1,6 +1,6 @@ using System.CommandLine; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Commands.Init.Helpers; using Drift.Cli.Commands.Scan.NonInteractive; using Drift.Cli.Presentation.Console; @@ -11,7 +11,6 @@ using Drift.Common.Network; using Drift.Domain.Scan; using Drift.Scanning.Subnets.Interface; -using Microsoft.Extensions.Logging; using Spectre.Console; namespace Drift.Cli.Commands.Init; @@ -179,7 +178,9 @@ private async Task Initialize( InitOptions options ) { return false; } - var scanOptions = new NetworkScanOptions { Cidrs = interfaceSubnetProvider.Get().ToList() }; + var scanOptions = new NetworkScanOptions { + Cidrs = ( await interfaceSubnetProvider.GetAsync() ).Select( subnet => subnet.Cidr ).ToList() + }; LogSubnetDetails( scanOptions ); diff --git a/src/Cli/Commands/Init/InitParameters.cs b/src/Cli/Commands/Init/InitParameters.cs index 8115012a..0eac12a6 100644 --- a/src/Cli/Commands/Init/InitParameters.cs +++ b/src/Cli/Commands/Init/InitParameters.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Init; -internal record InitParameters : DefaultParameters { +internal record InitParameters : SpecParameters { internal bool? Discover { get; } diff --git a/src/Cli/Commands/Lint/LintCommand.cs b/src/Cli/Commands/Lint/LintCommand.cs index 28f99de0..ba9ab8e4 100644 --- a/src/Cli/Commands/Lint/LintCommand.cs +++ b/src/Cli/Commands/Lint/LintCommand.cs @@ -1,13 +1,13 @@ using System.CommandLine; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Commands.Lint.Presentation; using Drift.Cli.Presentation.Console; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Rendering; using Drift.Cli.SpecFile; +using Drift.Spec.Schema; using Drift.Spec.Validation; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Lint; @@ -48,7 +48,7 @@ public async Task Invoke( LintParameters parameters, CancellationToken canc var yamlContent = await File.ReadAllTextAsync( filePath!.FullName, cancellationToken ); - var result = SpecValidator.Validate( yamlContent, Spec.Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yamlContent, SpecVersion.V1_preview ); IRenderer renderer = parameters.OutputFormat switch { diff --git a/src/Cli/Commands/Lint/LintParameters.cs b/src/Cli/Commands/Lint/LintParameters.cs index b3e533bf..ef92c71d 100644 --- a/src/Cli/Commands/Lint/LintParameters.cs +++ b/src/Cli/Commands/Lint/LintParameters.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Lint; -internal record LintParameters : DefaultParameters { +internal record LintParameters : SpecParameters { internal LintParameters( ParseResult parseResult ) : base( parseResult ) { } } \ No newline at end of file diff --git a/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs b/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs index e4cebbd5..776c3ebb 100644 --- a/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs +++ b/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs @@ -1,7 +1,6 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Rendering; using Drift.Spec.Validation; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Lint.Presentation; diff --git a/src/Cli/Commands/Preview/AgentCommand.cs b/src/Cli/Commands/Preview/AgentCommand.cs deleted file mode 100644 index 4c036ff7..00000000 --- a/src/Cli/Commands/Preview/AgentCommand.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.CommandLine; - -namespace Drift.Cli.Commands.Preview; - -internal class AgentCommand : Command { - internal AgentCommand() : base( "agent", "Manage the local Drift agent" ) { - var runCmd = new Command( "run", "Start the agent process" ); - runCmd.Options.Add( new Option( "--adoptable" ) { - Description = "Allow this agent to be adopted by another peer in the distributed agent network" - } ); - // terminology: agent network or agent group? - // support @ for supplying local file - runCmd.Options.Add( new Option( "--join" ) { - Description = "Join the distributed agent network using a JWT" - } ); - runCmd.Options.Add( new Option( "--daemon", "-d" ) { Description = "Run the agent as a background daemon" } ); - runCmd.Options.Add( new Option( "--adoptable" - ) { Description = "Allow this agent to be adopted by another peer in the distributed agent network" } ); - Subcommands.Add( runCmd ); - - // Support other init systems in the future - var installCmd = new Command( "install", "Create agent systemd service file" ); - installCmd.Options.Add( new Option( "--join" ) { - Description = "Join the distributed agent network using a JWT" - } ); - Subcommands.Add( installCmd ); - - var uninstallCmd = new Command( "uninstall", "Remove agent systemd service file" ); - Subcommands.Add( uninstallCmd ); - - var statusCmd = new Command( "status", "Show agent status" ); - Subcommands.Add( statusCmd ); - - // logs? - } -} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/AgentSubnetProvider.cs b/src/Cli/Commands/Scan/AgentSubnetProvider.cs new file mode 100644 index 00000000..c8a188ab --- /dev/null +++ b/src/Cli/Commands/Scan/AgentSubnetProvider.cs @@ -0,0 +1,47 @@ +using Drift.Domain; +using Drift.Networking.Cluster; +using Drift.Scanning.Subnets; + +namespace Drift.Cli.Commands.Scan; + +internal sealed class AgentSubnetProvider( + ILogger logger, + List agents, + ICluster cluster, + CancellationToken cancellationToken +) : ISubnetProvider { + public async Task> GetAsync() { + logger.LogDebug( "Getting subnets from agents" ); + var allSubnets = new List(); + + foreach ( var agent in agents ) { + logger.LogInformation( "Requesting subnets from agent {Id}", agent.Id ); + + try { + var response = await cluster.GetSubnetsAsync( agent, cancellationToken ); + + logger.LogInformation( + "Received subnet(s) from agent {Id}: {Subnets}", + agent.Id, + string.Join( ", ", response.Subnets ) + ); + + allSubnets.AddRange( response.Subnets.Select( cidr => + new ResolvedSubnet( cidr, SubnetSource.Agent( + new AgentId( agent.Id ) + ) ) ) + ); + } + catch ( Exception ex ) { + logger.LogWarning( + ex, + "Failed requesting subnets from agent {Id} ({Address}) — agent will be excluded from scan", + agent.Id, + agent.Address + ); + } + } + + return allSubnets; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/ClusterExtensions.cs b/src/Cli/Commands/Scan/ClusterExtensions.cs new file mode 100644 index 00000000..fd340461 --- /dev/null +++ b/src/Cli/Commands/Scan/ClusterExtensions.cs @@ -0,0 +1,50 @@ +using Drift.Agent.PeerProtocol.Scan; +using Drift.Agent.PeerProtocol.Subnets; +using Drift.Domain; +using Drift.Networking.Cluster; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Cli.Commands.Scan; + +internal static class ClusterExtensions { + internal static Task GetSubnetsAsync( + this ICluster cluster, + Domain.Agent agent, + CancellationToken cancellationToken + ) { + return cluster.SendAndWaitAsync( + agent, + new SubnetsRequest(), + timeout: TimeSpan.FromSeconds( 10 ), + cancellationToken + ); + } + + internal static Task ScanSubnetAsync( + this ICluster cluster, + Domain.Agent agent, + CidrBlock cidr, + uint pingsPerSecond, + IPeerMessageEnvelopeConverter converter, + Action onProgressUpdate, + CancellationToken cancellationToken + ) { + var request = new ScanSubnetRequest { Cidr = cidr, PingsPerSecond = pingsPerSecond }; + + return cluster.SendAndWaitStreamingAsync( + agent, + request, + ScanSubnetCompleteResponse.MessageType, + ( progressEnvelope ) => { + // Deserialize progress update and call handler + if ( progressEnvelope.MessageType == ScanSubnetProgressUpdate.MessageType ) { + var progressUpdate = converter.FromEnvelope( progressEnvelope ); + onProgressUpdate( progressUpdate ); + } + }, + timeout: TimeSpan.FromMinutes( 10 ), + cancellationToken + ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs new file mode 100644 index 00000000..ac27d9b9 --- /dev/null +++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs @@ -0,0 +1,304 @@ +using System.Collections.Immutable; +using Drift.Domain; +using Drift.Domain.Device; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Discovered; +using Drift.Domain.Scan; +using Drift.Networking.Cluster; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Scanning.Subnets; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Commands.Scan; + +/// +/// Network scanner that delegates scanning to agents based on subnet source. +/// +internal sealed class DistributedNetworkScanner( + INetworkScanner localScanner, + ICluster cluster, + IPeerMessageEnvelopeConverter converter, + List resolvedSubnets, + Inventory inventory, + ILogger logger +) : INetworkScanner { + public event EventHandler? ResultUpdated; + + public async Task ScanAsync( + NetworkScanOptions options, + ILogger logger, + CancellationToken cancellationToken = default + ) { + logger.LogDebug( "Starting distributed network scan" ); + + var subnetsBySource = PartitionSubnetsBySource( options ); + var allSubnetResults = new List(); + var startTime = DateTime.UtcNow; + + foreach ( var (source, cidrs) in subnetsBySource ) { + if ( source is Local ) { + await ScanLocalSubnetsAsync( + cidrs, + options.PingsPerSecond, + allSubnetResults, + startTime, + logger, + cancellationToken + ); + } + else if ( source is Scanning.Subnets.Agent agentSource ) { + await ScanAgentSubnetsAsync( agentSource, cidrs, options.PingsPerSecond, allSubnetResults, cancellationToken ); + } + } + + return BuildFinalResult( allSubnetResults, startTime, logger ); + } + + private List<(SubnetSource Source, List Cidrs)> PartitionSubnetsBySource( NetworkScanOptions options ) { + // Allow each source to scan subnets it can see - don't deduplicate + // Different agents may see different devices on the same subnet from their network position + return resolvedSubnets + .Where( rs => options.Cidrs.Contains( rs.Cidr ) ) + .GroupBy( rs => rs.Source ) + .Select( group => ( group.Key, group.Select( rs => rs.Cidr ).Distinct().ToList() ) ) + .ToList(); + } + + private async Task ScanLocalSubnetsAsync( + List cidrs, + uint pingsPerSecond, + List allResults, + DateTime startTime, + ILogger logger, + CancellationToken cancellationToken + ) { + logger.LogDebug( "Scanning {Count} local subnet(s)", cidrs.Count ); + + var localOptions = new NetworkScanOptions { Cidrs = cidrs, PingsPerSecond = pingsPerSecond }; + + EventHandler progressHandler = ( _, result ) => { + var overallResult = BuildProgressResult( allResults, result.Subnets, startTime ); + ResultUpdated?.Invoke( this, overallResult ); + }; + + try { + localScanner.ResultUpdated += progressHandler; + var localResult = await localScanner.ScanAsync( localOptions, logger, cancellationToken ); + allResults.AddRange( localResult.Subnets ); + } + finally { + localScanner.ResultUpdated -= progressHandler; + } + } + + private async Task ScanAgentSubnetsAsync( + Scanning.Subnets.Agent agentSource, + List cidrs, + uint pingsPerSecond, + List allResults, + CancellationToken cancellationToken + ) { + logger.LogDebug( "Scanning {Count} subnet(s) via agent {AgentId}", cidrs.Count, agentSource.AgentId ); + + // TODO: Parallel scanning of multiple subnets per agent + foreach ( var cidr in cidrs ) { + var result = await ScanSingleSubnetViaAgentAsync( agentSource, cidr, pingsPerSecond, cancellationToken ); + allResults.Add( result ); + } + } + + private async Task ScanSingleSubnetViaAgentAsync( + Scanning.Subnets.Agent agentSource, + CidrBlock cidr, + uint pingsPerSecond, + CancellationToken cancellationToken + ) { + try { + var response = await cluster.ScanSubnetAsync( + MapAgentIdToDomainAgent( agentSource.AgentId ), + cidr, + pingsPerSecond, + converter, + progressUpdate => LogAgentProgress( agentSource.AgentId, cidr, progressUpdate.ProgressPercentage ), + cancellationToken + ); + + return response.Result; + } + catch ( OperationCanceledException ex ) { + logger.LogWarning( ex, "Scan of subnet {Cidr} via agent {AgentId} was cancelled", cidr, agentSource.AgentId ); + return CreateFailedScanResult( cidr ); + } + catch ( Exception ex ) { + logger.LogWarning( + ex, + "Failed to scan subnet {Cidr} via agent {AgentId}: {ErrorMessage}. Returning partial results.", + cidr, + agentSource.AgentId, + ex.Message + ); + return CreateFailedScanResult( cidr ); + } + } + + private Domain.Agent MapAgentIdToDomainAgent( AgentId agentId ) { + var agent = inventory.Agents.FirstOrDefault( a => a.Id == agentId.Value ); + + if ( agent == null ) { + logger.LogWarning( "Agent {AgentId} not found in inventory", agentId ); + return new Domain.Agent { Id = agentId.Value, Address = string.Empty }; + } + + return agent; + } + + private void LogAgentProgress( AgentId agentId, CidrBlock cidr, byte progressPercentage ) { + logger.LogDebug( "Agent {AgentId} scan progress for {Cidr}: {Progress}%", agentId, cidr, progressPercentage ); + // TODO: Emit progress updates via ResultUpdated event + } + + private static SubnetScanResult CreateFailedScanResult( CidrBlock cidr ) { + return new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = [], + Metadata = new Metadata { StartedAt = DateTime.UtcNow, EndedAt = DateTime.UtcNow }, + Status = ScanResultStatus.Error + }; + } + + private static NetworkScanResult BuildProgressResult( + List completedSubnets, + IReadOnlyCollection inProgressSubnets, + DateTime startTime + ) { + var allSubnets = completedSubnets.Concat( inProgressSubnets ).ToList(); + var progress = CalculateProgress( completedSubnets.Count, allSubnets.Count ); + + return new NetworkScanResult { + Subnets = allSubnets, + Metadata = new Metadata { StartedAt = startTime, EndedAt = DateTime.UtcNow }, + Status = ScanResultStatus.InProgress, + Progress = progress + }; + } + + private NetworkScanResult BuildFinalResult( + List allResults, + DateTime startTime, + ILogger logger + ) { + var endTime = DateTime.UtcNow; + + // Merge results for subnets that were scanned multiple times + var mergedResults = MergeOverlappingSubnetResults( allResults, logger ); + var successCount = allResults.Count( s => s.Status == ScanResultStatus.Success ); + var failureCount = allResults.Count - successCount; + + var finalResult = new NetworkScanResult { + Subnets = mergedResults, + Metadata = new Metadata { StartedAt = startTime, EndedAt = endTime }, + Status = successCount == allResults.Count ? ScanResultStatus.Success : ScanResultStatus.Error, + Progress = Percentage.Hundred + }; + + ResultUpdated?.Invoke( this, finalResult ); + + // Log summary with warnings if there were failures + if ( failureCount > 0 ) { + var failedSubnets = allResults.Where( s => s.Status == ScanResultStatus.Error ).Select( s => s.CidrBlock ) + .ToList(); + logger.LogWarning( + "Distributed scan completed with partial results: {SuccessCount}/{TotalCount} scan operations successful, {FailureCount} failed. Failed subnets: {FailedSubnets}", + successCount, + allResults.Count, + failureCount, + string.Join( ", ", failedSubnets ) + ); + } + else { + logger.LogInformation( + "Distributed scan completed: {SuccessCount}/{TotalCount} scan operations successful, {UniqueSubnets} unique subnets", + successCount, + allResults.Count, + mergedResults.Count + ); + } + + return finalResult; + } + + private List MergeOverlappingSubnetResults( List allResults, ILogger logger ) { + // Group results by CIDR and merge devices from multiple scans + var resultsByCidr = allResults + .GroupBy( r => r.CidrBlock ) + .Select( group => { + var cidr = group.Key; + var scans = group.ToList(); + + if ( scans.Count == 1 ) { + return scans[0]; + } + + // Multiple scans of the same subnet - merge the results + logger.LogDebug( "Merging {Count} scan results for subnet {Cidr}", scans.Count, cidr ); + + // Combine all discovered devices, deduplicating by Device ID and unioning addresses + var allDevices = scans + .SelectMany( s => s.DiscoveredDevices ) + .GroupBy( d => ( (IAddressableDevice) d ).GetDeviceId().ToString() ) + .Where( g => g.Key != string.Empty ) + .Select( g => { + // Union addresses from all instances of this device, preserving richest data + var mergedAddresses = g + .SelectMany( d => d.Addresses ) + .GroupBy( a => ( a.Type, a.Value ) ) + .Select( ag => ag.First() ) + .ToList(); + return new DiscoveredDevice { + Addresses = mergedAddresses, + Ports = g.SelectMany( d => d.Ports ).Distinct().ToList(), + Timestamp = g.Min( d => d.Timestamp ) + }; + } ) + .ToList(); + + // Combine all discovery attempts + var allAttempts = scans + .SelectMany( s => s.DiscoveryAttempts ) + .ToImmutableHashSet(); + + // Use the earliest start time and latest end time + var startTime = scans.Min( s => s.Metadata.StartedAt ); + var endTime = scans.Max( s => s.Metadata.EndedAt ); + + logger.LogInformation( + "Merged {DeviceCount} unique devices from {ScanCount} scans of {Cidr}", + allDevices.Count, + scans.Count, + cidr + ); + + return new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = allDevices, + Metadata = new Metadata { StartedAt = startTime, EndedAt = endTime }, + Status = scans.All( s => s.Status == ScanResultStatus.Success ) + ? ScanResultStatus.Success + : ScanResultStatus.Error, + DiscoveryAttempts = allAttempts + }; + } ) + .ToList(); + + return resultsByCidr; + } + + private static Percentage CalculateProgress( int completed, int total ) { + if ( total == 0 ) { + return Percentage.Zero; + } + + var progressValue = (byte) ( completed * 100 / total ); + return new Percentage( progressValue ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs b/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs index 169a91cb..5fc1bd79 100644 --- a/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs +++ b/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs @@ -8,7 +8,6 @@ using Drift.Cli.Presentation.Rendering; using Drift.Domain; using Drift.Domain.Scan; -using Microsoft.Extensions.Logging; using Spectre.Console; namespace Drift.Cli.Commands.Scan.NonInteractive; diff --git a/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs b/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs index 54ad61a9..1e6c6b7f 100644 --- a/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs +++ b/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs @@ -2,7 +2,6 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Rendering; using Drift.Cli.Presentation.Rendering.DeviceState; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Scan.Rendering; diff --git a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs index 0359f08a..2f4ebabf 100644 --- a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs +++ b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs @@ -18,7 +18,7 @@ namespace Drift.Cli.Commands.Scan.ResultProcessors; internal static class SubnetScanResultProcessor { private const string Na = "n/a"; - private static int _deviceIdCounter = 0; + private static int _deviceIdCounter; internal static List Process( SubnetScanResult scanResult, Network? network ) { // TODO test throw new Exception( "ads" ); diff --git a/src/Cli/Commands/Scan/ScanCommand.cs b/src/Cli/Commands/Scan/ScanCommand.cs index 1aac7dc0..7f17c70e 100644 --- a/src/Cli/Commands/Scan/ScanCommand.cs +++ b/src/Cli/Commands/Scan/ScanCommand.cs @@ -1,29 +1,25 @@ using System.CommandLine; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Commands.Scan.Interactive; using Drift.Cli.Commands.Scan.Interactive.Input; using Drift.Cli.Commands.Scan.NonInteractive; +using Drift.Cli.Presentation.Console.Logging; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.SpecFile; using Drift.Common.Network; using Drift.Domain; using Drift.Domain.Scan; +using Drift.Networking.Cluster; +using Drift.Networking.PeerStreaming.Core.Abstractions; using Drift.Scanning.Subnets; using Drift.Scanning.Subnets.Interface; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Scan; /* - * Ideas: - * Interactive mode: - * ➤ New host found: 192.168.1.42 - * ➤ Port 22 no longer open on 192.168.1.10 - * → Would you like to update the declared state? [y/N] - * Monitor mode: - * drift monitor --reference declared.yaml --interval 10m --notify slack,email,log,webhook + * drift monitor declared.yaml --interval 10m --notify slack,email,log,webhook */ internal class ScanCommand : CommandBase { public ScanCommand( IServiceProvider provider ) : base( "scan", "Scan the network and detect drift", provider ) { @@ -58,70 +54,149 @@ protected override ScanParameters CreateParameters( ParseResult result ) { internal class ScanCommandHandler( IOutputManager output, - INetworkScanner scanner, + INetworkScanner localScanner, IInterfaceSubnetProvider interfaceSubnetProvider, - ISpecFileProvider specProvider + ISpecFileProvider specProvider, + ICluster cluster, + IPeerMessageEnvelopeConverter converter ) : ICommandHandler { public async Task Invoke( ScanParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running scan command" ); - Network? network; + var (inventory, loadFailed) = await LoadInventoryAsync( parameters.SpecFile ); + if ( loadFailed ) { + return ExitCodes.GeneralError; + } + + var resolvedSubnets = await ResolveSubnetsAsync( inventory, cancellationToken ); + var scanRequest = BuildScanRequest( resolvedSubnets ); + + PrintScanSummary( resolvedSubnets, scanRequest, inventory.Agents.Any() ); + + var scanner = CreateScanner( inventory, resolvedSubnets ); + var uiTask = StartUi( parameters, inventory, scanner, scanRequest ); + Task.WaitAll( uiTask ); + + output.Log.LogDebug( "scan command completed" ); + + return ExitCodes.Success; + } + + private async Task<(Inventory inventory, bool loadFailed)> LoadInventoryAsync( FileInfo? specFile ) { try { - network = ( await specProvider.GetDeserializedAsync( parameters.SpecFile ) )?.Network; + var loadedInventory = await specProvider.GetDeserializedAsync( specFile ); + // If no spec file provided, use empty inventory + var inventory = loadedInventory ?? new Inventory { Network = new Network(), Agents = [] }; + return (inventory, false); } catch ( FileNotFoundException ) { - return ExitCodes.GeneralError; - } - - var subnetProviders = new List { interfaceSubnetProvider }; - if ( network != null ) { - subnetProviders.Add( new PredefinedSubnetProvider( network.Subnets ) ); + // Spec file was explicitly provided but not found - this is an error + return (new Inventory { Network = new Network(), Agents = [] }, true); } + } + private async Task> ResolveSubnetsAsync( Inventory inventory, CancellationToken cancellationToken ) { + var subnetProviders = BuildSubnetProviders( inventory, cancellationToken ); var subnetProvider = new CompositeSubnetProvider( subnetProviders ); output.Normal.WriteLineVerbose( $"Using {subnetProvider.GetType().Name}" ); output.Log.LogDebug( "Using {SubnetProviderType}", subnetProvider.GetType().Name ); - var subnets = subnetProvider.Get(); + return await subnetProvider.GetAsync(); + } + + private List BuildSubnetProviders( Inventory inventory, CancellationToken cancellationToken ) { + var providers = new List { interfaceSubnetProvider }; + + if ( inventory.Network != null ) { + providers.Add( new PredefinedSubnetProvider( inventory.Network.Subnets ) ); + } + + if ( inventory.Agents.Any() ) { + providers.Add( new AgentSubnetProvider( + output.GetLogger(), + inventory.Agents, + cluster, + cancellationToken + ) ); + } + + return providers; + } + + private static NetworkScanOptions BuildScanRequest( List resolvedSubnets ) { + var uniqueCidrs = resolvedSubnets + .Select( rs => rs.Cidr ) + .Distinct() + .ToList(); - var scanRequest = new NetworkScanOptions { Cidrs = subnets }; + return new NetworkScanOptions { Cidrs = uniqueCidrs }; + } - // TODO many more varieties - output.Normal.WriteLine( 0, $"Scanning {subnets.Count} subnet{( subnets.Count > 1 ? "s" : string.Empty )}" ); - foreach ( var cidr in subnets ) { - // TODO write name if from spec: Ui.WriteLine( 1, $"{subnet.Id}: {subnet.Network}" ); - output.Normal.Write( 1, $"{cidr}", ConsoleColor.Cyan ); + private void PrintScanSummary( List resolvedSubnets, NetworkScanOptions scanRequest, bool hasAgents ) { + var groupedSubnets = resolvedSubnets + .GroupBy( subnet => subnet.Cidr ) + .Select( group => new { Cidr = group.Key, Sources = group.Select( r => r.Source ).Distinct().ToList() } ) + .ToList(); + + output.Normal.WriteLine( + 0, + $"Scanning {groupedSubnets.Count} subnet{( groupedSubnets.Count > 1 ? "s" : string.Empty )}" + ); + + foreach ( var subnet in groupedSubnets ) { + var sourceList = string.Join( ", ", subnet.Sources ); + output.Normal.Write( 1, $"{subnet.Cidr}", ConsoleColor.Cyan ); output.Normal.WriteLine( - " (" + IpNetworkUtils.GetIpRangeCount( cidr ) + + " (" + IpNetworkUtils.GetIpRangeCount( subnet.Cidr ) + " addresses, estimated scan time is " + - scanRequest.EstimatedDuration( - cidr ) + // TODO .Humanize( 2, CultureInfo.InvariantCulture, minUnit: TimeUnit.Second ) - ")", ConsoleColor.DarkGray ); + scanRequest.EstimatedDuration( subnet.Cidr ) + + ")" + + ( hasAgents ? $" via {sourceList}" : string.Empty ), + ConsoleColor.DarkGray + ); } output.Log.LogInformation( "Scanning {SubnetCount} subnet(s): {SubnetList}", - subnets.Count, - string.Join( ", ", subnets ) + groupedSubnets.Count, + string.Join( ", ", groupedSubnets.Select( s => s.Cidr ) ) ); + } - Task uiTask; + private INetworkScanner CreateScanner( Inventory inventory, List resolvedSubnets ) { + if ( !inventory.Agents.Any() ) { + return localScanner; + } + output.WarnAgentPreview(); + + return new DistributedNetworkScanner( + localScanner, + cluster, + converter, + resolvedSubnets, + inventory, + output.GetLogger() + ); + } + + private Task StartUi( ScanParameters parameters, Inventory inventory, INetworkScanner scanner, NetworkScanOptions scanRequest ) { if ( parameters.Interactive ) { - var ui = new InteractiveUi( output, network, scanner, scanRequest, new DefaultKeyMap(), parameters.ShowLogPanel ); - uiTask = ui.RunAsync(); + var ui = new InteractiveUi( + output, + inventory.Network, + scanner, + scanRequest, + new DefaultKeyMap(), + parameters.ShowLogPanel + ); + return ui.RunAsync(); } else { var ui = new NonInteractiveUi( output, scanner ); - uiTask = ui.RunAsync( scanRequest, network, parameters.OutputFormat ); + return ui.RunAsync( scanRequest, inventory.Network, parameters.OutputFormat ); } - - Task.WaitAll( uiTask ); - - output.Log.LogDebug( "scan command completed" ); - - return ExitCodes.Success; } } \ No newline at end of file diff --git a/src/Cli/Commands/Scan/ScanParameters.cs b/src/Cli/Commands/Scan/ScanParameters.cs index 37178c9e..63235d26 100644 --- a/src/Cli/Commands/Scan/ScanParameters.cs +++ b/src/Cli/Commands/Scan/ScanParameters.cs @@ -1,9 +1,10 @@ using System.CommandLine; using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Scan; -internal record ScanParameters : DefaultParameters { +internal record ScanParameters : SpecParameters { internal static class Options { internal static readonly Option Interactive = new("--interactive", "-i") { Description = "Interactive mode", Arity = ArgumentArity.Zero diff --git a/src/Cli/DriftCli.cs b/src/Cli/DriftCli.cs index e50e8b62..2b988b6b 100644 --- a/src/Cli/DriftCli.cs +++ b/src/Cli/DriftCli.cs @@ -2,7 +2,6 @@ using Drift.Cli.Abstractions; using Drift.Cli.Infrastructure; using Drift.Cli.Presentation.Rendering; -using Microsoft.Extensions.DependencyInjection; namespace Drift.Cli; diff --git a/src/Cli/ExecutionEnvironment.cs b/src/Cli/ExecutionEnvironment.cs index 96674e39..f1933a23 100644 --- a/src/Cli/ExecutionEnvironment.cs +++ b/src/Cli/ExecutionEnvironment.cs @@ -5,7 +5,7 @@ namespace Drift.Cli; internal static class ExecutionEnvironment { internal static DriftExecutionEnvironment GetCurrent() { - var envVar = System.Environment.GetEnvironmentVariable( nameof(EnvVar.DRIFT_EXECUTION__ENVIRONMENT) ); + var envVar = Environment.GetEnvironmentVariable( nameof(EnvVar.DRIFT_EXECUTION__ENVIRONMENT) ); return Get( envVar ); } diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs index 1161862f..9a38652e 100644 --- a/src/Cli/Infrastructure/RootCommandFactory.cs +++ b/src/Cli/Infrastructure/RootCommandFactory.cs @@ -2,6 +2,9 @@ using System.CommandLine.Help; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using Drift.Agent.PeerProtocol; +using Drift.Cli.Commands.Agent; +using Drift.Cli.Commands.Agent.Subcommands.Start; using Drift.Cli.Commands.Common; using Drift.Cli.Commands.Help; using Drift.Cli.Commands.Init; @@ -14,11 +17,12 @@ using Drift.Cli.SpecFile; using Drift.Domain.ExecutionEnvironment; using Drift.Domain.Scan; +using Drift.Networking.Cluster; +using Drift.Networking.PeerStreaming.Client; +using Drift.Networking.PeerStreaming.Core; using Drift.Scanning; using Drift.Scanning.Scanners; using Drift.Scanning.Subnets.Interface; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Infrastructure; @@ -50,7 +54,12 @@ internal static RootCommand Create( ConfigureDefaults( services, toConsole, plainConsole ); ConfigureBuiltInCommandHandlers( services ); ConfigureDynamicCommands( services, customCommands ?? [] ); - configureServices?.Invoke( services ); + + if ( configureServices != null ) { + configureServices.Invoke( services ); + // Allow agent host to override it's services with the same configuration + services.AddScoped>( _ => configureServices ); + } var provider = services.BuildServiceProvider(); var rootCommand = CreateRootCommand( provider ); @@ -66,9 +75,18 @@ private static void ConfigureDefaults( IServiceCollection services, bool toConso ConfigureSpecProvider( services ); ConfigureSubnetProvider( services ); ConfigureNetworkScanner( services ); + ConfigureAgentCluster( services ); + } + + private static void ConfigureAgentCluster( IServiceCollection services ) { + services.AddPeerStreamingCore( new PeerStreamingOptions { + MessageAssembly = typeof(PeerProtocolAssemblyMarker).Assembly + } ); + services.AddPeerStreamingClient(); + services.AddClustering(); } - private static void ConfigureExecutionEnvironment( IServiceCollection services ) { + internal static void ConfigureExecutionEnvironment( IServiceCollection services ) { services.AddSingleton(); } @@ -76,7 +94,10 @@ private static RootCommand CreateRootCommand( IServiceProvider provider ) { // TODO 'from' or 'against'? var rootCommand = new RootCommand( $"{Chars.SatelliteAntenna} Drift CLI — monitor network drift against your declared state" ) { - new InitCommand( provider ), new ScanCommand( provider ), new LintCommand( provider ) + new InitCommand( provider ), + new ScanCommand( provider ), + new LintCommand( provider ), + new AgentCommand( provider ) }; rootCommand.TreatUnmatchedTokensAsErrors = true; @@ -95,6 +116,7 @@ private static void ConfigureOutput( IServiceCollection services, bool toConsole var factory = sp.GetRequiredService(); return factory.Create( parseResult, plainConsole ); } ); + // Note: since ILogger is scoped, singletons cannot access logging via DI services.AddScoped( sp => sp.GetRequiredService().GetLogger() ); } @@ -102,7 +124,7 @@ private static void ConfigureSpecProvider( IServiceCollection services ) { services.AddScoped(); } - private static void ConfigureSubnetProvider( IServiceCollection services ) { + public static void ConfigureSubnetProvider( IServiceCollection services ) { services.AddScoped(); } @@ -110,6 +132,7 @@ private static void ConfigureBuiltInCommandHandlers( IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void ConfigureDynamicCommands( IServiceCollection services, CommandRegistration[] commands ) { @@ -128,7 +151,7 @@ private static void ConfigureDynamicCommands( } } - private static void ConfigureNetworkScanner( IServiceCollection services ) { + internal static void ConfigureNetworkScanner( IServiceCollection services ) { if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { services.AddSingleton(); } @@ -140,6 +163,16 @@ private static void ConfigureNetworkScanner( IServiceCollection services ) { services.AddScoped(); } + /// + /// Configures core services required for agent functionality (scanning, subnet discovery, execution environment). + /// This is used both by the CLI when running locally and by agents when handling remote requests. + /// + internal static void ConfigureAgentCoreServices( IServiceCollection services ) { + ConfigureExecutionEnvironment( services ); + ConfigureSubnetProvider( services ); + ConfigureNetworkScanner( services ); + } + private static void AddFigletHeaderToHelpCommand( RootCommand rootCommand ) { // rootCommand.Add(CommonParameters.Options.OutputFormat ); foreach ( var t in rootCommand.Options ) { diff --git a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs index dbb31e36..330d3204 100644 --- a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs +++ b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs @@ -1,5 +1,4 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Presentation.Console.Logging; @@ -17,18 +16,38 @@ public void Log( case LogLevel.Critical: case LogLevel.Error: normalOutput.WriteLineError( message ); + if ( exception != null ) { + normalOutput.WriteLineError( exception.ToString() ); + } + break; case LogLevel.Warning: normalOutput.WriteLineWarning( message ); + if ( exception != null ) { + normalOutput.WriteLineWarning( exception.ToString() ); + } + break; case LogLevel.Information: normalOutput.WriteLine( message ); + if ( exception != null ) { + normalOutput.WriteLine( exception.ToString() ); + } + break; case LogLevel.Debug: normalOutput.WriteLineVerbose( message ); + if ( exception != null ) { + normalOutput.WriteLineVerbose( exception.ToString() ); + } + break; case LogLevel.Trace: normalOutput.WriteLineVeryVerbose( message ); + if ( exception != null ) { + normalOutput.WriteLineVeryVerbose( exception.ToString() ); + } + break; case LogLevel.None: break; diff --git a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs index f252e41e..cfbe94ec 100644 --- a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs +++ b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs @@ -1,6 +1,5 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Common.Logging; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Presentation.Console.Logging; @@ -12,4 +11,16 @@ internal static class OutputManagerExtensions { public static ILogger GetLogger( this IOutputManager outputManager ) { return new CompoundLogger( [new NormalOutputLoggerAdapter( outputManager.Normal ), outputManager.Log] ); } + + /// + /// Writes preview-mode warnings for agent support to all outputs. + /// Applicable to both agent hosting and distributed scanning via agents. + /// + public static void WarnAgentPreview( this IOutputManager outputManager ) { + var logger = outputManager.GetLogger(); + logger.LogWarning( "Distributed scanning via agents is a preview feature and should be used with caution." ); + logger.LogWarning( "Agent communication is unencrypted. Do not use on untrusted networks." ); + logger.LogWarning( "Agents run without authentication. Any client that can reach the agent port can connect." ); + logger.LogWarning( "Agent adoption (--adoptable / --join) is not yet implemented and has no effect." ); + } } \ No newline at end of file diff --git a/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs b/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs index 05bd518c..0dedb05b 100644 --- a/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.Logging; - namespace Drift.Cli.Presentation.Console.Managers.Abstractions; internal interface ILogOutput : ILogger; \ No newline at end of file diff --git a/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs index 5295d769..a16bd5a0 100644 --- a/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs +++ b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs @@ -1,6 +1,5 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Console.Managers.Outputs; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Presentation.Console.Managers; diff --git a/src/Cli/Presentation/Console/Managers/NullOutputManager.cs b/src/Cli/Presentation/Console/Managers/NullOutputManager.cs index b6460270..a80b6003 100644 --- a/src/Cli/Presentation/Console/Managers/NullOutputManager.cs +++ b/src/Cli/Presentation/Console/Managers/NullOutputManager.cs @@ -1,5 +1,4 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Microsoft.Extensions.Logging; using Spectre.Console; namespace Drift.Cli.Presentation.Console.Managers; diff --git a/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs b/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs index 3d87c45a..d035010a 100644 --- a/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs @@ -1,5 +1,4 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Presentation.Console.Managers.Outputs; diff --git a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs index e8fb4d76..f197d94f 100644 --- a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs @@ -79,14 +79,13 @@ private static void WriteLineInternal( // ... on bgcolor] } } - else { - if ( foreground.HasValue ) { - System.Console.ForegroundColor = foreground.Value; - } - if ( background.HasValue ) { - System.Console.BackgroundColor = background.Value; - } + if ( foreground.HasValue ) { + System.Console.ForegroundColor = foreground.Value; + } + + if ( background.HasValue ) { + System.Console.BackgroundColor = background.Value; } textWriter.Write( line ); @@ -100,9 +99,8 @@ private static void WriteLineInternal( System.Console.BackgroundColor = background.Value; } } - else { - System.Console.ResetColor(); - } + + System.Console.ResetColor(); textWriter.WriteLine(); } diff --git a/src/Cli/Presentation/Console/OutputManagerFactory.cs b/src/Cli/Presentation/Console/OutputManagerFactory.cs index 976338ec..691a6ad8 100644 --- a/src/Cli/Presentation/Console/OutputManagerFactory.cs +++ b/src/Cli/Presentation/Console/OutputManagerFactory.cs @@ -5,7 +5,6 @@ using Drift.Cli.Presentation.Console.Managers; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Common.IO; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Serilog.Events; diff --git a/src/Cli/Properties/launchSettings.json b/src/Cli/Properties/launchSettings.json index e8bbdf6a..d9f79120 100644 --- a/src/Cli/Properties/launchSettings.json +++ b/src/Cli/Properties/launchSettings.json @@ -20,7 +20,7 @@ }, "scan ~": { "commandName": "Project", - "commandLineArgs": "scan ~", + "commandLineArgs": "scan ~ -v", "dotnetRunMessages": true, "environmentVariables": {} }, @@ -47,6 +47,18 @@ "commandLineArgs": "lint ~", "dotnetRunMessages": true, "environmentVariables": {} + }, + "agent start (home)": { + "commandName": "Project", + "commandLineArgs": "agent start ~ -vv -o log --adoptable", + "dotnetRunMessages": true, + "environmentVariables": {} + }, + "agent start --port 51516 (home)": { + "commandName": "Project", + "commandLineArgs": "agent start ~ --port 51516", + "dotnetRunMessages": true, + "environmentVariables": {} } } } diff --git a/src/Cli/SpecFile/FileSystemSpecProvider.cs b/src/Cli/SpecFile/FileSystemSpecProvider.cs index 7c46a0df..c2b312e7 100644 --- a/src/Cli/SpecFile/FileSystemSpecProvider.cs +++ b/src/Cli/SpecFile/FileSystemSpecProvider.cs @@ -5,7 +5,6 @@ using Drift.Spec.Schema; using Drift.Spec.Serialization; using Drift.Spec.Validation; -using Microsoft.Extensions.Logging; namespace Drift.Cli.SpecFile; diff --git a/src/Cli/SpecFile/SpecFilePathResolver.cs b/src/Cli/SpecFile/SpecFilePathResolver.cs index 7b6c54db..4b7ee1c5 100644 --- a/src/Cli/SpecFile/SpecFilePathResolver.cs +++ b/src/Cli/SpecFile/SpecFilePathResolver.cs @@ -1,5 +1,4 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Microsoft.Extensions.Logging; namespace Drift.Cli.SpecFile; diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index 2bdadc5e..a49980c3 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -1,11 +1,11 @@  - + - + \ No newline at end of file diff --git a/src/Diff.Tests/DiffTest.cs b/src/Diff.Tests/DiffTest.cs index fbb171db..363206a9 100644 --- a/src/Diff.Tests/DiffTest.cs +++ b/src/Diff.Tests/DiffTest.cs @@ -1,6 +1,4 @@ using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; using Drift.Diff.Domain; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -8,6 +6,7 @@ using Drift.Domain.Device.Discovered; using Drift.Domain.Extensions; using Drift.Domain.Scan; +using Drift.Serialization.Converters; using Drift.TestUtilities; using JsonConverter = Drift.Serialization.JsonConverter; @@ -247,21 +246,4 @@ private static void Print( List diffs ) { Console.WriteLine( $"{diff.PropertyPath}: {diff.DiffType} — '{diff.Original}' → '{diff.Updated}'" ); } } - - private sealed class IpAddressConverter : JsonConverter { - public override System.Net.IPAddress Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options - ) { - string? ip = reader.GetString(); - var ipAddress = ( ip == null ) ? null : System.Net.IPAddress.Parse( ip ); - return ipAddress ?? throw new Exception( "Cannot read" ); // System.Net.IPAddress.None; - } - - public override void Write( Utf8JsonWriter writer, System.Net.IPAddress value, JsonSerializerOptions options ) { - ArgumentNullException.ThrowIfNull( writer ); - writer.WriteStringValue( value?.ToString() ); - } - } } \ No newline at end of file diff --git a/src/Domain/AgentId.cs b/src/Domain/AgentId.cs new file mode 100644 index 00000000..8fc538e5 --- /dev/null +++ b/src/Domain/AgentId.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Serialization; + +namespace Drift.Domain; + +public class AgentId { + private const string Prefix = "agentid_"; + + [JsonConstructor] + public AgentId() { + } + + public AgentId( string value ) { + if ( !value.StartsWith( Prefix ) ) { + throw new FormatException( $"AgentId must start with '{Prefix}'." ); + } + + Value = value; + } + + public string Value { + get; + set; + } = string.Empty; + + public bool IsGuidBased => + Guid.TryParse( Value[Prefix.Length..], out _ ); + + public Guid? AsGuidOrNull => + Guid.TryParse( Value[Prefix.Length..], out var guid ) ? guid : null; + + public static implicit operator AgentId( string value ) => new AgentId( value ); + + public static implicit operator string( AgentId id ) => id.Value; + + public static AgentId New() => new AgentId( Prefix + Guid.NewGuid() ); + + public override string ToString() => Value; +} \ No newline at end of file diff --git a/src/Domain/Environment.cs b/src/Domain/Environment.cs new file mode 100644 index 00000000..e56727f2 --- /dev/null +++ b/src/Domain/Environment.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; + +// TODO remove when no longer a draft +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + +namespace Drift.Domain; + +/*[JsonSerializable( typeof(Environment) )] // Enable source generation for this type +[JsonSourceGenerationOptions( GenerationMode = JsonSourceGenerationMode.Default )] +public partial class EnvironmentContext : JsonSerializerContext { +}*/ + +public record Environment { + public required string Name { + get; + init; + } + + public bool Active { + get; + set; + } + + public List Agents { + get; + set; + } +} + +public record Agent { + public string Id { // TODO use AgentId???? or should that only be for internal use + get; + set; + } + + /*public IpAddress Address { + //TODO support hostname too, maybe even mac!? + get; + set; + }*/ + + public string Address { + get; + set; + } + + public AgentAuthentication Authentication { + get; + set; + } +} + +public record AgentAuthentication { + [JsonIgnore( Condition = JsonIgnoreCondition.Never )] + public AuthType Type { + get; + set; + } +} + +public enum AuthType { + None = 1, + ApiKey = 2, + Certificate =3 +} \ No newline at end of file diff --git a/src/Domain/Inventory.cs b/src/Domain/Inventory.cs index 2ece762e..347999be 100644 --- a/src/Domain/Inventory.cs +++ b/src/Domain/Inventory.cs @@ -5,4 +5,9 @@ public required Network Network { get; init; } + + public List Agents { + get; + set; + } = []; } \ No newline at end of file diff --git a/src/Domain/RequestId.cs b/src/Domain/RequestId.cs new file mode 100644 index 00000000..259b7369 --- /dev/null +++ b/src/Domain/RequestId.cs @@ -0,0 +1,23 @@ +namespace Drift.Domain; + +public record RequestId( Guid Value ) { + private const string Prefix = "requestid_"; + + public static implicit operator RequestId( string value ) { +#pragma warning disable S3877 // Throwing in an implicit operator is intentional here — invalid inputs should be rejected early + if ( !value.StartsWith( Prefix ) ) { + throw new FormatException( $"Invalid RequestId format. Must start with '{Prefix}'." ); + } + + var guidPart = value[Prefix.Length..]; + + if ( !Guid.TryParse( guidPart, out var guid ) ) { + throw new FormatException( "Invalid GUID in RequestId." ); + } +#pragma warning restore S3877 + + return new RequestId( guid ); + } + + public static implicit operator string( RequestId id ) => $"{Prefix}{id.Value}"; +} \ No newline at end of file diff --git a/src/Networking.Cluster/Cluster.cs b/src/Networking.Cluster/Cluster.cs new file mode 100644 index 00000000..722bd799 --- /dev/null +++ b/src/Networking.Cluster/Cluster.cs @@ -0,0 +1,210 @@ +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.PeerStreaming.Core.Messages; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.Cluster; + +internal sealed class Cluster( + IPeerMessageEnvelopeConverter envelopeConverter, + IPeerStreamManager peerStreamManager, + PeerResponseCorrelator responseCorrelator, + ILogger logger, + ClusterOptions? options = null +) : ICluster { + private readonly ClusterOptions _options = options ?? new ClusterOptions(); + /*public async Task SendAsync( + Domain.Agent agent, + TMessage message, + CancellationToken cancellationToken = default + ) where TMessage : IPeerMessage { + try { + await SendInternalAsync( agent, message, cancellationToken ); + } + catch ( Exception ex ) { + logger.LogWarning( ex, "Send to {Peer} failed", agent ); + } + }*/ + + /* public async Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default ) { + var peers = peerStreamManager.GetConnectedPeers(); + + var tasks = peers.Select( async peer => { + // TODO optimistically assume connection is alive, but automatically reconnect if it's not + try { + await SendInternalAsync( peer, message, cancellationToken ); + } + catch ( Exception ex ) { + logger.LogWarning( ex, "Broadcast to {Peer} failed", peer ); + } + } ); + + await Task.WhenAll( tasks ); + }*/ + + /*public async Task SendInternalAsync( + Domain.Agent agent, + TMessage message, + CancellationToken cancellationToken = default + ) where TMessage : IPeerMessage { + var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), "agentid_local1" ); + var envelope = envelopeConverter.ToEnvelope( message ); + await connection.SendAsync( envelope ); + }*/ + + public async Task SendAndWaitAsync( + Domain.Agent agent, + TRequest message, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) where TResponse : IPeerResponse where TRequest : IPeerRequest { + return await ExecuteWithRetryAsync( + agent, + async () => await SendAndWaitInternalAsync( agent, message, timeout, cancellationToken ), + cancellationToken + ); + } + + private async Task SendAndWaitInternalAsync( + Domain.Agent agent, + TRequest message, + TimeSpan? timeout, + CancellationToken cancellationToken + ) where TResponse : IPeerResponse where TRequest : IPeerRequest { + var correlationId = Guid.NewGuid().ToString(); + var envelope = envelopeConverter.ToEnvelope( message ); + envelope.CorrelationId = correlationId; + + // Register correlator BEFORE sending + var responseTask = responseCorrelator.WaitForResponseAsync( + correlationId, + timeout ?? _options.DefaultTimeout, + cancellationToken + ); + + // Request + var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); + await connection.SendAsync( envelope ); + + // Response + var response = await responseTask; + return envelopeConverter.FromEnvelope( response ); + } + + public async Task SendAndWaitStreamingAsync( + Domain.Agent agent, + TRequest message, + string finalMessageType, + Action onProgressUpdate, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage { + return await ExecuteWithRetryAsync( + agent, + async () => await SendAndWaitStreamingInternalAsync( + agent, + message, + finalMessageType, + onProgressUpdate, + timeout, + cancellationToken + ), + cancellationToken + ); + } + + private async Task SendAndWaitStreamingInternalAsync( + Domain.Agent agent, + TRequest message, + string finalMessageType, + Action onProgressUpdate, + TimeSpan? timeout, + CancellationToken cancellationToken + ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage { + var correlationId = Guid.NewGuid().ToString(); + var envelope = envelopeConverter.ToEnvelope( message ); + envelope.CorrelationId = correlationId; + + // Register streaming correlator BEFORE sending + var responseTask = responseCorrelator.WaitForStreamingResponseAsync( + correlationId, + finalMessageType, + onProgressUpdate, + timeout ?? _options.StreamingTimeout, + cancellationToken + ); + + // Request + var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); + await connection.SendAsync( envelope ); + + // Final Response + var response = await responseTask; + return envelopeConverter.FromEnvelope( response ); + } + + private async Task ExecuteWithRetryAsync( + Domain.Agent agent, + Func> operation, + CancellationToken cancellationToken + ) { + var attempt = 0; + Exception? lastException = null; + + while ( attempt <= _options.MaxRetryAttempts ) { + try { + if ( attempt > 0 ) { + var delay = CalculateBackoffDelay( attempt ); + logger.LogDebug( + "Retrying operation for agent {AgentId} (attempt {Attempt}/{MaxAttempts}) after {Delay}ms", + agent.Id, + attempt, + _options.MaxRetryAttempts, + delay + ); + await Task.Delay( delay, cancellationToken ); + } + + return await operation(); + } + catch ( OperationCanceledException ) { + // Don't retry on cancellation + throw; + } + catch ( Exception ex ) { + lastException = ex; + attempt++; + + if ( attempt > _options.MaxRetryAttempts ) { + logger.LogError( + ex, + "Operation failed for agent {AgentId} after {Attempts} attempts", + agent.Id, + attempt + ); + break; + } + + logger.LogWarning( + ex, + "Operation failed for agent {AgentId} (attempt {Attempt}/{MaxAttempts}): {Message}", + agent.Id, + attempt, + _options.MaxRetryAttempts, + ex.Message + ); + } + } + + // All retries exhausted + throw new AggregateException( + $"Operation failed for agent {agent.Id} after {attempt} attempts", + lastException! + ); + } + + private int CalculateBackoffDelay( int attempt ) { + // Exponential backoff: base * 2^(attempt-1) + var delay = _options.RetryBaseDelayMs * Math.Pow( 2, attempt - 1 ); + return (int)Math.Min( delay, _options.RetryMaxDelayMs ); + } +} \ No newline at end of file diff --git a/src/Networking.Cluster/ClusterOptions.cs b/src/Networking.Cluster/ClusterOptions.cs new file mode 100644 index 00000000..032ccbb7 --- /dev/null +++ b/src/Networking.Cluster/ClusterOptions.cs @@ -0,0 +1,43 @@ +namespace Drift.Networking.Cluster; + +public sealed class ClusterOptions { + /// + /// Gets the maximum number of retry attempts for failed operations. + /// + public int MaxRetryAttempts { + get; + init; + } = 3; + + /// + /// Gets the base delay between retry attempts in milliseconds. + /// + public int RetryBaseDelayMs { + get; + init; + } = 100; + + /// + /// Gets the maximum delay between retry attempts in milliseconds. + /// + public int RetryMaxDelayMs { + get; + init; + } = 5000; + + /// + /// Gets the default timeout for send-and-wait operations. + /// + public TimeSpan DefaultTimeout { + get; + init; + } = TimeSpan.FromSeconds( 30 ); + + /// + /// Gets the default timeout for streaming operations (e.g., scans). + /// + public TimeSpan StreamingTimeout { + get; + init; + } = TimeSpan.FromMinutes( 5 ); +} \ No newline at end of file diff --git a/src/Networking.Cluster/Enrollment.cs b/src/Networking.Cluster/Enrollment.cs new file mode 100644 index 00000000..4d8fcee4 --- /dev/null +++ b/src/Networking.Cluster/Enrollment.cs @@ -0,0 +1,12 @@ +namespace Drift.Networking.Cluster; + +#pragma warning disable CS9113 +public class EnrollmentRequest( bool parametersAdoptable, string? parametersJoin ) { +#pragma warning restore CS9113 + public EnrollmentMethod Method => parametersAdoptable ? EnrollmentMethod.Adoption : EnrollmentMethod.Jwt; +} + +public enum EnrollmentMethod { + Adoption, + Jwt +} \ No newline at end of file diff --git a/src/Networking.Cluster/ICluster.cs b/src/Networking.Cluster/ICluster.cs new file mode 100644 index 00000000..14861026 --- /dev/null +++ b/src/Networking.Cluster/ICluster.cs @@ -0,0 +1,29 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Networking.Cluster; + +public interface ICluster { + // Task SendAsync( Domain.Agent agent, IPeerMessage message, CancellationToken cancellationToken = default ); + + Task SendAndWaitAsync( + Domain.Agent agent, + TRequest message, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) where TResponse : IPeerResponse where TRequest : IPeerRequest; + + Task SendAndWaitStreamingAsync( + Domain.Agent agent, + TRequest message, + string finalMessageType, + Action onProgressUpdate, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage; + + /*Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default ); + Task> RequestSubnetsAsync( string peerAddress, CancellationToken cancellationToken = default ); + Task EnsureConnectedAsync( string peerAddress, CancellationToken cancellationToken = default ); + IReadOnlyCollection GetConnectedPeers();*/ +} \ No newline at end of file diff --git a/src/Networking.Cluster/Networking.Cluster.csproj b/src/Networking.Cluster/Networking.Cluster.csproj new file mode 100644 index 00000000..9c366ae1 --- /dev/null +++ b/src/Networking.Cluster/Networking.Cluster.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/Networking.Cluster/ServiceCollectionExtensions.cs b/src/Networking.Cluster/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..5d26edd7 --- /dev/null +++ b/src/Networking.Cluster/ServiceCollectionExtensions.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.Cluster; + +public static class ServiceCollectionExtensions { + public static void AddClustering( this IServiceCollection services ) { + services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs b/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs new file mode 100644 index 00000000..85cbf055 --- /dev/null +++ b/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs @@ -0,0 +1,13 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Grpc.Net.Client; + +namespace Drift.Networking.PeerStreaming.Client; + +internal sealed class DefaultPeerClientFactory : IPeerClientFactory { + public (PeerService.PeerServiceClient Client, GrpcChannel Channel) Create( Uri address ) { + var channel = GrpcChannel.ForAddress( address, new GrpcChannelOptions() ); + var client = new PeerService.PeerServiceClient( channel ); + return ( client, channel ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj b/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj new file mode 100644 index 00000000..20592b5f --- /dev/null +++ b/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..81cf54a9 --- /dev/null +++ b/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,10 @@ +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.PeerStreaming.Client; + +public static class ServiceCollectionExtensions { + public static void AddPeerStreamingClient( this IServiceCollection services ) { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs b/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs new file mode 100644 index 00000000..d516ef7b --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs @@ -0,0 +1,6 @@ +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public enum ConnectionSide { + Incoming, + Outgoing +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs b/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs new file mode 100644 index 00000000..2c43940d --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public class Empty : IPeerResponse { + private Empty() { + } + + internal static Empty Instance { + get; + } = new(); + + public static string MessageType => "empty-response"; + + public static JsonTypeInfo JsonInfo => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs new file mode 100644 index 00000000..f7a92656 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs @@ -0,0 +1,8 @@ +using Drift.Networking.Grpc.Generated; +using Grpc.Net.Client; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerClientFactory { + (PeerService.PeerServiceClient Client, GrpcChannel Channel) Create( Uri address ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs new file mode 100644 index 00000000..0d6ef8e3 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerMessage { + static abstract string MessageType { + get; + } + + static abstract JsonTypeInfo JsonInfo { + get; + } +} + +#pragma warning disable S2326 // TResponse is an intentional phantom type parameter for type-safe request/response pairing +public interface IPeerRequest : IPeerMessage where TResponse : IPeerResponse; +#pragma warning restore S2326 + +public interface IPeerResponse : IPeerMessage { + static readonly Empty Empty = Empty.Instance; +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs new file mode 100644 index 00000000..69a02c1a --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs @@ -0,0 +1,9 @@ +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerMessageEnvelopeConverter { + public PeerMessage ToEnvelope( IPeerMessage message, string? requestId = null ) where T : IPeerMessage; + + public T FromEnvelope( PeerMessage envelope ) where T : IPeerMessage; +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs new file mode 100644 index 00000000..26a7740b --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs @@ -0,0 +1,23 @@ +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerMessageHandler { + /// + /// Gets the message type name that this handler can process. + /// + string MessageType { + get; + } + + /// + /// Handles an incoming peer message. The handler is responsible for sending response(s) + /// via the provided stream. Can send multiple responses for streaming scenarios. + /// + Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs new file mode 100644 index 00000000..37d6fe8d --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerMessageTypesProvider { + Dictionary Get(); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs new file mode 100644 index 00000000..6d34c9b8 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs @@ -0,0 +1,20 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerStream : IAsyncDisposable { + public int InstanceNo { + get; + } + + public AgentId AgentId { + get; + } + + public Task ReadTask { + get; + } + + public Task SendAsync( PeerMessage message ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs new file mode 100644 index 00000000..cf4576e3 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs @@ -0,0 +1,15 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; +using Grpc.Core; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public interface IPeerStreamManager : IAsyncDisposable { + public IPeerStream GetOrCreate( Uri peerAddress, AgentId id ); + + public IPeerStream Create( + IAsyncStreamReader requestStream, + IAsyncStreamWriter responseStream, + ServerCallContext context + ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj b/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj new file mode 100644 index 00000000..97a14ed6 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs new file mode 100644 index 00000000..92ea5b17 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs @@ -0,0 +1,36 @@ +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.PeerStreaming.Core.Abstractions; + +public static class PeerMessageHandlerExtensions { + /// + /// Helper to send a response with the correct correlation ID set. + /// + /// The type of the response message to send. + public static async Task SendResponseAsync( + this IPeerStream stream, + IPeerMessageEnvelopeConverter converter, + TResponse response, + string correlationId + ) where TResponse : IPeerMessage { + var envelope = converter.ToEnvelope( response ); + envelope.ReplyTo = correlationId; + await stream.SendAsync( envelope ); + } + + /// + /// Helper to send a response without awaiting (fire and forget). + /// Useful for progress updates that shouldn't block processing. + /// + /// The type of the response message to send. + public static void SendResponseFireAndForget( + this IPeerStream stream, + IPeerMessageEnvelopeConverter converter, + TResponse response, + string correlationId + ) where TResponse : IPeerMessage { + var envelope = converter.ToEnvelope( response ); + envelope.ReplyTo = correlationId; + _ = stream.SendAsync( envelope ); + } +} diff --git a/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs b/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs new file mode 100644 index 00000000..77717661 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs @@ -0,0 +1,16 @@ +using Drift.Domain; +using Grpc.Core; + +namespace Drift.Networking.PeerStreaming.Core.Common; + +internal static class GrpcMetadataExtensions { + internal static AgentId GetAgentId( this Metadata metadata ) { + var v = metadata.Get( "agent-id" ); + + if ( v == null ) { + throw new Exception( "AgentId not found in gRPC metadata" ); + } + + return new AgentId( v.Value ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs b/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs new file mode 100644 index 00000000..18fa525e --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs @@ -0,0 +1,48 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Core.Messages; + +public sealed class PeerMessageDispatcher { + private readonly PeerResponseCorrelator _responseCorrelator; + private readonly IPeerMessageEnvelopeConverter _envelopeConverter; + private readonly ILogger _logger; + private readonly Dictionary _handlers; + + public PeerMessageDispatcher( + IEnumerable handlers, + IPeerMessageEnvelopeConverter envelopeConverter, + PeerResponseCorrelator responseCorrelator, + ILogger logger + ) { + _responseCorrelator = responseCorrelator; + _envelopeConverter = envelopeConverter; + _logger = logger; + _handlers = handlers.ToDictionary( h => h.MessageType, StringComparer.OrdinalIgnoreCase ); + } + + public async Task DispatchAsync( PeerMessage message, PeerStream peerStream, CancellationToken ct = default ) { + _logger.LogDebug( "Dispatching message: {Type}", message.MessageType ); + + // If this is a response to a pending request, complete it + if ( !string.IsNullOrEmpty( message.ReplyTo ) ) { + if ( _responseCorrelator.TryCompleteResponse( message.ReplyTo, message ) ) { + _logger.LogDebug( "Completed pending request: {CorrelationId}", message.ReplyTo ); + } + else { + _logger.LogWarning( "Ignoring response for unknown correlation ID: {CorrelationId}", message.ReplyTo ); + } + + return; + } + + // Dispatch to handler - handler is responsible for sending response(s) + if ( _handlers.TryGetValue( message.MessageType, out var handler ) ) { + await handler.HandleAsync( message, _envelopeConverter, peerStream, ct ); + return; + } + + throw new NotImplementedException( "Unknown message type '" + message.MessageType + "'" ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs new file mode 100644 index 00000000..3b9a4467 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Networking.PeerStreaming.Core.Messages; + +internal sealed class PeerMessageEnvelopeConverter : IPeerMessageEnvelopeConverter { + public PeerMessage ToEnvelope( IPeerMessage message, string? requestId = null ) where T : IPeerMessage { + string json = JsonSerializer.Serialize( message, T.JsonInfo ); + return new PeerMessage { MessageType = T.MessageType, Message = json, }; + } + + public T FromEnvelope( PeerMessage envelope ) where T : IPeerMessage { + if ( envelope.MessageType != T.MessageType ) { + throw new InvalidOperationException( + $"Envelope contains '{envelope.MessageType}' but caller expects '{T.MessageType}'." + ); + } + + return JsonSerializer.Deserialize( envelope.Message, T.JsonInfo.Options )!; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs b/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs new file mode 100644 index 00000000..d0c16f2f --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs @@ -0,0 +1,104 @@ +using System.Collections.Concurrent; +using Drift.Networking.Grpc.Generated; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Core.Messages; + +// TODO private? +public sealed class PeerResponseCorrelator { + private readonly ConcurrentDictionary> _pendingRequests = new(); + private readonly ConcurrentDictionary _streamingRequests = new(); + private readonly ILogger _logger; + + public PeerResponseCorrelator( ILogger logger ) { + _logger = logger; + } + + public Task WaitForResponseAsync( string correlationId, TimeSpan timeout, CancellationToken ct ) { + var tcs = new TaskCompletionSource(); + + if ( !_pendingRequests.TryAdd( correlationId, tcs ) ) { + throw new InvalidOperationException( $"Correlation ID {correlationId} already exists" ); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + cts.CancelAfter( timeout ); + + cts.Token.Register( () => { + if ( _pendingRequests.TryRemove( correlationId, out var removed ) ) { + removed.TrySetCanceled(); + } + } ); + + return tcs.Task; + } + + public Task WaitForStreamingResponseAsync( + string correlationId, + string finalMessageType, + Action onProgressUpdate, + TimeSpan timeout, + CancellationToken ct + ) { + var handler = new StreamingResponseHandler { + CompletionSource = new TaskCompletionSource(), + FinalMessageType = finalMessageType, + OnProgressUpdate = onProgressUpdate + }; + + if ( !_streamingRequests.TryAdd( correlationId, handler ) ) { + throw new InvalidOperationException( $"Correlation ID {correlationId} already exists" ); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + cts.CancelAfter( timeout ); + + cts.Token.Register( () => { + if ( _streamingRequests.TryRemove( correlationId, out var removed ) ) { + removed.CompletionSource.TrySetCanceled(); + } + } ); + + return handler.CompletionSource.Task; + } + + public bool TryCompleteResponse( string correlationId, PeerMessage response ) { + // Check for streaming response first + if ( _streamingRequests.TryGetValue( correlationId, out var streamingHandler ) ) { + // If this is the final message, complete the task + if ( response.MessageType == streamingHandler.FinalMessageType ) { + _streamingRequests.TryRemove( correlationId, out _ ); + return streamingHandler.CompletionSource.TrySetResult( response ); + } + + // Otherwise, it's a progress update + streamingHandler.OnProgressUpdate( response ); + return true; + } + + // Check for regular single response + if ( _pendingRequests.TryRemove( correlationId, out var tcs ) ) { + return tcs.TrySetResult( response ); + } + + _logger.LogWarning( "Received response for unknown correlation ID: {CorrelationId}", correlationId ); + return false; + } + + private sealed class StreamingResponseHandler { + public required TaskCompletionSource CompletionSource { + get; + init; + } + + public required string FinalMessageType { + get; + init; + } + + public required Action OnProgressUpdate { + get; + init; + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj b/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj new file mode 100644 index 00000000..9d441dfb --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Core/PeerStream.cs b/src/Networking.PeerStreaming.Core/PeerStream.cs new file mode 100644 index 00000000..4608989c --- /dev/null +++ b/src/Networking.PeerStreaming.Core/PeerStream.cs @@ -0,0 +1,121 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.PeerStreaming.Core.Messages; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Core; + +public sealed class PeerStream : IPeerStream { + private static int _instanceCounter; // Being static is not ideal for testing with multiple instances + private readonly IAsyncStreamReader _reader; + private readonly IAsyncStreamWriter _writer; + private readonly PeerMessageDispatcher _dispatcher; + private readonly ILogger _logger; + private readonly CancellationToken _cancellationToken; + + public PeerStream( + IAsyncStreamReader reader, + IAsyncStreamWriter writer, + PeerMessageDispatcher dispatcher, + ILogger logger, + CancellationToken cancellationToken + ) { + Side = ConnectionSide.Incoming; + _reader = reader; + _writer = writer; + _dispatcher = dispatcher; + _logger = logger; + _cancellationToken = cancellationToken; + // The read loop is considering the cancellation token to ensure a clean shutdown. Don't pass it to the task. + ReadTask = Task.Run( ReadLoopAsync, CancellationToken.None ); + } + + public PeerStream( + Uri address, + IAsyncStreamReader reader, + IAsyncStreamWriter writer, + PeerMessageDispatcher dispatcher, + ILogger logger, + CancellationToken cancellationToken + ) : this( reader, writer, dispatcher, logger, cancellationToken ) { + Side = ConnectionSide.Outgoing; + Address = address; + } + + public int InstanceNo { + get; + } = Interlocked.Increment( ref _instanceCounter ); + + private ConnectionSide Side { + get; + } + + private Uri? Address { + get; + } + + public required AgentId AgentId { + get; + init; + } + + public Task ReadTask { + get; + private init; + } + + public async Task SendAsync( PeerMessage message ) { + await _writer.WriteAsync( message, _cancellationToken ); // TODO also take another cancellation token (combine) + } + + private async Task ReadLoopAsync() { + _logger.LogDebug( "Read loop starting..." ); + + try { + await foreach ( var message in _reader.ReadAllAsync( _cancellationToken ) ) { + try { + // TODO ensure this is printed in the output + using var scope = _logger.BeginScope( + new Dictionary { ["RequestId"] = message.CorrelationId ?? "no-id" } + ); + _logger.LogDebug( "Received message. Dispatching to handler..." ); + await _dispatcher.DispatchAsync( message, this, CancellationToken.None ); + _logger.LogDebug( "Dispatch completed. Waiting for next message..." ); + } + catch ( Exception ex ) { + _logger.LogError( ex, "Message dispatch failed" ); + } + } + + _logger.LogDebug( "Read loop ended gracefully (end of stream)" ); + } + catch ( OperationCanceledException ) { + // Justification: exception is control flow, not an error +#pragma warning disable S6667 + _logger.LogDebug( "Read loop ended gracefully (cancelled)" ); +#pragma warning restore S6667 + } + catch ( Exception ex ) { + _logger.LogError( ex, "Read loop failed" ); + } + } + + public async ValueTask DisposeAsync() { + _logger.LogTrace( "Disposing {PeerStream}", this ); + + if ( _writer is IClientStreamWriter clientWriter ) { + // I.e., outgoing stream (client initiated) + // Server streams are automatically completed by the gRPC framework + await clientWriter.CompleteAsync(); + } + + await ReadTask; + } + + public override string ToString() { + return + $"{nameof(PeerStream)}[#{InstanceNo}, {nameof(AgentId)}={AgentId}, {nameof(Side)}={Side}, {nameof(Address)}={Address?.ToString() ?? "n/a"}]"; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/PeerStreamManager.cs b/src/Networking.PeerStreaming.Core/PeerStreamManager.cs new file mode 100644 index 00000000..78b0f673 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/PeerStreamManager.cs @@ -0,0 +1,79 @@ +using System.Collections.Concurrent; +using Drift.Domain; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.PeerStreaming.Core.Common; +using Drift.Networking.PeerStreaming.Core.Messages; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Core; + +internal sealed class PeerStreamManager( + ILogger logger, + IPeerClientFactory? peerClientFactory, + PeerMessageDispatcher dispatcher, + PeerStreamingOptions options +) : IPeerStreamManager { + private readonly ConcurrentDictionary _streams = new(); + + public IPeerStream GetOrCreate( Uri peerAddress, AgentId id ) { + logger.LogDebug( + "Getting or creating {ConnectionSide} stream to agent {Id} ({Address})", + ConnectionSide.Outgoing, + id, + peerAddress + ); + + return _streams.GetOrAdd( id, agentId => Create( peerAddress, agentId ) ); + } + + private IPeerStream Create( Uri peerAddress, AgentId id ) { + if ( peerClientFactory == null ) { + throw new Exception( $"Cannot create outbound stream since {nameof(peerClientFactory)} is null" ); + } + + var (client, _) = peerClientFactory.Create( peerAddress ); + var callOptions = new CallOptions( new Metadata { { "agent-id", id } } ); + var call = client.PeerStream( callOptions ); + + var stream = new PeerStream( + peerAddress, + call.ResponseStream, + call.RequestStream, + dispatcher, + logger, + options.StoppingToken + ) { AgentId = id }; + Add( stream ); + return stream; + } + + public IPeerStream Create( + IAsyncStreamReader requestStream, + IAsyncStreamWriter responseStream, + ServerCallContext context + ) { + var agentId = context.RequestHeaders.GetAgentId(); + + logger.LogInformation( "Creating {ConnectionSide} stream from agent {Id}", ConnectionSide.Incoming, agentId ); + + var stream = + new PeerStream( requestStream, responseStream, dispatcher, logger, options.StoppingToken ) { AgentId = agentId }; + Add( stream ); + return stream; + } + + private void Add( IPeerStream stream ) { + logger.LogTrace( "Created {Stream}", stream ); + _streams[stream.AgentId] = stream; + } + + public async ValueTask DisposeAsync() { + logger.LogDebug( "Disposing peer stream manager (including all streams)" ); + foreach ( var stream in _streams.Values ) { + logger.LogTrace( "Disposing peer stream #{StreamNo}", stream.InstanceNo ); + await stream.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs b/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs new file mode 100644 index 00000000..2ca2a36e --- /dev/null +++ b/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs @@ -0,0 +1,15 @@ +using System.Reflection; + +namespace Drift.Networking.PeerStreaming.Core; + +public sealed class PeerStreamingOptions { + public CancellationToken StoppingToken { + get; + set; + } + + public Assembly MessageAssembly { + get; + init; + } = Assembly.GetExecutingAssembly(); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..4ca96603 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.PeerStreaming.Core.Messages; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.PeerStreaming.Core; + +public static class ServiceCollectionExtensions { + public static void AddPeerStreamingCore( + this IServiceCollection services, + PeerStreamingOptions options + ) { + services.AddSingleton( options ); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj b/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj new file mode 100644 index 00000000..92ab3e5b --- /dev/null +++ b/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Grpc/Protos/peer.proto b/src/Networking.PeerStreaming.Grpc/Protos/peer.proto new file mode 100644 index 00000000..c0fccd55 --- /dev/null +++ b/src/Networking.PeerStreaming.Grpc/Protos/peer.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option csharp_namespace = "Drift.Networking.Grpc.Generated"; + +package peer; + +// AgentService? +service PeerService { + rpc PeerStream (stream PeerMessage) returns (stream PeerMessage); +} + +message PeerMessage { + string message_type = 1; + string message = 2; + string correlation_id = 3; // For request-response matching + string reply_to = 4; // If this is a response, which request it replies to +} diff --git a/src/Networking.PeerStreaming.Server/InboundPeerService.cs b/src/Networking.PeerStreaming.Server/InboundPeerService.cs new file mode 100644 index 00000000..d503718f --- /dev/null +++ b/src/Networking.PeerStreaming.Server/InboundPeerService.cs @@ -0,0 +1,31 @@ +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.PeerStreaming.Server; + +// Handle incoming connections (AKA server-side). +internal sealed class InboundPeerService( IPeerStreamManager peerStreamManager, ILogger logger ) + : PeerService.PeerServiceBase { + public override async Task PeerStream( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context + ) { + try { + logger.LogInformation( "Inbound stream starting..." ); + var stream = peerStreamManager.Create( requestStream, responseStream, context ); + logger.LogInformation( "Peer stream #{StreamNo} created", stream.InstanceNo ); + + // The stream is closed when the method returns. + // We thus wait for the read loop to complete (meaning that this client is no longer interested in the stream). + await stream.ReadTask; + + logger.LogInformation( "Peer stream #{StreamNo} completed", stream.InstanceNo ); + } + catch ( Exception ex ) { + logger.LogError( ex, "Inbound stream failed" ); + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj b/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj new file mode 100644 index 00000000..76db19ff --- /dev/null +++ b/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs b/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs new file mode 100644 index 00000000..2b09ab2f --- /dev/null +++ b/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs @@ -0,0 +1,8 @@ +namespace Drift.Networking.PeerStreaming.Server; + +internal sealed class PeerStreamingServerMarker { + internal bool EndpointsMapped { + get; + set; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..79843ab4 --- /dev/null +++ b/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +using Grpc.AspNetCore.Server; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.PeerStreaming.Server; + +public static class ServiceCollectionExtensions { + public static void AddPeerStreamingServer( + this IServiceCollection services, + Action? configureOptions = null + ) { + services.AddSingleton(); + services.AddGrpc( options => configureOptions?.Invoke( options ) ); + services.AddTransient(); + } + + public static void MapPeerStreamingServerEndpoints( this IEndpointRouteBuilder app ) { + var marker = app.ServiceProvider.GetService(); + + if ( marker == null ) { + throw new InvalidOperationException( + $"Unable to find the required services. Add them by calling '{nameof(IServiceCollection)}.{nameof(AddPeerStreamingServer)}'." + ); + } + + app.MapGrpcService(); + + marker.EndpointsMapped = true; + } +} + +internal sealed class PeerStreamingServerValidationFilter : IStartupFilter { + public Action Configure( Action next ) { + return app => { + next( app ); + + var marker = app.ApplicationServices.GetRequiredService(); + + if ( !marker.EndpointsMapped ) { + throw new InvalidOperationException( + $"Server endpoints were not mapped. Map them by calling '{nameof(IEndpointRouteBuilder)}.{nameof(ServiceCollectionExtensions.MapPeerStreamingServerEndpoints)}'." ); + } + }; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs b/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..5493e66a --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Category( "Unit" )] \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs b/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs new file mode 100644 index 00000000..d4a78d08 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs @@ -0,0 +1,121 @@ +using System.Threading.Channels; +using Grpc.Core; +using Channel = System.Threading.Channels.Channel; + +namespace Drift.Networking.PeerStreaming.Tests.Helpers; + +internal sealed record DuplexStreamEndpoint( TRequest RequestStream, TResponse ResponseStream ); + +/// +/// Provides an in-memory bidirectional gRPC stream pair (one endpoint is the client, the other is the server). +/// +internal static class InMemoryDuplexStreamPair { + public static ( + DuplexStreamEndpoint, IAsyncStreamReader> Client, + DuplexStreamEndpoint, IServerStreamWriter> Server + ) + Create( ServerCallContext serverContext ) where TRequest : class where TResponse : class { + var clientToServer = Channel.CreateUnbounded(); + var serverToClient = Channel.CreateUnbounded(); + + var server = new DuplexStreamEndpoint, IServerStreamWriter>( + new InMemoryServerStreamReader( clientToServer.Reader, serverContext ), + new InMemoryServerStreamWriter( serverToClient.Writer, serverContext ) + ); + + var client = new DuplexStreamEndpoint, IAsyncStreamReader>( + new InMemoryStreamWriter( clientToServer.Writer ), + new InMemoryStreamReader( serverToClient.Reader ) + ); + + return ( client, server ); + } + + private sealed class InMemoryStreamWriter : IClientStreamWriter where T : class { + private readonly ChannelWriter _writer; + + internal InMemoryStreamWriter( ChannelWriter writer ) { + _writer = writer; + } + + public WriteOptions? WriteOptions { + get; + set; + } + + public async Task WriteAsync( T message ) { + await _writer.WriteAsync( message ); + } + + public Task CompleteAsync() { + _writer.Complete(); + return Task.CompletedTask; + } + } + + private sealed class InMemoryServerStreamReader : InMemoryStreamReader where T : class { + private readonly ServerCallContext _context; + + internal InMemoryServerStreamReader( ChannelReader reader, ServerCallContext context ) : base( reader ) { + _context = context; + } + + public override Task MoveNext( CancellationToken cancellationToken ) { + _context.CancellationToken.ThrowIfCancellationRequested(); + return base.MoveNext( cancellationToken ); + } + } + + private class InMemoryStreamReader : IAsyncStreamReader where T : class { + private readonly ChannelReader _reader; + + internal InMemoryStreamReader( ChannelReader reader ) { + _reader = reader; + } + + public T Current { + get; + private set; + } = null!; + + public virtual async Task MoveNext( CancellationToken cancellationToken ) { + if ( await _reader.WaitToReadAsync( cancellationToken ) && _reader.TryRead( out var message ) ) { + Current = message; + return true; + } + + Current = null!; + return false; + } + } + + private sealed class InMemoryServerStreamWriter : IServerStreamWriter where T : class { + private readonly ChannelWriter _writer; + private readonly ServerCallContext _context; + + internal InMemoryServerStreamWriter( ChannelWriter writer, ServerCallContext context ) { + _writer = writer; + _context = context; + } + + public WriteOptions? WriteOptions { + get { + return new WriteOptions(); + } + + set { + throw new NotSupportedException(); + } + } + + public Task WriteAsync( T message ) { + _context.CancellationToken.ThrowIfCancellationRequested(); + + if ( !_writer.TryWrite( message ) ) { + throw new InvalidOperationException( "Unable to write message." ); + } + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs b/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs new file mode 100644 index 00000000..4cef4584 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Networking.Grpc.Generated; +using Drift.Networking.PeerStreaming.Core.Abstractions; + +namespace Drift.Networking.PeerStreaming.Tests.Helpers; + +internal sealed class TestPeerMessage : IPeerRequest, IPeerResponse { + public static string MessageType => "test-peer-message"; + + public static JsonTypeInfo JsonInfo => TestPeerMessageJsonContext.Default.TestPeerMessage; +} + +[JsonSerializable( typeof(TestPeerMessage) )] +internal sealed partial class TestPeerMessageJsonContext : JsonSerializerContext; + +internal sealed class TestMessageHandler : IPeerMessageHandler { + public TestPeerMessage? LastMessage { + get; + private set; + } + + public string MessageType => TestPeerMessage.MessageType; + + public Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + IPeerStream stream, + CancellationToken cancellationToken + ) { + var message = converter.FromEnvelope( envelope ); + LastMessage = message; + + // For this test handler, we don't send a response + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs b/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs new file mode 100644 index 00000000..01212db3 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs @@ -0,0 +1,74 @@ +using Grpc.Core; + +namespace Drift.Networking.PeerStreaming.Tests.Helpers; + +internal sealed class TestServerCallContext : ServerCallContext { + private readonly Metadata _requestHeaders; + private readonly CancellationToken _cancellationToken; + private readonly Metadata _responseTrailers; + private readonly AuthContext _authContext; + private readonly Dictionary _userState; + + private TestServerCallContext( Metadata requestHeaders, CancellationToken cancellationToken ) { + _requestHeaders = requestHeaders; + _cancellationToken = cancellationToken; + _responseTrailers = new Metadata(); + _authContext = new AuthContext( string.Empty, new Dictionary>() ); + _userState = new Dictionary(); + } + + public Metadata? ResponseHeaders { + get; + private set; + } + + protected override string MethodCore => "MethodName"; + + protected override string HostCore => "HostName"; + + protected override string PeerCore => "PeerName"; + + protected override DateTime DeadlineCore { + get; + } + + protected override Metadata RequestHeadersCore => _requestHeaders; + + protected override CancellationToken CancellationTokenCore => _cancellationToken; + + protected override Metadata ResponseTrailersCore => _responseTrailers; + + protected override Status StatusCore { + get; + set; + } + + protected override WriteOptions? WriteOptionsCore { + get; + set; + } + + protected override AuthContext AuthContextCore => _authContext; + + protected override IDictionary UserStateCore => _userState; + + public static TestServerCallContext Create( + Metadata? requestHeaders = null, + CancellationToken cancellationToken = default + ) { + return new TestServerCallContext( requestHeaders ?? new Metadata(), cancellationToken ); + } + + protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options ) { + throw new NotImplementedException(); + } + + protected override Task WriteResponseHeadersAsyncCore( Metadata responseHeaders ) { + if ( ResponseHeaders != null ) { + throw new InvalidOperationException( "Response headers have already been written." ); + } + + ResponseHeaders = responseHeaders; + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs b/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs new file mode 100644 index 00000000..3129b711 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs @@ -0,0 +1,23 @@ +using Drift.Networking.Grpc.Generated; +using Grpc.Core; + +namespace Drift.Networking.PeerStreaming.Tests.Helpers; + +internal static class TestServerCallContextExtensions { + public static ( + DuplexStreamEndpoint, IAsyncStreamReader> Client, + DuplexStreamEndpoint, IServerStreamWriter> Server + ) + CreateDuplexStreams( this TestServerCallContext serverContext ) { + return InMemoryDuplexStreamPair.Create( serverContext ); + } + + internal static ( + DuplexStreamEndpoint, IAsyncStreamReader> Client, + DuplexStreamEndpoint, IServerStreamWriter> Server + ) + CreateDuplexStreams( TestServerCallContext serverContext ) where TRequest : class + where TResponse : class { + return InMemoryDuplexStreamPair.Create( serverContext ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/InboundTests.cs b/src/Networking.PeerStreaming.Tests/InboundTests.cs new file mode 100644 index 00000000..a54b2260 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/InboundTests.cs @@ -0,0 +1,77 @@ +using Drift.Networking.PeerStreaming.Core; +using Drift.Networking.PeerStreaming.Core.Messages; +using Drift.Networking.PeerStreaming.Server; +using Drift.Networking.PeerStreaming.Tests.Helpers; +using Drift.TestUtilities; + +namespace Drift.Networking.PeerStreaming.Tests; + +internal sealed class InboundTests { + [Test] + public async Task InboundStreamIsClosedWhenCancelledTest() { + // Arrange + using var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var responseCorrelator = new PeerResponseCorrelator( logger ); + var envelopeConverter = new PeerMessageEnvelopeConverter(); + var peerStreamManager = new PeerStreamManager( + logger, + null, + new PeerMessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), + new PeerStreamingOptions { StoppingToken = cts.Token } + ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + + var inboundPeerService = new InboundPeerService( peerStreamManager, logger ); + + // Act / Assert + var serverStreams = duplexStreams.Server; + var peerStreamTask = + inboundPeerService.PeerStream( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + + Assert.That( peerStreamTask.IsCompleted, Is.False ); + + await cts.CancelAsync(); + + await Task.WhenAny( peerStreamTask, Task.Delay( 1000 ) ); + + Assert.That( peerStreamTask.IsCompleted, Is.True ); + } + + [Test] + public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { + // Arrange + using var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var responseCorrelator = new PeerResponseCorrelator( logger ); + var envelopeConverter = new PeerMessageEnvelopeConverter(); + var peerStreamManager = new PeerStreamManager( + logger, + null, + new PeerMessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), + new PeerStreamingOptions { StoppingToken = cts.Token } + ); + var inboundPeerService = new InboundPeerService( peerStreamManager, logger ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + + // Act + var peerStreamTask = inboundPeerService.PeerStream( + duplexStreams.Server.RequestStream, + duplexStreams.Server.ResponseStream, + callContext + ); + + // Assert + Assert.That( peerStreamTask.IsCompleted, Is.False ); + + await Task.WhenAny( peerStreamTask, Task.Delay( 1000 ) ); + + Assert.That( peerStreamTask.IsCompleted, Is.False ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj b/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj new file mode 100644 index 00000000..42b50c1e --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs b/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs new file mode 100644 index 00000000..9e930af8 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs @@ -0,0 +1,45 @@ +using Drift.Networking.PeerStreaming.Core; +using Drift.Networking.PeerStreaming.Core.Messages; +using Drift.Networking.PeerStreaming.Tests.Helpers; +using Drift.TestUtilities; + +namespace Drift.Networking.PeerStreaming.Tests; + +internal sealed class PeerStreamManagerTests { + [Test] + public async Task IncomingMessageIsDispatchedToHandler() { + // Arrange + var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var testMessageHandler = new TestMessageHandler(); + var envelopeConverter = new PeerMessageEnvelopeConverter(); + var responseCorrelator = new PeerResponseCorrelator( logger ); + var dispatcher = new PeerMessageDispatcher( [testMessageHandler], envelopeConverter, responseCorrelator, logger ); + var peerStreamManager = new PeerStreamManager( + logger, + null, + dispatcher, + new PeerStreamingOptions { StoppingToken = cts.Token } + ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + var serverStreams = duplexStreams.Server; + var stream = peerStreamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + var converter = new PeerMessageEnvelopeConverter(); + + // Act + var clientStreams = duplexStreams.Client; + await clientStreams.RequestStream.WriteAsync( converter.ToEnvelope( new TestPeerMessage() ) ); + + await cts.CancelAsync(); + await stream.ReadTask; + + // Assert + Assert.That( testMessageHandler.LastMessage, Is.Not.Null ); + // Assert.That( testMessageHandler.LastMessage.MessageType, Is.EqualTo( "TestMessageType" ) ); + + cts.Dispose(); + } +} \ No newline at end of file diff --git a/src/Scanning/Scanners/PingSubnetScannerBase.cs b/src/Scanning/Scanners/PingSubnetScannerBase.cs index 11e8a9ff..701fab39 100644 --- a/src/Scanning/Scanners/PingSubnetScannerBase.cs +++ b/src/Scanning/Scanners/PingSubnetScannerBase.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.Diagnostics; using System.Net; +using System.Net.NetworkInformation; using System.Threading.RateLimiting; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -75,7 +76,8 @@ public async Task ScanAsync( Status = ScanResultStatus.InProgress, DiscoveredDevices = ToDiscoveredDevices( pingReplies, ArpTables().Cached ), DiscoveryAttempts = ToDiscoveryAttempts( ipRange, completed ), - Progress = new((byte) Math.Ceiling( ( (double) completed / total ) * 100 )), + // Intermediate result should never report 100%, so cap at 99% + Progress = new((byte) Math.Min( 99, Math.Ceiling( ( (double) completed / total ) * 100 ) )), CidrBlock = cidr }; @@ -124,6 +126,8 @@ private static List ToDiscoveredDevices( ConcurrentBag<( IPAddress Ip, bool Success, string? Hostname)> pingReplies, ArpTable arpTable ) { + var localMacs = BuildLocalInterfaceMacTable(); + return pingReplies.Where( r => r.Success ).Select( pingReply => new DiscoveredDevice { Addresses = CreateAddresses( pingReply ) } ).ToList(); @@ -138,11 +142,42 @@ List CreateAddresses( ( IPAddress Ip, bool Success, string? Host if ( arpTable.TryGetValue( pingReply.Ip, out var mac ) ) { list.Add( mac ); } + else if ( localMacs.TryGetValue( pingReply.Ip, out var localMac ) ) { + // ARP cache never contains the machine's own IPs. Fall back to reading + // the MAC directly from the matching local interface. + list.Add( localMac ); + } return list; } } + /// + /// Builds a map of local unicast IPv4 addresses to the MAC address of the + /// interface that owns them. Used to resolve the MAC for the scanner's own + /// IP addresses, which never appear in the ARP cache. + /// + private static Dictionary BuildLocalInterfaceMacTable() { + var map = new Dictionary(); + + foreach ( var iface in NetworkInterface.GetAllNetworkInterfaces() ) { + var physicalAddress = iface.GetPhysicalAddress(); + if ( physicalAddress.GetAddressBytes().Length == 0 ) { + continue; // loopback and tunnel interfaces have no MAC + } + + var macString = string.Join( "-", physicalAddress.GetAddressBytes().Select( b => b.ToString( "X2" ) ) ); + + foreach ( var unicastAddress in iface.GetIPProperties().UnicastAddresses.Select( u => u.Address ) ) { + if ( unicastAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ) { + map[unicastAddress] = new MacAddress( macString ); + } + } + } + + return map; + } + private static async Task GetHostNameAsync( IPAddress ip, int timeoutMs = 1000 ) { var task = Dns.GetHostEntryAsync( ip ); if ( await Task.WhenAny( task, Task.Delay( timeoutMs ) ) == task ) { diff --git a/src/Scanning/Subnets/CompositeSubnetProvider.cs b/src/Scanning/Subnets/CompositeSubnetProvider.cs index 5fab4eff..8a14fd54 100644 --- a/src/Scanning/Subnets/CompositeSubnetProvider.cs +++ b/src/Scanning/Subnets/CompositeSubnetProvider.cs @@ -1,12 +1,11 @@ -using Drift.Domain; - namespace Drift.Scanning.Subnets; // TODO needed? public class CompositeSubnetProvider( IEnumerable providers ) : ISubnetProvider { private readonly List _providers = providers.ToList(); - public List Get() { - return _providers.SelectMany( p => p.Get() ).Distinct().ToList(); + public async Task> GetAsync() { + var results = await Task.WhenAll( _providers.Select( p => p.GetAsync() ) ); + return results.SelectMany( x => x ).Distinct().ToList(); } } \ No newline at end of file diff --git a/src/Scanning/Subnets/IResolvedSubnet.cs b/src/Scanning/Subnets/IResolvedSubnet.cs new file mode 100644 index 00000000..c1d43f19 --- /dev/null +++ b/src/Scanning/Subnets/IResolvedSubnet.cs @@ -0,0 +1,26 @@ +using Drift.Domain; + +namespace Drift.Scanning.Subnets; + +public abstract record SubnetSource { + public static readonly Local Local = new(); + + public static Agent Agent( AgentId agentId ) { + ArgumentNullException.ThrowIfNull( agentId ); + return new Agent( agentId ); + } +} + +public sealed record Agent( AgentId AgentId ) : SubnetSource { + public override string ToString() { + return AgentId; + } +} + +public sealed record Local : SubnetSource { + public override string ToString() { + return "local"; + } +} + +public sealed record ResolvedSubnet( CidrBlock Cidr, SubnetSource Source ); \ No newline at end of file diff --git a/src/Scanning/Subnets/ISubnetProvider.cs b/src/Scanning/Subnets/ISubnetProvider.cs index f62aaee0..e8067faa 100644 --- a/src/Scanning/Subnets/ISubnetProvider.cs +++ b/src/Scanning/Subnets/ISubnetProvider.cs @@ -1,7 +1,5 @@ -using Drift.Domain; - namespace Drift.Scanning.Subnets; public interface ISubnetProvider { - List Get(); + Task> GetAsync(); } \ No newline at end of file diff --git a/src/Scanning/Subnets/Interface/InterfaceSubnetProviderBase.cs b/src/Scanning/Subnets/Interface/InterfaceSubnetProviderBase.cs index 1def3545..48cb1817 100644 --- a/src/Scanning/Subnets/Interface/InterfaceSubnetProviderBase.cs +++ b/src/Scanning/Subnets/Interface/InterfaceSubnetProviderBase.cs @@ -1,6 +1,5 @@ using System.Net.NetworkInformation; using Drift.Common.Network; -using Drift.Domain; using Microsoft.Extensions.Logging; namespace Drift.Scanning.Subnets.Interface; @@ -8,7 +7,7 @@ namespace Drift.Scanning.Subnets.Interface; public abstract class InterfaceSubnetProviderBase( ILogger? logger ) : IInterfaceSubnetProvider { public abstract List GetInterfaces(); - public List Get() { + public Task> GetAsync() { var interfaces = GetInterfaces(); var interfaceDescriptions = string.Join( @@ -41,7 +40,7 @@ public List Get() { string.Join( ", ", cidrs ) ); - return cidrs; + return Task.FromResult( cidrs.Select( cidr => new ResolvedSubnet( cidr, SubnetSource.Local ) ).ToList() ); } private static bool IsUp( INetworkInterface i ) { diff --git a/src/Scanning/Subnets/PredefinedSubnetProvider.cs b/src/Scanning/Subnets/PredefinedSubnetProvider.cs index 5607d676..0cf08797 100644 --- a/src/Scanning/Subnets/PredefinedSubnetProvider.cs +++ b/src/Scanning/Subnets/PredefinedSubnetProvider.cs @@ -3,10 +3,16 @@ namespace Drift.Scanning.Subnets; public class PredefinedSubnetProvider( IEnumerable subnets ) : ISubnetProvider { - public List Get() { - return subnets - .Where( s => s.Enabled ?? true ) - .Select( s => new CidrBlock( s.Address ) ) - .ToList(); + public Task> GetAsync() { + return Task.FromResult( + subnets + .Where( s => s.Enabled ?? true ) + .Select( s => new ResolvedSubnet( + new CidrBlock( s.Address ), + // TODO how to determine source when from spec? + SubnetSource.Local + ) ) + .ToList() + ); } } \ No newline at end of file diff --git a/src/Serialization/Converters/CidrBlockConverter.cs b/src/Serialization/Converters/CidrBlockConverter.cs new file mode 100644 index 00000000..cfcf45fa --- /dev/null +++ b/src/Serialization/Converters/CidrBlockConverter.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Drift.Domain; + +namespace Drift.Serialization.Converters; + +public sealed class CidrBlockConverter : JsonConverter { + public override CidrBlock Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) { + var cidrString = reader.GetString(); + if ( string.IsNullOrEmpty( cidrString ) ) { + throw new JsonException( "CIDR block string cannot be null or empty" ); + } + + return new CidrBlock( cidrString ); + } + + public override void Write( Utf8JsonWriter writer, CidrBlock value, JsonSerializerOptions options ) { + ArgumentNullException.ThrowIfNull( writer ); + writer.WriteStringValue( value.ToString() ); + } +} \ No newline at end of file diff --git a/src/Serialization/Converters/DeviceAddressConverter.cs b/src/Serialization/Converters/DeviceAddressConverter.cs new file mode 100644 index 00000000..1c1ec4f6 --- /dev/null +++ b/src/Serialization/Converters/DeviceAddressConverter.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Drift.Domain.Device.Addresses; + +namespace Drift.Serialization.Converters; + +public sealed class DeviceAddressConverter : JsonConverter { + public override IDeviceAddress? Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) { + using var doc = JsonDocument.ParseValue( ref reader ); + var root = doc.RootElement; + + if ( !root.TryGetProperty( "type", out var typeProperty ) ) { + throw new JsonException( "Missing 'type' property in IDeviceAddress JSON" ); + } + + var addressType = (AddressType) typeProperty.GetInt32(); + var value = root.GetProperty( "value" ).GetString()!; + var isId = root.TryGetProperty( "isId", out var isIdProperty ) ? isIdProperty.GetBoolean() : (bool?) null; + + return addressType switch { + AddressType.IpV4 => new IpV4Address( value, isId ), + AddressType.Mac => new MacAddress( value, isId ), + AddressType.Hostname => new HostnameAddress( value, isId ), + _ => throw new JsonException( $"Unknown AddressType: {addressType}" ) + }; + } + + public override void Write( Utf8JsonWriter writer, IDeviceAddress value, JsonSerializerOptions options ) { + writer.WriteStartObject(); + writer.WriteNumber( "type", (int) value.Type ); + writer.WriteString( "value", value.Value ); + if ( value.IsId.HasValue ) { + writer.WriteBoolean( "isId", value.IsId.Value ); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Serialization/Converters/IpAddressConverter.cs b/src/Serialization/Converters/IpAddressConverter.cs new file mode 100644 index 00000000..2ffb2b8c --- /dev/null +++ b/src/Serialization/Converters/IpAddressConverter.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Drift.Serialization.Converters; + +public sealed class IpAddressConverter : JsonConverter { + public override System.Net.IPAddress Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) { + string? ip = reader.GetString(); + var ipAddress = ( ip == null ) ? null : System.Net.IPAddress.Parse( ip ); + return ipAddress ?? throw new Exception( "Cannot read" ); // System.Net.IPAddress.None; + } + + public override void Write( Utf8JsonWriter writer, System.Net.IPAddress value, JsonSerializerOptions options ) { + ArgumentNullException.ThrowIfNull( writer ); + writer.WriteStringValue( value?.ToString() ); + } +} diff --git a/src/Serialization/Converters/IpV4AddressSetConverter.cs b/src/Serialization/Converters/IpV4AddressSetConverter.cs new file mode 100644 index 00000000..c542b8ad --- /dev/null +++ b/src/Serialization/Converters/IpV4AddressSetConverter.cs @@ -0,0 +1,43 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using Drift.Domain.Device.Addresses; + +namespace Drift.Serialization.Converters; + +public sealed class IpV4AddressSetConverter : JsonConverter> { + public override IReadOnlySet? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) { + if ( reader.TokenType != JsonTokenType.StartArray ) { + throw new JsonException( "Expected array" ); + } + + var builder = ImmutableHashSet.CreateBuilder(); + + while ( reader.Read() ) { + if ( reader.TokenType == JsonTokenType.EndArray ) { + return builder.ToImmutable(); + } + + var ipAddress = JsonSerializer.Deserialize( ref reader, options ); + if ( ipAddress != null ) { + builder.Add( new IpV4Address( ipAddress ) ); + } + } + + throw new JsonException( "Unexpected end of JSON" ); + } + + public override void Write( Utf8JsonWriter writer, IReadOnlySet value, JsonSerializerOptions options ) { + writer.WriteStartArray(); + + foreach ( var ip in value ) { + JsonSerializer.Serialize( writer, ip.Value, options ); + } + + writer.WriteEndArray(); + } +} \ No newline at end of file diff --git a/src/Serialization/Serialization.csproj b/src/Serialization/Serialization.csproj index c6321610..493404aa 100644 --- a/src/Serialization/Serialization.csproj +++ b/src/Serialization/Serialization.csproj @@ -1,3 +1,7 @@  + + + + diff --git a/src/Spec.Tests/ValidationTests.cs b/src/Spec.Tests/ValidationTests.cs index be3892e3..6ea6498a 100644 --- a/src/Spec.Tests/ValidationTests.cs +++ b/src/Spec.Tests/ValidationTests.cs @@ -1,3 +1,4 @@ +using Drift.Spec.Schema; using Drift.Spec.Validation; using Drift.TestUtilities; using Json.Schema; @@ -89,7 +90,7 @@ internal sealed class ValidationTests { )] public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) { // Arrange / Act - var result = SpecValidator.Validate( yaml, Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); using ( Assert.EnterMultipleScope() ) { Assert.That( result.IsValid, Is.False, "Expected YAML to be invalid, but it was not" ); Assert.That( result.Errors, Is.Not.Empty ); @@ -132,7 +133,7 @@ public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) """ )] public void YamlIsValidTest( int caseNo, string yaml ) { // Arrange / Act - var result = SpecValidator.Validate( yaml, Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); using ( Assert.EnterMultipleScope() ) { Assert.That( result.IsValid, Is.True, result.ToUnitTestMessage() ); Assert.That( result.Errors, Is.Empty ); diff --git a/src/Spec/Dtos/V1_preview/DriftSpec.cs b/src/Spec/Dtos/V1_preview/DriftSpec.cs index b2bc980a..1dbc1a23 100644 --- a/src/Spec/Dtos/V1_preview/DriftSpec.cs +++ b/src/Spec/Dtos/V1_preview/DriftSpec.cs @@ -32,6 +32,11 @@ public Network Network { get; set; }*/ + + public List? Agents { + get; + set; + } } // [Title( "Network declaration" )] @@ -147,6 +152,21 @@ public bool? ScanOnlyDeclaredSubnets { } } +[AdditionalProperties( false )] +public record Agent { + [Required] + public string Id { + get; + set; + } + + [Required] + public string Address { + get; + set; + } +} + public enum UnknownDevicePolicy { Disallowed = 1, Allowed = 2 diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs index d1926213..3f53429c 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs @@ -1,9 +1,32 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Declared; + namespace Drift.Spec.Dtos.V1_preview.Mappers; public static partial class Mapper { - public static Domain.Inventory ToDomain( DriftSpec dto ) { + public static Inventory ToDomain( DriftSpec dto ) { // ArgumentNullException.ThrowIfNull( dto.Address ); - return new Domain.Inventory { Network = Map( dto.Network ) }; + var spec = new Inventory { Network = Map( dto.Network ) }; + + if ( dto.Agents != null ) { + spec.Agents = Map( dto.Agents ); + } + + return spec; + } + + private static List Map( List dto ) { + return dto.Select( Map ).ToList(); + } + + private static Domain.Agent Map( Agent dto ) { + var agent = new Domain.Agent(); + + agent.Id = dto.Id; + agent.Address = dto.Address; + + return agent; } private static Domain.Network Map( Network dto ) { @@ -20,14 +43,14 @@ private static Domain.Network Map( Network dto ) { return network; } - private static List Map( List dto ) { + private static List Map( List dto ) { return dto.Select( Map ).ToList(); } - private static Domain.DeclaredSubnet Map( Subnet dto ) { + private static DeclaredSubnet Map( Subnet dto ) { // ArgumentNullException.ThrowIfNull( dto.Address ); - var subnet = new Domain.DeclaredSubnet { Address = dto.Address }; + var subnet = new DeclaredSubnet { Address = dto.Address }; if ( dto.Id != null ) { subnet.Id = dto.Id; @@ -40,14 +63,14 @@ private static Domain.DeclaredSubnet Map( Subnet dto ) { return subnet; } - private static List Map( List dto ) { + private static List Map( List dto ) { return dto.Select( Map ).ToList(); } - private static Domain.Device.Declared.DeclaredDevice Map( Device dto ) { + private static DeclaredDevice Map( Device dto ) { // ArgumentNullException.ThrowIfNull( dto.Addresses ); - var declaredDevice = new Domain.Device.Declared.DeclaredDevice { Addresses = dto.Addresses.Select( Map ).ToList() }; + var declaredDevice = new DeclaredDevice { Addresses = dto.Addresses.Select( Map ).ToList() }; if ( dto.Id != null ) { declaredDevice.Id = dto.Id; @@ -64,7 +87,7 @@ private static Domain.Device.Declared.DeclaredDevice Map( Device dto ) { return declaredDevice; } - private static Domain.Device.Addresses.IDeviceAddress Map( DeviceAddress dto ) { + private static IDeviceAddress Map( DeviceAddress dto ) { return dto.Type switch { "ip-v4" => new Domain.Device.Addresses.IpV4Address( dto.Value, dto.IsId ?? true ), "mac" => new Domain.Device.Addresses.MacAddress( dto.Value, dto.IsId ?? true ), @@ -73,12 +96,12 @@ private static Domain.Device.Addresses.IDeviceAddress Map( DeviceAddress dto ) { }; } - private static Domain.Device.Declared.DeclaredDeviceState? Map( DeviceState? dto ) { + private static DeclaredDeviceState? Map( DeviceState? dto ) { return dto switch { null => null, - DeviceState.Up => Domain.Device.Declared.DeclaredDeviceState.Up, - DeviceState.Dynamic => Domain.Device.Declared.DeclaredDeviceState.Dynamic, - DeviceState.Down => Domain.Device.Declared.DeclaredDeviceState.Down, + DeviceState.Up => DeclaredDeviceState.Up, + DeviceState.Dynamic => DeclaredDeviceState.Dynamic, + DeviceState.Down => DeclaredDeviceState.Down, _ => throw new ArgumentOutOfRangeException( nameof(dto), dto, null ) }; } diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs index 910568bf..65ab15d9 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs @@ -1,9 +1,13 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Declared; + namespace Drift.Spec.Dtos.V1_preview.Mappers; public static partial class Mapper { internal const string VersionConstant = "v1-preview"; - public static DriftSpec ToDto( Domain.Inventory domain ) { + public static DriftSpec ToDto( Inventory domain ) { return new DriftSpec { Version = VersionConstant, Network = Map( domain.Network ) }; } @@ -13,11 +17,11 @@ private static Network Map( Domain.Network domain ) { }; } - private static Subnet Map( Domain.DeclaredSubnet domain ) { + private static Subnet Map( DeclaredSubnet domain ) { return new Subnet { Id = domain.Id, Address = domain.Address, Enabled = domain.Enabled }; } - private static Device Map( Domain.Device.Declared.DeclaredDevice domain ) { + private static Device Map( DeclaredDevice domain ) { return new Device { Id = domain.Id, Addresses = domain.Addresses.Select( Map ).ToList(), @@ -26,25 +30,25 @@ private static Device Map( Domain.Device.Declared.DeclaredDevice domain ) { }; } - private static DeviceAddress Map( Domain.Device.Addresses.IDeviceAddress domain ) { + private static DeviceAddress Map( IDeviceAddress domain ) { return new DeviceAddress { Value = domain.Value, Type = Map( domain.Type ), IsId = domain.IsId }; } - private static string Map( Domain.Device.Addresses.AddressType addressType ) { + private static string Map( AddressType addressType ) { return addressType switch { - Domain.Device.Addresses.AddressType.IpV4 => "ip-v4", - Domain.Device.Addresses.AddressType.Mac => "mac", - Domain.Device.Addresses.AddressType.Hostname => "hostname", + AddressType.IpV4 => "ip-v4", + AddressType.Mac => "mac", + AddressType.Hostname => "hostname", _ => throw new ArgumentOutOfRangeException( nameof(addressType), addressType, null ) }; } - private static DeviceState? Map( Domain.Device.Declared.DeclaredDeviceState? domain ) { + private static DeviceState? Map( DeclaredDeviceState? domain ) { return domain switch { null => null, - Domain.Device.Declared.DeclaredDeviceState.Up => DeviceState.Up, - Domain.Device.Declared.DeclaredDeviceState.Dynamic => DeviceState.Dynamic, - Domain.Device.Declared.DeclaredDeviceState.Down => DeviceState.Down, + DeclaredDeviceState.Up => DeviceState.Up, + DeclaredDeviceState.Dynamic => DeviceState.Dynamic, + DeclaredDeviceState.Down => DeviceState.Down, _ => throw new ArgumentOutOfRangeException( nameof(domain), domain, null ) }; } diff --git a/src/Spec/Schema/SchemaGenerator.cs b/src/Spec/Schema/SchemaGenerator.cs index b9656436..de37aa5f 100644 --- a/src/Spec/Schema/SchemaGenerator.cs +++ b/src/Spec/Schema/SchemaGenerator.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Drift.Common.Schemas; +using Drift.Spec.Dtos.V1_preview; using Json.Schema; using Json.Schema.Generation; @@ -19,7 +20,7 @@ public static class SchemaGenerator { public static string Generate( SpecVersion version ) { return version switch { - SpecVersion.V1_preview => Generate( version ), + SpecVersion.V1_preview => Generate( version ), _ => throw new ArgumentOutOfRangeException( nameof(version), version, "Unknown spec version" ) }; } diff --git a/src/Spec/Serialization/YamlStaticContext.cs b/src/Spec/Serialization/YamlStaticContext.cs index 78473bcd..b06693eb 100644 --- a/src/Spec/Serialization/YamlStaticContext.cs +++ b/src/Spec/Serialization/YamlStaticContext.cs @@ -1,19 +1,21 @@ +using Drift.Spec.Dtos.V1_preview; using YamlDotNet.Serialization; namespace Drift.Spec.Serialization; [YamlStaticContext] // TODO rely on attributes on the individual types instead? -[YamlSerializable( typeof(Dtos.V1_preview.DriftSpec) )] -[YamlSerializable( typeof(Dtos.V1_preview.Network) )] -[YamlSerializable( typeof(Dtos.V1_preview.Subnet) )] -[YamlSerializable( typeof(Dtos.V1_preview.Device) )] -[YamlSerializable( typeof(Dtos.V1_preview.DeviceState) )] -[YamlSerializable( typeof(Dtos.V1_preview.DeviceAddress) )] +[YamlSerializable( typeof(DriftSpec) )] +[YamlSerializable( typeof(Network) )] +[YamlSerializable( typeof(Subnet) )] +[YamlSerializable( typeof(Device) )] +[YamlSerializable( typeof(DeviceState) )] +[YamlSerializable( typeof(DeviceAddress) )] +[YamlSerializable( typeof(Agent) )] /*[YamlSerializable( typeof(IpV4Address) )] [YamlSerializable( typeof(HostnameAddress) )] [YamlSerializable( typeof(MacAddress) )] [YamlSerializable( typeof(Port) )] [YamlSerializable( typeof(AddressType) )]*/ -public partial class YamlStaticContext : YamlDotNet.Serialization.StaticContext { +public partial class YamlStaticContext : StaticContext { } \ No newline at end of file diff --git a/src/Spec/Validation/SpecValidator.cs b/src/Spec/Validation/SpecValidator.cs index b8ef946c..640f183f 100644 --- a/src/Spec/Validation/SpecValidator.cs +++ b/src/Spec/Validation/SpecValidator.cs @@ -2,13 +2,27 @@ using Drift.Spec.Schema; using Drift.Spec.Serialization; using Json.Schema; +using YamlDotNet.Core; namespace Drift.Spec.Validation; public static class SpecValidator { public static ValidationResult Validate( string yaml, SpecVersion version ) { - var schema = SpecSchemaProvider.Get( version ); - return Validate( yaml, schema ); + try { + var schema = SpecSchemaProvider.Get( version ); + return Validate( yaml, schema ); + } + catch ( YamlException ex ) { + var errors = new List(); + + Exception? exp = ex; + do { + errors.Add( new ValidationError { Message = exp.Message } ); + exp = exp.InnerException; + } while ( exp != null ); + + return new ValidationResult { IsValid = false, Errors = errors }; + } } private static ValidationResult Validate( string yaml, JsonSchema schema ) { diff --git a/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json b/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json index 3c955ed3..cc11b3d2 100644 --- a/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json +++ b/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json @@ -102,6 +102,28 @@ } }, "additionalProperties": false + }, + "agents": { + "type": [ + "null", + "array" + ], + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "address": { + "type": "string" + } + }, + "required": [ + "id", + "address" + ], + "additionalProperties": false + } } }, "required": [ diff --git a/src/TestUtilities/StringLogger.cs b/src/TestUtilities/StringLogger.cs index 5fe21b83..9c612518 100644 --- a/src/TestUtilities/StringLogger.cs +++ b/src/TestUtilities/StringLogger.cs @@ -2,8 +2,8 @@ namespace Drift.TestUtilities; -public sealed class StringLogger( StringWriter? writer = null ) : ILogger { - private readonly StringWriter _writer = writer ?? new StringWriter(); +public sealed class StringLogger( TextWriter? writer = null ) : ILogger { + private readonly TextWriter _writer = writer ?? new StringWriter(); public void Log( LogLevel logLevel, @@ -35,7 +35,7 @@ public bool IsEnabled( LogLevel logLevel ) { } public override string ToString() { - return _writer.ToString(); + return _writer.ToString() ?? string.Empty; } private static string ToSerilogStyleLevel( LogLevel logLevel ) {