From 6c0b8c1952d6706f8490bb39049c1585e5c3bbce Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 19 May 2026 21:28:29 +0200 Subject: [PATCH 01/43] wip --- .editorconfig | 2 + .github/actions/setup-runner/action.yml | 5 + .gitignore | 4 +- .nuke/build.schema.json | 13 + Directory.Packages.props | 8 + Drift.Build.slnx | 2 + Drift.sln | 69 ++++ README_dev.md | 4 + .../NukeBuild/TestNukeBuild.cs | 1 + .../Versioning/TestNukeBuildExtensions.cs | 2 + .../Versioning/VersioningTests.cs | 4 + build/NukeBuild.Test.cs | 2 +- build/NukeBuild.TestContainerlab.cs | 353 ++++++++++++++++++ build/NukeBuild.cs | 2 + build/_build.csproj | 9 +- containerlab/README.md | 124 ++++++ containerlab/cooperation-test-spec.yaml | 21 ++ containerlab/cooperation-test.clab.yaml | 70 ++++ containerlab/simple-test-spec.yaml | 11 + containerlab/simple-test.clab.yaml | 26 ++ containerlab/subnet-isolation-test-spec.yaml | 18 + containerlab/subnet-isolation-test.clab.yaml | 112 ++++++ src/Agent.Hosting/Agent.Hosting.csproj | 14 + src/Agent.Hosting/AgentHost.cs | 70 ++++ .../Identity/AgentIdentity.Serialization.cs | 77 ++++ src/Agent.Hosting/Identity/AgentIdentity.cs | 10 + .../Identity/AgentIdentityJsonContext.cs | 17 + .../DefaultAgentIdentityLocationProvider.cs | 24 ++ .../IAgentIdentityLocationProvider.cs | 9 + .../Agent.PeerProtocol.Tests.csproj | 19 + src/Agent.PeerProtocol.Tests/AssemblyInfo.cs | 1 + .../PeerMessageHandlerTests.cs | 59 +++ .../Adopt/AdoptRequestHandler.cs | 24 ++ .../Adopt/AdoptRequestPayload.cs | 22 ++ .../Adopt/AdoptResponsePayload.cs | 18 + .../Agent.PeerProtocol.csproj | 10 + .../PeerProtocolAssemblyMarker.cs | 7 + .../Scan/ScanSubnetCompleteResponse.cs | 25 ++ .../Scan/ScanSubnetProgressUpdate.cs | 30 ++ .../Scan/ScanSubnetRequest.cs | 30 ++ .../Scan/ScanSubnetRequestHandler.cs | 112 ++++++ .../ServiceCollectionExtensions.cs | 15 + .../Subnets/SubnetsRequest.cs | 14 + .../Subnets/SubnetsRequestHandler.cs | 29 ++ .../Subnets/SubnetsResponse.cs | 25 ++ src/ArchTests/SanityTests.cs | 4 +- ...alOptionsTests.HelpOptionTest.verified.txt | 1 + ...ommand_ReturnsSuccessExitCode.verified.txt | 1 + .../TemporarySettingsLocationProvider.cs | 5 +- src/Cli.Tests/BadSettingsFileTests.cs | 2 +- src/Cli.Tests/Cli.Tests.csproj | 1 + ...entCommandTests.MissingOption.verified.txt | 1 + ...ommandTests.SuccessfulStartup.verified.txt | 10 + src/Cli.Tests/Commands/AgentCommandTests.cs | 51 +++ src/Cli.Tests/Commands/InitCommandTests.cs | 8 +- src/Cli.Tests/Commands/LintCommandTests.cs | 6 +- ...ommandTests.Remote.RemoteScan.verified.txt | 18 + .../Commands/ScanCommandTests.Remote.cs | 348 +++++++++++++++++ .../ScanCommandTests.RemoteScan.verified.txt | 24 ++ ..._AgentsOnly_NoLocalInterfaces.verified.txt | 19 + ...Tests.RemoteScan_EmptyResults.verified.txt | 15 + ...RemoteScan_OverlappingSubnets.verified.txt | 23 ++ src/Cli.Tests/Commands/ScanCommandTests.cs | 100 ++++- ...emCommandLineDefaultErrorTest.verified.txt | 1 + ...eptionReturnsUnknownErrorTest.verified.txt | 2 +- src/Cli.Tests/ExitCodeTests.cs | 34 +- src/Cli.Tests/FeatureFlagTest.cs | 80 ++++ src/Cli.Tests/SpecFilePathResolverTests.cs | 2 +- src/Cli.Tests/Utils/CliCommandResult.cs | 24 ++ src/Cli.Tests/Utils/DriftTestCli.cs | 103 ++++- src/Cli.Tests/Utils/RunningCliCommand.cs | 20 + src/Cli/Cli.csproj | 13 +- src/Cli/Commands/Agent/AgentCommand.cs | 24 ++ .../Agent/Subcommands/AgentLifetime.cs | 7 + .../Subcommands/Start/AgentStartCommand.cs | 122 ++++++ .../Subcommands/Start/AgentStartParameters.cs | 64 ++++ .../Common/{ => Commands}/CommandBase.cs | 19 +- .../Common/Commands/ContainerCommandBase.cs | 8 + .../Common/{ => Commands}/ICommandHandler.cs | 6 +- src/Cli/Commands/Common/CommonParameters.cs | 5 +- src/Cli/Commands/Common/DefaultParameters.cs | 19 - .../Common/Parameters/BaseParameters.cs | 14 + .../Common/Parameters/SpecParameters.cs | 13 + src/Cli/Commands/Common/ParseResultHolder.cs | 2 +- src/Cli/Commands/Init/InitCommand.cs | 6 +- src/Cli/Commands/Init/InitParameters.cs | 4 +- src/Cli/Commands/Lint/LintCommand.cs | 5 +- src/Cli/Commands/Lint/LintParameters.cs | 4 +- src/Cli/Commands/Preview/AgentCommand.cs | 36 -- src/Cli/Commands/Scan/AgentSubnetProvider.cs | 48 +++ src/Cli/Commands/Scan/ClusterExtensions.cs | 50 +++ .../Scan/DistributedNetworkScanner.cs | 304 +++++++++++++++ .../SubnetScanResultProcessor.cs | 2 +- src/Cli/Commands/Scan/ScanCommand.cs | 154 +++++--- src/Cli/Commands/Scan/ScanParameters.cs | 3 +- src/Cli/DriftCli.cs | 2 +- src/Cli/Infrastructure/RootCommandFactory.cs | 47 ++- .../Logging/NormalOutputLoggerAdapter.cs | 20 + .../Logging/OutputManagerExtensions.cs | 12 + .../Console/Managers/ConsoleOutputManager.cs | 5 +- .../Console/Managers/Outputs/NormalOutput.cs | 23 +- .../Console/OutputManagerFactory.cs | 31 +- src/Cli/Properties/launchSettings.json | 14 +- src/Common/Common.csproj | 4 +- src/Diff.Tests/DiffTest.cs | 20 +- src/Domain/AgentId.cs | 38 ++ src/Domain/Environment.cs | 65 ++++ src/Domain/Inventory.cs | 5 + src/Domain/RequestId.cs | 23 ++ src/Networking.Cluster/Cluster.cs | 210 +++++++++++ src/Networking.Cluster/ClusterOptions.cs | 43 +++ src/Networking.Cluster/Enrollment.cs | 12 + src/Networking.Cluster/ICluster.cs | 29 ++ .../Networking.Cluster.csproj | 7 + .../ServiceCollectionExtensions.cs | 9 + .../DefaultPeerClientFactory.cs | 13 + .../Networking.PeerStreaming.Client.csproj | 11 + .../ServiceCollectionExtensions.cs | 10 + .../ConnectionSide.cs | 6 + .../Empty.cs | 16 + .../IPeerClientFactory.cs | 8 + .../IPeerMessage.cs | 21 ++ .../IPeerMessageEnvelopeConverter.cs | 9 + .../IPeerMessageHandler.cs | 23 ++ .../IPeerMessageTypesProvider.cs | 7 + .../IPeerStream.cs | 20 + .../IPeerStreamManager.cs | 15 + ...ing.PeerStreaming.Core.Abstractions.csproj | 12 + .../PeerMessageHandlerExtensions.cs | 36 ++ .../Common/GrpcMetadataExtensions.cs | 16 + .../Messages/PeerMessageDispatcher.cs | 48 +++ .../Messages/PeerMessageEnvelopeConverter.cs | 22 ++ .../Messages/PeerResponseCorrelator.cs | 108 ++++++ .../Networking.PeerStreaming.Core.csproj | 14 + .../PeerStream.cs | 121 ++++++ .../PeerStreamManager.cs | 79 ++++ .../PeerStreamingOptions.cs | 15 + .../ServiceCollectionExtensions.cs | 18 + .../Networking.PeerStreaming.Grpc.csproj | 13 + .../Protos/peer.proto | 17 + .../InboundPeerService.cs | 31 ++ .../Networking.PeerStreaming.Server.csproj | 15 + .../PeerStreamingServerMarker.cs | 8 + .../ServiceCollectionExtensions.cs | 47 +++ .../AssemblyInfo.cs | 1 + .../Helpers/InMemoryDuplexStreamPair.cs | 121 ++++++ .../Helpers/TestPeerMessage.cs | 37 ++ .../Helpers/TestServerCallContext.cs | 74 ++++ .../TestServerCallContextExtensions.cs | 23 ++ .../InboundTests.cs | 77 ++++ .../Networking.PeerStreaming.Tests.csproj | 20 + .../PeerStreamManagerTests.cs | 45 +++ .../Scanners/PingSubnetScannerBase.cs | 39 +- .../Subnets/CompositeSubnetProvider.cs | 7 +- src/Scanning/Subnets/IResolvedSubnet.cs | 26 ++ src/Scanning/Subnets/ISubnetProvider.cs | 4 +- .../Interface/InterfaceSubnetProviderBase.cs | 5 +- .../Subnets/PredefinedSubnetProvider.cs | 16 +- .../Converters/CidrBlockConverter.cs | 25 ++ .../Converters/DeviceAddressConverter.cs | 38 ++ .../Converters/IpAddressConverter.cs | 21 ++ .../Converters/IpV4AddressSetConverter.cs | 43 +++ src/Serialization/Serialization.csproj | 4 + src/Spec.Tests/ValidationTests.cs | 5 +- src/Spec/Dtos/V1_preview/DriftSpec.cs | 20 + .../V1_preview/Mappers/Mapper.ToDomain.cs | 49 ++- .../Dtos/V1_preview/Mappers/Mapper.ToDto.cs | 28 +- src/Spec/Schema/SchemaGenerator.cs | 3 +- src/Spec/Serialization/YamlStaticContext.cs | 16 +- src/Spec/Validation/SpecValidator.cs | 18 +- .../schemas/drift-spec-v1-preview.schema.json | 22 ++ src/TestUtilities/StringLogger.cs | 6 +- 172 files changed, 5251 insertions(+), 298 deletions(-) create mode 100644 build/NukeBuild.TestContainerlab.cs create mode 100644 containerlab/README.md create mode 100644 containerlab/cooperation-test-spec.yaml create mode 100644 containerlab/cooperation-test.clab.yaml create mode 100644 containerlab/simple-test-spec.yaml create mode 100644 containerlab/simple-test.clab.yaml create mode 100644 containerlab/subnet-isolation-test-spec.yaml create mode 100644 containerlab/subnet-isolation-test.clab.yaml create mode 100644 src/Agent.Hosting/Agent.Hosting.csproj create mode 100644 src/Agent.Hosting/AgentHost.cs create mode 100644 src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs create mode 100644 src/Agent.Hosting/Identity/AgentIdentity.cs create mode 100644 src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs create mode 100644 src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs create mode 100644 src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs create mode 100644 src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj create mode 100644 src/Agent.PeerProtocol.Tests/AssemblyInfo.cs create mode 100644 src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs create mode 100644 src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs create mode 100644 src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs create mode 100644 src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs create mode 100644 src/Agent.PeerProtocol/Agent.PeerProtocol.csproj create mode 100644 src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs create mode 100644 src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs create mode 100644 src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs create mode 100644 src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs create mode 100644 src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs create mode 100644 src/Agent.PeerProtocol/ServiceCollectionExtensions.cs create mode 100644 src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs create mode 100644 src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs create mode 100644 src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs create mode 100644 src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt create mode 100644 src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt create mode 100644 src/Cli.Tests/Commands/AgentCommandTests.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Remote.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt create mode 100644 src/Cli.Tests/FeatureFlagTest.cs create mode 100644 src/Cli.Tests/Utils/CliCommandResult.cs create mode 100644 src/Cli.Tests/Utils/RunningCliCommand.cs create mode 100644 src/Cli/Commands/Agent/AgentCommand.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs rename src/Cli/Commands/Common/{ => Commands}/CommandBase.cs (66%) create mode 100644 src/Cli/Commands/Common/Commands/ContainerCommandBase.cs rename src/Cli/Commands/Common/{ => Commands}/ICommandHandler.cs (56%) delete mode 100644 src/Cli/Commands/Common/DefaultParameters.cs create mode 100644 src/Cli/Commands/Common/Parameters/BaseParameters.cs create mode 100644 src/Cli/Commands/Common/Parameters/SpecParameters.cs delete mode 100644 src/Cli/Commands/Preview/AgentCommand.cs create mode 100644 src/Cli/Commands/Scan/AgentSubnetProvider.cs create mode 100644 src/Cli/Commands/Scan/ClusterExtensions.cs create mode 100644 src/Cli/Commands/Scan/DistributedNetworkScanner.cs create mode 100644 src/Domain/AgentId.cs create mode 100644 src/Domain/Environment.cs create mode 100644 src/Domain/RequestId.cs create mode 100644 src/Networking.Cluster/Cluster.cs create mode 100644 src/Networking.Cluster/ClusterOptions.cs create mode 100644 src/Networking.Cluster/Enrollment.cs create mode 100644 src/Networking.Cluster/ICluster.cs create mode 100644 src/Networking.Cluster/Networking.Cluster.csproj create mode 100644 src/Networking.Cluster/ServiceCollectionExtensions.cs create mode 100644 src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs create mode 100644 src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj create mode 100644 src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/Empty.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj create mode 100644 src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs create mode 100644 src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs create mode 100644 src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs create mode 100644 src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs create mode 100644 src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs create mode 100644 src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj create mode 100644 src/Networking.PeerStreaming.Core/PeerStream.cs create mode 100644 src/Networking.PeerStreaming.Core/PeerStreamManager.cs create mode 100644 src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs create mode 100644 src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs create mode 100644 src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj create mode 100644 src/Networking.PeerStreaming.Grpc/Protos/peer.proto create mode 100644 src/Networking.PeerStreaming.Server/InboundPeerService.cs create mode 100644 src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj create mode 100644 src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs create mode 100644 src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs create mode 100644 src/Networking.PeerStreaming.Tests/AssemblyInfo.cs create mode 100644 src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs create mode 100644 src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs create mode 100644 src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs create mode 100644 src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs create mode 100644 src/Networking.PeerStreaming.Tests/InboundTests.cs create mode 100644 src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj create mode 100644 src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs create mode 100644 src/Scanning/Subnets/IResolvedSubnet.cs create mode 100644 src/Serialization/Converters/CidrBlockConverter.cs create mode 100644 src/Serialization/Converters/DeviceAddressConverter.cs create mode 100644 src/Serialization/Converters/IpAddressConverter.cs create mode 100644 src/Serialization/Converters/IpV4AddressSetConverter.cs diff --git a/.editorconfig b/.editorconfig index 0c9e904f..8c034db3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -225,6 +225,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 7bc05321..0fd503d1 100644 --- a/.github/actions/setup-runner/action.yml +++ b/.github/actions/setup-runner/action.yml @@ -18,3 +18,8 @@ runs: - name: Restore .NET tools shell: bash run: dotnet tool restore + + - name: Install Containerlab + if: runner.os == 'Linux' + shell: bash + run: bash -c "$(curl -sL https://get.containerlab.dev)" 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 092c5c15..af8e28ea 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -58,6 +58,7 @@ "Test", "TestE2E", "TestE2E_Binary", + "TestE2E_Clab", "TestE2E_Container", "TestE2E_General", "TestLocal", @@ -137,6 +138,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'" @@ -167,6 +172,10 @@ "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'" @@ -218,6 +227,10 @@ "description": "ReleaseType - None (default/safe), PreRelease, or Release", "$ref": "#/definitions/ReleaseType" }, + "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 cc8f0603..ecdbd14f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,12 @@ + + + + + + @@ -18,6 +24,7 @@ + @@ -41,6 +48,7 @@ + diff --git a/Drift.Build.slnx b/Drift.Build.slnx index 7b2e0f28..e6781204 100644 --- a/Drift.Build.slnx +++ b/Drift.Build.slnx @@ -2,8 +2,10 @@ + + diff --git a/Drift.sln b/Drift.sln index 0086ed09..a42a0e23 100644 --- a/Drift.sln +++ b/Drift.sln @@ -81,6 +81,28 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Commo EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "E2E", "E2E", "{D1DBBF0F-1A0D-486C-A893-ACEB667F2A63}" 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 @@ -193,11 +215,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 8fafffae..cf90c3e0 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/NukeBuild/TestNukeBuild.cs b/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs index dd4c6d05..7c09695c 100644 --- a/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs +++ b/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using Drift.Build.Utilities.Versioning.Abstractions; using Nuke.Common; using Nuke.Common.Execution; diff --git a/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs b/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs index ce055e5d..34e0bb60 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq; using Drift.Build.Utilities.Tests.NukeBuild; using Nuke.Common; using Nuke.Common.Execution; diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index f2d79555..03a2dd7c 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text.RegularExpressions; using Drift.Build.Utilities.Tests.NukeBuild; using Drift.Build.Utilities.Versioning; @@ -7,6 +9,8 @@ using Nuke.Common; using Nuke.Common.Git; using Octokit; +using TUnit.Assertions.Extensions; +using TUnit.Core; using Assert = TUnit.Assertions.Assert; namespace Drift.Build.Utilities.Tests.Versioning; diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index 75d54042..13e15344 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -22,7 +22,7 @@ sealed partial class NukeBuild { throw new PlatformNotSupportedException(); Target Test => _ => _ - .DependsOn( TestSelf, TestUnit, TestE2E ); + .DependsOn( TestSelf, TestUnit, TestE2E, TestE2E_Clab ); Target TestSelf => _ => _ .Before( BuildInfo ) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs new file mode 100644 index 00000000..0d3313e1 --- /dev/null +++ b/build/NukeBuild.TestContainerlab.cs @@ -0,0 +1,353 @@ +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 Nuke.Common.Tools.DotNet; +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 TestE2E_Clab => _ => _ + .DependsOn( BuildContainerImage ) + .After( TestUnit, TestE2E_Container ) + .OnlyWhenDynamic( () => Platform != DotNetRuntimeIdentifier.win_x64 ) + .Executes( async () => { + using var _ = new OperationTimer( nameof(TestE2E_Clab) ); + + 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 94be3123..4148d23d 100644 --- a/build/NukeBuild.cs +++ b/build/NukeBuild.cs @@ -129,6 +129,8 @@ private static class Paths { internal static AbsolutePath PublishDirectoryForRuntime( DotNetRuntimeIdentifier id ) => PublishDirectory / id.ToString(); + + internal static AbsolutePath ContainerlabsDirectory => RootDirectory / "containerlab"; } Target OutputVersion => _ => _ diff --git a/build/_build.csproj b/build/_build.csproj index 7737c9f5..1db84060 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -12,16 +12,17 @@ false + + + + + - - - - 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 8639eace..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 = 25; - private const uint ExpectedAssemblyCountTolerance = 5; + private const uint ExpectedAssemblyCount = 30; + private const uint ExpectedAssemblyCountTolerance = 10; [Test] public void FindManyAssemblies() { diff --git a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt index 046ac81e..c69b8662 100644 --- a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt +++ b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt @@ -17,4 +17,5 @@ Commands: init Create a network spec scan Scan the network and detect drift lint Validate a network spec + agent Manage the local Drift agent 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 2e01a0f3..b8d6e086 100644 --- a/src/Cli.Tests/Commands/LintCommandTests.cs +++ b/src/Cli.Tests/Commands/LintCommandTests.cs @@ -19,7 +19,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 ); @@ -45,7 +45,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 ); @@ -60,7 +60,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 6b515840..2973d897 100644 --- a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt +++ b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt @@ -1,2 +1,2 @@ ✕ This exception was thrown from ExceptionCommandHandler - at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DefaultParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 111 \ No newline at end of file + at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DummyParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 112 \ No newline at end of file diff --git a/src/Cli.Tests/ExitCodeTests.cs b/src/Cli.Tests/ExitCodeTests.cs index 0781e1bd..204d664a 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() ) { @@ -78,37 +79,42 @@ await Verify( } 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..ed240dc8 --- /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.Write( 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.Read( 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 aee842be..90111ed5 100644 --- a/src/Cli.Tests/SpecFilePathResolverTests.cs +++ b/src/Cli.Tests/SpecFilePathResolverTests.cs @@ -20,7 +20,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 ); if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { 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 b7b7abac..162f0163 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; @@ -11,11 +12,12 @@ internal static class DriftTestCli { // Console.SetOut/SetError mutates global state, so only one invocation may redirect it at a time. private static readonly SemaphoreSlim ConsoleRedirectLock = new(1, 1); - 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, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + bool redirectConsole = true ) { var token = cancellationToken; CancellationTokenSource? cancellationTokenSource = null; @@ -36,17 +38,22 @@ void ConfigureInvocation( InvocationConfiguration config ) { /* * Most output is written to the InvocationConfiguration's TextWriters, but a few errors may be written to * Console.Out/Error when DI is not yet available. + * + * Long-running background processes (e.g. agents) must NOT hold ConsoleRedirectLock for their + * entire lifetime — doing so would prevent any other InvokeAsync call from acquiring the lock + * (deadlock when running multiple agents or a scan alongside agents). Pass redirectConsole: false + * to skip the lock and Console.SetOut/SetError for those cases. */ - await ConsoleRedirectLock.WaitAsync( token ); + if ( redirectConsole ) { + await ConsoleRedirectLock.WaitAsync( token ); - var previousOut = Console.Out; - var previousErr = Console.Error; - Console.SetOut( output ); - Console.SetError( error ); + var previousOut = Console.Out; + var previousErr = Console.Error; + Console.SetOut( output ); + Console.SetError( error ); - try { - return ( - await DriftCli.InvokeAsync( + try { + var exitCode = await DriftCli.InvokeAsync( CommandLineParser.SplitCommandLine( args ).ToArray(), false, true, @@ -54,16 +61,80 @@ await DriftCli.InvokeAsync( customCommands, ConfigureInvocation, token - ), - output, - error + ); + + return new CliCommandResult { ExitCode = exitCode, Output = output, Error = error }; + } + finally { + Console.SetOut( previousOut ); + Console.SetError( previousErr ); + ConsoleRedirectLock.Release(); + cancellationTokenSource?.Dispose(); + } + } + + try { + 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 { - Console.SetOut( previousOut ); - Console.SetError( previousErr ); - ConsoleRedirectLock.Release(); 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, + redirectConsole: false + ); + + 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 c9d77929..7991f9e0 100644 --- a/src/Cli/Cli.csproj +++ b/src/Cli/Cli.csproj @@ -13,14 +13,19 @@ + + + + + @@ -31,13 +36,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..02e00a69 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs @@ -0,0 +1,122 @@ +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; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +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..." ); + + try { + await AgentHost.Run( parameters.Port, logger, ConfigureServices, cancellationToken, agentLifetime?.Ready ); + } + catch ( OperationCanceledException ) when ( cancellationToken.IsCancellationRequested ) { + // Graceful shutdown via cancellation + } + + 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 66% rename from src/Cli/Commands/Common/CommandBase.cs rename to src/Cli/Commands/Common/Commands/CommandBase.cs index b9c6f3ee..e7862574 100644 --- a/src/Cli/Commands/Common/CommandBase.cs +++ b/src/Cli/Commands/Common/Commands/CommandBase.cs @@ -1,10 +1,13 @@ using System.CommandLine; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Presentation.Console.Managers.Abstractions; using Microsoft.Extensions.DependencyInjection; -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 ); @@ -20,11 +23,19 @@ protected CommandBase( string name, string description, IServiceProvider provide serviceProvider.GetRequiredService().ParseResult = parseResult; var handler = serviceProvider.GetRequiredService(); - var parameters = CreateParameters( parseResult ); + + TParameters parameters; + try { + parameters = CreateParameters( parseResult ); + } + catch ( ArgumentException e ) { + serviceProvider.GetRequiredService().Normal.WriteLineError( $"✗ {e.Message}" ); + return ExitCodes.GeneralError; + } return await handler.Invoke( parameters, cancellationToken ); } ); } protected abstract TParameters CreateParameters( ParseResult result ); -} \ No newline at end of file +} 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 f4f7f5b3..bd9beff8 100644 --- a/src/Cli/Commands/Common/CommonParameters.cs +++ b/src/Cli/Commands/Common/CommonParameters.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using System.Diagnostics; using Drift.Cli.Presentation.Console; using Drift.Cli.Presentation.Console.Logging; using Drift.Cli.Settings.V1_preview; @@ -25,14 +26,14 @@ internal static class Arguments { /// internal static class Options { /// - /// Enable detailed output, corresponding to log level. + /// Enable detailed output, corresponding to log level. /// internal static readonly Option Verbose = new("--verbose", "-v") { Description = "Verbose output", Arity = ArgumentArity.Zero }; /// - /// Enable the most detailed output available, corresponding to log level. + /// Enable the most detailed output available, corresponding to log level. /// internal static readonly Option VeryVerbose = new("--very-verbose", "-vv") { Description = "Very verbose output", Arity = ArgumentArity.Zero, Hidden = true 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 2b5cc347..c72b080e 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; @@ -179,7 +179,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..0f822178 100644 --- a/src/Cli/Commands/Lint/LintCommand.cs +++ b/src/Cli/Commands/Lint/LintCommand.cs @@ -1,11 +1,12 @@ 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; @@ -48,7 +49,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/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..97ebfdc2 --- /dev/null +++ b/src/Cli/Commands/Scan/AgentSubnetProvider.cs @@ -0,0 +1,48 @@ +using Drift.Domain; +using Drift.Networking.Cluster; +using Drift.Scanning.Subnets; +using Microsoft.Extensions.Logging; + +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/ResultProcessors/SubnetScanResultProcessor.cs b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs index 2ea80b35..f5e8c89a 100644 --- a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs +++ b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs @@ -20,7 +20,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 e9f7b902..34eeafb0 100644 --- a/src/Cli/Commands/Scan/ScanCommand.cs +++ b/src/Cli/Commands/Scan/ScanCommand.cs @@ -1,14 +1,17 @@ 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.DependencyInjection; @@ -17,14 +20,8 @@ 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 ) { @@ -59,76 +56,147 @@ protected override ScanParameters CreateParameters( ParseResult result ) { internal class ScanCommandHandler( IOutputManager output, - INetworkScanner scanner, + INetworkScanner localScanner, IInterfaceSubnetProvider interfaceSubnetProvider, ISpecFileProvider specProvider, - IServiceProvider serviceProvider + 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 ); + + 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 }; - var scanRequest = new NetworkScanOptions { Cidrs = subnets }; + if ( inventory.Network != null ) { + providers.Add( new PredefinedSubnetProvider( inventory.Network.Subnets ) ); + } - // 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 ); + 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(); + + return new NetworkScanOptions { Cidrs = uniqueCidrs }; + } + + 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 ) ) + ); + } + + 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 ) { - await using var ui = new InteractiveUi( + var ui = new InteractiveUi( output, - network, + inventory.Network, scanner, scanRequest, new DefaultKeyMap(), - parameters.ShowLogPanel, - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService() + parameters.ShowLogPanel ); - await ui.RunAsync( cancellationToken ); + return ui.RunAsync(); } else { var ui = new NonInteractiveUi( output, scanner ); - await ui.RunAsync( scanRequest, network, parameters.OutputFormat ); + return ui.RunAsync( scanRequest, inventory.Network, parameters.OutputFormat ); } - - 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 d7d88550..f1f36fb7 100644 --- a/src/Cli/DriftCli.cs +++ b/src/Cli/DriftCli.cs @@ -16,7 +16,7 @@ internal static async Task InvokeAsync( Action? configureInvocation = null, CancellationToken cancellationToken = default ) { - // Justification: intentionally using the most basic output form to make sure the error is surfaced, no matter what code fails + // Justification: intentionally using the most basic output form to make sure the error is surfaced, no matter what fails #pragma warning disable RS0030 var error = Console.Error; diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs index 5a4f0304..e04cad12 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; @@ -15,6 +18,9 @@ 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; @@ -51,7 +57,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 ); @@ -68,9 +79,18 @@ private static void ConfigureDefaults( IServiceCollection services, bool toConso ConfigureSubnetProvider( services ); ConfigureNetworkScanner( services ); ConfigureInteractiveServices( 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(); } @@ -78,7 +98,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; @@ -97,6 +120,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() ); } @@ -104,7 +128,7 @@ private static void ConfigureSpecProvider( IServiceCollection services ) { services.AddScoped(); } - private static void ConfigureSubnetProvider( IServiceCollection services ) { + public static void ConfigureSubnetProvider( IServiceCollection services ) { services.AddScoped(); } @@ -112,6 +136,7 @@ private static void ConfigureBuiltInCommandHandlers( IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void ConfigureInteractiveServices( IServiceCollection services ) { @@ -135,7 +160,7 @@ private static void ConfigureDynamicCommands( } } - private static void ConfigureNetworkScanner( IServiceCollection services ) { + internal static void ConfigureNetworkScanner( IServiceCollection services ) { if ( RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { services.AddSingleton(); } @@ -150,6 +175,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 ) { @@ -160,4 +195,4 @@ private static void AddFigletHeaderToHelpCommand( RootCommand rootCommand ) { } } } -} \ No newline at end of file +} diff --git a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs index dbb31e36..56f3986d 100644 --- a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs +++ b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs @@ -17,18 +17,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..991e0372 100644 --- a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs +++ b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs @@ -12,4 +12,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/ConsoleOutputManager.cs b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs index 5295d769..71510390 100644 --- a/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs +++ b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs @@ -13,7 +13,8 @@ internal class ConsoleOutputManager( bool normalVeryVerbose, OutputFormat outputFormat, bool plainConsole, - TextReader reader + TextReader reader, + TextWriter? ansiConsoleOut = null ) : IOutputManager { public ILogOutput Log { get; @@ -21,7 +22,7 @@ public ILogOutput Log { public INormalOutput Normal { get; - } = new NormalOutput( normalStdOut, normalErrOut, plainConsole, normalVerbose, normalVeryVerbose ); + } = new NormalOutput( normalStdOut, normalErrOut, plainConsole, normalVerbose, normalVeryVerbose, ansiConsoleOut ); public IJsonOutput Json { get; diff --git a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs index e8fb4d76..5c1da1c9 100644 --- a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs @@ -14,13 +14,14 @@ internal partial class NormalOutput( TextWriter errOut, bool plainOutput = false, bool verbose = false, - bool veryVerbose = false + bool veryVerbose = false, + TextWriter? ansiConsoleOut = null ) : INormalOutput { // TODO test private const bool MarkupOutput = false; public IAnsiConsole GetAnsiConsole() { - var settings = new AnsiConsoleSettings { Out = new AnsiConsoleOutput( stdOut ) }; + var settings = new AnsiConsoleSettings { Out = new AnsiConsoleOutput( ansiConsoleOut ?? stdOut ) }; if ( plainOutput ) { settings.Ansi = AnsiSupport.No; @@ -79,14 +80,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 +100,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 4fb31798..5556d6aa 100644 --- a/src/Cli/Presentation/Console/OutputManagerFactory.cs +++ b/src/Cli/Presentation/Console/OutputManagerFactory.cs @@ -59,16 +59,34 @@ public IOutputManager Create( bool plainConsole ) { var bridge = new WriterReaderBridge(); + + // Validate that we don't have duplicate writer instances that would break synchronization + if ( !interactiveOutputOnly ) { + if ( ReferenceEquals( consoleOut, consoleErr ) ) { + throw new InvalidOperationException( + $"{nameof(consoleOut)} and {nameof(consoleErr)} cannot be the same instance - this would break thread synchronization" + ); + } + + if ( ReferenceEquals( bridge.Writer, consoleOut ) || ReferenceEquals( bridge.Writer, consoleErr ) ) { + throw new InvalidOperationException( + $"bridge.Writer cannot be the same instance as {nameof(consoleOut)} or {nameof(consoleErr)} - this would break thread synchronization" + ); + } + } + + var syncBridgeWriter = TextWriter.Synchronized( bridge.Writer ); + var outWrapper = new CompoundTextWriter(); - outWrapper.Writers.Add( bridge.Writer ); + outWrapper.Writers.Add( syncBridgeWriter ); if ( !interactiveOutputOnly ) { - outWrapper.Writers.Add( consoleOut ); + outWrapper.Writers.Add( TextWriter.Synchronized( consoleOut ) ); } var errWrapper = new CompoundTextWriter(); - errWrapper.Writers.Add( bridge.Writer ); + errWrapper.Writers.Add( syncBridgeWriter ); if ( !interactiveOutputOnly ) { - errWrapper.Writers.Add( consoleErr ); + errWrapper.Writers.Add( TextWriter.Synchronized( consoleErr ) ); } var consoleOuts = GetConsoleOuts( @@ -100,7 +118,10 @@ bool plainConsole veryVerbose, outputFormat, plainConsole, - bridge.Reader + bridge.Reader, + // When in interactive mode, text output is suppressed from the terminal (goes to pipe only), + // but the AnsiConsole for the Live display must still write to the real terminal. + interactiveOutputOnly ? consoleOut : null ); } 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/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..3f0b2138 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs @@ -0,0 +1,108 @@ +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" ); + } + + var cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + cts.CancelAfter( timeout ); + + cts.Token.Register( () => { + if ( _pendingRequests.TryRemove( correlationId, out var removed ) ) { + removed.TrySetCanceled(); + } + + cts.Dispose(); + } ); + + 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" ); + } + + var cts = CancellationTokenSource.CreateLinkedTokenSource( ct ); + cts.CancelAfter( timeout ); + + cts.Token.Register( () => { + if ( _streamingRequests.TryRemove( correlationId, out var removed ) ) { + removed.CompletionSource.TrySetCanceled(); + } + + cts.Dispose(); + } ); + + 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 28fc02e9..de468e5c 100644 --- a/src/Scanning/Scanners/PingSubnetScannerBase.cs +++ b/src/Scanning/Scanners/PingSubnetScannerBase.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net; using System.Threading; +using System.Net.NetworkInformation; using System.Threading.RateLimiting; using System.Threading.Tasks; using Drift.Domain; @@ -80,7 +81,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 }; @@ -131,6 +133,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(); @@ -145,11 +149,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 ) { @@ -165,4 +200,4 @@ List CreateAddresses( ( IPAddress Ip, bool Success, string? Host // Timed out return null; } -} \ No newline at end of file +} 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 4407f7ec..a75398c0 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; @@ -88,7 +89,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 ); @@ -131,7 +132,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 bdd3ce14..5a0681aa 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 4403f8f0..b0600edd 100644 --- a/src/Spec/Validation/SpecValidator.cs +++ b/src/Spec/Validation/SpecValidator.cs @@ -2,6 +2,7 @@ using Drift.Spec.Schema; using Drift.Spec.Serialization; using Json.Schema; +using YamlDotNet.Core; namespace Drift.Spec.Validation; @@ -16,8 +17,21 @@ 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 ) { From f334ae6d8e910eaffb74ff1f717c74bf600af77c Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:12:43 +0200 Subject: [PATCH 02/43] refactor --- .../NukeBuild/TestNukeBuild.cs | 1 - .../Versioning/TestNukeBuildExtensions.cs | 2 - .../Versioning/VersioningTests.cs | 32 +- .../Adopt/AdoptRequestHandler.cs | 1 - src/Cli.Abstractions/Ports.cs | 5 + .../ScanCommandTests.Orchestration.cs | 179 +++++++++ .../Commands/ScanCommandTests.Remote.cs | 348 ------------------ ...DuplicateDevices_Deduplicates.verified.txt | 18 + ...hAgents_EmptyResults_Succeeds.verified.txt | 12 + ...rewallRules_FiltersVisibility.verified.txt | 22 ++ ...rfaces_UsesDistributedScanner.verified.txt | 22 ++ ...rlappingSubnets_MergesDevices.verified.txt | 24 ++ ...ection_UsesDistributedScanner.verified.txt | 18 + ...sjointSubnets_CombinesResults.verified.txt | 24 ++ src/Cli.Tests/Commands/ScanCommandTests.cs | 37 +- src/Cli.Tests/GlobalSuppressions.cs | 24 ++ .../Utils/Agent/AgentConfiguration.cs | 47 +++ src/Cli.Tests/Utils/Agent/AgentTestHarness.cs | 325 ++++++++++++++++ src/Cli.Tests/Utils/Agent/CliConfiguration.cs | 44 +++ src/Cli.Tests/Utils/Agent/HarnessResult.cs | 142 +++++++ .../Utils/Agent/NetworkTopologyAdapter.cs | 159 ++++++++ src/Cli.Tests/Utils/FirewallRulesTests.cs | 279 ++++++++++++++ .../Network/Firewall/FirewallEvaluator.cs | 114 ++++++ .../Utils/Network/Firewall/FirewallRules.cs | 83 +++++ .../Utils/Network/Firewall/FirewallTarget.cs | 95 +++++ .../Utils/Network/Topology/NetworkTopology.cs | 133 +++++++ .../Topology/NetworkTopologyBuilder.cs | 110 ++++++ .../Utils/Network/Topology/Topologies.cs | 229 ++++++++++++ .../Utils/Testing/MockSubnetScannerFactory.cs | 65 ++++ .../Utils/Testing/ScanResultHelper.cs | 157 ++++++++ .../Subcommands/Start/AgentStartParameters.cs | 7 +- src/Cli/Commands/Scan/ClusterExtensions.cs | 1 - .../Scan/DistributedNetworkScanner.cs | 10 +- src/Domain/AgentId.cs | 4 +- src/Domain/CidrBlock.cs | 4 + src/Domain/Device/Addresses/AddressType.cs | 2 +- .../Device/Addresses/DeviceAddressSet.cs | 50 +++ src/Domain/Device/Addresses/IIPAddress.cs | 9 + src/Domain/Device/Addresses/IpV4Address.cs | 4 +- .../Device/Discovered/DiscoveredDevice.cs | 1 + .../PeerMessageHandlerExtensions.cs | 2 - .../Scanners/LinuxFpingSubnetScanner.cs | 4 +- .../Scanners/PingSubnetScannerBase.cs | 45 ++- src/Scanning/Scanners/Preview/Ideas.cs | 2 +- 44 files changed, 2450 insertions(+), 446 deletions(-) create mode 100644 src/Cli.Abstractions/Ports.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs delete mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Remote.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt create mode 100644 src/Cli.Tests/GlobalSuppressions.cs create mode 100644 src/Cli.Tests/Utils/Agent/AgentConfiguration.cs create mode 100644 src/Cli.Tests/Utils/Agent/AgentTestHarness.cs create mode 100644 src/Cli.Tests/Utils/Agent/CliConfiguration.cs create mode 100644 src/Cli.Tests/Utils/Agent/HarnessResult.cs create mode 100644 src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs create mode 100644 src/Cli.Tests/Utils/FirewallRulesTests.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/Topologies.cs create mode 100644 src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs create mode 100644 src/Cli.Tests/Utils/Testing/ScanResultHelper.cs create mode 100644 src/Domain/Device/Addresses/DeviceAddressSet.cs create mode 100644 src/Domain/Device/Addresses/IIPAddress.cs diff --git a/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs b/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs index 7c09695c..dd4c6d05 100644 --- a/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs +++ b/build-utils/Build.Utilities.Tests/NukeBuild/TestNukeBuild.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using Drift.Build.Utilities.Versioning.Abstractions; using Nuke.Common; using Nuke.Common.Execution; diff --git a/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs b/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs index 34e0bb60..ce055e5d 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/TestNukeBuildExtensions.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Drift.Build.Utilities.Tests.NukeBuild; using Nuke.Common; using Nuke.Common.Execution; diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index 03a2dd7c..82bf8f7a 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Text.RegularExpressions; using Drift.Build.Utilities.Tests.NukeBuild; using Drift.Build.Utilities.Versioning; @@ -9,15 +7,13 @@ using Nuke.Common; using Nuke.Common.Git; using Octokit; -using TUnit.Assertions.Extensions; -using TUnit.Core; using Assert = TUnit.Assertions.Assert; namespace Drift.Build.Utilities.Tests.Versioning; internal sealed class VersioningTests { [Test] - public async Task DefaultVersioningVersionTest() { + public static async Task DefaultVersioningVersionTest() { // Arrange var build = new TestNukeBuild(); @@ -33,9 +29,9 @@ public async Task DefaultVersioningVersionTest() { } [Test] - public async Task DefaultVersioningWhenNoReleaseTargets() { + public static async Task DefaultVersioningWhenNoReleaseTargets() { // Arrange - var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => b.Arbitrary ); + var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => NukeBuildWithArbitraryTarget.Arbitrary ); // Act var factory = new VersioningStrategyFactory( build ); @@ -50,7 +46,7 @@ public async Task DefaultVersioningWhenNoReleaseTargets() { } [Test] - public async Task MultipleReleaseTargetsInPlanThrows() { + public static async Task MultipleReleaseTargetsInPlanThrows() { // Arrange var build = new TestNukeBuild().WithExecutionPlan( b => b.CreateRelease, b => b.CreatePreRelease ); @@ -106,7 +102,7 @@ public async Task MismatchedReleaseTypeThrows( } [Test] - public void PreReleaseWithoutVersionThrows() { + public static void PreReleaseWithoutVersionThrows() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -149,7 +145,7 @@ public void DebugConfigurationThrows( } [Test] - public async Task PreReleaseValid() { + public static async Task PreReleaseValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -170,7 +166,7 @@ await Assert.That( version.PrereleaseIdentifiers[version.PrereleaseIdentifiers.C } [Test] - public void PreReleaseRejectsFullSemVerString() { + public static void PreReleaseRejectsFullSemVerString() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -186,7 +182,7 @@ public void PreReleaseRejectsFullSemVerString() { } [Test] - public async Task PreReleaseVersionIsTemporallyConsistent() { + public static async Task PreReleaseVersionIsTemporallyConsistent() { // Arrange var timeProvider = new FakeTimeProvider(); var build = new TestNukeBuild() @@ -205,7 +201,7 @@ public async Task PreReleaseVersionIsTemporallyConsistent() { } [Test] - public void ReleaseWithPrereleaseIdentifiersThrows() { + public static void ReleaseWithPrereleaseIdentifiersThrows() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -221,7 +217,7 @@ public void ReleaseWithPrereleaseIdentifiersThrows() { } [Test] - public async Task ReleaseValid() { + public static async Task ReleaseValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -266,7 +262,7 @@ public async Task ReleaseValid() { } [Test] - public async Task ExactVersioningValid() { + public static async Task ExactVersioningValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -282,7 +278,7 @@ public async Task ExactVersioningValid() { } [Test] - public void ExactVersioningWithNullThrows() { + public static void ExactVersioningWithNullThrows() { // Arrange var strategy = new ExactVersioning( Configuration.Release, " ", ReleaseType.None, null!, null! ); @@ -291,7 +287,7 @@ public void ExactVersioningWithNullThrows() { } [Test] - public async Task ExactVersioningValidWithReleaseVersion() { + public static async Task ExactVersioningValidWithReleaseVersion() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -353,7 +349,7 @@ public async Task ExactVersioningPreReleaseImageReferencesDoesNotIncludeLatest() internal sealed class NukeBuildWithArbitraryTarget : TestNukeBuild { // Justification: NUKE Target properties are instance properties by convention; static is not valid here #pragma warning disable S2325 - public Target Arbitrary => _ => _ + public static Target Arbitrary => _ => _ #pragma warning restore S2325 .Executes( () => { } diff --git a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs index c586ed34..5b9778c4 100644 --- a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs +++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs @@ -1,6 +1,5 @@ using Drift.Networking.Grpc.Generated; using Drift.Networking.PeerStreaming.Core.Abstractions; -using Microsoft.Extensions.Logging; namespace Drift.Agent.PeerProtocol.Adopt; diff --git a/src/Cli.Abstractions/Ports.cs b/src/Cli.Abstractions/Ports.cs new file mode 100644 index 00000000..50ae2171 --- /dev/null +++ b/src/Cli.Abstractions/Ports.cs @@ -0,0 +1,5 @@ +namespace Drift.Cli.Abstractions; + +public static class Ports { + public const ushort AgentDefault = 51515; +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs new file mode 100644 index 00000000..db181c9d --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs @@ -0,0 +1,179 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils.Agent; +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Cli.Tests.Utils.Network.Topology; +using Drift.Domain; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class ScanCommandTests { + [Test] + public async Task WithAgents_TwoDisjointSubnets_CombinesResults() { + // Arrange + var builder = Topologies.TwoAgentsDisjointSubnetsWithCli() + .WithFirewall( fw => { + fw.DefaultPolicy = FirewallAction.Deny; + // CLI must be able to reach agents to collect their results (control plane = same firewall). + // Allow CLI→agent routing; deny-default blocks all other cross-subnet traffic (agent↔agent). + fw.Allow( FirewallTarget.Subnet( "cli-subnet" ), FirewallTarget.Subnet( "agent1-subnet" ) ); + fw.Allow( FirewallTarget.Subnet( "cli-subnet" ), FirewallTarget.Subnet( "agent2-subnet" ) ); + } ); + var topology = builder.Build(); + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 2 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + // Verify full output + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_TwoDisjointSubnets_CombinesResults)}" ); + } + + [Test] + public async Task WithAgents_OverlappingSubnets_MergesDevices() { + // Arrange + var topology = Topologies.TwoAgentsOverlappingSubnet(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + + // Verify output shows merged results + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_OverlappingSubnets_MergesDevices)}" ); + } + + [Test] + public async Task WithAgents_EmptyResults_Succeeds() { + // Arrange + var topology = Topologies.SingleAgentEmptyResults(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 1 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_EmptyResults_Succeeds)}" ); + } + + [Test] + public async Task WithAgents_NoLocalInterfaces_UsesDistributedScanner() { + // Arrange - Two agents, no CLI config (so CLI has no local interfaces) + var topology = Topologies.TwoAgentsDisjointSubnetsNoCli(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 2 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_NoLocalInterfaces_UsesDistributedScanner)}" ); + } + + [Test] + public async Task WithAgents_ScannerSelection_UsesDistributedScanner() { + // Arrange + var topology = Topologies.MixedCliAndAgents(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + + // The output should show both local scan (192.168.0.x) and agent scan (192.168.10.x) + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_ScannerSelection_UsesDistributedScanner)}" ); + } + + [Test] + public async Task WithAgents_DuplicateDevices_Deduplicates() { + // Arrange - Both agents discover the same device on the same subnet + var topology = new NetworkTopologyBuilder() + .AddSubnet( "shared", "192.168.10.0/24", [ + ( "192.168.10.100", "Shared device" ), // Both agents will see this + ( "192.168.10.101", "Device from agent1" ), + ( "192.168.10.102", "Device from agent2" ) + ], out var sharedSubnet ) + .AddAgent( new AgentId( "agentid_agent1" ), sharedSubnet ) + .AddAgent( new AgentId( "agentid_agent2" ), sharedSubnet ) + .Build(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 2 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + // The duplicate device (192.168.10.100) should only appear once in merged results + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_DuplicateDevices_Deduplicates)}" ); + } + + [Test] + public async Task WithAgents_FirewallRules_FiltersVisibility() { + // Arrange - DMZ agent can see internal, but internal agent cannot see DMZ + // This tests that firewall rules properly restrict network visibility + var topology = new NetworkTopologyBuilder() + .AddSubnet( "dmz", "192.168.1.0/24", [ + ( "192.168.1.100", "Web server" ), + ( "192.168.1.101", "App server" ) + ], out var dmzSubnet ) + .AddSubnet( "internal", "10.0.0.0/24", [ + ( "10.0.0.100", "Database" ), + ( "10.0.0.101", "File server" ) + ], out var internalSubnet ) + .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) + .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) + .WithFirewall( fw => { + // DMZ can see both networks + fw.Allow( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "dmz" ) ); + fw.Allow( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "internal" ) ); + + // Internal can only see itself + fw.Allow( FirewallTarget.Subnet( "internal" ), FirewallTarget.Subnet( "internal" ) ); + fw.Deny( FirewallTarget.Subnet( "internal" ), FirewallTarget.Subnet( "dmz" ) ); // Explicit deny + } ) + .Build(); + + await using var harness = await AgentTestHarness.CreateAsync( topology ); + + // Act + var result = await harness.RunScanAsync(); + + // Assert + Assert.That( result.ScanExitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( result.AgentExitCodes, Has.Count.EqualTo( 2 ) ); + Assert.That( result.AgentExitCodes.Values, Is.All.EqualTo( ExitCodes.Success ) ); + + // Verify output shows DMZ agent scanned both networks, internal agent only scanned internal + await Verify( result.CombinedOutput ) + .UseFileName( $"{nameof(ScanCommandTests)}.{nameof(WithAgents_FirewallRules_FiltersVisibility)}" ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs deleted file mode 100644 index af1356c6..00000000 --- a/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs +++ /dev/null @@ -1,348 +0,0 @@ -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.WithAgents_DuplicateDevices_Deduplicates.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt new file mode 100644 index 00000000..3765181a --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt @@ -0,0 +1,18 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.10.0/24 +Requesting subnets from agent agentid_agent2 +Received subnet(s) from agent agentid_agent2: 192.168.10.0/24 +Scanning 1 subnet + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +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 3 unique devices from 2 scans of 192.168.10.0/24 +Scan completed: 0 local, 2 via agents, 1 unique subnets + +192.168.10.0/24 (3 devices) +├── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) +├── 192.168.10.101 AA-BB-C0-A8-0A-65 Online (unknown device) +└── 192.168.10.102 AA-BB-C0-A8-0A-66 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt new file mode 100644 index 00000000..171df816 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt @@ -0,0 +1,12 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.10.0/24 +Scanning 1 subnet + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 +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. +Scan completed: 0 local, 1 via agents, 1 unique subnets + +192.168.10.0/24 (0 devices) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt new file mode 100644 index 00000000..64f9f78f --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt @@ -0,0 +1,22 @@ +Requesting subnets from agent agentid_dmz +Received subnet(s) from agent agentid_dmz: 192.168.1.0/24, 10.0.0.0/24 +Requesting subnets from agent agentid_internal +Received subnet(s) from agent agentid_internal: 10.0.0.0/24 +Scanning 2 subnets + 192.168.1.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_dmz + 10.0.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_dmz, agentid_internal +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 2 unique devices from 2 scans of 10.0.0.0/24 +Scan completed: 0 local, 3 via agents, 2 unique subnets + +192.168.1.0/24 (2 devices) +├── 192.168.1.100 AA-BB-C0-A8-01-64 Online (unknown device) +└── 192.168.1.101 AA-BB-C0-A8-01-65 Online (unknown device) + +10.0.0.0/24 (2 devices) +├── 10.0.0.100 AA-BB-0A-00-00-64 Online (unknown device) +└── 10.0.0.101 AA-BB-0A-00-00-65 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt new file mode 100644 index 00000000..f63d42b0 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt @@ -0,0 +1,22 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.10.0/24, 192.168.20.0/24 +Requesting subnets from agent agentid_agent2 +Received subnet(s) from agent agentid_agent2: 192.168.10.0/24, 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_agent1, agentid_agent2 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +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 2 unique devices from 2 scans of 192.168.10.0/24 +Merged 1 unique devices from 2 scans of 192.168.20.0/24 +Scan completed: 0 local, 4 via agents, 2 unique subnets + +192.168.10.0/24 (2 devices) +├── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) +└── 192.168.10.101 AA-BB-C0-A8-0A-65 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 AA-BB-C0-A8-14-64 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt new file mode 100644 index 00000000..6f068c80 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt @@ -0,0 +1,24 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.0.0/24, 192.168.10.0/24 +Requesting subnets from agent agentid_agent2 +Received subnet(s) from agent agentid_agent2: 192.168.0.0/24, 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, agentid_agent1, agentid_agent2 + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +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 1 unique devices from 3 scans of 192.168.0.0/24 +Merged 4 unique devices from 2 scans of 192.168.10.0/24 +Scan completed: 3 local, 2 via agents, 2 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) + +192.168.10.0/24 (4 devices) +├── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) +├── 192.168.10.101 AA-BB-C0-A8-0A-65 Online (unknown device) +├── 192.168.10.102 AA-BB-C0-A8-0A-66 Online (unknown device) +└── 192.168.10.103 AA-BB-C0-A8-0A-67 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt new file mode 100644 index 00000000..de22f662 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt @@ -0,0 +1,18 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.0.0/24, 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, agentid_agent1 + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 +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 1 unique devices from 2 scans of 192.168.0.0/24 +Scan completed: 2 local, 1 via agents, 2 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) + +192.168.10.0/24 (1 devices) +└── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt new file mode 100644 index 00000000..e9dfccb1 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt @@ -0,0 +1,24 @@ +Requesting subnets from agent agentid_agent1 +Received subnet(s) from agent agentid_agent1: 192.168.10.0/24 +Requesting subnets from agent agentid_agent2 +Received subnet(s) from agent agentid_agent2: 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_agent1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent2 +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. +Scan completed: 1 local, 2 via agents, 3 unique subnets + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) + +192.168.10.0/24 (2 devices) +├── 192.168.10.100 AA-BB-C0-A8-0A-64 Online (unknown device) +└── 192.168.10.101 AA-BB-C0-A8-0A-65 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 AA-BB-C0-A8-14-64 Online (unknown device) + diff --git a/src/Cli.Tests/Commands/ScanCommandTests.cs b/src/Cli.Tests/Commands/ScanCommandTests.cs index c245dabb..56b3be10 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.cs @@ -5,6 +5,7 @@ using Drift.Cli.Presentation.Rendering; using Drift.Cli.SpecFile; using Drift.Cli.Tests.Utils; +using Drift.Cli.Tests.Utils.Testing; using Drift.Domain; using Drift.Domain.Device.Addresses; using Drift.Domain.Device.Declared; @@ -229,42 +230,6 @@ 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, diff --git a/src/Cli.Tests/GlobalSuppressions.cs b/src/Cli.Tests/GlobalSuppressions.cs new file mode 100644 index 00000000..5c1b0887 --- /dev/null +++ b/src/Cli.Tests/GlobalSuppressions.cs @@ -0,0 +1,24 @@ + // This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +// Test infrastructure classes are intentionally public to be used across test files +[assembly: + SuppressMessage( "Design", "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + Target = "~N:Drift.Cli.Tests.Utils.Agent" )] +[assembly: + SuppressMessage( "Design", "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + Target = "~N:Drift.Cli.Tests.Utils.Testing" )] +[assembly: + SuppressMessage( "Design", "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + Target = "~N:Drift.Cli.Tests.Utils.Network.Firewall" )] +[assembly: + SuppressMessage( "Design", "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + Target = "~N:Drift.Cli.Tests.Utils.Network.Topology" )] \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs b/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs new file mode 100644 index 00000000..a639551e --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs @@ -0,0 +1,47 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Configuration for an agent in a test scenario. +/// +public sealed record AgentConfiguration { + public required AgentId Id { + get; + init; + } + + /// + /// Gets HTTP address where the agent listens (e.g., http://localhost:51515). + /// Assigned by after port allocation. + /// + public Uri Address { + get; + init; + } = null!; + + /// + /// Gets subnets visible to this agent (what interfaces/networks the agent can see). + /// + public List VisibleSubnets { + get; + init; + } = []; + + /// + /// Gets devices that this agent will discover when scanning its visible subnets. + /// + public Dictionary> DiscoveredDevices { + get; + init; + } = new(); + + /// + /// Gets additional command-line arguments to pass when starting the agent. + /// + public string AdditionalArgs { + get; + init; + } = "--adoptable"; +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/AgentTestHarness.cs b/src/Cli.Tests/Utils/Agent/AgentTestHarness.cs new file mode 100644 index 00000000..03b1c8dc --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/AgentTestHarness.cs @@ -0,0 +1,325 @@ +using System.Collections.Immutable; +using System.Net.NetworkInformation; +using Drift.Cli.Abstractions; +using Drift.Cli.SpecFile; +using Drift.Cli.Tests.Utils.Network.Topology; +using Drift.Cli.Tests.Utils.Testing; +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Discovered; +using Drift.Domain.Scan; +using Drift.Scanning.Scanners; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using DomainNetwork = Drift.Domain.Network; +using NetworkInterface = Drift.Scanning.Subnets.Interface.NetworkInterface; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Test harness for orchestrating distributed scans with multiple agents. +/// +public sealed class AgentTestHarness : IAsyncDisposable { + private const string SpecName = "unittest"; + + // Thread-safe port allocation for parallel test execution + // Note that this does not guarantee that the port is free... + private static int _nextPort = Ports.AgentDefault; + + private readonly List _agents; + private readonly CliConfiguration _cli; + private readonly List _runningAgents = []; + private readonly CancellationTokenSource _cancellationTokenSource; + + private AgentTestHarness( + List agents, + CliConfiguration cli, + TimeSpan timeout + ) { + _agents = agents; + _cli = cli; + _cancellationTokenSource = new CancellationTokenSource( timeout ); + } + + /// + /// Creates a harness from a . + /// + public static async Task CreateAsync( + NetworkTopology topology, + TimeSpan? timeout = null + ) { + var agents = NetworkTopologyAdapter.ToAgentConfigurations( topology ) + .Select( a => a with { Address = new Uri( $"http://localhost:{Interlocked.Add( ref _nextPort, 1 )}" ) } ) + .ToList(); + var cliConfig = NetworkTopologyAdapter.ToCliConfiguration( topology ); + + var harness = new AgentTestHarness( + agents, + cliConfig, + timeout ?? TimeSpan.FromMinutes( 1 ) + ); + + Validate( agents, cliConfig ); + + await harness.StartAgentsAsync(); + + return harness; + } + + private static void Validate( List agents, CliConfiguration cliConfig ) { + // Agent IDs must be non-empty and unique + var blankAgents = agents.Where( a => string.IsNullOrWhiteSpace( a.Id.Value ) ).ToList(); + if ( blankAgents.Count > 0 ) { + throw new InvalidOperationException( "One or more agents have a blank ID." ); + } + + var duplicateIds = agents + .GroupBy( a => a.Id ) + .Where( g => g.Count() > 1 ) + .Select( g => g.Key ) + .ToList(); + if ( duplicateIds.Count > 0 ) { + throw new InvalidOperationException( + $"Agent IDs must be unique. Duplicates: {string.Join( ", ", duplicateIds )}" ); + } + + foreach ( var agent in agents ) { + // Device subnets must be in VisibleSubnets + var extraKeys = agent.DiscoveredDevices.Keys + .Except( agent.VisibleSubnets ) + .ToList(); + if ( extraKeys.Count > 0 ) { + throw new InvalidOperationException( + $"Agent {agent.Id} has devices in subnets it cannot see: {string.Join( ", ", extraKeys )}" ); + } + + // Device IPs must fall within their declared subnet + foreach ( var (cidr, devices) in agent.DiscoveredDevices ) { + var outOfRange = devices + .Where( d => d.Ip is not null && !cidr.Contains( d.Ip.Value ) ) + .Select( d => d.Ip!.Value.Value ) + .ToList(); + if ( outOfRange.Count > 0 ) { + throw new InvalidOperationException( + $"Agent {agent.Id} has devices in subnet {cidr} whose IPs are outside that subnet: {string.Join( ", ", outOfRange )}" ); + } + } + } + + var cliExtraKeys = cliConfig.DiscoveredDevices.Keys + .Except( cliConfig.VisibleSubnets ) + .ToList(); + if ( cliExtraKeys.Count > 0 ) { + throw new InvalidOperationException( + $"CLI has devices in subnets it cannot see: {string.Join( ", ", cliExtraKeys )}" ); + } + + // CLI device IPs must fall within their declared subnet + foreach ( var (cidr, devices) in cliConfig.DiscoveredDevices ) { + var outOfRange = devices + .Where( d => d.Ip is not null && !cidr.Contains( d.Ip.Value ) ) + .Select( d => d.Ip!.Value.Value ) + .ToList(); + if ( outOfRange.Count > 0 ) { + throw new InvalidOperationException( + $"CLI has devices in subnet {cidr} whose IPs are outside that subnet: {string.Join( ", ", outOfRange )}" ); + } + } + + if ( cliConfig.ReachableAgents != null ) { + var knownIds = agents.Select( a => a.Id ).ToHashSet(); + var unknownIds = cliConfig.ReachableAgents + .Except( knownIds ) + .ToList(); + if ( unknownIds.Count > 0 ) { + throw new InvalidOperationException( + $"CLI.ReachableAgentIds references unknown agent IDs: {string.Join( ", ", unknownIds )}" ); + } + } + } + + /// + /// Runs a scan using the configured agents. + /// + public async Task RunScanAsync() { + Console.WriteLine( "Starting scan..." ); + + var scanConfig = BuildScanConfiguration(); + + var (scanExitCode, scanOutput, scanError) = await DriftTestCli.InvokeAsync( + "scan " + SpecName, + scanConfig, + cancellationToken: _cancellationTokenSource.Token + ); + + Console.WriteLine( "\nScan finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( scanOutput.ToString() + scanError ); + Console.WriteLine( "----------------\n" ); + + // Stop agents and collect their exit codes + Console.WriteLine( "Signalling agent cancellation..." ); + await _cancellationTokenSource.CancelAsync(); + Console.WriteLine( "Waiting for agents to shut down..." ); + + var agentExitCodes = new Dictionary(); + for ( int i = 0; i < _runningAgents.Count; i++ ) { + var agent = _runningAgents[i]; + var agentId = _agents[i].Id; + + var (agentExitCode, agentOutput, agentError) = await agent.Completion; + + Console.WriteLine( $"\nAgent {agentId} finished" ); + Console.WriteLine( "----------------" ); + Console.WriteLine( agentOutput.ToString() + agentError ); + Console.WriteLine( "----------------\n" ); + + agentExitCodes[agentId] = agentExitCode; + } + + return new HarnessResult { + ScanExitCode = scanExitCode, + ScanOutput = scanOutput.ToString() ?? string.Empty, + ScanError = scanError.ToString() ?? string.Empty, + ScanResult = null, // TODO: Capture actual result + AgentExitCodes = agentExitCodes + }; + } + + private async Task StartAgentsAsync() { + Console.WriteLine( $"Starting {_agents.Count} agent(s)..." ); + + foreach ( var agentConfig in _agents ) { + Console.WriteLine( $"Starting agent {agentConfig.Id} on port {agentConfig.Address.Port}..." ); + + var args = $"{agentConfig.AdditionalArgs} --port {agentConfig.Address.Port}"; + + var agent = await DriftTestCli.StartAgentAsync( + args, + _cancellationTokenSource.Token, + BuildAgentConfiguration( agentConfig ) + ); + + _runningAgents.Add( agent ); + + Console.WriteLine( $"Agent {agentConfig.Id} started successfully" ); + } + } + + private static Action BuildAgentConfiguration( AgentConfiguration agentConfig ) { + return services => { + // Configure interfaces the agent can see + var interfaces = agentConfig.VisibleSubnets + .Select( cidr => new NetworkInterface { + Description = $"eth_{cidr}", OperationalStatus = OperationalStatus.Up, UnicastAddress = cidr + } ) + .ToList(); + + services.Replace( ServiceDescriptor.Scoped( _ => + new PredefinedInterfaceSubnetProvider( interfaces ) + ) + ); + + // Configure subnet scanner factory with mock results + var resultsByCidr = new Dictionary(); + + foreach ( var (cidr, deviceAddressList) in agentConfig.DiscoveredDevices ) { + var discoveredDevices = deviceAddressList + .Select( d => new DiscoveredDevice { Addresses = d.ToAddresses(), Timestamp = DateTime.UtcNow } ) + .ToList(); + + var ipAddresses = discoveredDevices + .SelectMany( d => d.Addresses.OfType() ) + .ToImmutableHashSet(); + + resultsByCidr[cidr] = new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = discoveredDevices, + Metadata = new Metadata { StartedAt = DateTime.UtcNow, EndedAt = DateTime.UtcNow }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = ipAddresses + }; + } + + services.Replace( ServiceDescriptor.Scoped( _ => + new MockSubnetScannerFactory( resultsByCidr ) + ) + ); + }; + } + + private Action BuildScanConfiguration() { + return services => { + // Configure CLI's local interfaces + var cliInterfaces = _cli.VisibleSubnets + .Select( cidr => new NetworkInterface { + Description = $"cli_eth_{cidr}", OperationalStatus = OperationalStatus.Up, UnicastAddress = cidr + } ) + .ToList(); + + services.Replace( ServiceDescriptor.Scoped( _ => + new PredefinedInterfaceSubnetProvider( cliInterfaces ) + ) + ); + + // Configure subnet scanner factory with mock results for CLI's local scans + var resultsByCidr = new Dictionary(); + + foreach ( var (cidr, deviceAddressList) in _cli.DiscoveredDevices ) { + var discoveredDevices = deviceAddressList + .Select( d => new DiscoveredDevice { Addresses = d.ToAddresses(), Timestamp = DateTime.UtcNow } ) + .ToList(); + + var ipAddresses = discoveredDevices + .SelectMany( d => d.Addresses.OfType() ) + .ToImmutableHashSet(); + + resultsByCidr[cidr] = new SubnetScanResult { + CidrBlock = cidr, + DiscoveredDevices = discoveredDevices, + Metadata = new Metadata { StartedAt = DateTime.UtcNow, EndedAt = DateTime.UtcNow }, + Status = ScanResultStatus.Success, + DiscoveryAttempts = ipAddresses + }; + } + + services.Replace( ServiceDescriptor.Scoped( _ => + new MockSubnetScannerFactory( resultsByCidr ) + ) + ); + + // Configure inventory with only the agents CLI can reach. + // If ReachableAgentIds is null, all agents are reachable (no CLI subnet or no firewall). + var reachableAgents = _cli.ReachableAgents == null + ? _agents + : _agents.Where( a => _cli.ReachableAgents.Contains( a.Id ) ).ToList(); + + var inventory = new Inventory { + Network = _cli.Network ?? new DomainNetwork(), + Agents = reachableAgents + .Select( a => new Domain.Agent { Id = a.Id, Address = a.Address.ToString() } ) + .ToList() + }; + + services.AddScoped( _ => + new PredefinedSpecProvider( new Dictionary { { SpecName, inventory } } ) + ); + }; + } + + public async ValueTask DisposeAsync() { + await _cancellationTokenSource.CancelAsync(); + + foreach ( var agent in _runningAgents ) { + try { + await agent.Completion; + } + catch { + // Ignore errors during disposal + } + } + + _cancellationTokenSource.Dispose(); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/CliConfiguration.cs b/src/Cli.Tests/Utils/Agent/CliConfiguration.cs new file mode 100644 index 00000000..08339c24 --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/CliConfiguration.cs @@ -0,0 +1,44 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using DomainNetwork = Drift.Domain.Network; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Configuration for the CLI in a test scenario. +/// Defines what the CLI can see locally (interfaces and devices). +/// +public sealed class CliConfiguration { + /// + /// Gets subnets visible to the CLI locally (what interfaces the CLI has). + /// + public List VisibleSubnets { + get; + init; + } = []; + + /// + /// Gets devices that the CLI will discover when scanning its local subnets. + /// + public Dictionary> DiscoveredDevices { + get; + init; + } = new(); + + /// + /// Gets network spec (optional) - devices declared in the spec file. + /// + public DomainNetwork? Network { + get; + init; + } + + /// + /// Gets the IDs of agents that the CLI can reach based on network topology and firewall rules. + /// null means all agents are reachable (no CLI subnet defined, or no firewall configured). + /// + public IReadOnlyList? ReachableAgents { + get; + init; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/HarnessResult.cs b/src/Cli.Tests/Utils/Agent/HarnessResult.cs new file mode 100644 index 00000000..bcdf0bb2 --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/HarnessResult.cs @@ -0,0 +1,142 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils.Testing; +using Drift.Domain; +using Drift.Domain.Scan; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Result from running a scan with the agent test harness. +/// +/// RECOMMENDED USAGE: +/// 1. Assert on exit codes: Assert.That(result.ScanExitCode, Is.EqualTo(ExitCodes.Success)) +/// 2. Use Verify for output: await Verify(result.CombinedOutput) +/// 3. Query results if needed: result.Results.GetDevicesWithIp(...) +/// +/// Only use result.Logs when you need to programmatically query specific log entries. +/// For most tests, Verify on CombinedOutput is easier to work with. +/// +public sealed class HarnessResult { + /// + /// Gets the exit code from the scan command. + /// + public required int ScanExitCode { + get; + init; + } + + /// + /// Gets the standard output from the scan command. + /// + public required string ScanOutput { + get; + init; + } + + /// + /// Gets the standard error from the scan command. + /// + public required string ScanError { + get; + init; + } + + /// + /// Gets the combined output (stdout + stderr). + /// + public string CombinedOutput => ScanOutput + ScanError; + + /// + /// Gets the parsed scan result (if scan was successful). + /// + public NetworkScanResult? ScanResult { + get; + init; + } + + /// + /// Gets the exit codes from each agent, keyed by agent ID. + /// + public required Dictionary AgentExitCodes { + get; + init; + } + + /// + /// Gets all agent IDs from the result. + /// + public IEnumerable AgentIds => AgentExitCodes.Keys; + + /// + /// Gets a value indicating whether the scan completed successfully. + /// + public bool IsSuccess => ScanExitCode == ExitCodes.Success; + + /// + /// Gets the query methods for scan results (convenience accessors to ScanResultHelper). + /// + public ScanResultQuery Results => new(ScanResult); +} + +/// +/// Convenience wrapper for querying scan results. +/// Provides direct access to ScanResultHelper methods without needing to pass the result each time. +/// +public sealed class ScanResultQuery( NetworkScanResult? result ) { + private readonly NetworkScanResult _result = result ?? throw new InvalidOperationException( + "Cannot query results - scan did not produce a result" + ); + + public List GetAllDevices() => + ScanResultHelper.GetAllDevices( _result ); + + public List GetDevicesWithIp( + Drift.Domain.Device.Addresses.IpV4Address ipAddress ) => + ScanResultHelper.GetDevicesWithIp( _result, ipAddress ); + + public List GetDevicesWithMac( + Drift.Domain.Device.Addresses.MacAddress macAddress ) => + ScanResultHelper.GetDevicesWithMac( _result, macAddress ); + + public List GetAllIpAddresses() => + ScanResultHelper.GetAllIpAddresses( _result ); + + public List GetAllMacAddresses() => + ScanResultHelper.GetAllMacAddresses( _result ); + + public List GetScannedSubnets() => + ScanResultHelper.GetScannedSubnets( _result ); + + public SubnetScanResult? GetSubnetResult( Drift.Domain.CidrBlock cidr ) => + ScanResultHelper.GetSubnetResult( _result, cidr ); + + public List GetSubnetsByStatus( ScanResultStatus status ) => + ScanResultHelper.GetSubnetsByStatus( _result, status ); + + public int GetTotalDeviceCount() => + ScanResultHelper.GetTotalDeviceCount( _result ); + + public int GetUniqueIpCount() => + ScanResultHelper.GetUniqueIpCount( _result ); + + public int GetUniqueMacCount() => + ScanResultHelper.GetUniqueMacCount( _result ); + + public bool HasIpAddress( Drift.Domain.Device.Addresses.IpV4Address ipAddress ) => + ScanResultHelper.HasIpAddress( _result, ipAddress ); + + public bool HasMacAddress( Drift.Domain.Device.Addresses.MacAddress macAddress ) => + ScanResultHelper.HasMacAddress( _result, macAddress ); + + public bool HasSubnet( Drift.Domain.CidrBlock cidr ) => + ScanResultHelper.HasSubnet( _result, cidr ); + + public List GetDevicesInSubnet( Drift.Domain.CidrBlock cidr ) => + ScanResultHelper.GetDevicesInSubnet( _result, cidr ); + + public List GetFailedSubnets() => + ScanResultHelper.GetFailedSubnets( _result ); + + public List GetSuccessfulSubnets() => + ScanResultHelper.GetSuccessfulSubnets( _result ); +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs b/src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs new file mode 100644 index 00000000..a7bdbc2a --- /dev/null +++ b/src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs @@ -0,0 +1,159 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Cli.Tests.Utils.Network.Topology; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Agent; + +/// +/// Adapter for converting to individual s +/// and s. +/// Computes visibility based on firewall rules and generates configurations for test execution. +/// +internal static class NetworkTopologyAdapter { + /// + /// to individual s. + /// + internal static List ToAgentConfigurations( NetworkTopology topology ) { + var configs = new List(); + + foreach ( var agent in topology.Agents ) { + var visibleSubnets = GetAccessibleSubnets( topology, agent ); + var visibleDevices = GetAccessibleDevices( topology, agent ); + + var devicesBySubnet = new Dictionary>(); + + foreach ( var subnet in visibleSubnets ) { + // Get devices that are in this subnet AND visible to the agent + var devicesInSubnet = subnet.Devices + .Where( visibleDevices.Contains ) + .Select( d => new DeviceAddressSet( ip: d.Ip, mac: d.Mac ) ) + .ToList(); + + devicesBySubnet[subnet.Cidr] = devicesInSubnet; + } + + configs.Add( new AgentConfiguration { + Id = agent.Id, + VisibleSubnets = visibleSubnets.Select( s => s.Cidr ).ToList(), + DiscoveredDevices = devicesBySubnet + } ); + } + + return configs; + } + + /// + /// to individual s. + /// + internal static CliConfiguration ToCliConfiguration( NetworkTopology topology ) { + var cliSubnet = topology.GetCliSubnet(); + + var devicesBySubnet = new Dictionary>(); + + if ( cliSubnet != null ) { + var devicesInSubnet = cliSubnet.Devices + .Select( d => new DeviceAddressSet( ip: d.Ip, mac: d.Mac ) ) + .ToList(); + + devicesBySubnet[cliSubnet.Cidr] = devicesInSubnet; + } + + // Compute which agents CLI can actually reach based on firewall rules. + // null means all agents are reachable (no CLI subnet or no firewall = flat/management network). + IReadOnlyList? reachableAgentIds = null; + + if ( cliSubnet != null && topology.FirewallEvaluator != null ) { + reachableAgentIds = GetAccessibleAgents( topology, cliSubnet ) + .Select( agent => agent.Id ) + .ToList(); + } + + return new CliConfiguration { + VisibleSubnets = cliSubnet != null ? [cliSubnet.Cidr] : [], + DiscoveredDevices = devicesBySubnet, + Network = null, // Can be extended if needed + ReachableAgents = reachableAgentIds + }; + } + + /// + /// Returns subnets accessible to an agent given its attachment point and firewall rules. + /// Directly attached subnet is always accessible unless ClientIsolation is enabled. + /// Firewall governs cross-subnet (routed) traffic only. + /// If no firewall is configured, all subnets are accessible (flat routed network). + /// + private static List GetAccessibleSubnets( + NetworkTopology topology, + NetworkTopology.AgentDefinition agent + ) { + if ( topology.FirewallEvaluator == null ) { + return topology.Subnets; + } + + var accessible = new HashSet(); + + if ( !agent.AttachedSubnet.ClientIsolation ) { + accessible.Add( agent.AttachedSubnet ); + } + + foreach ( var targetSubnet in topology.Subnets ) { + if ( targetSubnet == agent.AttachedSubnet ) { + continue; + } + + if ( topology.FirewallEvaluator.IsAllowed( + FirewallTarget.Subnet( agent.AttachedSubnet.Name ), + FirewallTarget.Subnet( targetSubnet.Name ) ) ) { + accessible.Add( targetSubnet ); + } + } + + return accessible.ToList(); + } + + /// + /// Returns devices accessible to an agent given its attachment point and firewall rules. + /// Devices on the directly attached subnet are accessible unless ClientIsolation is enabled. + /// Firewall governs cross-subnet (routed) traffic only. + /// + private static List GetAccessibleDevices( + NetworkTopology topology, + NetworkTopology.AgentDefinition agent + ) { + var accessible = new List(); + + foreach ( var subnet in topology.Subnets ) { + bool isSameSubnet = subnet == agent.AttachedSubnet; + + foreach ( var device in subnet.Devices ) { + bool canAccess = isSameSubnet + ? !subnet.ClientIsolation + : ( topology.FirewallEvaluator?.IsAllowed( + FirewallTarget.Subnet( agent.AttachedSubnet.Name ), + FirewallTarget.FromIp( device.Ip ) ) ?? true ); + + if ( canAccess ) { + accessible.Add( device ); + } + } + } + + return accessible; + } + + /// + /// Returns agents accessible from the CLI's subnet through the firewall. + /// + private static List GetAccessibleAgents( + NetworkTopology topology, + NetworkTopology.SubnetDefinition cliSubnet + ) { + return topology.Agents + .Where( agent => topology.FirewallEvaluator!.IsAllowed( + FirewallTarget.Subnet( cliSubnet.Name ), + FirewallTarget.Subnet( agent.AttachedSubnet.Name ) ) + ) + .ToList(); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/FirewallRulesTests.cs b/src/Cli.Tests/Utils/FirewallRulesTests.cs new file mode 100644 index 00000000..869f77e2 --- /dev/null +++ b/src/Cli.Tests/Utils/FirewallRulesTests.cs @@ -0,0 +1,279 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils; + +/// +/// Tests for FirewallEvaluator functionality including rule matching, +/// priority evaluation, and device-level filtering. +/// +internal sealed class FirewallRulesTests { + // Standard test subnets + private static readonly List<(string Name, CidrBlock Cidr)> TestSubnets = [ + ( "guest", new CidrBlock( "192.168.100.0/24" ) ), + ( "internal", new CidrBlock( "10.0.0.0/24" ) ), + ( "external", new CidrBlock( "172.16.0.0/24" ) ), + ( "dmz", new CidrBlock( "192.168.1.0/24" ) ), + ( "internet", new CidrBlock( "1.0.0.0/8" ) ), + ( "anywhere", new CidrBlock( "192.168.2.0/24" ) ), + ( "source", new CidrBlock( "192.168.3.0/24" ) ), + ( "dest", new CidrBlock( "192.168.4.0/24" ) ), + ( "subnet1", new CidrBlock( "192.168.5.0/24" ) ), + ( "subnet2", new CidrBlock( "192.168.6.0/24" ) ), + ( "subnet3", new CidrBlock( "192.168.7.0/24" ) ), + ( "subnet4", new CidrBlock( "192.168.8.0/24" ) ) + ]; + + [Test] + public void NoRules_DefaultAllow_ReturnsTrue() { + // Arrange + var rules = new FirewallRules(); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( allowed, Is.True ); + } + + [Test] + public void SubnetNameMatching_ExactMatch_Works() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "internal" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var toExternalAllowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "external" ) + ); + var toInternalAllowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( toExternalAllowed, Is.True ); + Assert.That( toInternalAllowed, Is.False ); + } + + [Test] + public void FirstMatchWins_OrderMatters() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Any ); + rules.Allow( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "dmz" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "dmz" ) + ); + + // Assert + Assert.That( allowed, Is.False ); + } + + [Test] + public void FirstMatchWins_AllowBeforeDeny() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "dmz" ) ); + rules.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "dmz" ) + ); + + // Assert + Assert.That( allowed, Is.True ); + } + + [Test] + public void DeviceIpMatching_ExactIp_Works() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Any, FirewallTarget.FromIp( new IpV4Address( "10.0.0.100" ) ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var toDeniedAllowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.FromIp( new IpV4Address( "10.0.0.100" ) ) + ); + + var toAllowedAllowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.FromIp( new IpV4Address( "10.0.0.101" ) ) + ); + + // Assert + Assert.That( toDeniedAllowed, Is.False ); + Assert.That( toAllowedAllowed, Is.True, "Traffic to other device should be allowed" ); + } + + [Test] + public void DeviceIpMatching_SourceIp_Works() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), FirewallTarget.Any ); + rules.Deny( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var bastionAllowed = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + FirewallTarget.Subnet( "internal" ) + ); + + var otherDenied = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.20" ) ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( bastionAllowed, Is.True, "Bastion host should be allowed" ); + Assert.That( otherDenied, Is.False, "Other DMZ hosts should be denied" ); + } + + [Test] + public void CidrMatching_DeviceInRange_Matches() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.FromCidr( new CidrBlock( "192.168.1.0/24" ) ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var deviceInRange = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.50" ) ), + FirewallTarget.Subnet( "anywhere" ) + ); + + var deviceOutOfRange = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.2.50" ) ), + FirewallTarget.Subnet( "anywhere" ) + ); + + // Assert + Assert.That( deviceInRange, Is.False, "Device in CIDR range should be blocked" ); + Assert.That( deviceOutOfRange, Is.True, "Device outside CIDR range should be allowed" ); + } + + [Test] + public void CidrMatching_DestinationCidr_Works() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( FirewallTarget.Subnet( "external" ), FirewallTarget.FromCidr( new CidrBlock( "10.0.0.0/8" ) ) ); + rules.Deny( FirewallTarget.Subnet( "external" ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowedRange = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.FromIp( new IpV4Address( "10.5.10.100" ) ) + ); + + var deniedRange = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.FromIp( new IpV4Address( "192.168.1.100" ) ) + ); + + // Assert + Assert.That( allowedRange, Is.True, "Device in allowed CIDR range should pass" ); + Assert.That( deniedRange, Is.False, "Device outside allowed range should be denied" ); + } + + [Test] + public void MixedRules_SubnetAndDeviceLevel_Work() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + FirewallTarget.FromIp( new IpV4Address( "10.0.0.100" ) ) + ); + rules.Deny( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "internal" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var specificAllowed = evaluator.IsAllowed( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + FirewallTarget.FromIp( new IpV4Address( "10.0.0.100" ) ) + ); + + var genericDenied = evaluator.IsAllowed( + FirewallTarget.Subnet( "dmz" ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( specificAllowed, Is.True, "Specific device exception should be allowed" ); + Assert.That( genericDenied, Is.False, "Generic subnet traffic should be denied" ); + } + + [Test] + public void GuestNetwork_Isolation_DevicesCantSeeEachOther() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "guest" ) ); + rules.Allow( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "internet" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var guestToGuest = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "guest" ) + ); + var guestToInternet = evaluator.IsAllowed( + FirewallTarget.Subnet( "guest" ), + FirewallTarget.Subnet( "internet" ) + ); + + // Assert + Assert.That( guestToGuest, Is.False ); + Assert.That( guestToInternet, Is.True ); + } + + [Test] + public void AnyKeyword_WorksSameAsWildcard() { + // Arrange + var rules = new FirewallRules(); + rules.Deny( FirewallTarget.Any, FirewallTarget.Subnet( "internal" ) ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act + var allowed = evaluator.IsAllowed( + FirewallTarget.Subnet( "external" ), + FirewallTarget.Subnet( "internal" ) + ); + + // Assert + Assert.That( allowed, Is.False ); + } + + [Test] + public void InvalidCidr_DoesNotCrash_ReturnsFalse() { + // Arrange + var rules = new FirewallRules(); + rules.Allow( FirewallTarget.Subnet( "invalid-cidr" ), FirewallTarget.Any ); + var evaluator = new FirewallEvaluator( rules, TestSubnets ); + + // Act / Assert + Assert.DoesNotThrow( () => { + var result = evaluator.IsAllowed( + FirewallTarget.Subnet( "source" ), + FirewallTarget.Subnet( "dest" ) + ); + Assert.That( result, Is.True, "Subnet name 'invalid-cidr' should not match 'source', fall through to default" ); + } ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs new file mode 100644 index 00000000..257672c7 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs @@ -0,0 +1,114 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Firewall; + +/// +/// Evaluates firewall rules with subnet awareness. +/// Combines firewall rules with subnet topology to determine if traffic is allowed. +/// +public sealed class FirewallEvaluator { + private readonly FirewallRules _rules; + private readonly IReadOnlyList<(string Name, CidrBlock Cidr)> _subnets; + + public FirewallEvaluator( FirewallRules rules, IReadOnlyList<(string Name, CidrBlock Cidr)> subnets ) { + _rules = rules; + _subnets = subnets; + } + + /// + /// Evaluate if traffic is allowed from source to destination. + /// Supports subnet names, IP addresses, CIDR blocks, and wildcards. + /// Rules are evaluated in order - first match wins. + /// If no rule matches, the default policy from is applied. + /// + /// Source firewall target (subnet, IP, CIDR, or wildcard). + /// Destination firewall target (subnet, IP, CIDR, or wildcard). + /// True if traffic is allowed, false if denied. + public bool IsAllowed( FirewallTarget source, FirewallTarget dest ) { + // Extract subnet name and IP from source and destination targets + var (sourceSubnet, sourceIp) = ResolveTarget( source ); + var (destSubnet, destIp) = ResolveTarget( dest ); + + // Evaluate rules in order - first match wins + foreach ( var rule in _rules.Rules ) { + if ( RuleMatches( rule, sourceSubnet, destSubnet, sourceIp, destIp ) ) { + return rule.Action == FirewallAction.Allow; + } + } + + // Default policy: defer to FirewallRules + return _rules.DefaultPolicy == FirewallAction.Allow; + } + + /// + /// Resolve a FirewallTarget to a subnet name and optional IP address. + /// For IP targets, looks up which subnet the IP belongs to. + /// + private (string? subnetName, IpV4Address? ip) ResolveTarget( FirewallTarget target ) { + return target switch { + FirewallTarget.SubnetTarget subnet => ( subnet.Name, null ), + FirewallTarget.IpTarget ipTarget => ( ResolveSubnet( ipTarget.Address ), ipTarget.Address ), + FirewallTarget.CidrTarget => ( null, null ), // CIDR targets don't resolve to a single subnet + FirewallTarget.WildcardTarget => ( null, null ), + _ => throw new InvalidOperationException( $"Unknown FirewallTarget type: {target.GetType().Name}" ) + }; + } + + /// + /// Resolve an IP address to its subnet name by checking which subnet's CIDR contains the IP. + /// + private string? ResolveSubnet( IpV4Address ip ) { + foreach ( var subnet in _subnets ) { + if ( IpInCidr( ip, subnet.Cidr ) ) { + return subnet.Name; + } + } + + return null; // IP doesn't belong to any known subnet + } + + /// + /// Check if an IP address is within a CIDR block. + /// + private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { + try { + var ipBytes = ip.Ip.GetAddressBytes(); + var cidrParts = cidr.ToString().Split( '/' ); + if ( cidrParts.Length != 2 ) { + return false; + } + + var networkBytes = System.Net.IPAddress.Parse( cidrParts[0] ).GetAddressBytes(); + if ( !int.TryParse( cidrParts[1], out var prefixLength ) || prefixLength < 0 || prefixLength > 32 ) { + return false; + } + + var mask = ~0u << ( 32 - prefixLength ); + var ipInt = ( (uint) ipBytes[0] << 24 ) | ( (uint) ipBytes[1] << 16 ) | ( (uint) ipBytes[2] << 8 ) | ipBytes[3]; + var networkInt = ( (uint) networkBytes[0] << 24 ) | ( (uint) networkBytes[1] << 16 ) | + ( (uint) networkBytes[2] << 8 ) | networkBytes[3]; + + return ( ipInt & mask ) == ( networkInt & mask ); + } + catch { + return false; + } + } + + /// + /// Check if a firewall rule matches the given traffic parameters. + /// + private static bool RuleMatches( + FirewallRule rule, + string? sourceSubnet, + string? destSubnet, + IpV4Address? sourceIp, + IpV4Address? destIp + ) { + bool sourceMatches = rule.Source.Matches( sourceSubnet ?? string.Empty, sourceIp ); + bool destMatches = rule.Destination.Matches( destSubnet ?? string.Empty, destIp ); + + return sourceMatches && destMatches; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs new file mode 100644 index 00000000..f21b3a78 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs @@ -0,0 +1,83 @@ +namespace Drift.Cli.Tests.Utils.Network.Firewall; + +/// +/// Represents firewall rules for controlling network visibility in test topologies. +/// Rules are evaluated in order (first match wins), with a default ALLOW policy. +/// Inspired by OPNsense firewall behavior. +/// Use FirewallEvaluator to evaluate rules with subnet awareness. +/// +public sealed class FirewallRules { + private readonly List _rules = new(); + + /// + /// Add an ALLOW rule. Rules are evaluated in the order they are added. + /// + /// Source target (subnet, IP, CIDR, or wildcard). + /// Destination target (subnet, IP, CIDR, or wildcard). + public void Allow( FirewallTarget source, FirewallTarget destination ) { + _rules.Add( new FirewallRule { Source = source, Destination = destination, Action = FirewallAction.Allow } ); + } + + /// + /// Add a DENY rule. Rules are evaluated in the order they are added. + /// + /// Source target (subnet, IP, CIDR, or wildcard). + /// Destination target (subnet, IP, CIDR, or wildcard). + public void Deny( FirewallTarget source, FirewallTarget destination ) { + _rules.Add( new FirewallRule { Source = source, Destination = destination, Action = FirewallAction.Deny } ); + } + + /// + /// Gets or sets the policy applied when no rule matches. Defaults to . + /// + public FirewallAction DefaultPolicy { get; set; } = FirewallAction.Allow; + + public IReadOnlyList Rules => _rules.AsReadOnly(); +} + +/// +/// Represents a single firewall rule. +/// +public sealed class FirewallRule { + /// + /// Gets source firewall target (subnet, IP, CIDR, or wildcard). + /// + public required FirewallTarget Source { + get; + init; + } + + /// + /// Gets destination firewall target (subnet, IP, CIDR, or wildcard). + /// + public required FirewallTarget Destination { + get; + init; + } + + /// + /// Gets action to take when this rule matches. + /// + public required FirewallAction Action { + get; + init; + } + + public override string ToString() => + $"{Action.ToString().ToUpper()}: {Source} → {Destination}"; +} + +/// +/// Action to take when a firewall rule matches. +/// +public enum FirewallAction { + /// + /// Allow the traffic to pass. + /// + Allow, + + /// + /// Deny/block the traffic. + /// + Deny +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs new file mode 100644 index 00000000..b6533d05 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs @@ -0,0 +1,95 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Firewall; + +/// +/// Represents a firewall target - can be a subnet name, IP address, CIDR block, or wildcard. +/// This is a discriminated union type providing type-safe firewall rule specification. +/// +public abstract record FirewallTarget { + /// + /// Create a firewall target from a subnet name (e.g., "dmz", "internal"). + /// + public static FirewallTarget Subnet( string subnetName ) => new SubnetTarget( subnetName ); + + /// + /// Create a firewall target from an IP address. + /// + public static FirewallTarget FromIp( IpV4Address ip ) => new IpTarget( ip ); + + /// + /// Create a firewall target from a CIDR block (e.g., "192.168.1.0/24"). + /// + public static FirewallTarget FromCidr( CidrBlock cidr ) => new CidrTarget( cidr ); + + /// + /// Wildcard target matching any source or destination. + /// + public static readonly FirewallTarget Any = new WildcardTarget(); + + /// + /// Check if this target matches a given subnet name and optional IP address. + /// Used internally for firewall rule evaluation. + /// + internal abstract bool Matches( string subnetName, IpV4Address? ip ); + + internal sealed record SubnetTarget( string Name ) : FirewallTarget { + internal override bool Matches( string subnetName, IpV4Address? ip ) => Name == subnetName; + + public override string ToString() => Name; + } + + internal sealed record IpTarget( IpV4Address Address ) : FirewallTarget { + internal override bool Matches( string subnetName, IpV4Address? ip ) => ip.HasValue && Address.Equals( ip.Value ); + + public override string ToString() => Address.Value; + } + + internal sealed record CidrTarget( CidrBlock Block ) : FirewallTarget { + internal override bool Matches( string subnetName, IpV4Address? ip ) { + if ( ip == null ) { + return false; + } + + return IpInCidr( ip.Value, Block ); + } + + /// + /// Check if an IP address is within a CIDR block. + /// Since CidrBlock doesn't have a Contains() method, we implement our own. + /// + private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { + try { + var ipBytes = ip.Ip.GetAddressBytes(); + var cidrParts = cidr.ToString().Split( '/' ); + if ( cidrParts.Length != 2 ) { + return false; + } + + var networkBytes = System.Net.IPAddress.Parse( cidrParts[0] ).GetAddressBytes(); + if ( !int.TryParse( cidrParts[1], out var prefixLength ) || prefixLength < 0 || prefixLength > 32 ) { + return false; + } + + var mask = ~0u << ( 32 - prefixLength ); + var ipInt = ( (uint) ipBytes[0] << 24 ) | ( (uint) ipBytes[1] << 16 ) | ( (uint) ipBytes[2] << 8 ) | ipBytes[3]; + var networkInt = ( (uint) networkBytes[0] << 24 ) | ( (uint) networkBytes[1] << 16 ) | + ( (uint) networkBytes[2] << 8 ) | networkBytes[3]; + + return ( ipInt & mask ) == ( networkInt & mask ); + } + catch { + return false; + } + } + + public override string ToString() => Block.ToString(); + } + + internal sealed record WildcardTarget : FirewallTarget { + internal override bool Matches( string subnetName, IpV4Address? ip ) => true; + + public override string ToString() => "*"; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs b/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs new file mode 100644 index 00000000..a4943e27 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs @@ -0,0 +1,133 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Topology; + +/// +/// Represents a network topology for testing. +/// Supports optional firewall rules to control network visibility and simulate realistic network segmentation. +/// +public sealed class NetworkTopology { + public required List Subnets { + get; + init; + } + + /// + /// Gets the CLI's network attachment definition. + /// + public CliDefinition? Cli { + get; + init; + } + + public List Agents { + get; + init; + } = []; + + /// + /// Gets optional firewall rules controlling network visibility. + /// If null, all subnets can route to each other (flat network). + /// If present, rules are evaluated in order with a default ALLOW policy. + /// + public FirewallRules? FirewallRules { + get; + init; + } + + /// + /// Gets optional firewall evaluator for subnet-aware rule evaluation. + /// Constructed from FirewallRules + Subnets. + /// + public FirewallEvaluator? FirewallEvaluator { + get; + init; + } + + /// + /// Gets a value indicating whether check if this topology includes agents (distributed scenario). + /// + public bool HasAgents => Agents.Count > 0; + + /// + /// Get the subnet the CLI is attached to, or null if no local interface is configured. + /// + public SubnetDefinition? GetCliSubnet() => Cli?.AttachedSubnet; + + /// + /// Represents a subnet in the topology. + /// + public sealed class SubnetDefinition { + public required string Name { + get; + init; + } + + public required CidrBlock Cidr { + get; + init; + } + + public required List Devices { + get; + init; + } + + /// + /// When true, devices on this subnet cannot communicate with each other (peer isolation). + /// Mirrors UniFi's "Client Device Isolation" / "Device Isolation (ACL)" feature. + /// Defaults to false (devices on the same subnet can communicate freely). + /// + public bool ClientIsolation { + get; + init; + } = false; + } + + /// + /// Represents a device in the topology. + /// + public sealed class DeviceDefinition { + public required IpV4Address Ip { + get; + init; + } + + public required MacAddress Mac { + get; + init; + } + + public required string Description { + get; + init; + } + } + + /// + /// Represents the CLI's network attachment. + /// + public sealed class CliDefinition { + public SubnetDefinition? AttachedSubnet { + get; + init; + } + } + + /// + /// Represents an agent in the topology. + /// + public sealed class AgentDefinition { + public required AgentId Id { + get; + init; + } + + public required SubnetDefinition AttachedSubnet { + get; + init; + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs b/src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs new file mode 100644 index 00000000..78e5e5c8 --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs @@ -0,0 +1,110 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Topology; + +public sealed class NetworkTopologyBuilder { + private readonly Dictionary _subnets = new(); + private readonly List _agents = []; + private NetworkTopology.SubnetDefinition? _cli; + private FirewallRules? _firewall; + + public NetworkTopologyBuilder AddSubnet( + string name, + string cidr, + List<(string ip, string description)>? devices, + out NetworkTopology.SubnetDefinition subnet + ) { + subnet = new NetworkTopology.SubnetDefinition { + Name = name, + Cidr = new CidrBlock( cidr ), + Devices = devices?.Select( d => new NetworkTopology.DeviceDefinition { + Ip = new IpV4Address( d.ip ), Mac = GenerateMacFromIp( d.ip ), Description = d.description + } ).ToList() ?? [] + }; + + _subnets[name] = subnet; + return this; + } + + public NetworkTopologyBuilder AddSubnet( + string name, + string cidr, + List<(string ip, string description)>? devices = null + ) => AddSubnet( name, cidr, devices, out _ ); + + /// + /// Attach the CLI to a specific subnet. + /// + public NetworkTopologyBuilder WithCli( NetworkTopology.SubnetDefinition subnet ) { + _cli = subnet; + return this; + } + + /// + /// Add an agent attached to specific subnets. + /// + public NetworkTopologyBuilder AddAgent( AgentId id, NetworkTopology.SubnetDefinition attachedSubnet) { + _agents.Add( new NetworkTopology.AgentDefinition { Id = id, AttachedSubnet = attachedSubnet } ); + return this; + } + + /// + /// Configure firewall rules using a configuration action. + /// When firewall is configured, rules are evaluated in order with default ALLOW policy. + /// + public NetworkTopologyBuilder WithFirewall( Action configure ) { + _firewall ??= new FirewallRules(); + configure( _firewall ); + return this; + } + + /// + /// Shorthand: Add an ALLOW rule from source to destination. + /// Creates a firewall if one doesn't exist yet. + /// + public NetworkTopologyBuilder AllowConnection( string source, string destination ) { + _firewall ??= new FirewallRules(); + _firewall.Allow( FirewallTarget.Subnet( source ), FirewallTarget.Subnet( destination ) ); + return this; + } + + /// + /// Shorthand: Add a DENY rule from source to destination. + /// Creates a firewall if one doesn't exist yet. + /// + public NetworkTopologyBuilder DenyConnection( string source, string destination ) { + _firewall ??= new FirewallRules(); + _firewall.Deny( FirewallTarget.Subnet( source ), FirewallTarget.Subnet( destination ) ); + return this; + } + + /// + /// Build the topology. + /// + public NetworkTopology Build() { + var subnets = _subnets.Values.ToList(); + + // Construct FirewallEvaluator if firewall rules exist + FirewallEvaluator? evaluator = null; + if ( _firewall != null ) { + evaluator = new FirewallEvaluator( _firewall, subnets.Select( s => ( s.Name, s.Cidr ) ).ToList() ); + } + + return new NetworkTopology { + Subnets = subnets, + Cli = new NetworkTopology.CliDefinition { AttachedSubnet = _cli }, + Agents = _agents, + FirewallRules = _firewall, + FirewallEvaluator = evaluator + }; + } + + private static MacAddress GenerateMacFromIp( string ip ) { + var parts = ip.Split( '.' ); + var macString = + $"aa:bb:{int.Parse( parts[0] ):x2}:{int.Parse( parts[1] ):x2}:{int.Parse( parts[2] ):x2}:{int.Parse( parts[3] ):x2}"; + return new MacAddress( macString ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Network/Topology/Topologies.cs b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs new file mode 100644 index 00000000..a75427cf --- /dev/null +++ b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs @@ -0,0 +1,229 @@ +using Drift.Cli.Tests.Utils.Network.Firewall; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +namespace Drift.Cli.Tests.Utils.Network.Topology; + +/// +/// Pre-configured topology scenarios for common test cases. +/// Returns NetworkTopology instances that can be used with AgentTestHarness.CreateFromTopologyAsync(). +/// +public static class Topologies { + /// + /// Two agents, each on different subnets, CLI on its own subnet. + /// No firewall = flat routed network (all can see all). + /// Tests basic distributed scanning with CLI participation. + /// + public static NetworkTopologyBuilder TwoAgentsDisjointSubnetsWithCli() { + return new NetworkTopologyBuilder() + .AddSubnet( "cli-subnet", "192.168.0.0/24", [( "192.168.0.100", "CLI device" )], out var cliSubnet ) + .AddSubnet( + "agent1-subnet", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Agent1 device 1" ), + ( "192.168.10.101", "Agent1 device 2" ) + ], + out var agent1Subnet + ) + .AddSubnet( + "agent2-subnet", + "192.168.20.0/24", + [ + ( "192.168.20.100", "Agent2 device" ) + ], + out var agent2Subnet + ) + .AddAgent( new AgentId( "agentid_agent1" ), agent1Subnet ) + .AddAgent( new AgentId( "agentid_agent2" ), agent2Subnet ) + .WithCli( cliSubnet ); + } + + /// + /// Two agents, each on different subnets, CLI has no local interfaces. + /// No firewall = flat routed network. + /// Tests agents-only scanning without local CLI scanner. + /// + public static NetworkTopology TwoAgentsDisjointSubnetsNoCli() { + return new NetworkTopologyBuilder() + .AddSubnet( "agent1-subnet", "192.168.10.0/24", [ + ( "192.168.10.100", "Agent1 device 1" ), + ( "192.168.10.101", "Agent1 device 2" ) + ], out var agent1Subnet ) + .AddSubnet( "agent2-subnet", "192.168.20.0/24", [ + ( "192.168.20.100", "Agent2 device" ) + ], out var agent2Subnet ) + .AddAgent( new AgentId( "agentid_agent1" ), agent1Subnet ) + .AddAgent( new AgentId( "agentid_agent2" ), agent2Subnet ) + .Build(); + } + + /// + /// Two agents on the SAME subnet with CLI on different subnet. + /// No firewall = flat routed network. + /// Tests result merging when multiple agents scan overlapping networks. + /// + public static NetworkTopology TwoAgentsOverlappingSubnet() { + return new NetworkTopologyBuilder() + .AddSubnet( "cli-subnet", "192.168.0.0/24", [ + ( "192.168.0.100", "CLI device" ) + ], out var cliSubnet ) + .AddSubnet( "shared-subnet", "192.168.10.0/24", [ + ( "192.168.10.100", "Shared device 1" ), + ( "192.168.10.101", "Shared device 2" ), + ( "192.168.10.102", "Shared device 3" ), + ( "192.168.10.103", "Shared device 4" ) + ], out var sharedSubnet ) + .AddAgent( new AgentId( "agentid_agent1" ), sharedSubnet ) + .AddAgent( new AgentId( "agentid_agent2" ), sharedSubnet ) + .WithCli( cliSubnet ) + .Build(); + } + + /// + /// Single agent with empty subnet (no devices). + /// Tests handling of empty scan results. + /// + public static NetworkTopology SingleAgentEmptyResults() { + return new NetworkTopologyBuilder() + .AddSubnet( "empty-subnet", "192.168.10.0/24", [], out var emptySubnet ) // No devices + .AddAgent( new AgentId( "agentid_agent1" ), emptySubnet ) + .Build(); + } + + /// + /// CLI + one agent on different subnets. + /// No firewall = flat routed network. + /// Tests mixed local and distributed scanning. + /// + public static NetworkTopology MixedCliAndAgents() { + return new NetworkTopologyBuilder() + .AddSubnet( "cli-subnet", "192.168.0.0/24", [ + ( "192.168.0.100", "CLI device" ) + ], out var cliSubnet ) + .AddSubnet( "agent-subnet", "192.168.10.0/24", [ + ( "192.168.10.100", "Agent device" ) + ], out var agentSubnet ) + .AddAgent( new AgentId( "agentid_agent1" ), agentSubnet ) + .WithCli( cliSubnet ) + .Build(); + } + + // ========== FIREWALL SCENARIOS ========== + + /// + /// DMZ with firewall rules. + /// DMZ subnet can reach internal subnet, but internal cannot initiate connections to DMZ. + /// Tests unidirectional firewall rules. + /// + public static NetworkTopology DmzWithFirewall() { + return new NetworkTopologyBuilder() + .AddSubnet( "dmz", "192.168.1.0/24", [ + ( "192.168.1.100", "Web server" ), + ( "192.168.1.101", "App server" ) + ], out var dmzSubnet ) + .AddSubnet( "internal", "10.0.0.0/24", [ + ( "10.0.0.100", "Database server" ), + ( "10.0.0.101", "File server" ) + ], out var internalSubnet ) + .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) + .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) + // Firewall rules: + .AllowConnection( "dmz", "dmz" ) // DMZ can see itself + .AllowConnection( "dmz", "internal" ) // DMZ → internal allowed + .AllowConnection( "internal", "internal" ) // Internal can see itself + // internal → dmz NOT allowed (not specified, will fall through to default ALLOW) + // So we need to explicitly deny: + .DenyConnection( "internal", "dmz" ) + .Build(); + } + + /// + /// Guest network isolation with firewall. + /// Guest devices cannot see each other (client isolation), but can reach external subnet. + /// Tests device-level isolation on same subnet. + /// + public static NetworkTopology GuestNetworkIsolation() { + return new NetworkTopologyBuilder() + .AddSubnet( "guest", "192.168.100.0/24", [ + ( "192.168.100.10", "Guest device 1" ), + ( "192.168.100.11", "Guest device 2" ), + ( "192.168.100.12", "Guest device 3" ) + ], out var guestSubnet ) + .AddSubnet( "external", "203.0.113.0/24", [ + ( "203.0.113.1", "Internet gateway" ) + ] ) + .AddAgent( new AgentId( "agentid_guest" ), guestSubnet ) + .WithFirewall( fw => { + fw.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "guest" ) ); // Block guest-to-guest + fw.Allow( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "external" ) ); // Allow guest-to-external + } ) + .Build(); + } + + /// + /// Bastion host scenario with device-level firewall rules. + /// Only specific bastion host (192.168.1.10) can reach internal network. + /// Other DMZ devices are blocked. + /// Tests device-specific firewall rules with first-match-wins priority. + /// + public static NetworkTopology BastionHostAccess() { + return new NetworkTopologyBuilder() + .AddSubnet( "dmz", "192.168.1.0/24", [ + ( "192.168.1.10", "Bastion host" ), + ( "192.168.1.20", "Web server" ), + ( "192.168.1.30", "App server" ) + ], out var dmzSubnet ) + .AddSubnet( "internal", "10.0.0.0/24", [ + ( "10.0.0.100", "Database" ), + ( "10.0.0.101", "Admin server" ) + ], out var internalSubnet ) + .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) + .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) + .WithFirewall( fw => { + // Order matters - first match wins! + fw.Allow( FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + FirewallTarget.Subnet( "internal" ) ); // Bastion → internal (FIRST) + fw.Deny( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "internal" ) ); // Other DMZ → internal (SECOND) + fw.Allow( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "dmz" ) ); // DMZ can see itself + fw.Allow( FirewallTarget.Subnet( "internal" ), FirewallTarget.Subnet( "internal" ) ); // Internal can see itself + fw.Allow( FirewallTarget.Subnet( "internal" ), FirewallTarget.Subnet( "dmz" ) ); // Internal → DMZ allowed + } ) + .Build(); + } + + /// + /// Management network with full visibility using CIDR rules. + /// Management subnet (10.0.0.0/24) can see everything. + /// Production subnets are isolated from each other. + /// Tests CIDR-based firewall rules. + /// + public static NetworkTopology ManagementNetworkWithCidr() { + return new NetworkTopologyBuilder() + .AddSubnet( "management", "10.0.0.0/24", [ + ( "10.0.0.10", "Admin workstation" ) + ], out var mgmtSubnet ) + .AddSubnet( "production1", "192.168.1.0/24", [ + ( "192.168.1.100", "Prod server 1" ) + ], out var prod1Subnet ) + .AddSubnet( "production2", "192.168.2.0/24", [ + ( "192.168.2.100", "Prod server 2" ) + ], out var prod2Subnet ) + .AddAgent( new AgentId( "agentid_mgmt" ), mgmtSubnet ) + .AddAgent( new AgentId( "agentid_prod1" ), prod1Subnet ) + .AddAgent( new AgentId( "agentid_prod2" ), prod2Subnet ) + .WithFirewall( fw => { + // Management can reach everything + fw.Allow( FirewallTarget.Subnet( "management" ), FirewallTarget.Any ); + + // Production subnets can only see themselves + fw.Allow( FirewallTarget.Subnet( "production1" ), FirewallTarget.Subnet( "production1" ) ); + fw.Allow( FirewallTarget.Subnet( "production2" ), FirewallTarget.Subnet( "production2" ) ); + + // Block production cross-talk (explicit deny) + fw.Deny( FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ), + FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ) ); // Block any 192.168.x.x to 192.168.x.x + } ) + .Build(); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs b/src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs new file mode 100644 index 00000000..0a0a63a2 --- /dev/null +++ b/src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs @@ -0,0 +1,65 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Scan; +using Drift.Scanning.Scanners; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Tests.Utils.Testing; + +/// +/// Mock subnet scanner that returns predefined results for testing. +/// +public 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. +/// +public 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/Utils/Testing/ScanResultHelper.cs b/src/Cli.Tests/Utils/Testing/ScanResultHelper.cs new file mode 100644 index 00000000..2fe1966a --- /dev/null +++ b/src/Cli.Tests/Utils/Testing/ScanResultHelper.cs @@ -0,0 +1,157 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Discovered; +using Drift.Domain.Scan; + +namespace Drift.Cli.Tests.Utils.Testing; + +/// +/// Helper for querying scan result data in tests. +/// Provides query methods that return data - tests should make assertions using standard Assert.That(). +/// +public static class ScanResultHelper { + /// + /// Gets all discovered devices from all subnets in the scan result. + /// + public static List GetAllDevices( NetworkScanResult result ) { + return result.Subnets + .SelectMany( s => s.DiscoveredDevices ) + .ToList(); + } + + /// + /// Gets all discovered devices that have a specific IP address. + /// + public static List GetDevicesWithIp( NetworkScanResult result, IpV4Address ipAddress ) { + return GetAllDevices( result ) + .Where( d => d.Addresses.OfType().Any( ip => ip.Equals( ipAddress ) ) ) + .ToList(); + } + + /// + /// Gets all discovered devices that have a specific MAC address. + /// + public static List GetDevicesWithMac( NetworkScanResult result, MacAddress macAddress ) { + return GetAllDevices( result ) + .Where( d => d.Addresses.OfType().Any( mac => mac.Equals( macAddress ) ) ) + .ToList(); + } + + /// + /// Gets all unique IP addresses discovered across all subnets. + /// + public static List GetAllIpAddresses( NetworkScanResult result ) { + return GetAllDevices( result ) + .SelectMany( d => d.Addresses.OfType() ) + .Distinct() + .ToList(); + } + + /// + /// Gets all unique MAC addresses discovered across all subnets. + /// + public static List GetAllMacAddresses( NetworkScanResult result ) { + return GetAllDevices( result ) + .SelectMany( d => d.Addresses.OfType() ) + .Distinct() + .ToList(); + } + + /// + /// Gets all subnets that were scanned (from the result). + /// + public static List GetScannedSubnets( NetworkScanResult result ) { + return result.Subnets + .Select( s => s.CidrBlock ) + .ToList(); + } + + /// + /// Gets the subnet scan result for a specific CIDR block. + /// Returns null if the subnet was not scanned. + /// + public static SubnetScanResult? GetSubnetResult( NetworkScanResult result, CidrBlock cidr ) { + return result.Subnets.FirstOrDefault( s => s.CidrBlock.Equals( cidr ) ); + } + + /// + /// Gets all subnets with a specific scan status. + /// + public static List GetSubnetsByStatus( + NetworkScanResult result, + ScanResultStatus status + ) { + return result.Subnets + .Where( s => s.Status == status ) + .ToList(); + } + + /// + /// Gets the total number of devices discovered across all subnets. + /// + public static int GetTotalDeviceCount( NetworkScanResult result ) { + return GetAllDevices( result ).Count; + } + + /// + /// Gets the total number of unique IP addresses discovered. + /// + public static int GetUniqueIpCount( NetworkScanResult result ) { + return GetAllIpAddresses( result ).Count; + } + + /// + /// Gets the total number of unique MAC addresses discovered. + /// + public static int GetUniqueMacCount( NetworkScanResult result ) { + return GetAllMacAddresses( result ).Count; + } + + /// + /// Checks if a specific IP address was discovered in the scan. + /// + public static bool HasIpAddress( NetworkScanResult result, IpV4Address ipAddress ) { + return GetDevicesWithIp( result, ipAddress ).Any(); + } + + /// + /// Checks if a specific MAC address was discovered in the scan. + /// + public static bool HasMacAddress( NetworkScanResult result, MacAddress macAddress ) { + return GetDevicesWithMac( result, macAddress ).Any(); + } + + /// + /// Checks if a specific subnet was scanned. + /// + public static bool HasSubnet( NetworkScanResult result, CidrBlock cidr ) { + return GetSubnetResult( result, cidr ) != null; + } + + /// + /// Gets devices discovered in a specific subnet. + /// Returns empty list if the subnet was not scanned. + /// + public static List GetDevicesInSubnet( NetworkScanResult result, CidrBlock cidr ) { + var subnetResult = GetSubnetResult( result, cidr ); + return subnetResult?.DiscoveredDevices.ToList() ?? []; + } + + /// + /// Gets all subnets that had failures during scanning. + /// + public static List GetFailedSubnets( NetworkScanResult result ) { + return GetSubnetsByStatus( result, ScanResultStatus.Error ) + .Select( s => s.CidrBlock ) + .ToList(); + } + + /// + /// Gets all subnets that were scanned successfully. + /// + public static List GetSuccessfulSubnets( NetworkScanResult result ) { + return GetSubnetsByStatus( result, ScanResultStatus.Success ) + .Select( s => s.CidrBlock ) + .ToList(); + } +} \ 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 index b4c07c02..d946d1c8 100644 --- a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using Drift.Cli.Abstractions; using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Agent.Subcommands.Start; @@ -18,12 +19,12 @@ internal static class Options { }; internal static readonly Option Port = new("--port", "-p") { - DefaultValueFactory = _ => 51515, Description = "Set the port used for both adoption and communication" + DefaultValueFactory = _ => Ports.AgentDefault, + 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 + Description = "Set a specific agent ID (for testing purposes)", Hidden = true }; } diff --git a/src/Cli/Commands/Scan/ClusterExtensions.cs b/src/Cli/Commands/Scan/ClusterExtensions.cs index fd340461..4967ca30 100644 --- a/src/Cli/Commands/Scan/ClusterExtensions.cs +++ b/src/Cli/Commands/Scan/ClusterExtensions.cs @@ -2,7 +2,6 @@ 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; diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs index ac27d9b9..204b18b1 100644 --- a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs +++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs @@ -216,10 +216,12 @@ ILogger logger ); } else { + var localCount = allResults.Count( r => resolvedSubnets.Any( rs => rs.Cidr == r.CidrBlock && rs.Source is Local ) ); + var agentCount = allResults.Count - localCount; logger.LogInformation( - "Distributed scan completed: {SuccessCount}/{TotalCount} scan operations successful, {UniqueSubnets} unique subnets", - successCount, - allResults.Count, + "Scan completed: {LocalCount} local, {AgentCount} via agents, {UniqueSubnets} unique subnets", + localCount, + agentCount, mergedResults.Count ); } @@ -227,7 +229,7 @@ ILogger logger return finalResult; } - private List MergeOverlappingSubnetResults( List allResults, ILogger logger ) { + private static List MergeOverlappingSubnetResults( List allResults, ILogger logger ) { // Group results by CIDR and merge devices from multiple scans var resultsByCidr = allResults .GroupBy( r => r.CidrBlock ) diff --git a/src/Domain/AgentId.cs b/src/Domain/AgentId.cs index 8fc538e5..e317e86d 100644 --- a/src/Domain/AgentId.cs +++ b/src/Domain/AgentId.cs @@ -28,11 +28,11 @@ public string Value { 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 AgentId( string value ) => new(value); public static implicit operator string( AgentId id ) => id.Value; - public static AgentId New() => new AgentId( Prefix + Guid.NewGuid() ); + public static AgentId New() => new(Prefix + Guid.NewGuid()); public override string ToString() => Value; } \ No newline at end of file diff --git a/src/Domain/CidrBlock.cs b/src/Domain/CidrBlock.cs index acd98fd7..a99b3a31 100644 --- a/src/Domain/CidrBlock.cs +++ b/src/Domain/CidrBlock.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using Drift.Domain.Device.Addresses; namespace Drift.Domain; @@ -58,4 +59,7 @@ public CidrBlock( string cidrNotation ) { } public override string ToString() => $"{NetworkAddress}/{PrefixLength}"; + + public bool Contains( IIpAddress ip ) => + IPNetwork.Parse( ToString() ).Contains( ip.Ip ); } \ No newline at end of file diff --git a/src/Domain/Device/Addresses/AddressType.cs b/src/Domain/Device/Addresses/AddressType.cs index 9549f0cf..fc3ede4b 100644 --- a/src/Domain/Device/Addresses/AddressType.cs +++ b/src/Domain/Device/Addresses/AddressType.cs @@ -12,7 +12,7 @@ Layer Name Purpose / Function Examples */ public enum AddressType { IpV4 = 1, - IpV6 = 2, + // IpV6 = 2, Mac = 3, Hostname = 4 } \ No newline at end of file diff --git a/src/Domain/Device/Addresses/DeviceAddressSet.cs b/src/Domain/Device/Addresses/DeviceAddressSet.cs new file mode 100644 index 00000000..84175e11 --- /dev/null +++ b/src/Domain/Device/Addresses/DeviceAddressSet.cs @@ -0,0 +1,50 @@ +namespace Drift.Domain.Device.Addresses; + +/// +/// Describes the set of known addresses for a discovered device. +/// At least one address must be provided; no single address type is universally required, +/// as different scan methods (ping, ARP, etc.) discover different address types. +/// +public sealed class DeviceAddressSet { + public IpV4Address? Ip { + get; + } + + public MacAddress? Mac { + get; + } + + public HostnameAddress? Hostname { + get; + } + + public DeviceAddressSet( IpV4Address? ip = null, MacAddress? mac = null, HostnameAddress? hostname = null ) { + if ( ip is null && mac is null && hostname is null ) { + throw new ArgumentException( "At least one address must be provided." ); + } + + Ip = ip; + Mac = mac; + Hostname = hostname; + } + + /// + /// Converts to a flat address list suitable for . + /// + public List ToAddresses() { + var list = new List(); + if ( Ip is not null ) { + list.Add( Ip.Value ); + } + + if ( Mac is not null ) { + list.Add( Mac.Value ); + } + + if ( Hostname is not null ) { + list.Add( Hostname.Value ); + } + + return list; + } +} \ No newline at end of file diff --git a/src/Domain/Device/Addresses/IIPAddress.cs b/src/Domain/Device/Addresses/IIPAddress.cs new file mode 100644 index 00000000..fd9ad85c --- /dev/null +++ b/src/Domain/Device/Addresses/IIPAddress.cs @@ -0,0 +1,9 @@ +using System.Net; + +namespace Drift.Domain.Device.Addresses; + +public interface IIpAddress { + IPAddress Ip { + get; + } +} \ No newline at end of file diff --git a/src/Domain/Device/Addresses/IpV4Address.cs b/src/Domain/Device/Addresses/IpV4Address.cs index f32cafa4..ceb95160 100644 --- a/src/Domain/Device/Addresses/IpV4Address.cs +++ b/src/Domain/Device/Addresses/IpV4Address.cs @@ -3,7 +3,7 @@ namespace Drift.Domain.Device.Addresses; -public readonly record struct IpV4Address : IDeviceAddress { +public readonly record struct IpV4Address : IDeviceAddress, IIpAddress { public AddressType Type => AddressType.IpV4; public string Value { @@ -14,6 +14,8 @@ public bool? IsId { get; } + public IPAddress Ip => IPAddress.Parse( Value ); + public IpV4Address( string ipAddress, bool? isId = null ) { if ( !IPAddress.TryParse( ipAddress, out var ip ) || ip.AddressFamily != AddressFamily.InterNetwork ) { throw new ArgumentException( $"'{ipAddress}' is not a valid IPv4 address.", nameof(ipAddress) ); diff --git a/src/Domain/Device/Discovered/DiscoveredDevice.cs b/src/Domain/Device/Discovered/DiscoveredDevice.cs index 4d29c71f..aaa93637 100644 --- a/src/Domain/Device/Discovered/DiscoveredDevice.cs +++ b/src/Domain/Device/Discovered/DiscoveredDevice.cs @@ -3,6 +3,7 @@ namespace Drift.Domain.Device.Discovered; public record DiscoveredDevice : IAddressableDevice { + // TODO use DeviceAddressSet? public List Addresses { get; init; diff --git a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs index 92ea5b17..cf3c9f6e 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs +++ b/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs @@ -1,5 +1,3 @@ -using Drift.Networking.Grpc.Generated; - namespace Drift.Networking.PeerStreaming.Core.Abstractions; public static class PeerMessageHandlerExtensions { diff --git a/src/Scanning/Scanners/LinuxFpingSubnetScanner.cs b/src/Scanning/Scanners/LinuxFpingSubnetScanner.cs index 11d9a4d0..775df248 100644 --- a/src/Scanning/Scanners/LinuxFpingSubnetScanner.cs +++ b/src/Scanning/Scanners/LinuxFpingSubnetScanner.cs @@ -40,7 +40,9 @@ public async Task ScanAsync( return; } - discoveredDevices.Add( new DiscoveredDevice { Addresses = [new IpV4Address( data )] } ); + discoveredDevices.Add( new DiscoveredDevice { + Addresses = new DeviceAddressSet( ip: new IpV4Address( data ) ).ToAddresses() + } ); var elapsed = stopwatch.Elapsed; var estimatedScanned = elapsed.TotalSeconds * options.PingsPerSecond; var progress = Math.Min( 99, Math.Ceiling( ( estimatedScanned / ipRange.Count ) * 100 ) ); diff --git a/src/Scanning/Scanners/PingSubnetScannerBase.cs b/src/Scanning/Scanners/PingSubnetScannerBase.cs index de468e5c..906209c1 100644 --- a/src/Scanning/Scanners/PingSubnetScannerBase.cs +++ b/src/Scanning/Scanners/PingSubnetScannerBase.cs @@ -139,23 +139,20 @@ ArpTable arpTable new DiscoveredDevice { Addresses = CreateAddresses( pingReply ) } ).ToList(); - List CreateAddresses( ( IPAddress Ip, bool Success, string? Hostname) pingReply ) { - var list = new List { new IpV4Address( pingReply.Ip ) }; + List CreateAddresses( (IPAddress Ip, bool Success, string? Hostname) pingReply ) { + IpV4Address ip = new IpV4Address( pingReply.Ip ); - if ( !string.IsNullOrWhiteSpace( pingReply.Hostname ) ) { - list.Add( new HostnameAddress( pingReply.Hostname ) ); - } + MacAddress? mac = arpTable.TryGetValue( pingReply.Ip, out var arpMac ) + ? arpMac + : localMacs.TryGetValue( pingReply.Ip, out var localMac ) + ? localMac + : null; - 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 ); - } + HostnameAddress? hostname = string.IsNullOrWhiteSpace( pingReply.Hostname ) + ? null + : new HostnameAddress( pingReply.Hostname ); - return list; + return new DeviceAddressSet( ip, mac, hostname ).ToAddresses(); } } @@ -187,17 +184,17 @@ private static Dictionary BuildLocalInterfaceMacTable() { private static async Task GetHostNameAsync( IPAddress ip, int timeoutMs = 1000 ) { var task = Dns.GetHostEntryAsync( ip ); - if ( await Task.WhenAny( task, Task.Delay( timeoutMs ) ) == task ) { - try { - return task.Result.HostName; - } - catch { - // Reverse DNS failed - return null; - } + if ( await Task.WhenAny( task, Task.Delay( timeoutMs ) ) != task ) { + // Timed out + return null; } - // Timed out - return null; + try { + return task.Result.HostName; + } + catch { + // Reverse DNS failed + return null; + } } } diff --git a/src/Scanning/Scanners/Preview/Ideas.cs b/src/Scanning/Scanners/Preview/Ideas.cs index 640494f9..42e72247 100644 --- a/src/Scanning/Scanners/Preview/Ideas.cs +++ b/src/Scanning/Scanners/Preview/Ideas.cs @@ -110,7 +110,7 @@ public DefaultScanOrchestrator( _logger = logger; } - public async Task RunScanAsync( ScanOptions options ) { + public static async Task RunScanAsync( ScanOptions options ) { /*_logger.LogInformation("Starting network scan..."); var actualState = await _networkScanner.ScanAsync(); From 29dae2746df80e8b594959f76d027a7ccd987f45 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:27:14 +0200 Subject: [PATCH 03/43] fix tests --- Drift.Build.slnx | 7 +++--- .../Versioning/VersioningTests.cs | 24 +++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Drift.Build.slnx b/Drift.Build.slnx index e6781204..087e9f37 100644 --- a/Drift.Build.slnx +++ b/Drift.Build.slnx @@ -1,6 +1,7 @@ + @@ -11,7 +12,7 @@ - - - + + + \ No newline at end of file diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index 82bf8f7a..d6b0ab51 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -13,7 +13,7 @@ namespace Drift.Build.Utilities.Tests.Versioning; internal sealed class VersioningTests { [Test] - public static async Task DefaultVersioningVersionTest() { + public async Task DefaultVersioningVersionTest() { // Arrange var build = new TestNukeBuild(); @@ -29,7 +29,7 @@ public static async Task DefaultVersioningVersionTest() { } [Test] - public static async Task DefaultVersioningWhenNoReleaseTargets() { + public async Task DefaultVersioningWhenNoReleaseTargets() { // Arrange var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => NukeBuildWithArbitraryTarget.Arbitrary ); @@ -46,7 +46,7 @@ public static async Task DefaultVersioningWhenNoReleaseTargets() { } [Test] - public static async Task MultipleReleaseTargetsInPlanThrows() { + public async Task MultipleReleaseTargetsInPlanThrows() { // Arrange var build = new TestNukeBuild().WithExecutionPlan( b => b.CreateRelease, b => b.CreatePreRelease ); @@ -102,7 +102,7 @@ public async Task MismatchedReleaseTypeThrows( } [Test] - public static void PreReleaseWithoutVersionThrows() { + public void PreReleaseWithoutVersionThrows() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -145,7 +145,7 @@ public void DebugConfigurationThrows( } [Test] - public static async Task PreReleaseValid() { + public async Task PreReleaseValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -166,7 +166,7 @@ await Assert.That( version.PrereleaseIdentifiers[version.PrereleaseIdentifiers.C } [Test] - public static void PreReleaseRejectsFullSemVerString() { + public void PreReleaseRejectsFullSemVerString() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -182,7 +182,7 @@ public static void PreReleaseRejectsFullSemVerString() { } [Test] - public static async Task PreReleaseVersionIsTemporallyConsistent() { + public async Task PreReleaseVersionIsTemporallyConsistent() { // Arrange var timeProvider = new FakeTimeProvider(); var build = new TestNukeBuild() @@ -201,7 +201,7 @@ public static async Task PreReleaseVersionIsTemporallyConsistent() { } [Test] - public static void ReleaseWithPrereleaseIdentifiersThrows() { + public void ReleaseWithPrereleaseIdentifiersThrows() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -217,7 +217,7 @@ public static void ReleaseWithPrereleaseIdentifiersThrows() { } [Test] - public static async Task ReleaseValid() { + public async Task ReleaseValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) @@ -262,7 +262,7 @@ public static async Task ReleaseValid() { } [Test] - public static async Task ExactVersioningValid() { + public async Task ExactVersioningValid() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreatePreRelease ) @@ -278,7 +278,7 @@ public static async Task ExactVersioningValid() { } [Test] - public static void ExactVersioningWithNullThrows() { + public void ExactVersioningWithNullThrows() { // Arrange var strategy = new ExactVersioning( Configuration.Release, " ", ReleaseType.None, null!, null! ); @@ -287,7 +287,7 @@ public static void ExactVersioningWithNullThrows() { } [Test] - public static async Task ExactVersioningValidWithReleaseVersion() { + public async Task ExactVersioningValidWithReleaseVersion() { // Arrange var build = new TestNukeBuild() .WithExecutionPlan( b => b.CreateRelease ) From 57b0c1014ed08f35bc069bb9f23c71e86e437376 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:39:37 +0200 Subject: [PATCH 04/43] editorconfig update --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 8c034db3..c21b764c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ root = true indent_style = space # Code files -[*.{cs,csproj,slnx,props,json}] +[*.{cs,csproj,slnx,props,targets,json}] indent_size = 2 insert_final_newline = false charset = utf-8 From d331a9c96439becd9b4af414a6857891c91b2204 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:50:30 +0200 Subject: [PATCH 05/43] fix tests --- .../Utils/Network/Firewall/FirewallEvaluator.cs | 3 ++- src/Domain/CidrBlock.cs | 4 ++-- src/Domain/Device/Addresses/IIPAddress.cs | 9 --------- src/Domain/Device/Addresses/IpV4Address.cs | 5 +---- 4 files changed, 5 insertions(+), 16 deletions(-) delete mode 100644 src/Domain/Device/Addresses/IIPAddress.cs diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs index 257672c7..16e46561 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs @@ -1,3 +1,4 @@ +using System.Net; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -73,7 +74,7 @@ public bool IsAllowed( FirewallTarget source, FirewallTarget dest ) { /// private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { try { - var ipBytes = ip.Ip.GetAddressBytes(); + var ipBytes = IPAddress.Parse( ip.Value ).GetAddressBytes(); var cidrParts = cidr.ToString().Split( '/' ); if ( cidrParts.Length != 2 ) { return false; diff --git a/src/Domain/CidrBlock.cs b/src/Domain/CidrBlock.cs index a99b3a31..e42c25ff 100644 --- a/src/Domain/CidrBlock.cs +++ b/src/Domain/CidrBlock.cs @@ -60,6 +60,6 @@ public CidrBlock( string cidrNotation ) { public override string ToString() => $"{NetworkAddress}/{PrefixLength}"; - public bool Contains( IIpAddress ip ) => - IPNetwork.Parse( ToString() ).Contains( ip.Ip ); + public bool Contains( IpV4Address ip ) => + IPNetwork.Parse( ToString() ).Contains( IPAddress.Parse( ip.Value ) ); } \ No newline at end of file diff --git a/src/Domain/Device/Addresses/IIPAddress.cs b/src/Domain/Device/Addresses/IIPAddress.cs deleted file mode 100644 index fd9ad85c..00000000 --- a/src/Domain/Device/Addresses/IIPAddress.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Net; - -namespace Drift.Domain.Device.Addresses; - -public interface IIpAddress { - IPAddress Ip { - get; - } -} \ No newline at end of file diff --git a/src/Domain/Device/Addresses/IpV4Address.cs b/src/Domain/Device/Addresses/IpV4Address.cs index ceb95160..5ba1f9b6 100644 --- a/src/Domain/Device/Addresses/IpV4Address.cs +++ b/src/Domain/Device/Addresses/IpV4Address.cs @@ -1,9 +1,8 @@ using System.Net; using System.Net.Sockets; - namespace Drift.Domain.Device.Addresses; -public readonly record struct IpV4Address : IDeviceAddress, IIpAddress { +public readonly record struct IpV4Address : IDeviceAddress { public AddressType Type => AddressType.IpV4; public string Value { @@ -14,8 +13,6 @@ public bool? IsId { get; } - public IPAddress Ip => IPAddress.Parse( Value ); - public IpV4Address( string ipAddress, bool? isId = null ) { if ( !IPAddress.TryParse( ipAddress, out var ip ) || ip.AddressFamily != AddressFamily.InterNetwork ) { throw new ArgumentException( $"'{ipAddress}' is not a valid IPv4 address.", nameof(ipAddress) ); From f0c6af828f8580e3914285b773d248834f25563a Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 21:55:06 +0200 Subject: [PATCH 06/43] f --- src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs index b6533d05..3768a802 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs @@ -1,3 +1,4 @@ +using System.Net; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -61,7 +62,7 @@ internal override bool Matches( string subnetName, IpV4Address? ip ) { /// private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { try { - var ipBytes = ip.Ip.GetAddressBytes(); + var ipBytes = IPAddress.Parse( ip.Value ).GetAddressBytes(); var cidrParts = cidr.ToString().Split( '/' ); if ( cidrParts.Length != 2 ) { return false; From a35cb802a0f23569fed0488d3614f223a06b807e Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 22:16:16 +0200 Subject: [PATCH 07/43] fix clab tests --- build/NukeBuild.TestContainerlab.cs | 10 ++++++---- src/Cli/Commands/Scan/DistributedNetworkScanner.cs | 13 ++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs index 0d3313e1..285a6ff3 100644 --- a/build/NukeBuild.TestContainerlab.cs +++ b/build/NukeBuild.TestContainerlab.cs @@ -39,8 +39,9 @@ sealed partial class NukeBuild { 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" ) ), + output => output.Contains( "from 2 scans of" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 1 local, 1 via agents" ) ), ] ), new( @@ -51,8 +52,9 @@ sealed partial class NukeBuild { 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" ) ), + output => output.Contains( "from 4 scans of" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 1 local, 3 via agents" ) ), ] ), new( diff --git a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs index 204b18b1..32cdea2c 100644 --- a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs +++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs @@ -51,7 +51,10 @@ await ScanLocalSubnetsAsync( } } - return BuildFinalResult( allSubnetResults, startTime, logger ); + var localScanCount = subnetsBySource.Where( x => x.Source is Local ).Sum( x => x.Cidrs.Count ); + var agentScanCount = subnetsBySource.Where( x => x.Source is Scanning.Subnets.Agent ).Sum( x => x.Cidrs.Count ); + + return BuildFinalResult( allSubnetResults, startTime, localScanCount, agentScanCount, logger ); } private List<(SubnetSource Source, List Cidrs)> PartitionSubnetsBySource( NetworkScanOptions options ) { @@ -185,6 +188,8 @@ DateTime startTime private NetworkScanResult BuildFinalResult( List allResults, DateTime startTime, + int localScanCount, + int agentScanCount, ILogger logger ) { var endTime = DateTime.UtcNow; @@ -216,12 +221,10 @@ ILogger logger ); } else { - var localCount = allResults.Count( r => resolvedSubnets.Any( rs => rs.Cidr == r.CidrBlock && rs.Source is Local ) ); - var agentCount = allResults.Count - localCount; logger.LogInformation( "Scan completed: {LocalCount} local, {AgentCount} via agents, {UniqueSubnets} unique subnets", - localCount, - agentCount, + localScanCount, + agentScanCount, mergedResults.Count ); } From 139bf3f62f0289bd9f84ddd080221fed4ccc699e Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 22:22:49 +0200 Subject: [PATCH 08/43] align snapshots --- ...sts.WithAgents_OverlappingSubnets_MergesDevices.verified.txt | 2 +- ...hAgents_ScannerSelection_UsesDistributedScanner.verified.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt index 6f068c80..4ba45cbb 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt @@ -11,7 +11,7 @@ Agents run without authentication. Any client that can reach the agent port can Agent adoption (--adoptable / --join) is not yet implemented and has no effect. Merged 1 unique devices from 3 scans of 192.168.0.0/24 Merged 4 unique devices from 2 scans of 192.168.10.0/24 -Scan completed: 3 local, 2 via agents, 2 unique subnets +Scan completed: 1 local, 4 via agents, 2 unique subnets 192.168.0.0/24 (1 devices) └── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt index de22f662..77f50b59 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt @@ -8,7 +8,7 @@ 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 1 unique devices from 2 scans of 192.168.0.0/24 -Scan completed: 2 local, 1 via agents, 2 unique subnets +Scan completed: 1 local, 2 via agents, 2 unique subnets 192.168.0.0/24 (1 devices) └── 192.168.0.100 AA-BB-C0-A8-00-64 Online (unknown device) From 3482b5fe0388dc3c7d3d8a11f8d1b0c81659d10e Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 22:35:00 +0200 Subject: [PATCH 09/43] simplify and fix clab tests --- build/NukeBuild.TestContainerlab.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs index 285a6ff3..c790de52 100644 --- a/build/NukeBuild.TestContainerlab.cs +++ b/build/NukeBuild.TestContainerlab.cs @@ -38,10 +38,8 @@ sealed partial class NukeBuild { 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( "from 2 scans of" ) ), new ScanAssertion( "Scan completed successfully", - output => output.Contains( "Scan completed: 1 local, 1 via agents" ) ), + output => output.Contains( "Scan completed: 1 local, 1 via agents, 1 unique subnets" ) ), ] ), new( @@ -51,10 +49,8 @@ sealed partial class NukeBuild { 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( "from 4 scans of" ) ), new ScanAssertion( "Scan completed successfully", - output => output.Contains( "Scan completed: 1 local, 3 via agents" ) ), + output => output.Contains( "Scan completed: 1 local, 3 via agents, 1 unique subnets" ) ), ] ), new( @@ -65,9 +61,8 @@ sealed partial class NukeBuild { 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" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 3 local, 4 via agents, 3 unique subnets" ) ), ] ), ]; From 13c847ada63924b74f0772062aefb18acfa30065 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 27 May 2026 23:03:44 +0200 Subject: [PATCH 10/43] fix build warnings --- .../ScanCommandTests.Orchestration.cs | 41 ++-- src/Cli.Tests/GlobalSuppressions.cs | 30 ++- src/Cli.Tests/Utils/Agent/HarnessResult.cs | 4 +- .../Utils/Network/Topology/NetworkTopology.cs | 2 +- .../Utils/Network/Topology/Topologies.cs | 192 ++++++++++++------ 5 files changed, 186 insertions(+), 83 deletions(-) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs index db181c9d..5c83c405 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs @@ -112,11 +112,16 @@ await Verify( result.CombinedOutput ) public async Task WithAgents_DuplicateDevices_Deduplicates() { // Arrange - Both agents discover the same device on the same subnet var topology = new NetworkTopologyBuilder() - .AddSubnet( "shared", "192.168.10.0/24", [ - ( "192.168.10.100", "Shared device" ), // Both agents will see this - ( "192.168.10.101", "Device from agent1" ), - ( "192.168.10.102", "Device from agent2" ) - ], out var sharedSubnet ) + .AddSubnet( + "shared", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Shared device" ), // Both agents will see this + ( "192.168.10.101", "Device from agent1" ), + ( "192.168.10.102", "Device from agent2" ) + ], + out var sharedSubnet + ) .AddAgent( new AgentId( "agentid_agent1" ), sharedSubnet ) .AddAgent( new AgentId( "agentid_agent2" ), sharedSubnet ) .Build(); @@ -141,14 +146,24 @@ public async Task WithAgents_FirewallRules_FiltersVisibility() { // Arrange - DMZ agent can see internal, but internal agent cannot see DMZ // This tests that firewall rules properly restrict network visibility var topology = new NetworkTopologyBuilder() - .AddSubnet( "dmz", "192.168.1.0/24", [ - ( "192.168.1.100", "Web server" ), - ( "192.168.1.101", "App server" ) - ], out var dmzSubnet ) - .AddSubnet( "internal", "10.0.0.0/24", [ - ( "10.0.0.100", "Database" ), - ( "10.0.0.101", "File server" ) - ], out var internalSubnet ) + .AddSubnet( + "dmz", + "192.168.1.0/24", + [ + ( "192.168.1.100", "Web server" ), + ( "192.168.1.101", "App server" ) + ], + out var dmzSubnet + ) + .AddSubnet( + "internal", + "10.0.0.0/24", + [ + ( "10.0.0.100", "Database" ), + ( "10.0.0.101", "File server" ) + ], + out var internalSubnet + ) .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) .WithFirewall( fw => { diff --git a/src/Cli.Tests/GlobalSuppressions.cs b/src/Cli.Tests/GlobalSuppressions.cs index 5c1b0887..b6301b01 100644 --- a/src/Cli.Tests/GlobalSuppressions.cs +++ b/src/Cli.Tests/GlobalSuppressions.cs @@ -1,4 +1,4 @@ - // This file is used by Code Analysis to maintain SuppressMessage +// This file is used by Code Analysis to maintain SuppressMessage // attributes that are applied to this project. // Project-level suppressions either have no target or are given // a specific target and scoped to a namespace, type, member, etc. @@ -7,18 +7,30 @@ // Test infrastructure classes are intentionally public to be used across test files [assembly: - SuppressMessage( "Design", "CA1515:Consider making public types internal", - Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + SuppressMessage( + "Design", + "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", + Scope = "namespaceanddescendants", Target = "~N:Drift.Cli.Tests.Utils.Agent" )] [assembly: - SuppressMessage( "Design", "CA1515:Consider making public types internal", - Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + SuppressMessage( + "Design", + "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", + Scope = "namespaceanddescendants", Target = "~N:Drift.Cli.Tests.Utils.Testing" )] [assembly: - SuppressMessage( "Design", "CA1515:Consider making public types internal", - Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + SuppressMessage( + "Design", + "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", + Scope = "namespaceanddescendants", Target = "~N:Drift.Cli.Tests.Utils.Network.Firewall" )] [assembly: - SuppressMessage( "Design", "CA1515:Consider making public types internal", - Justification = "Test infrastructure is intentionally public", Scope = "namespaceanddescendants", + SuppressMessage( + "Design", + "CA1515:Consider making public types internal", + Justification = "Test infrastructure is intentionally public", + Scope = "namespaceanddescendants", Target = "~N:Drift.Cli.Tests.Utils.Network.Topology" )] \ No newline at end of file diff --git a/src/Cli.Tests/Utils/Agent/HarnessResult.cs b/src/Cli.Tests/Utils/Agent/HarnessResult.cs index bcdf0bb2..eb81b4a7 100644 --- a/src/Cli.Tests/Utils/Agent/HarnessResult.cs +++ b/src/Cli.Tests/Utils/Agent/HarnessResult.cs @@ -7,12 +7,12 @@ namespace Drift.Cli.Tests.Utils.Agent; /// /// Result from running a scan with the agent test harness. -/// +/// /// RECOMMENDED USAGE: /// 1. Assert on exit codes: Assert.That(result.ScanExitCode, Is.EqualTo(ExitCodes.Success)) /// 2. Use Verify for output: await Verify(result.CombinedOutput) /// 3. Query results if needed: result.Results.GetDevicesWithIp(...) -/// +/// /// Only use result.Logs when you need to programmatically query specific log entries. /// For most tests, Verify on CombinedOutput is easier to work with. /// diff --git a/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs b/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs index a4943e27..9e4ee629 100644 --- a/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs +++ b/src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs @@ -76,7 +76,7 @@ public required List Devices { } /// - /// When true, devices on this subnet cannot communicate with each other (peer isolation). + /// Gets a value indicating whether devices on this subnet cannot communicate with each other (peer isolation). /// Mirrors UniFi's "Client Device Isolation" / "Device Isolation (ACL)" feature. /// Defaults to false (devices on the same subnet can communicate freely). /// diff --git a/src/Cli.Tests/Utils/Network/Topology/Topologies.cs b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs index a75427cf..c00f54c3 100644 --- a/src/Cli.Tests/Utils/Network/Topology/Topologies.cs +++ b/src/Cli.Tests/Utils/Network/Topology/Topologies.cs @@ -46,13 +46,23 @@ out var agent2Subnet /// public static NetworkTopology TwoAgentsDisjointSubnetsNoCli() { return new NetworkTopologyBuilder() - .AddSubnet( "agent1-subnet", "192.168.10.0/24", [ - ( "192.168.10.100", "Agent1 device 1" ), - ( "192.168.10.101", "Agent1 device 2" ) - ], out var agent1Subnet ) - .AddSubnet( "agent2-subnet", "192.168.20.0/24", [ - ( "192.168.20.100", "Agent2 device" ) - ], out var agent2Subnet ) + .AddSubnet( + "agent1-subnet", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Agent1 device 1" ), + ( "192.168.10.101", "Agent1 device 2" ) + ], + out var agent1Subnet + ) + .AddSubnet( + "agent2-subnet", + "192.168.20.0/24", + [ + ( "192.168.20.100", "Agent2 device" ) + ], + out var agent2Subnet + ) .AddAgent( new AgentId( "agentid_agent1" ), agent1Subnet ) .AddAgent( new AgentId( "agentid_agent2" ), agent2Subnet ) .Build(); @@ -65,15 +75,25 @@ public static NetworkTopology TwoAgentsDisjointSubnetsNoCli() { /// public static NetworkTopology TwoAgentsOverlappingSubnet() { return new NetworkTopologyBuilder() - .AddSubnet( "cli-subnet", "192.168.0.0/24", [ - ( "192.168.0.100", "CLI device" ) - ], out var cliSubnet ) - .AddSubnet( "shared-subnet", "192.168.10.0/24", [ - ( "192.168.10.100", "Shared device 1" ), - ( "192.168.10.101", "Shared device 2" ), - ( "192.168.10.102", "Shared device 3" ), - ( "192.168.10.103", "Shared device 4" ) - ], out var sharedSubnet ) + .AddSubnet( + "cli-subnet", + "192.168.0.0/24", + [ + ( "192.168.0.100", "CLI device" ) + ], + out var cliSubnet + ) + .AddSubnet( + "shared-subnet", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Shared device 1" ), + ( "192.168.10.101", "Shared device 2" ), + ( "192.168.10.102", "Shared device 3" ), + ( "192.168.10.103", "Shared device 4" ) + ], + out var sharedSubnet + ) .AddAgent( new AgentId( "agentid_agent1" ), sharedSubnet ) .AddAgent( new AgentId( "agentid_agent2" ), sharedSubnet ) .WithCli( cliSubnet ) @@ -98,12 +118,22 @@ public static NetworkTopology SingleAgentEmptyResults() { /// public static NetworkTopology MixedCliAndAgents() { return new NetworkTopologyBuilder() - .AddSubnet( "cli-subnet", "192.168.0.0/24", [ - ( "192.168.0.100", "CLI device" ) - ], out var cliSubnet ) - .AddSubnet( "agent-subnet", "192.168.10.0/24", [ - ( "192.168.10.100", "Agent device" ) - ], out var agentSubnet ) + .AddSubnet( + "cli-subnet", + "192.168.0.0/24", + [ + ( "192.168.0.100", "CLI device" ) + ], + out var cliSubnet + ) + .AddSubnet( + "agent-subnet", + "192.168.10.0/24", + [ + ( "192.168.10.100", "Agent device" ) + ], + out var agentSubnet + ) .AddAgent( new AgentId( "agentid_agent1" ), agentSubnet ) .WithCli( cliSubnet ) .Build(); @@ -118,14 +148,24 @@ public static NetworkTopology MixedCliAndAgents() { /// public static NetworkTopology DmzWithFirewall() { return new NetworkTopologyBuilder() - .AddSubnet( "dmz", "192.168.1.0/24", [ - ( "192.168.1.100", "Web server" ), - ( "192.168.1.101", "App server" ) - ], out var dmzSubnet ) - .AddSubnet( "internal", "10.0.0.0/24", [ - ( "10.0.0.100", "Database server" ), - ( "10.0.0.101", "File server" ) - ], out var internalSubnet ) + .AddSubnet( + "dmz", + "192.168.1.0/24", + [ + ( "192.168.1.100", "Web server" ), + ( "192.168.1.101", "App server" ) + ], + out var dmzSubnet + ) + .AddSubnet( + "internal", + "10.0.0.0/24", + [ + ( "10.0.0.100", "Database server" ), + ( "10.0.0.101", "File server" ) + ], + out var internalSubnet + ) .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) // Firewall rules: @@ -145,14 +185,23 @@ public static NetworkTopology DmzWithFirewall() { /// public static NetworkTopology GuestNetworkIsolation() { return new NetworkTopologyBuilder() - .AddSubnet( "guest", "192.168.100.0/24", [ - ( "192.168.100.10", "Guest device 1" ), - ( "192.168.100.11", "Guest device 2" ), - ( "192.168.100.12", "Guest device 3" ) - ], out var guestSubnet ) - .AddSubnet( "external", "203.0.113.0/24", [ - ( "203.0.113.1", "Internet gateway" ) - ] ) + .AddSubnet( + "guest", + "192.168.100.0/24", + [ + ( "192.168.100.10", "Guest device 1" ), + ( "192.168.100.11", "Guest device 2" ), + ( "192.168.100.12", "Guest device 3" ) + ], + out var guestSubnet + ) + .AddSubnet( + "external", + "203.0.113.0/24", + [ + ( "203.0.113.1", "Internet gateway" ) + ] + ) .AddAgent( new AgentId( "agentid_guest" ), guestSubnet ) .WithFirewall( fw => { fw.Deny( FirewallTarget.Subnet( "guest" ), FirewallTarget.Subnet( "guest" ) ); // Block guest-to-guest @@ -169,20 +218,31 @@ public static NetworkTopology GuestNetworkIsolation() { /// public static NetworkTopology BastionHostAccess() { return new NetworkTopologyBuilder() - .AddSubnet( "dmz", "192.168.1.0/24", [ - ( "192.168.1.10", "Bastion host" ), - ( "192.168.1.20", "Web server" ), - ( "192.168.1.30", "App server" ) - ], out var dmzSubnet ) - .AddSubnet( "internal", "10.0.0.0/24", [ - ( "10.0.0.100", "Database" ), - ( "10.0.0.101", "Admin server" ) - ], out var internalSubnet ) + .AddSubnet( + "dmz", + "192.168.1.0/24", + [ + ( "192.168.1.10", "Bastion host" ), + ( "192.168.1.20", "Web server" ), + ( "192.168.1.30", "App server" ) + ], + out var dmzSubnet + ) + .AddSubnet( + "internal", + "10.0.0.0/24", + [ + ( "10.0.0.100", "Database" ), + ( "10.0.0.101", "Admin server" ) + ], + out var internalSubnet + ) .AddAgent( new AgentId( "agentid_dmz" ), dmzSubnet ) .AddAgent( new AgentId( "agentid_internal" ), internalSubnet ) .WithFirewall( fw => { // Order matters - first match wins! - fw.Allow( FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), + fw.Allow( + FirewallTarget.FromIp( new IpV4Address( "192.168.1.10" ) ), FirewallTarget.Subnet( "internal" ) ); // Bastion → internal (FIRST) fw.Deny( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "internal" ) ); // Other DMZ → internal (SECOND) fw.Allow( FirewallTarget.Subnet( "dmz" ), FirewallTarget.Subnet( "dmz" ) ); // DMZ can see itself @@ -200,15 +260,30 @@ public static NetworkTopology BastionHostAccess() { /// public static NetworkTopology ManagementNetworkWithCidr() { return new NetworkTopologyBuilder() - .AddSubnet( "management", "10.0.0.0/24", [ - ( "10.0.0.10", "Admin workstation" ) - ], out var mgmtSubnet ) - .AddSubnet( "production1", "192.168.1.0/24", [ - ( "192.168.1.100", "Prod server 1" ) - ], out var prod1Subnet ) - .AddSubnet( "production2", "192.168.2.0/24", [ - ( "192.168.2.100", "Prod server 2" ) - ], out var prod2Subnet ) + .AddSubnet( + "management", + "10.0.0.0/24", + [ + ( "10.0.0.10", "Admin workstation" ) + ], + out var mgmtSubnet + ) + .AddSubnet( + "production1", + "192.168.1.0/24", + [ + ( "192.168.1.100", "Prod server 1" ) + ], + out var prod1Subnet + ) + .AddSubnet( + "production2", + "192.168.2.0/24", + [ + ( "192.168.2.100", "Prod server 2" ) + ], + out var prod2Subnet + ) .AddAgent( new AgentId( "agentid_mgmt" ), mgmtSubnet ) .AddAgent( new AgentId( "agentid_prod1" ), prod1Subnet ) .AddAgent( new AgentId( "agentid_prod2" ), prod2Subnet ) @@ -221,7 +296,8 @@ public static NetworkTopology ManagementNetworkWithCidr() { fw.Allow( FirewallTarget.Subnet( "production2" ), FirewallTarget.Subnet( "production2" ) ); // Block production cross-talk (explicit deny) - fw.Deny( FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ), + fw.Deny( + FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ), FirewallTarget.FromCidr( new CidrBlock( "192.168.0.0/16" ) ) ); // Block any 192.168.x.x to 192.168.x.x } ) .Build(); From 4b6497f4bba84269b39dc537c8c9c551b887e0ca Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:03:18 +0200 Subject: [PATCH 11/43] add agents.md --- AGENTS.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..ff47e365 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# AGENTS.md + +This file provides guidance to AI agents when working with code in this repository. + +## Project Overview + +Drift is a .NET 10 CLI tool for network drift detection — it compares a declarative YAML spec (desired network state) against live network scanning results and reports differences. It supports distributed scanning via agents communicating over gRPC. + +## Build System + +The build uses [NUKE](https://nuke.build/). Entry point is `dotnet nuke`. + +Common targets: + +```sh +dotnet nuke Build # Restore + compile +dotnet nuke TestUnit # Unit tests only (fast) +dotnet nuke Test # All tests (unit + E2E) +dotnet nuke TestE2E # E2E tests (General, Binary, Container image, Container network topologies using Containerlab) +dotnet nuke PublishBinaries # Self-contained binary ( +dotnet nuke BuildContainerImage +``` + +Run a single test class or filter by name using standard `dotnet test` filters: + +```sh +dotnet test src/Domain.Tests --filter "FullyQualifiedName~MyTest" +``` + +## Architecture + +### Source layout (`src/`) + +The solution is split into focused projects. The main ones: + +| Project | Role | +|---|------------------------------------------------------------------------------------------| +| `Cli` | Entry point; commands: `init`, `scan`, `agent start`; AOT-compiled | +| `Cli.Abstractions` | Shared CLI constants: exit codes, env var names, port numbers, file names | +| `Cli.Settings` | User settings file (`~/.config/drift/settings.json`) | +| `Domain` | Core value types: `Network`, `Device`, `Inventory`, `CidrBlock`, `Port`, `Scan`, `AgentId` | +| `Spec` | YAML spec parsing and validation (YamlDotNet + JsonSchema.Net) | +| `Scanning` | Network discovery: ARP, ping, port scanning with rate limiting | +| `Diff` | Compares declared spec state vs. discovered scan state to produce drift report | +| `Networking.PeerStreaming.*` | Abstract P2P streaming protocol + gRPC implementation (`peer.proto`) | +| `Networking.Cluster` | Multi-agent coordination | +| `Agent.Hosting` | Agent runtime (`AgentHost`, `Identity`) | +| `Agent.PeerProtocol` | Agent-specific peer messaging | +| `Serialization` | Cross-module serialization helpers | +| `ArchTests` | ArchUnitNET tests enforcing dependency rules and naming conventions | + +Schema generators live in `Spec.SchemaGenerator.Cli` and `Cli.Settings.SchemaGenerator.Cli` — they produce JSON Schema from C# types. + +### Data flow + +``` +YAML spec → Spec (parse/validate) → Domain types (declared state) + ↓ +Network → Scanning → Domain types (discovered state) + ↓ + Diff → Drift report → Cli (render) +``` + +Agents (remote Drift instances) report discovered state back to the coordinator over gRPC, extending scan coverage across subnets. + +### Key conventions + +- **Central package management**: all NuGet versions in `Directory.Packages.props`; do not add `Version=` attributes to `` in individual project files. +- **Shared project defaults**: `Directory.Build.props` applies nullable refs, implicit usings, and logging config to all projects. +- **InternalsVisibleTo**: test projects access internal members for white-box testing; this is intentional. +- **Snapshot testing**: `Verify.NUnit` is used for golden file comparisons. Run tests to regenerate snapshots when output changes; committed `.verified.*` files are the source of truth. +- **AOT**: `Cli` is published with `PublishAot=true`. Avoid reflection-heavy patterns in the CLI project; use source generators instead. +- **Embedded resources**: schemas, default specs, and scripts are embedded in project assemblies under `embedded_resources/`. + +## Testing + +- **Unit tests**: `*.Tests` projects using NUnit 4 and NSubstitute for mocking. +- **E2E tests**: `Cli.E2ETests.*` projects — General (Testcontainers), Binary (published binary), Container (Docker image), Containerlab (real multi-node topologies). +- **Architecture tests**: `ArchTests` project validates project dependency graph and naming rules. + +Containerlab tests require Containerlab installed and use topology files in `containerlab/`. + +## Terminology (from domain model) + +- **Spec**: declarative YAML definition of desired network state +- **Declared resource**: a device/subnet defined in the spec +- **Discovered resource**: a device/subnet found by scanning +- **Drift**: difference between declared and discovered state +- **Device ID**: one or more addresses (MAC, IPv4, IPv6, hostname) that uniquely identify a device; spec addresses with `is_id: false` are metadata only +- **Agent**: a Drift instance in agent mode that reports scan results to peers From 3af08ec81b18c99c4c76f79241ed52a2f1578800 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:06:49 +0200 Subject: [PATCH 12/43] add claude.md symlink --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 5cac894e4ecff99173cb3d17cc544a0a83a09e7f Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:02:17 +0200 Subject: [PATCH 13/43] major refactor --- AGENTS.md | 1 + Drift.sln | 54 +++++++++--- .../Agent.Host.Tests.csproj} | 5 +- .../AssemblyInfo.cs | 0 .../MessageHandlerTests.cs} | 26 +++--- src/Agent.Host/Agent.Host.csproj | 16 ++++ .../AgentHost.cs | 26 +++--- .../AgentHostAssemblyMarker.cs} | 5 +- .../Scan/ScanSubnetRequestHandler.cs | 25 +++--- src/Agent.Host/ServiceCollectionExtensions.cs | 15 ++++ .../Subnets/SubnetsRequestHandler.cs | 13 +-- src/Agent.Hosting/Agent.Hosting.csproj | 14 ---- .../Identity/AgentIdentity.Serialization.cs | 77 ----------------- src/Agent.Hosting/Identity/AgentIdentity.cs | 10 --- .../Identity/AgentIdentityJsonContext.cs | 17 ---- .../DefaultAgentIdentityLocationProvider.cs | 24 ------ .../IAgentIdentityLocationProvider.cs | 9 -- .../Adopt/AdoptRequestHandler.cs | 23 ----- .../Adopt/AdoptRequestPayload.cs | 22 ----- .../Adopt/AdoptResponsePayload.cs | 18 ---- .../Agent.PeerProtocol.csproj | 10 --- .../ServiceCollectionExtensions.cs | 15 ---- ...entCommandTests.MissingOption.verified.txt | 1 - ...ommandTests.SuccessfulStartup.verified.txt | 4 +- src/Cli.Tests/Commands/AgentCommandTests.cs | 14 +--- .../ScanCommandTests.RemoteScan.verified.txt | 3 +- ..._AgentsOnly_NoLocalInterfaces.verified.txt | 3 +- ...Tests.RemoteScan_EmptyResults.verified.txt | 3 +- ...RemoteScan_OverlappingSubnets.verified.txt | 3 +- ...DuplicateDevices_Deduplicates.verified.txt | 3 +- ...hAgents_EmptyResults_Succeeds.verified.txt | 3 +- ...rewallRules_FiltersVisibility.verified.txt | 3 +- ...rfaces_UsesDistributedScanner.verified.txt | 3 +- ...rlappingSubnets_MergesDevices.verified.txt | 3 +- ...ection_UsesDistributedScanner.verified.txt | 3 +- ...sjointSubnets_CombinesResults.verified.txt | 3 +- .../Utils/Agent/AgentConfiguration.cs | 2 +- src/Cli/Cli.csproj | 6 +- .../Subcommands/Start/AgentStartCommand.cs | 55 +----------- .../Subcommands/Start/AgentStartParameters.cs | 31 +------ .../Commands/Scan/AgentClientExtensions.cs | 49 +++++++++++ src/Cli/Commands/Scan/AgentSubnetProvider.cs | 6 +- src/Cli/Commands/Scan/ClusterExtensions.cs | 49 ----------- .../Scan/DistributedNetworkScanner.cs | 15 ++-- src/Cli/Commands/Scan/ScanCommand.cs | 12 +-- src/Cli/Infrastructure/RootCommandFactory.cs | 19 +++-- .../Logging/OutputManagerExtensions.cs | 3 +- src/Coordinator.Host/Coordinator.Host.csproj | 8 ++ .../ServiceCollectionExtensions.cs | 8 ++ .../AgentClient.cs} | 76 +++++------------ .../AgentClientOptions.cs} | 4 +- .../IAgentClient.cs} | 16 ++-- src/Messaging.Client/Messaging.Client.csproj | 7 ++ .../ServiceCollectionExtensions.cs | 9 ++ .../Messaging.Protocol.csproj | 9 ++ .../ProtocolMessagesAssemblyMarker.cs | 6 ++ .../Scan/ScanSubnetCompleteResponse.cs | 6 +- .../Scan/ScanSubnetProgressUpdate.cs | 6 +- .../Scan/ScanSubnetRequest.cs | 6 +- .../Subnets/SubnetsRequest.cs | 6 +- .../Subnets/SubnetsResponse.cs | 6 +- .../DefaultMessagingClientFactory.cs | 13 +++ .../Networking.Client.csproj} | 4 +- .../ServiceCollectionExtensions.cs | 10 +++ src/Networking.Cluster/Enrollment.cs | 12 --- .../Networking.Cluster.csproj | 7 -- .../ServiceCollectionExtensions.cs | 9 -- .../ConnectionSide.cs | 6 ++ .../Empty.cs | 4 +- .../IMessage.cs} | 8 +- .../IMessageEnvelopeConverter.cs | 9 ++ .../IMessageHandler.cs} | 10 +-- .../IMessageStream.cs | 20 +++++ .../IMessageStreamManager.cs | 15 ++++ .../IMessageTypesProvider.cs | 7 ++ .../IMessagingClientFactory.cs | 8 ++ .../MessageHandlerExtensions.cs} | 28 ++++--- .../Networking.Core.Abstractions.csproj} | 2 +- .../Common/GrpcMetadataExtensions.cs | 2 +- .../MessageStream.cs} | 44 +++++----- src/Networking.Core/MessageStreamManager.cs | 83 +++++++++++++++++++ .../Messages/MessageDispatcher.cs} | 24 +++--- .../Messages/MessageEnvelopeConverter.cs | 22 +++++ .../Messages/MessageResponseCorrelator.cs} | 29 +++---- .../MessagingOptions.cs} | 4 +- src/Networking.Core/Networking.Core.csproj | 14 ++++ .../ServiceCollectionExtensions.cs | 18 ++++ .../Networking.Grpc.csproj} | 2 +- .../Protos/agent.proto} | 11 ++- .../DefaultPeerClientFactory.cs | 13 --- .../ServiceCollectionExtensions.cs | 10 --- .../ConnectionSide.cs | 6 -- .../IPeerClientFactory.cs | 8 -- .../IPeerMessageEnvelopeConverter.cs | 9 -- .../IPeerMessageTypesProvider.cs | 7 -- .../IPeerStream.cs | 20 ----- .../IPeerStreamManager.cs | 15 ---- .../Messages/PeerMessageEnvelopeConverter.cs | 22 ----- .../Networking.PeerStreaming.Core.csproj | 14 ---- .../PeerStreamManager.cs | 79 ------------------ .../ServiceCollectionExtensions.cs | 18 ---- .../InboundPeerService.cs | 31 ------- .../Networking.PeerStreaming.Server.csproj | 15 ---- .../PeerStreamingServerMarker.cs | 8 -- .../InboundTests.cs | 77 ----------------- .../InboundMessageService.cs | 31 +++++++ .../MessagingServerMarker.cs | 8 ++ .../Networking.Server.csproj | 15 ++++ .../ServiceCollectionExtensions.cs | 26 +++--- .../AssemblyInfo.cs | 0 .../Helpers/InMemoryDuplexStreamPair.cs | 2 +- .../Helpers/TestPeerMessage.cs | 14 ++-- .../Helpers/TestServerCallContext.cs | 2 +- .../TestServerCallContextExtensions.cs | 8 +- src/Networking.Tests/InboundTests.cs | 77 +++++++++++++++++ .../MessageStreamManagerTests.cs} | 24 +++--- .../Networking.Tests.csproj} | 6 +- 117 files changed, 815 insertions(+), 1072 deletions(-) rename src/{Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj => Agent.Host.Tests/Agent.Host.Tests.csproj} (71%) rename src/{Agent.PeerProtocol.Tests => Agent.Host.Tests}/AssemblyInfo.cs (100%) rename src/{Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs => Agent.Host.Tests/MessageHandlerTests.cs} (68%) create mode 100644 src/Agent.Host/Agent.Host.csproj rename src/{Agent.Hosting => Agent.Host}/AgentHost.cs (65%) rename src/{Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs => Agent.Host/AgentHostAssemblyMarker.cs} (53%) rename src/{Agent.PeerProtocol => Agent.Host}/Scan/ScanSubnetRequestHandler.cs (87%) create mode 100644 src/Agent.Host/ServiceCollectionExtensions.cs rename src/{Agent.PeerProtocol => Agent.Host}/Subnets/SubnetsRequestHandler.cs (76%) delete mode 100644 src/Agent.Hosting/Agent.Hosting.csproj delete mode 100644 src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs delete mode 100644 src/Agent.Hosting/Identity/AgentIdentity.cs delete mode 100644 src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs delete mode 100644 src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs delete mode 100644 src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs delete mode 100644 src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs delete mode 100644 src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs delete mode 100644 src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs delete mode 100644 src/Agent.PeerProtocol/Agent.PeerProtocol.csproj delete mode 100644 src/Agent.PeerProtocol/ServiceCollectionExtensions.cs delete mode 100644 src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt create mode 100644 src/Cli/Commands/Scan/AgentClientExtensions.cs delete mode 100644 src/Cli/Commands/Scan/ClusterExtensions.cs create mode 100644 src/Coordinator.Host/Coordinator.Host.csproj create mode 100644 src/Coordinator.Host/ServiceCollectionExtensions.cs rename src/{Networking.Cluster/Cluster.cs => Messaging.Client/AgentClient.cs} (64%) rename src/{Networking.Cluster/ClusterOptions.cs => Messaging.Client/AgentClientOptions.cs} (92%) rename src/{Networking.Cluster/ICluster.cs => Messaging.Client/IAgentClient.cs} (58%) create mode 100644 src/Messaging.Client/Messaging.Client.csproj create mode 100644 src/Messaging.Client/ServiceCollectionExtensions.cs create mode 100644 src/Messaging.Protocol/Messaging.Protocol.csproj create mode 100644 src/Messaging.Protocol/ProtocolMessagesAssemblyMarker.cs rename src/{Agent.PeerProtocol => Messaging.Protocol}/Scan/ScanSubnetCompleteResponse.cs (82%) rename src/{Agent.PeerProtocol => Messaging.Protocol}/Scan/ScanSubnetProgressUpdate.cs (81%) rename src/{Agent.PeerProtocol => Messaging.Protocol}/Scan/ScanSubnetRequest.cs (80%) rename src/{Agent.PeerProtocol => Messaging.Protocol}/Subnets/SubnetsRequest.cs (68%) rename src/{Agent.PeerProtocol => Messaging.Protocol}/Subnets/SubnetsResponse.cs (81%) create mode 100644 src/Networking.Client/DefaultMessagingClientFactory.cs rename src/{Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj => Networking.Client/Networking.Client.csproj} (59%) create mode 100644 src/Networking.Client/ServiceCollectionExtensions.cs delete mode 100644 src/Networking.Cluster/Enrollment.cs delete mode 100644 src/Networking.Cluster/Networking.Cluster.csproj delete mode 100644 src/Networking.Cluster/ServiceCollectionExtensions.cs create mode 100644 src/Networking.Core.Abstractions/ConnectionSide.cs rename src/{Networking.PeerStreaming.Core.Abstractions => Networking.Core.Abstractions}/Empty.cs (73%) rename src/{Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs => Networking.Core.Abstractions/IMessage.cs} (61%) create mode 100644 src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs rename src/{Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs => Networking.Core.Abstractions/IMessageHandler.cs} (69%) create mode 100644 src/Networking.Core.Abstractions/IMessageStream.cs create mode 100644 src/Networking.Core.Abstractions/IMessageStreamManager.cs create mode 100644 src/Networking.Core.Abstractions/IMessageTypesProvider.cs create mode 100644 src/Networking.Core.Abstractions/IMessagingClientFactory.cs rename src/{Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs => Networking.Core.Abstractions/MessageHandlerExtensions.cs} (62%) rename src/{Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj => Networking.Core.Abstractions/Networking.Core.Abstractions.csproj} (67%) rename src/{Networking.PeerStreaming.Core => Networking.Core}/Common/GrpcMetadataExtensions.cs (85%) rename src/{Networking.PeerStreaming.Core/PeerStream.cs => Networking.Core/MessageStream.cs} (71%) create mode 100644 src/Networking.Core/MessageStreamManager.cs rename src/{Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs => Networking.Core/Messages/MessageDispatcher.cs} (61%) create mode 100644 src/Networking.Core/Messages/MessageEnvelopeConverter.cs rename src/{Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs => Networking.Core/Messages/MessageResponseCorrelator.cs} (72%) rename src/{Networking.PeerStreaming.Core/PeerStreamingOptions.cs => Networking.Core/MessagingOptions.cs} (67%) create mode 100644 src/Networking.Core/Networking.Core.csproj create mode 100644 src/Networking.Core/ServiceCollectionExtensions.cs rename src/{Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj => Networking.Grpc/Networking.Grpc.csproj} (80%) rename src/{Networking.PeerStreaming.Grpc/Protos/peer.proto => Networking.Grpc/Protos/agent.proto} (61%) delete mode 100644 src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs delete mode 100644 src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs delete mode 100644 src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs delete mode 100644 src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs delete mode 100644 src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj delete mode 100644 src/Networking.PeerStreaming.Core/PeerStreamManager.cs delete mode 100644 src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs delete mode 100644 src/Networking.PeerStreaming.Server/InboundPeerService.cs delete mode 100644 src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj delete mode 100644 src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs delete mode 100644 src/Networking.PeerStreaming.Tests/InboundTests.cs create mode 100644 src/Networking.Server/InboundMessageService.cs create mode 100644 src/Networking.Server/MessagingServerMarker.cs create mode 100644 src/Networking.Server/Networking.Server.csproj rename src/{Networking.PeerStreaming.Server => Networking.Server}/ServiceCollectionExtensions.cs (55%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/AssemblyInfo.cs (100%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/Helpers/InMemoryDuplexStreamPair.cs (98%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/Helpers/TestPeerMessage.cs (69%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/Helpers/TestServerCallContext.cs (97%) rename src/{Networking.PeerStreaming.Tests => Networking.Tests}/Helpers/TestServerCallContextExtensions.cs (65%) create mode 100644 src/Networking.Tests/InboundTests.cs rename src/{Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs => Networking.Tests/MessageStreamManagerTests.cs} (55%) rename src/{Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj => Networking.Tests/Networking.Tests.csproj} (62%) diff --git a/AGENTS.md b/AGENTS.md index ff47e365..f22ef0c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,6 +71,7 @@ Agents (remote Drift instances) report discovered state back to the coordinator - **Snapshot testing**: `Verify.NUnit` is used for golden file comparisons. Run tests to regenerate snapshots when output changes; committed `.verified.*` files are the source of truth. - **AOT**: `Cli` is published with `PublishAot=true`. Avoid reflection-heavy patterns in the CLI project; use source generators instead. - **Embedded resources**: schemas, default specs, and scripts are embedded in project assemblies under `embedded_resources/`. +- **`Networking.*` are role-agnostic**: No "Agent", "Peer", "Coordinator", or "Server" in type names, property names, parameter names, method names, or log strings inside `Networking.*`. These assemblies implement the transport layer only (streams, messages, connections). Role-specific concerns belong in `Agent.*`, `Coordinator.*`, or `Cli.*`. ## Testing diff --git a/Drift.sln b/Drift.sln index a42a0e23..deeebebc 100644 --- a/Drift.sln +++ b/Drift.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli", "src\Cli\Cli.csproj", "{51BC193B-A7CB-4A3D-8852-AC8A0CE74DF5}" EndProject @@ -81,27 +81,37 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Commo EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "E2E", "E2E", "{D1DBBF0F-1A0D-486C-A893-ACEB667F2A63}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Grpc", "src\Networking.PeerStreaming.Grpc\Networking.PeerStreaming.Grpc.csproj", "{8ED3FF22-90D2-4F08-A079-55FE7127D1C7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Grpc", "src\Networking.Grpc\Networking.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}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Core", "src\Networking.Core\Networking.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core", "src\Networking.PeerStreaming.Core\Networking.PeerStreaming.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Server", "src\Networking.Server\Networking.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Hosting", "src\Agent.Hosting\Agent.Hosting.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Client", "src\Networking.Client\Networking.Client.csproj", "{E69772D3-8A07-414F-8F9A-30370D81A972}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Server", "src\Networking.PeerStreaming.Server\Networking.PeerStreaming.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Networking", "Networking", "{75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Client", "src\Networking.PeerStreaming.Client\Networking.PeerStreaming.Client.csproj", "{E69772D3-8A07-414F-8F9A-30370D81A972}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Core.Abstractions", "src\Networking.Core.Abstractions\Networking.Core.Abstractions.csproj", "{ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Networking", "Networking", "{75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Tests", "src\Networking.Tests\Networking.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Protocol", "src\Messaging.Protocol\Messaging.Protocol.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Client", "src\Messaging.Client\Messaging.Client.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host", "src\Agent.Host\Agent.Host.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host", "src\Coordinator.Host\Coordinator.Host.csproj", "{B9C0D1E2-F3A4-5678-BCDE-F01234567891}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host.Tests", "src\Agent.Host.Tests\Agent.Host.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" 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}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{F1A2B3C4-D5E6-7890-ABCD-EF1234567890}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Tests", "src\Networking.PeerStreaming.Tests\Networking.PeerStreaming.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Messaging", "Messaging", "{E2F3A4B5-C6D7-8901-BCDE-F12345678901}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol", "src\Agent.PeerProtocol\Agent.PeerProtocol.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Coordinator", "Coordinator", "{0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol.Tests", "src\Agent.PeerProtocol.Tests\Agent.PeerProtocol.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Cli", "Cli", "{DD31E4C8-7058-4407-AEC4-43EDA0AB06D1}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -255,18 +265,36 @@ Global {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 + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.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} + {655124DB-312F-4135-B104-20518CAFDA82} = {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} + {C4576156-BD24-463F-88F2-8A4378855BCC} = {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {091D3DCE-F062-4D40-A8F6-5B6F123ED713} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {A2CE629F-8D56-4539-9642-C31B550F7C30} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {C2B3D4E5-F6A7-8901-BCDE-F12345678901} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {D3C4E5F6-A7B8-9012-CDEF-123456789012} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {B9C0D1E2-F3A4-5678-BCDE-F01234567891} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {51BC193B-A7CB-4A3D-8852-AC8A0CE74DF5} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {42ECC801-ADCB-43E4-86C0-E0E4B017D7B6} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {38621D2E-6AF0-4969-897D-898C39D41A20} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {B75C5C0C-A5A2-419E-B0DD-342A44B1B48D} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {130363FC-478E-472A-978B-731D39E3E264} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {76093473-3375-4519-9524-68448A19DE4A} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} EndGlobalSection EndGlobal diff --git a/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj b/src/Agent.Host.Tests/Agent.Host.Tests.csproj similarity index 71% rename from src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj rename to src/Agent.Host.Tests/Agent.Host.Tests.csproj index e00ea9c0..faa044fe 100644 --- a/src/Agent.PeerProtocol.Tests/Agent.PeerProtocol.Tests.csproj +++ b/src/Agent.Host.Tests/Agent.Host.Tests.csproj @@ -1,11 +1,12 @@ - + - + + diff --git a/src/Agent.PeerProtocol.Tests/AssemblyInfo.cs b/src/Agent.Host.Tests/AssemblyInfo.cs similarity index 100% rename from src/Agent.PeerProtocol.Tests/AssemblyInfo.cs rename to src/Agent.Host.Tests/AssemblyInfo.cs diff --git a/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs b/src/Agent.Host.Tests/MessageHandlerTests.cs similarity index 68% rename from src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs rename to src/Agent.Host.Tests/MessageHandlerTests.cs index eac8dbc8..82ff605e 100644 --- a/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs +++ b/src/Agent.Host.Tests/MessageHandlerTests.cs @@ -1,16 +1,18 @@ using System.Reflection; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Messaging.Protocol; +using Drift.Networking.Core.Abstractions; -namespace Drift.Agent.PeerProtocol.Tests; +namespace Drift.Agent.Host.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) ); +internal sealed class MessageHandlerTests { + private static readonly Assembly MessagesAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly; + private static readonly Assembly HandlersAssembly = typeof(AgentHostAssemblyMarker).Assembly; + private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IRequest<>) ); + private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IResponse) ); private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); [Test] - public void FindMessagesAndHandlersAndMessages() { + public void FindMessagesAndHandlers() { 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" ); @@ -23,20 +25,20 @@ public void FindMessagesAndHandlersAndMessages() { [TestCaseSource( nameof(ResponseTypes) )] public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { var messageTypeValue = type - .GetProperty( nameof(IPeerMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! + .GetProperty( nameof(IMessage.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 )! + .GetProperty( nameof(IMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! .GetValue( null ); Assert.That( jsonInfoValue, Is.Not.Null ); } private static List GetAllConcreteMessageTypes( Type baseType ) { - return ProtocolAssembly + return MessagesAssembly .GetTypes() .Concat( [typeof(Empty)] ) .Where( t => t is { IsAbstract: false, IsInterface: false } ) @@ -50,10 +52,10 @@ private static List GetAllConcreteMessageTypes( Type baseType ) { } private static List GetAllConcreteHandlerTypes() { - return ProtocolAssembly + return HandlersAssembly .GetTypes() .Where( t => t is { IsAbstract: false, IsInterface: false } ) - .Where( t => typeof(IPeerMessageHandler).IsAssignableFrom( t ) ) + .Where( t => typeof(IMessageHandler).IsAssignableFrom( t ) ) .ToList(); } } \ No newline at end of file diff --git a/src/Agent.Host/Agent.Host.csproj b/src/Agent.Host/Agent.Host.csproj new file mode 100644 index 00000000..4f67bdb9 --- /dev/null +++ b/src/Agent.Host/Agent.Host.csproj @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/Agent.Hosting/AgentHost.cs b/src/Agent.Host/AgentHost.cs similarity index 65% rename from src/Agent.Hosting/AgentHost.cs rename to src/Agent.Host/AgentHost.cs index fe8e4960..e86bb9ee 100644 --- a/src/Agent.Hosting/AgentHost.cs +++ b/src/Agent.Host/AgentHost.cs @@ -1,7 +1,7 @@ -using Drift.Agent.PeerProtocol.Subnets; -using Drift.Networking.PeerStreaming.Client; -using Drift.Networking.PeerStreaming.Core; -using Drift.Networking.PeerStreaming.Server; +using Drift.Messaging.Protocol; +using Drift.Networking.Client; +using Drift.Networking.Core; +using Drift.Networking.Server; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -9,7 +9,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace Drift.Agent.Hosting; +namespace Drift.Agent.Host; public static class AgentHost { public static Task Run( @@ -33,12 +33,14 @@ private static WebApplication Build( builder.Logging.ClearProviders(); builder.Services.AddSingleton( logger ); - builder.Services.AddPeerStreamingServer( options => { + // TODO consolidate all the addmessaging* into single configurable extension that can be used for all roles + // (CLI, Agent, Coordinator) with different config flags. Should be high-level (domain preferred) + builder.Services.AddMessagingServer( options => { options.EnableDetailedErrors = true; } ); - builder.Services.AddPeerStreamingClient(); - var peerStreamingOptions = new PeerStreamingOptions { MessageAssembly = typeof(SubnetsRequest).Assembly }; - builder.Services.AddPeerStreamingCore( peerStreamingOptions ); + builder.Services.AddMessagingClient(); + var messagingOptions = new MessagingOptions { MessageAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly }; + builder.Services.AddMessagingCore( messagingOptions ); configureServices?.Invoke( builder.Services ); builder.WebHost.ConfigureKestrel( options => { @@ -49,9 +51,11 @@ private static WebApplication Build( var app = builder.Build(); - peerStreamingOptions.StoppingToken = app.Lifetime.ApplicationStopping; + // Note: a service reading StoppingToken during initialization (really, any code run before this point) + // will get CancellationToken.None. + messagingOptions.StoppingToken = app.Lifetime.ApplicationStopping; - app.MapPeerStreamingServerEndpoints(); + app.MapMessagingServerEndpoints(); app.Lifetime.ApplicationStarted.Register( () => { logger.LogInformation( "Listening for incoming connections on port {Port}", port ); diff --git a/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs b/src/Agent.Host/AgentHostAssemblyMarker.cs similarity index 53% rename from src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs rename to src/Agent.Host/AgentHostAssemblyMarker.cs index 1907e215..e38447cb 100644 --- a/src/Agent.PeerProtocol/PeerProtocolAssemblyMarker.cs +++ b/src/Agent.Host/AgentHostAssemblyMarker.cs @@ -1,7 +1,6 @@ -namespace Drift.Agent.PeerProtocol; +namespace Drift.Agent.Host; // Justification: marker class #pragma warning disable S2094 -public class PeerProtocolAssemblyMarker { -} +public sealed class AgentHostAssemblyMarker; #pragma warning restore S2094 \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs similarity index 87% rename from src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs rename to src/Agent.Host/Scan/ScanSubnetRequestHandler.cs index 27c2db90..15a2b96e 100644 --- a/src/Agent.PeerProtocol/Scan/ScanSubnetRequestHandler.cs +++ b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs @@ -1,22 +1,23 @@ using Drift.Domain; using Drift.Domain.Scan; +using Drift.Messaging.Protocol.Scan; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; using Drift.Scanning.Scanners; using Microsoft.Extensions.Logging; -namespace Drift.Agent.PeerProtocol.Scan; +namespace Drift.Agent.Host.Scan; internal sealed class ScanSubnetRequestHandler( ISubnetScannerFactory subnetScannerFactory, ILogger logger -) : IPeerMessageHandler { +) : IMessageHandler { public string MessageType => ScanSubnetRequest.MessageType; public async Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, CancellationToken cancellationToken ) { logger.LogInformation( "Handling scan subnet request" ); @@ -49,9 +50,9 @@ CancellationToken cancellationToken } private sealed class ProgressUpdatePolicy { - private readonly IPeerStream _stream; - private readonly IPeerMessageEnvelopeConverter _converter; - private readonly PeerMessage _envelope; + private readonly IMessageStream _stream; + private readonly IMessageEnvelopeConverter _converter; + private readonly Message _envelope; private readonly CidrBlock _cidr; private readonly ILogger _logger; @@ -64,9 +65,9 @@ public EventHandler Handle { } public ProgressUpdatePolicy( - IPeerStream stream, - IPeerMessageEnvelopeConverter converter, - PeerMessage envelope, + IMessageStream stream, + IMessageEnvelopeConverter converter, + Message envelope, CidrBlock cidr, ILogger logger ) { diff --git a/src/Agent.Host/ServiceCollectionExtensions.cs b/src/Agent.Host/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..f43d65e8 --- /dev/null +++ b/src/Agent.Host/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Drift.Agent.Host.Scan; +using Drift.Agent.Host.Subnets; +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Agent.Host; + +public static class ServiceCollectionExtensions { + extension( IServiceCollection services ) { + public void AddAgentHandlers() { + services.AddScoped(); + services.AddScoped(); + } + } +} diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs similarity index 76% rename from src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs rename to src/Agent.Host/Subnets/SubnetsRequestHandler.cs index a8d3eee5..f5721f9d 100644 --- a/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs +++ b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs @@ -1,20 +1,21 @@ +using Drift.Messaging.Protocol.Subnets; +using Drift.Networking.Core.Abstractions; 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; +namespace Drift.Agent.Host.Subnets; internal sealed class SubnetsRequestHandler( IInterfaceSubnetProvider interfaceSubnetProvider, ILogger logger -) : IPeerMessageHandler { +) : IMessageHandler { public string MessageType => SubnetsRequest.MessageType; public async Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, CancellationToken cancellationToken ) { logger.LogInformation( "Handling subnet request" ); diff --git a/src/Agent.Hosting/Agent.Hosting.csproj b/src/Agent.Hosting/Agent.Hosting.csproj deleted file mode 100644 index 93719ce4..00000000 --- a/src/Agent.Hosting/Agent.Hosting.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs b/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs deleted file mode 100644 index a462b561..00000000 --- a/src/Agent.Hosting/Identity/AgentIdentity.Serialization.cs +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 30f82cff..00000000 --- a/src/Agent.Hosting/Identity/AgentIdentity.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 2d1be19f..00000000 --- a/src/Agent.Hosting/Identity/AgentIdentityJsonContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 9a05bab7..00000000 --- a/src/Agent.Hosting/Identity/DefaultAgentIdentityLocationProvider.cs +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 356686f7..00000000 --- a/src/Agent.Hosting/Identity/IAgentIdentityLocationProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -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/Adopt/AdoptRequestHandler.cs b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs deleted file mode 100644 index 5b9778c4..00000000 --- a/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; - -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 deleted file mode 100644 index 2ad48099..00000000 --- a/src/Agent.PeerProtocol/Adopt/AdoptRequestPayload.cs +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 67fa75dc..00000000 --- a/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 09269a20..00000000 --- a/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs deleted file mode 100644 index 693ba852..00000000 --- a/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -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/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt deleted file mode 100644 index 9b0bccae..00000000 --- a/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt +++ /dev/null @@ -1 +0,0 @@ -✗ 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 index 27ac3915..3e13a42b 100644 --- a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt +++ b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt @@ -1,9 +1,9 @@ +---------------------------------------- PREVIEW --------------------------------------- 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... diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs index 18b2a018..7ebc9c3f 100644 --- a/src/Cli.Tests/Commands/AgentCommandTests.cs +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -10,7 +10,7 @@ public async Task RespectsCancellationToken() { using var tcs = new CancellationTokenSource( TimeSpan.FromMilliseconds( 2000 ) ); var (exitCode, output, _) = await DriftTestCli.InvokeAsync( - "agent start --adoptable", + "agent start", cancellationToken: tcs.Token ); @@ -24,7 +24,7 @@ public async Task SuccessfulStartup() { using var tcs = new CancellationTokenSource(); var runningCommand = await DriftTestCli.StartAgentAsync( - "--adoptable", + string.Empty, cancellationToken: tcs.Token ); @@ -38,14 +38,4 @@ public async Task SuccessfulStartup() { 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/ScanCommandTests.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt index b86a1ad8..d3a069c5 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt @@ -6,10 +6,11 @@ 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 +---------------------------------------- PREVIEW --------------------------------------- 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) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt index 2765abc7..eacd2479 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt @@ -5,10 +5,11 @@ 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 +---------------------------------------- PREVIEW --------------------------------------- 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) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt index 21d98e9b..c7a5f9e1 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt @@ -3,10 +3,11 @@ 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 +---------------------------------------- PREVIEW --------------------------------------- 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) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt index aa5468ac..5f21fbdf 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt @@ -5,10 +5,11 @@ 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 +---------------------------------------- PREVIEW --------------------------------------- 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 diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt index 3765181a..b91f4e66 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_Deduplicates.verified.txt @@ -4,10 +4,11 @@ Requesting subnets from agent agentid_agent2 Received subnet(s) from agent agentid_agent2: 192.168.10.0/24 Scanning 1 subnet 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +---------------------------------------- PREVIEW --------------------------------------- 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 3 unique devices from 2 scans of 192.168.10.0/24 Scan completed: 0 local, 2 via agents, 1 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt index 171df816..dacb917f 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt @@ -2,10 +2,11 @@ Received subnet(s) from agent agentid_agent1: 192.168.10.0/24 Scanning 1 subnet 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 +---------------------------------------- PREVIEW --------------------------------------- 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. +---------------------------------------------------------------------------------------- Scan completed: 0 local, 1 via agents, 1 unique subnets 192.168.10.0/24 (0 devices) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt index 64f9f78f..ae070808 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_FiltersVisibility.verified.txt @@ -5,10 +5,11 @@ Received subnet(s) from agent agentid_internal: 10.0.0.0/24 Scanning 2 subnets 192.168.1.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_dmz 10.0.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_dmz, agentid_internal +---------------------------------------- PREVIEW --------------------------------------- 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 2 unique devices from 2 scans of 10.0.0.0/24 Scan completed: 0 local, 3 via agents, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt index f63d42b0..c810eddd 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesDistributedScanner.verified.txt @@ -5,10 +5,11 @@ Received subnet(s) from agent agentid_agent2: 192.168.10.0/24, 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_agent1, agentid_agent2 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +---------------------------------------- PREVIEW --------------------------------------- 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 2 unique devices from 2 scans of 192.168.10.0/24 Merged 1 unique devices from 2 scans of 192.168.20.0/24 Scan completed: 0 local, 4 via agents, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt index 4ba45cbb..a8e32469 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_MergesDevices.verified.txt @@ -5,10 +5,11 @@ Received subnet(s) from agent agentid_agent2: 192.168.0.0/24, 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, agentid_agent1, agentid_agent2 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1, agentid_agent2 +---------------------------------------- PREVIEW --------------------------------------- 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 1 unique devices from 3 scans of 192.168.0.0/24 Merged 4 unique devices from 2 scans of 192.168.10.0/24 Scan completed: 1 local, 4 via agents, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt index 77f50b59..df2db381 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesDistributedScanner.verified.txt @@ -3,10 +3,11 @@ Received subnet(s) from agent agentid_agent1: 192.168.0.0/24, 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, agentid_agent1 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent1 +---------------------------------------- PREVIEW --------------------------------------- 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 1 unique devices from 2 scans of 192.168.0.0/24 Scan completed: 1 local, 2 via agents, 2 unique subnets diff --git a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt index e9dfccb1..8260934e 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt +++ b/src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt @@ -6,10 +6,11 @@ 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_agent1 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_agent2 +---------------------------------------- PREVIEW --------------------------------------- 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. +---------------------------------------------------------------------------------------- Scan completed: 1 local, 2 via agents, 3 unique subnets 192.168.0.0/24 (1 devices) diff --git a/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs b/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs index a639551e..49510bff 100644 --- a/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs +++ b/src/Cli.Tests/Utils/Agent/AgentConfiguration.cs @@ -43,5 +43,5 @@ public Dictionary> DiscoveredDevices { public string AdditionalArgs { get; init; - } = "--adoptable"; + } = string.Empty; } \ No newline at end of file diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj index 7991f9e0..42f9985d 100644 --- a/src/Cli/Cli.csproj +++ b/src/Cli/Cli.csproj @@ -21,11 +21,11 @@ - - + + - + diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs index 02e00a69..e96dd517 100644 --- a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs @@ -1,24 +1,19 @@ using System.CommandLine; -using Drift.Agent.Hosting; -using Drift.Agent.Hosting.Identity; -using Drift.Agent.PeerProtocol; +using Drift.Agent.Host; 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; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Agent.Subcommands.Start; +// TODO No spec should be provided - do not inherit from CommandBase - or CommandBase should not include it 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 ); } @@ -42,23 +37,6 @@ public async Task Invoke( AgentStartParameters parameters, CancellationToke 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 { @@ -86,37 +64,10 @@ void ConfigureServices( IServiceCollection services ) { RootCommandFactory.ConfigureAgentCoreServices( services ); // Add peer protocol message handlers - services.AddPeerProtocol(); + services.AddAgentHandlers(); // 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 index d946d1c8..f93ae929 100644 --- a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -6,21 +6,12 @@ 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 = _ => Ports.AgentDefault, - Description = "Set the port used for both adoption and communication" + DefaultValueFactory = _ => Ports.AgentDefault, Description = "Set the port used for communication" }; internal static readonly Option Id = new("--id") { @@ -30,27 +21,7 @@ internal static class Options { 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 { diff --git a/src/Cli/Commands/Scan/AgentClientExtensions.cs b/src/Cli/Commands/Scan/AgentClientExtensions.cs new file mode 100644 index 00000000..49d01344 --- /dev/null +++ b/src/Cli/Commands/Scan/AgentClientExtensions.cs @@ -0,0 +1,49 @@ +using Drift.Domain; +using Drift.Messaging.Client; +using Drift.Messaging.Protocol.Scan; +using Drift.Messaging.Protocol.Subnets; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Cli.Commands.Scan; + +internal static class AgentClientExtensions { + extension( IAgentClient agentClient ) { + internal Task GetSubnetsAsync( + Domain.Agent agent, + CancellationToken cancellationToken + ) { + return agentClient.SendAndWaitAsync( + agent, + new SubnetsRequest(), + timeout: TimeSpan.FromSeconds( 10 ), + cancellationToken + ); + } + + internal Task ScanSubnetAsync( + Domain.Agent agent, + CidrBlock cidr, + uint pingsPerSecond, + IMessageEnvelopeConverter converter, + Action onProgressUpdate, + CancellationToken cancellationToken + ) { + var request = new ScanSubnetRequest { Cidr = cidr, PingsPerSecond = pingsPerSecond }; + + return agentClient.SendAndWaitStreamingAsync( + agent, + request, + finalMessageType: 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/AgentSubnetProvider.cs b/src/Cli/Commands/Scan/AgentSubnetProvider.cs index 97ebfdc2..e9265497 100644 --- a/src/Cli/Commands/Scan/AgentSubnetProvider.cs +++ b/src/Cli/Commands/Scan/AgentSubnetProvider.cs @@ -1,5 +1,5 @@ using Drift.Domain; -using Drift.Networking.Cluster; +using Drift.Messaging.Client; using Drift.Scanning.Subnets; using Microsoft.Extensions.Logging; @@ -8,7 +8,7 @@ namespace Drift.Cli.Commands.Scan; internal sealed class AgentSubnetProvider( ILogger logger, List agents, - ICluster cluster, + IAgentClient agentClient, CancellationToken cancellationToken ) : ISubnetProvider { public async Task> GetAsync() { @@ -19,7 +19,7 @@ public async Task> GetAsync() { logger.LogInformation( "Requesting subnets from agent {Id}", agent.Id ); try { - var response = await cluster.GetSubnetsAsync( agent, cancellationToken ); + var response = await agentClient.GetSubnetsAsync( agent, cancellationToken ); logger.LogInformation( "Received subnet(s) from agent {Id}: {Subnets}", diff --git a/src/Cli/Commands/Scan/ClusterExtensions.cs b/src/Cli/Commands/Scan/ClusterExtensions.cs deleted file mode 100644 index 4967ca30..00000000 --- a/src/Cli/Commands/Scan/ClusterExtensions.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Drift.Agent.PeerProtocol.Scan; -using Drift.Agent.PeerProtocol.Subnets; -using Drift.Domain; -using Drift.Networking.Cluster; -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 index 32cdea2c..8a3a3800 100644 --- a/src/Cli/Commands/Scan/DistributedNetworkScanner.cs +++ b/src/Cli/Commands/Scan/DistributedNetworkScanner.cs @@ -4,8 +4,8 @@ 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.Messaging.Client; +using Drift.Networking.Core.Abstractions; using Drift.Scanning.Subnets; using Microsoft.Extensions.Logging; @@ -16,8 +16,8 @@ namespace Drift.Cli.Commands.Scan; /// internal sealed class DistributedNetworkScanner( INetworkScanner localScanner, - ICluster cluster, - IPeerMessageEnvelopeConverter converter, + IAgentClient agentClient, + IMessageEnvelopeConverter converter, List resolvedSubnets, Inventory inventory, ILogger logger @@ -117,7 +117,7 @@ private async Task ScanSingleSubnetViaAgentAsync( CancellationToken cancellationToken ) { try { - var response = await cluster.ScanSubnetAsync( + var response = await agentClient.ScanSubnetAsync( MapAgentIdToDomainAgent( agentSource.AgentId ), cidr, pingsPerSecond, @@ -232,7 +232,10 @@ ILogger logger return finalResult; } - private static List MergeOverlappingSubnetResults( List allResults, ILogger logger ) { + private static List MergeOverlappingSubnetResults( + List allResults, + ILogger logger + ) { // Group results by CIDR and merge devices from multiple scans var resultsByCidr = allResults .GroupBy( r => r.CidrBlock ) diff --git a/src/Cli/Commands/Scan/ScanCommand.cs b/src/Cli/Commands/Scan/ScanCommand.cs index 34eeafb0..5553c33a 100644 --- a/src/Cli/Commands/Scan/ScanCommand.cs +++ b/src/Cli/Commands/Scan/ScanCommand.cs @@ -10,8 +10,8 @@ using Drift.Common.Network; using Drift.Domain; using Drift.Domain.Scan; -using Drift.Networking.Cluster; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Messaging.Client; +using Drift.Networking.Core.Abstractions; using Drift.Scanning.Subnets; using Drift.Scanning.Subnets.Interface; using Microsoft.Extensions.DependencyInjection; @@ -59,8 +59,8 @@ internal class ScanCommandHandler( INetworkScanner localScanner, IInterfaceSubnetProvider interfaceSubnetProvider, ISpecFileProvider specProvider, - ICluster cluster, - IPeerMessageEnvelopeConverter converter + IAgentClient agentClient, + IMessageEnvelopeConverter converter ) : ICommandHandler { public async Task Invoke( ScanParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running scan command" ); @@ -117,7 +117,7 @@ private List BuildSubnetProviders( Inventory inventory, Cancell providers.Add( new AgentSubnetProvider( output.GetLogger(), inventory.Agents, - cluster, + agentClient, cancellationToken ) ); } @@ -174,7 +174,7 @@ private INetworkScanner CreateScanner( Inventory inventory, List return new DistributedNetworkScanner( localScanner, - cluster, + agentClient, converter, resolvedSubnets, inventory, diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs index e04cad12..24e91f0f 100644 --- a/src/Cli/Infrastructure/RootCommandFactory.cs +++ b/src/Cli/Infrastructure/RootCommandFactory.cs @@ -2,7 +2,6 @@ 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; @@ -18,9 +17,10 @@ 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.Messaging.Client; +using Drift.Messaging.Protocol; +using Drift.Networking.Client; +using Drift.Networking.Core; using Drift.Scanning; using Drift.Scanning.Scanners; using Drift.Scanning.Subnets.Interface; @@ -80,14 +80,15 @@ private static void ConfigureDefaults( IServiceCollection services, bool toConso ConfigureNetworkScanner( services ); ConfigureInteractiveServices( services ); ConfigureAgentCluster( services ); + ConfigureAgentClient( services ); } - private static void ConfigureAgentCluster( IServiceCollection services ) { - services.AddPeerStreamingCore( new PeerStreamingOptions { - MessageAssembly = typeof(PeerProtocolAssemblyMarker).Assembly + private static void ConfigureAgentClient( IServiceCollection services ) { + services.AddMessagingCore( new MessagingOptions { + MessageAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly } ); - services.AddPeerStreamingClient(); - services.AddClustering(); + services.AddMessagingClient(); + services.AddAgentClient(); } internal static void ConfigureExecutionEnvironment( IServiceCollection services ) { diff --git a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs index 991e0372..96e47dd4 100644 --- a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs +++ b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs @@ -19,9 +19,10 @@ public static ILogger GetLogger( this IOutputManager outputManager ) { /// public static void WarnAgentPreview( this IOutputManager outputManager ) { var logger = outputManager.GetLogger(); + logger.LogWarning( "---------------------------------------- PREVIEW ---------------------------------------" ); 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." ); + logger.LogWarning( "----------------------------------------------------------------------------------------" ); } } \ No newline at end of file diff --git a/src/Coordinator.Host/Coordinator.Host.csproj b/src/Coordinator.Host/Coordinator.Host.csproj new file mode 100644 index 00000000..228a23b5 --- /dev/null +++ b/src/Coordinator.Host/Coordinator.Host.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/Coordinator.Host/ServiceCollectionExtensions.cs b/src/Coordinator.Host/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..08db94f0 --- /dev/null +++ b/src/Coordinator.Host/ServiceCollectionExtensions.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Coordinator.Host; + +public static class ServiceCollectionExtensions { + public static void AddCoordinatorHandlers( this IServiceCollection services ) { + } +} diff --git a/src/Networking.Cluster/Cluster.cs b/src/Messaging.Client/AgentClient.cs similarity index 64% rename from src/Networking.Cluster/Cluster.cs rename to src/Messaging.Client/AgentClient.cs index 722bd799..302eb62c 100644 --- a/src/Networking.Cluster/Cluster.cs +++ b/src/Messaging.Client/AgentClient.cs @@ -1,62 +1,24 @@ -using Drift.Networking.PeerStreaming.Core.Abstractions; -using Drift.Networking.PeerStreaming.Core.Messages; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Core.Messages; using Microsoft.Extensions.Logging; -namespace Drift.Networking.Cluster; +namespace Drift.Messaging.Client; -internal sealed class Cluster( - IPeerMessageEnvelopeConverter envelopeConverter, - IPeerStreamManager peerStreamManager, - PeerResponseCorrelator responseCorrelator, +internal sealed class AgentClient( + IMessageEnvelopeConverter envelopeConverter, + IMessageStreamManager messageStreamManager, + MessageResponseCorrelator 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 ); - }*/ + AgentClientOptions? options = null +) : IAgentClient { + private readonly AgentClientOptions _options = options ?? new AgentClientOptions(); public async Task SendAndWaitAsync( Domain.Agent agent, TRequest message, TimeSpan? timeout = null, CancellationToken cancellationToken = default - ) where TResponse : IPeerResponse where TRequest : IPeerRequest { + ) where TResponse : IResponse where TRequest : IRequest { return await ExecuteWithRetryAsync( agent, async () => await SendAndWaitInternalAsync( agent, message, timeout, cancellationToken ), @@ -69,7 +31,7 @@ private async Task SendAndWaitInternalAsync( TRequest message, TimeSpan? timeout, CancellationToken cancellationToken - ) where TResponse : IPeerResponse where TRequest : IPeerRequest { + ) where TResponse : IResponse where TRequest : IRequest { var correlationId = Guid.NewGuid().ToString(); var envelope = envelopeConverter.ToEnvelope( message ); envelope.CorrelationId = correlationId; @@ -82,7 +44,7 @@ CancellationToken cancellationToken ); // Request - var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); + var connection = messageStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); await connection.SendAsync( envelope ); // Response @@ -94,10 +56,10 @@ public async Task SendAndWaitStreamingAsync onProgressUpdate, + Action onProgressUpdate, TimeSpan? timeout = null, CancellationToken cancellationToken = default - ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage { + ) where TFinalResponse : IResponse where TRequest : IMessage { return await ExecuteWithRetryAsync( agent, async () => await SendAndWaitStreamingInternalAsync( @@ -116,10 +78,10 @@ private async Task SendAndWaitStreamingInternalAsync onProgressUpdate, + Action onProgressUpdate, TimeSpan? timeout, CancellationToken cancellationToken - ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage { + ) where TFinalResponse : IResponse where TRequest : IMessage { var correlationId = Guid.NewGuid().ToString(); var envelope = envelopeConverter.ToEnvelope( message ); envelope.CorrelationId = correlationId; @@ -134,7 +96,7 @@ CancellationToken cancellationToken ); // Request - var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); + var connection = messageStreamManager.GetOrCreate( new Uri( agent.Address ), agent.Id ); await connection.SendAsync( envelope ); // Final Response @@ -205,6 +167,6 @@ CancellationToken cancellationToken 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 ); + return (int) Math.Min( delay, _options.RetryMaxDelayMs ); } } \ No newline at end of file diff --git a/src/Networking.Cluster/ClusterOptions.cs b/src/Messaging.Client/AgentClientOptions.cs similarity index 92% rename from src/Networking.Cluster/ClusterOptions.cs rename to src/Messaging.Client/AgentClientOptions.cs index 032ccbb7..f0295a83 100644 --- a/src/Networking.Cluster/ClusterOptions.cs +++ b/src/Messaging.Client/AgentClientOptions.cs @@ -1,6 +1,6 @@ -namespace Drift.Networking.Cluster; +namespace Drift.Messaging.Client; -public sealed class ClusterOptions { +public sealed class AgentClientOptions { /// /// Gets the maximum number of retry attempts for failed operations. /// diff --git a/src/Networking.Cluster/ICluster.cs b/src/Messaging.Client/IAgentClient.cs similarity index 58% rename from src/Networking.Cluster/ICluster.cs rename to src/Messaging.Client/IAgentClient.cs index 14861026..041023e7 100644 --- a/src/Networking.Cluster/ICluster.cs +++ b/src/Messaging.Client/IAgentClient.cs @@ -1,28 +1,28 @@ +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; -namespace Drift.Networking.Cluster; +namespace Drift.Messaging.Client; -public interface ICluster { - // Task SendAsync( Domain.Agent agent, IPeerMessage message, CancellationToken cancellationToken = default ); +public interface IAgentClient { + // Task SendAsync( Domain.Agent agent, IMessage message, CancellationToken cancellationToken = default ); Task SendAndWaitAsync( Domain.Agent agent, TRequest message, TimeSpan? timeout = null, CancellationToken cancellationToken = default - ) where TResponse : IPeerResponse where TRequest : IPeerRequest; + ) where TResponse : IResponse where TRequest : IRequest; Task SendAndWaitStreamingAsync( Domain.Agent agent, TRequest message, string finalMessageType, - Action onProgressUpdate, + Action onProgressUpdate, TimeSpan? timeout = null, CancellationToken cancellationToken = default - ) where TFinalResponse : IPeerResponse where TRequest : IPeerMessage; + ) where TFinalResponse : IResponse where TRequest : IMessage; - /*Task BroadcastAsync( PeerMessage message, CancellationToken cancellationToken = default ); + /*Task BroadcastAsync( Message message, CancellationToken cancellationToken = default ); Task> RequestSubnetsAsync( string peerAddress, CancellationToken cancellationToken = default ); Task EnsureConnectedAsync( string peerAddress, CancellationToken cancellationToken = default ); IReadOnlyCollection GetConnectedPeers();*/ diff --git a/src/Messaging.Client/Messaging.Client.csproj b/src/Messaging.Client/Messaging.Client.csproj new file mode 100644 index 00000000..b5d05c36 --- /dev/null +++ b/src/Messaging.Client/Messaging.Client.csproj @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/Messaging.Client/ServiceCollectionExtensions.cs b/src/Messaging.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..af61f6d4 --- /dev/null +++ b/src/Messaging.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Messaging.Client; + +public static class ServiceCollectionExtensions { + public static void AddAgentClient( this IServiceCollection services ) { + services.AddScoped(); + } +} \ No newline at end of file diff --git a/src/Messaging.Protocol/Messaging.Protocol.csproj b/src/Messaging.Protocol/Messaging.Protocol.csproj new file mode 100644 index 00000000..7955e65e --- /dev/null +++ b/src/Messaging.Protocol/Messaging.Protocol.csproj @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Messaging.Protocol/ProtocolMessagesAssemblyMarker.cs b/src/Messaging.Protocol/ProtocolMessagesAssemblyMarker.cs new file mode 100644 index 00000000..54b85567 --- /dev/null +++ b/src/Messaging.Protocol/ProtocolMessagesAssemblyMarker.cs @@ -0,0 +1,6 @@ +namespace Drift.Messaging.Protocol; + +// Justification: marker class +#pragma warning disable S2094 +public class ProtocolMessagesAssemblyMarker; +#pragma warning restore S2094 \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs b/src/Messaging.Protocol/Scan/ScanSubnetCompleteResponse.cs similarity index 82% rename from src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs rename to src/Messaging.Protocol/Scan/ScanSubnetCompleteResponse.cs index e330d4b2..0645187f 100644 --- a/src/Agent.PeerProtocol/Scan/ScanSubnetCompleteResponse.cs +++ b/src/Messaging.Protocol/Scan/ScanSubnetCompleteResponse.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Drift.Domain.Scan; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; using Drift.Serialization.Converters; -namespace Drift.Agent.PeerProtocol.Scan; +namespace Drift.Messaging.Protocol.Scan; -public sealed class ScanSubnetCompleteResponse : IPeerResponse { +public sealed class ScanSubnetCompleteResponse : IResponse { public static string MessageType => "scan-complete"; public required SubnetScanResult Result { diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs b/src/Messaging.Protocol/Scan/ScanSubnetProgressUpdate.cs similarity index 81% rename from src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs rename to src/Messaging.Protocol/Scan/ScanSubnetProgressUpdate.cs index 596703fe..8ec83d3c 100644 --- a/src/Agent.PeerProtocol/Scan/ScanSubnetProgressUpdate.cs +++ b/src/Messaging.Protocol/Scan/ScanSubnetProgressUpdate.cs @@ -1,10 +1,10 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; -namespace Drift.Agent.PeerProtocol.Scan; +namespace Drift.Messaging.Protocol.Scan; -public sealed class ScanSubnetProgressUpdate : IPeerMessage { +public sealed class ScanSubnetProgressUpdate : IMessage { public static string MessageType => "scan-progress-update"; public required byte ProgressPercentage { diff --git a/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs b/src/Messaging.Protocol/Scan/ScanSubnetRequest.cs similarity index 80% rename from src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs rename to src/Messaging.Protocol/Scan/ScanSubnetRequest.cs index 4da42d37..52279291 100644 --- a/src/Agent.PeerProtocol/Scan/ScanSubnetRequest.cs +++ b/src/Messaging.Protocol/Scan/ScanSubnetRequest.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Drift.Domain; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; using Drift.Serialization.Converters; -namespace Drift.Agent.PeerProtocol.Scan; +namespace Drift.Messaging.Protocol.Scan; -public sealed class ScanSubnetRequest : IPeerRequest { +public sealed class ScanSubnetRequest : IRequest { public static string MessageType => "scan-subnet-request"; public required CidrBlock Cidr { diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs b/src/Messaging.Protocol/Subnets/SubnetsRequest.cs similarity index 68% rename from src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs rename to src/Messaging.Protocol/Subnets/SubnetsRequest.cs index 7f0323f7..5a2e4770 100644 --- a/src/Agent.PeerProtocol/Subnets/SubnetsRequest.cs +++ b/src/Messaging.Protocol/Subnets/SubnetsRequest.cs @@ -1,10 +1,10 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; -namespace Drift.Agent.PeerProtocol.Subnets; +namespace Drift.Messaging.Protocol.Subnets; -public sealed class SubnetsRequest : IPeerRequest { +public sealed class SubnetsRequest : IRequest { public static string MessageType => "subnets-request"; public static JsonTypeInfo JsonInfo => SubnetsRequestJsonContext.Default.SubnetsRequest; diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs b/src/Messaging.Protocol/Subnets/SubnetsResponse.cs similarity index 81% rename from src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs rename to src/Messaging.Protocol/Subnets/SubnetsResponse.cs index f2402b6c..511baf49 100644 --- a/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs +++ b/src/Messaging.Protocol/Subnets/SubnetsResponse.cs @@ -1,12 +1,12 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; using Drift.Domain; -using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Networking.Core.Abstractions; using Drift.Serialization.Converters; -namespace Drift.Agent.PeerProtocol.Subnets; +namespace Drift.Messaging.Protocol.Subnets; -public sealed class SubnetsResponse : IPeerResponse { +public sealed class SubnetsResponse : IResponse { public static string MessageType => "subnets-response"; public required IReadOnlyList Subnets { diff --git a/src/Networking.Client/DefaultMessagingClientFactory.cs b/src/Networking.Client/DefaultMessagingClientFactory.cs new file mode 100644 index 00000000..0af68d6e --- /dev/null +++ b/src/Networking.Client/DefaultMessagingClientFactory.cs @@ -0,0 +1,13 @@ +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Grpc.Generated; +using Grpc.Net.Client; + +namespace Drift.Networking.Client; + +internal sealed class DefaultMessagingClientFactory : IMessagingClientFactory { + public (MessagingService.MessagingServiceClient Client, GrpcChannel Channel) Create( Uri address ) { + var channel = GrpcChannel.ForAddress( address, new GrpcChannelOptions() ); + var client = new MessagingService.MessagingServiceClient( channel ); + return ( client, channel ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj b/src/Networking.Client/Networking.Client.csproj similarity index 59% rename from src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj rename to src/Networking.Client/Networking.Client.csproj index 20592b5f..a4e89826 100644 --- a/src/Networking.PeerStreaming.Client/Networking.PeerStreaming.Client.csproj +++ b/src/Networking.Client/Networking.Client.csproj @@ -3,9 +3,9 @@ - + - + diff --git a/src/Networking.Client/ServiceCollectionExtensions.cs b/src/Networking.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..4a9aabb6 --- /dev/null +++ b/src/Networking.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,10 @@ +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.Client; + +public static class ServiceCollectionExtensions { + public static void AddMessagingClient( this IServiceCollection services ) { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Networking.Cluster/Enrollment.cs b/src/Networking.Cluster/Enrollment.cs deleted file mode 100644 index 4d8fcee4..00000000 --- a/src/Networking.Cluster/Enrollment.cs +++ /dev/null @@ -1,12 +0,0 @@ -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/Networking.Cluster.csproj b/src/Networking.Cluster/Networking.Cluster.csproj deleted file mode 100644 index 9c366ae1..00000000 --- a/src/Networking.Cluster/Networking.Cluster.csproj +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/Networking.Cluster/ServiceCollectionExtensions.cs b/src/Networking.Cluster/ServiceCollectionExtensions.cs deleted file mode 100644 index 5d26edd7..00000000 --- a/src/Networking.Cluster/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,9 +0,0 @@ -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.Core.Abstractions/ConnectionSide.cs b/src/Networking.Core.Abstractions/ConnectionSide.cs new file mode 100644 index 00000000..e3ca7b79 --- /dev/null +++ b/src/Networking.Core.Abstractions/ConnectionSide.cs @@ -0,0 +1,6 @@ +namespace Drift.Networking.Core.Abstractions; + +public enum ConnectionSide { + Inbound, + Outbound +} diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs b/src/Networking.Core.Abstractions/Empty.cs similarity index 73% rename from src/Networking.PeerStreaming.Core.Abstractions/Empty.cs rename to src/Networking.Core.Abstractions/Empty.cs index 2c43940d..0f7ffe3c 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/Empty.cs +++ b/src/Networking.Core.Abstractions/Empty.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization.Metadata; -namespace Drift.Networking.PeerStreaming.Core.Abstractions; +namespace Drift.Networking.Core.Abstractions; -public class Empty : IPeerResponse { +public class Empty : IResponse { private Empty() { } diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs b/src/Networking.Core.Abstractions/IMessage.cs similarity index 61% rename from src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs rename to src/Networking.Core.Abstractions/IMessage.cs index 0d6ef8e3..49b90e99 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs +++ b/src/Networking.Core.Abstractions/IMessage.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization.Metadata; -namespace Drift.Networking.PeerStreaming.Core.Abstractions; +namespace Drift.Networking.Core.Abstractions; -public interface IPeerMessage { +public interface IMessage { static abstract string MessageType { get; } @@ -13,9 +13,9 @@ static abstract JsonTypeInfo JsonInfo { } #pragma warning disable S2326 // TResponse is an intentional phantom type parameter for type-safe request/response pairing -public interface IPeerRequest : IPeerMessage where TResponse : IPeerResponse; +public interface IRequest : IMessage where TResponse : IResponse; #pragma warning restore S2326 -public interface IPeerResponse : IPeerMessage { +public interface IResponse : IMessage { static readonly Empty Empty = Empty.Instance; } \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs b/src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs new file mode 100644 index 00000000..4ddddb7e --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs @@ -0,0 +1,9 @@ +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessageEnvelopeConverter { + public Message ToEnvelope( IMessage message, string? requestId = null ) where T : IMessage; + + public T FromEnvelope( Message envelope ) where T : IMessage; +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs b/src/Networking.Core.Abstractions/IMessageHandler.cs similarity index 69% rename from src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs rename to src/Networking.Core.Abstractions/IMessageHandler.cs index 26a7740b..eeea55f3 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs +++ b/src/Networking.Core.Abstractions/IMessageHandler.cs @@ -1,8 +1,8 @@ using Drift.Networking.Grpc.Generated; -namespace Drift.Networking.PeerStreaming.Core.Abstractions; +namespace Drift.Networking.Core.Abstractions; -public interface IPeerMessageHandler { +public interface IMessageHandler { /// /// Gets the message type name that this handler can process. /// @@ -15,9 +15,9 @@ string MessageType { /// via the provided stream. Can send multiple responses for streaming scenarios. /// Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, CancellationToken cancellationToken ); } \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessageStream.cs b/src/Networking.Core.Abstractions/IMessageStream.cs new file mode 100644 index 00000000..690885c9 --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessageStream.cs @@ -0,0 +1,20 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessageStream : IAsyncDisposable { + public int InstanceNo { + get; + } + + public AgentId RemoteId { + get; + } + + public Task ReadTask { + get; + } + + public Task SendAsync( Message message ); +} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessageStreamManager.cs b/src/Networking.Core.Abstractions/IMessageStreamManager.cs new file mode 100644 index 00000000..1e72b7a3 --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessageStreamManager.cs @@ -0,0 +1,15 @@ +using Drift.Domain; +using Drift.Networking.Grpc.Generated; +using Grpc.Core; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessageStreamManager : IAsyncDisposable { + public IMessageStream GetOrCreate( Uri peerAddress, AgentId id ); + + public IMessageStream Create( + IAsyncStreamReader requestStream, + IAsyncStreamWriter responseStream, + ServerCallContext context + ); +} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessageTypesProvider.cs b/src/Networking.Core.Abstractions/IMessageTypesProvider.cs new file mode 100644 index 00000000..20f7f7d4 --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessageTypesProvider.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessageTypesProvider { + Dictionary Get(); +} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/IMessagingClientFactory.cs b/src/Networking.Core.Abstractions/IMessagingClientFactory.cs new file mode 100644 index 00000000..b7bde538 --- /dev/null +++ b/src/Networking.Core.Abstractions/IMessagingClientFactory.cs @@ -0,0 +1,8 @@ +using Drift.Networking.Grpc.Generated; +using Grpc.Net.Client; + +namespace Drift.Networking.Core.Abstractions; + +public interface IMessagingClientFactory { + (MessagingService.MessagingServiceClient Client, GrpcChannel Channel) Create( Uri address ); +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs similarity index 62% rename from src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs rename to src/Networking.Core.Abstractions/MessageHandlerExtensions.cs index cf3c9f6e..6d8b4b16 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/PeerMessageHandlerExtensions.cs +++ b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs @@ -1,16 +1,16 @@ -namespace Drift.Networking.PeerStreaming.Core.Abstractions; +namespace Drift.Networking.Core.Abstractions; -public static class PeerMessageHandlerExtensions { +public static class MessageHandlerExtensions { /// /// 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, + this IMessageStream stream, + IMessageEnvelopeConverter converter, TResponse response, string correlationId - ) where TResponse : IPeerMessage { + ) where TResponse : IMessage { var envelope = converter.ToEnvelope( response ); envelope.ReplyTo = correlationId; await stream.SendAsync( envelope ); @@ -22,13 +22,21 @@ string correlationId /// /// The type of the response message to send. public static void SendResponseFireAndForget( - this IPeerStream stream, - IPeerMessageEnvelopeConverter converter, + this IMessageStream stream, + IMessageEnvelopeConverter converter, TResponse response, string correlationId - ) where TResponse : IPeerMessage { + ) where TResponse : IMessage { var envelope = converter.ToEnvelope( response ); envelope.ReplyTo = correlationId; - _ = stream.SendAsync( envelope ); + _ = stream.SendAsync( envelope ).ContinueWith( + t => { + t.Exception?.Handle( ex => { + // TODO: Handle exception + return true; + } ); + }, + TaskContinuationOptions.OnlyOnFaulted + ); } -} +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj b/src/Networking.Core.Abstractions/Networking.Core.Abstractions.csproj similarity index 67% rename from src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj rename to src/Networking.Core.Abstractions/Networking.Core.Abstractions.csproj index 97a14ed6..2086a57d 100644 --- a/src/Networking.PeerStreaming.Core.Abstractions/Networking.PeerStreaming.Core.Abstractions.csproj +++ b/src/Networking.Core.Abstractions/Networking.Core.Abstractions.csproj @@ -2,7 +2,7 @@ - + diff --git a/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs b/src/Networking.Core/Common/GrpcMetadataExtensions.cs similarity index 85% rename from src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs rename to src/Networking.Core/Common/GrpcMetadataExtensions.cs index 77717661..1f58d162 100644 --- a/src/Networking.PeerStreaming.Core/Common/GrpcMetadataExtensions.cs +++ b/src/Networking.Core/Common/GrpcMetadataExtensions.cs @@ -1,7 +1,7 @@ using Drift.Domain; using Grpc.Core; -namespace Drift.Networking.PeerStreaming.Core.Common; +namespace Drift.Networking.Core.Common; internal static class GrpcMetadataExtensions { internal static AgentId GetAgentId( this Metadata metadata ) { diff --git a/src/Networking.PeerStreaming.Core/PeerStream.cs b/src/Networking.Core/MessageStream.cs similarity index 71% rename from src/Networking.PeerStreaming.Core/PeerStream.cs rename to src/Networking.Core/MessageStream.cs index 4608989c..09d5d33e 100644 --- a/src/Networking.PeerStreaming.Core/PeerStream.cs +++ b/src/Networking.Core/MessageStream.cs @@ -1,28 +1,28 @@ using Drift.Domain; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Core.Messages; 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; +namespace Drift.Networking.Core; -public sealed class PeerStream : IPeerStream { +public sealed class MessageStream : IMessageStream { 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 IAsyncStreamReader _reader; + private readonly IAsyncStreamWriter _writer; + private readonly MessageDispatcher _dispatcher; private readonly ILogger _logger; private readonly CancellationToken _cancellationToken; - public PeerStream( - IAsyncStreamReader reader, - IAsyncStreamWriter writer, - PeerMessageDispatcher dispatcher, + public MessageStream( + IAsyncStreamReader reader, + IAsyncStreamWriter writer, + MessageDispatcher dispatcher, ILogger logger, CancellationToken cancellationToken ) { - Side = ConnectionSide.Incoming; + Side = ConnectionSide.Inbound; _reader = reader; _writer = writer; _dispatcher = dispatcher; @@ -32,15 +32,15 @@ CancellationToken cancellationToken ReadTask = Task.Run( ReadLoopAsync, CancellationToken.None ); } - public PeerStream( + public MessageStream( Uri address, - IAsyncStreamReader reader, - IAsyncStreamWriter writer, - PeerMessageDispatcher dispatcher, + IAsyncStreamReader reader, + IAsyncStreamWriter writer, + MessageDispatcher dispatcher, ILogger logger, CancellationToken cancellationToken ) : this( reader, writer, dispatcher, logger, cancellationToken ) { - Side = ConnectionSide.Outgoing; + Side = ConnectionSide.Outbound; Address = address; } @@ -56,7 +56,7 @@ private Uri? Address { get; } - public required AgentId AgentId { + public required AgentId RemoteId { get; init; } @@ -66,7 +66,7 @@ public Task ReadTask { private init; } - public async Task SendAsync( PeerMessage message ) { + public async Task SendAsync( Message message ) { await _writer.WriteAsync( message, _cancellationToken ); // TODO also take another cancellation token (combine) } @@ -103,9 +103,9 @@ private async Task ReadLoopAsync() { } public async ValueTask DisposeAsync() { - _logger.LogTrace( "Disposing {PeerStream}", this ); + _logger.LogTrace( "Disposing {MessageStream}", this ); - if ( _writer is IClientStreamWriter clientWriter ) { + if ( _writer is IClientStreamWriter clientWriter ) { // I.e., outgoing stream (client initiated) // Server streams are automatically completed by the gRPC framework await clientWriter.CompleteAsync(); @@ -116,6 +116,6 @@ public async ValueTask DisposeAsync() { public override string ToString() { return - $"{nameof(PeerStream)}[#{InstanceNo}, {nameof(AgentId)}={AgentId}, {nameof(Side)}={Side}, {nameof(Address)}={Address?.ToString() ?? "n/a"}]"; + $"{nameof(MessageStream)}[#{InstanceNo}, {nameof(RemoteId)}={RemoteId}, {nameof(Side)}={Side}, {nameof(Address)}={Address?.ToString() ?? "n/a"}]"; } } \ No newline at end of file diff --git a/src/Networking.Core/MessageStreamManager.cs b/src/Networking.Core/MessageStreamManager.cs new file mode 100644 index 00000000..3e503d0e --- /dev/null +++ b/src/Networking.Core/MessageStreamManager.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; +using Drift.Domain; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Core.Common; +using Drift.Networking.Core.Messages; +using Drift.Networking.Grpc.Generated; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.Core; + +internal sealed class MessageStreamManager( + ILogger logger, + IMessagingClientFactory? messageClientFactory, + MessageDispatcher dispatcher, + MessagingOptions options +) : IMessageStreamManager { + private readonly ConcurrentDictionary _streams = new(); + + public IMessageStream GetOrCreate( Uri peerAddress, AgentId id ) { + logger.LogDebug( + "Getting or creating {ConnectionSide} stream to agent {Id} ({Address})", + ConnectionSide.Outbound, + id, + peerAddress + ); + + return _streams.GetOrAdd( id, agentId => Create( peerAddress, agentId ) ); + } + + private IMessageStream Create( Uri peerAddress, AgentId id ) { + if ( messageClientFactory == null ) { + throw new Exception( + $"Cannot create {nameof(ConnectionSide.Outbound)} stream since {nameof(messageClientFactory)} is null" + ); + } + + var (client, _) = messageClientFactory.Create( peerAddress ); + var callOptions = new CallOptions( new Metadata { { "agent-id", id } } ); + var call = client.Connect( callOptions ); + + var stream = new MessageStream( + peerAddress, + call.ResponseStream, + call.RequestStream, + dispatcher, + logger, + options.StoppingToken + ) { RemoteId = id }; + Add( stream ); + return stream; + } + + public IMessageStream Create( + IAsyncStreamReader requestStream, + IAsyncStreamWriter responseStream, + ServerCallContext context + ) { + var agentId = context.RequestHeaders.GetAgentId(); + + logger.LogInformation( "Creating {ConnectionSide} stream from agent {Id}", ConnectionSide.Inbound, agentId ); + + var stream = + new MessageStream( requestStream, responseStream, dispatcher, logger, options.StoppingToken ) { + RemoteId = agentId + }; + Add( stream ); + return stream; + } + + private void Add( IMessageStream stream ) { + logger.LogTrace( "Created {Stream}", stream ); + _streams[stream.RemoteId] = stream; + } + + public async ValueTask DisposeAsync() { + logger.LogDebug( "Disposing stream manager (including all streams)" ); + foreach ( var stream in _streams.Values ) { + logger.LogTrace( "Disposing stream #{StreamNo}", stream.InstanceNo ); + await stream.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs b/src/Networking.Core/Messages/MessageDispatcher.cs similarity index 61% rename from src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs rename to src/Networking.Core/Messages/MessageDispatcher.cs index 18fa525e..dc2d2ee4 100644 --- a/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs +++ b/src/Networking.Core/Messages/MessageDispatcher.cs @@ -1,19 +1,19 @@ +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; using Microsoft.Extensions.Logging; -namespace Drift.Networking.PeerStreaming.Core.Messages; +namespace Drift.Networking.Core.Messages; -public sealed class PeerMessageDispatcher { - private readonly PeerResponseCorrelator _responseCorrelator; - private readonly IPeerMessageEnvelopeConverter _envelopeConverter; +public sealed class MessageDispatcher { + private readonly MessageResponseCorrelator _responseCorrelator; + private readonly IMessageEnvelopeConverter _envelopeConverter; private readonly ILogger _logger; - private readonly Dictionary _handlers; + private readonly Dictionary _handlers; - public PeerMessageDispatcher( - IEnumerable handlers, - IPeerMessageEnvelopeConverter envelopeConverter, - PeerResponseCorrelator responseCorrelator, + public MessageDispatcher( + IEnumerable handlers, + IMessageEnvelopeConverter envelopeConverter, + MessageResponseCorrelator responseCorrelator, ILogger logger ) { _responseCorrelator = responseCorrelator; @@ -22,7 +22,7 @@ ILogger logger _handlers = handlers.ToDictionary( h => h.MessageType, StringComparer.OrdinalIgnoreCase ); } - public async Task DispatchAsync( PeerMessage message, PeerStream peerStream, CancellationToken ct = default ) { + public async Task DispatchAsync( Message message, MessageStream stream, CancellationToken ct = default ) { _logger.LogDebug( "Dispatching message: {Type}", message.MessageType ); // If this is a response to a pending request, complete it @@ -39,7 +39,7 @@ ILogger logger // 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 ); + await handler.HandleAsync( message, _envelopeConverter, stream, ct ); return; } diff --git a/src/Networking.Core/Messages/MessageEnvelopeConverter.cs b/src/Networking.Core/Messages/MessageEnvelopeConverter.cs new file mode 100644 index 00000000..a1586a3c --- /dev/null +++ b/src/Networking.Core/Messages/MessageEnvelopeConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Grpc.Generated; + +namespace Drift.Networking.Core.Messages; + +internal sealed class MessageEnvelopeConverter : IMessageEnvelopeConverter { + public Message ToEnvelope( IMessage message, string? requestId = null ) where T : IMessage { + string json = JsonSerializer.Serialize( message, T.JsonInfo ); + return new Message { MessageType = T.MessageType, Payload = json, }; + } + + public T FromEnvelope( Message envelope ) where T : IMessage { + if ( envelope.MessageType != T.MessageType ) { + throw new InvalidOperationException( + $"Envelope contains '{envelope.MessageType}' but caller expects '{T.MessageType}'." + ); + } + + return JsonSerializer.Deserialize( envelope.Payload, T.JsonInfo.Options )!; + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs b/src/Networking.Core/Messages/MessageResponseCorrelator.cs similarity index 72% rename from src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs rename to src/Networking.Core/Messages/MessageResponseCorrelator.cs index 3f0b2138..0e07a2ec 100644 --- a/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs +++ b/src/Networking.Core/Messages/MessageResponseCorrelator.cs @@ -2,20 +2,15 @@ using Drift.Networking.Grpc.Generated; using Microsoft.Extensions.Logging; -namespace Drift.Networking.PeerStreaming.Core.Messages; +namespace Drift.Networking.Core.Messages; // TODO private? -public sealed class PeerResponseCorrelator { - private readonly ConcurrentDictionary> _pendingRequests = new(); +public sealed class MessageResponseCorrelator( ILogger logger ) { + 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(); + 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" ); @@ -35,15 +30,15 @@ public Task WaitForResponseAsync( string correlationId, TimeSpan ti return tcs.Task; } - public Task WaitForStreamingResponseAsync( + public Task WaitForStreamingResponseAsync( string correlationId, string finalMessageType, - Action onProgressUpdate, + Action onProgressUpdate, TimeSpan timeout, CancellationToken ct ) { var handler = new StreamingResponseHandler { - CompletionSource = new TaskCompletionSource(), + CompletionSource = new TaskCompletionSource(), FinalMessageType = finalMessageType, OnProgressUpdate = onProgressUpdate }; @@ -66,7 +61,7 @@ CancellationToken ct return handler.CompletionSource.Task; } - public bool TryCompleteResponse( string correlationId, PeerMessage response ) { + public bool TryCompleteResponse( string correlationId, Message response ) { // Check for streaming response first if ( _streamingRequests.TryGetValue( correlationId, out var streamingHandler ) ) { // If this is the final message, complete the task @@ -85,12 +80,12 @@ public bool TryCompleteResponse( string correlationId, PeerMessage response ) { return tcs.TrySetResult( response ); } - _logger.LogWarning( "Received response for unknown correlation ID: {CorrelationId}", correlationId ); + logger.LogWarning( "Received response for unknown correlation ID: {CorrelationId}", correlationId ); return false; } private sealed class StreamingResponseHandler { - public required TaskCompletionSource CompletionSource { + public required TaskCompletionSource CompletionSource { get; init; } @@ -100,7 +95,7 @@ public required string FinalMessageType { init; } - public required Action OnProgressUpdate { + public required Action OnProgressUpdate { get; init; } diff --git a/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs b/src/Networking.Core/MessagingOptions.cs similarity index 67% rename from src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs rename to src/Networking.Core/MessagingOptions.cs index 2ca2a36e..08d629a2 100644 --- a/src/Networking.PeerStreaming.Core/PeerStreamingOptions.cs +++ b/src/Networking.Core/MessagingOptions.cs @@ -1,8 +1,8 @@ using System.Reflection; -namespace Drift.Networking.PeerStreaming.Core; +namespace Drift.Networking.Core; -public sealed class PeerStreamingOptions { +public sealed class MessagingOptions { public CancellationToken StoppingToken { get; set; diff --git a/src/Networking.Core/Networking.Core.csproj b/src/Networking.Core/Networking.Core.csproj new file mode 100644 index 00000000..27a7fd88 --- /dev/null +++ b/src/Networking.Core/Networking.Core.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Networking.Core/ServiceCollectionExtensions.cs b/src/Networking.Core/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..609d82de --- /dev/null +++ b/src/Networking.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Core.Messages; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Networking.Core; + +public static class ServiceCollectionExtensions { + public static void AddMessagingCore( + this IServiceCollection services, + MessagingOptions 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.Grpc/Networking.Grpc.csproj similarity index 80% rename from src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj rename to src/Networking.Grpc/Networking.Grpc.csproj index 92ab3e5b..849107b6 100644 --- a/src/Networking.PeerStreaming.Grpc/Networking.PeerStreaming.Grpc.csproj +++ b/src/Networking.Grpc/Networking.Grpc.csproj @@ -1,7 +1,7 @@  - + diff --git a/src/Networking.PeerStreaming.Grpc/Protos/peer.proto b/src/Networking.Grpc/Protos/agent.proto similarity index 61% rename from src/Networking.PeerStreaming.Grpc/Protos/peer.proto rename to src/Networking.Grpc/Protos/agent.proto index c0fccd55..cc232c9c 100644 --- a/src/Networking.PeerStreaming.Grpc/Protos/peer.proto +++ b/src/Networking.Grpc/Protos/agent.proto @@ -2,16 +2,15 @@ syntax = "proto3"; option csharp_namespace = "Drift.Networking.Grpc.Generated"; -package peer; +package drift; -// AgentService? -service PeerService { - rpc PeerStream (stream PeerMessage) returns (stream PeerMessage); +service MessagingService { + rpc Connect (stream Message) returns (stream Message); } -message PeerMessage { +message Message { string message_type = 1; - string message = 2; + string payload = 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.Client/DefaultPeerClientFactory.cs b/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs deleted file mode 100644 index 85cbf055..00000000 --- a/src/Networking.PeerStreaming.Client/DefaultPeerClientFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -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/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs deleted file mode 100644 index 81cf54a9..00000000 --- a/src/Networking.PeerStreaming.Client/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index d516ef7b..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/ConnectionSide.cs +++ /dev/null @@ -1,6 +0,0 @@ -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/IPeerClientFactory.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs deleted file mode 100644 index f7a92656..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerClientFactory.cs +++ /dev/null @@ -1,8 +0,0 @@ -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/IPeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs deleted file mode 100644 index 69a02c1a..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageEnvelopeConverter.cs +++ /dev/null @@ -1,9 +0,0 @@ -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/IPeerMessageTypesProvider.cs b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs deleted file mode 100644 index 37d6fe8d..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageTypesProvider.cs +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 6d34c9b8..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStream.cs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index cf4576e3..00000000 --- a/src/Networking.PeerStreaming.Core.Abstractions/IPeerStreamManager.cs +++ /dev/null @@ -1,15 +0,0 @@ -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/Messages/PeerMessageEnvelopeConverter.cs b/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs deleted file mode 100644 index 3b9a4467..00000000 --- a/src/Networking.PeerStreaming.Core/Messages/PeerMessageEnvelopeConverter.cs +++ /dev/null @@ -1,22 +0,0 @@ -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/Networking.PeerStreaming.Core.csproj b/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj deleted file mode 100644 index 9d441dfb..00000000 --- a/src/Networking.PeerStreaming.Core/Networking.PeerStreaming.Core.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/Networking.PeerStreaming.Core/PeerStreamManager.cs b/src/Networking.PeerStreaming.Core/PeerStreamManager.cs deleted file mode 100644 index 78b0f673..00000000 --- a/src/Networking.PeerStreaming.Core/PeerStreamManager.cs +++ /dev/null @@ -1,79 +0,0 @@ -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/ServiceCollectionExtensions.cs b/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs deleted file mode 100644 index 4ca96603..00000000 --- a/src/Networking.PeerStreaming.Core/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -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.Server/InboundPeerService.cs b/src/Networking.PeerStreaming.Server/InboundPeerService.cs deleted file mode 100644 index d503718f..00000000 --- a/src/Networking.PeerStreaming.Server/InboundPeerService.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 76db19ff..00000000 --- a/src/Networking.PeerStreaming.Server/Networking.PeerStreaming.Server.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs b/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs deleted file mode 100644 index 2b09ab2f..00000000 --- a/src/Networking.PeerStreaming.Server/PeerStreamingServerMarker.cs +++ /dev/null @@ -1,8 +0,0 @@ -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.Tests/InboundTests.cs b/src/Networking.PeerStreaming.Tests/InboundTests.cs deleted file mode 100644 index a54b2260..00000000 --- a/src/Networking.PeerStreaming.Tests/InboundTests.cs +++ /dev/null @@ -1,77 +0,0 @@ -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.Server/InboundMessageService.cs b/src/Networking.Server/InboundMessageService.cs new file mode 100644 index 00000000..6341612e --- /dev/null +++ b/src/Networking.Server/InboundMessageService.cs @@ -0,0 +1,31 @@ +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Grpc.Generated; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Drift.Networking.Server; + +// Handle incoming connections (AKA server-side). +internal sealed class InboundMessageService( IMessageStreamManager messageStreamManager, ILogger logger ) + : MessagingService.MessagingServiceBase { + public override async Task Connect( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context + ) { + try { + logger.LogInformation( "Inbound stream starting..." ); + var stream = messageStreamManager.Create( requestStream, responseStream, context ); + logger.LogInformation( "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( "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.Server/MessagingServerMarker.cs b/src/Networking.Server/MessagingServerMarker.cs new file mode 100644 index 00000000..659167f2 --- /dev/null +++ b/src/Networking.Server/MessagingServerMarker.cs @@ -0,0 +1,8 @@ +namespace Drift.Networking.Server; + +internal sealed class MessagingServerMarker { + internal bool EndpointsMapped { + get; + set; + } +} \ No newline at end of file diff --git a/src/Networking.Server/Networking.Server.csproj b/src/Networking.Server/Networking.Server.csproj new file mode 100644 index 00000000..faaa011b --- /dev/null +++ b/src/Networking.Server/Networking.Server.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs b/src/Networking.Server/ServiceCollectionExtensions.cs similarity index 55% rename from src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs rename to src/Networking.Server/ServiceCollectionExtensions.cs index 79843ab4..ec260791 100644 --- a/src/Networking.PeerStreaming.Server/ServiceCollectionExtensions.cs +++ b/src/Networking.Server/ServiceCollectionExtensions.cs @@ -1,47 +1,47 @@ -using Grpc.AspNetCore.Server; +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; +namespace Drift.Networking.Server; public static class ServiceCollectionExtensions { - public static void AddPeerStreamingServer( + public static void AddMessagingServer( this IServiceCollection services, Action? configureOptions = null ) { - services.AddSingleton(); + services.AddSingleton(); services.AddGrpc( options => configureOptions?.Invoke( options ) ); - services.AddTransient(); + services.AddTransient(); } - public static void MapPeerStreamingServerEndpoints( this IEndpointRouteBuilder app ) { - var marker = app.ServiceProvider.GetService(); + public static void MapMessagingServerEndpoints( 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)}'." + $"Unable to find the required services. Add them by calling '{nameof(IServiceCollection)}.{nameof(AddMessagingServer)}'." ); } - app.MapGrpcService(); + app.MapGrpcService(); marker.EndpointsMapped = true; } } -internal sealed class PeerStreamingServerValidationFilter : IStartupFilter { +internal sealed class MessagingServerValidationFilter : IStartupFilter { public Action Configure( Action next ) { return app => { next( app ); - var marker = app.ApplicationServices.GetRequiredService(); + 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)}'." ); + $"Server endpoints were not mapped. Map them by calling '{nameof(IEndpointRouteBuilder)}.{nameof(ServiceCollectionExtensions.MapMessagingServerEndpoints)}'." ); } }; } -} \ No newline at end of file +} diff --git a/src/Networking.PeerStreaming.Tests/AssemblyInfo.cs b/src/Networking.Tests/AssemblyInfo.cs similarity index 100% rename from src/Networking.PeerStreaming.Tests/AssemblyInfo.cs rename to src/Networking.Tests/AssemblyInfo.cs diff --git a/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs b/src/Networking.Tests/Helpers/InMemoryDuplexStreamPair.cs similarity index 98% rename from src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs rename to src/Networking.Tests/Helpers/InMemoryDuplexStreamPair.cs index d4a78d08..aa0c9ed6 100644 --- a/src/Networking.PeerStreaming.Tests/Helpers/InMemoryDuplexStreamPair.cs +++ b/src/Networking.Tests/Helpers/InMemoryDuplexStreamPair.cs @@ -2,7 +2,7 @@ using Grpc.Core; using Channel = System.Threading.Channels.Channel; -namespace Drift.Networking.PeerStreaming.Tests.Helpers; +namespace Drift.Networking.Tests.Helpers; internal sealed record DuplexStreamEndpoint( TRequest RequestStream, TResponse ResponseStream ); diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs b/src/Networking.Tests/Helpers/TestPeerMessage.cs similarity index 69% rename from src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs rename to src/Networking.Tests/Helpers/TestPeerMessage.cs index 4cef4584..74564a77 100644 --- a/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs +++ b/src/Networking.Tests/Helpers/TestPeerMessage.cs @@ -1,11 +1,11 @@ using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; -using Drift.Networking.PeerStreaming.Core.Abstractions; -namespace Drift.Networking.PeerStreaming.Tests.Helpers; +namespace Drift.Networking.Tests.Helpers; -internal sealed class TestPeerMessage : IPeerRequest, IPeerResponse { +internal sealed class TestPeerMessage : IRequest, IResponse { public static string MessageType => "test-peer-message"; public static JsonTypeInfo JsonInfo => TestPeerMessageJsonContext.Default.TestPeerMessage; @@ -14,7 +14,7 @@ internal sealed class TestPeerMessage : IPeerRequest, IPeerResp [JsonSerializable( typeof(TestPeerMessage) )] internal sealed partial class TestPeerMessageJsonContext : JsonSerializerContext; -internal sealed class TestMessageHandler : IPeerMessageHandler { +internal sealed class TestMessageHandler : IMessageHandler { public TestPeerMessage? LastMessage { get; private set; @@ -23,9 +23,9 @@ public TestPeerMessage? LastMessage { public string MessageType => TestPeerMessage.MessageType; public Task HandleAsync( - PeerMessage envelope, - IPeerMessageEnvelopeConverter converter, - IPeerStream stream, + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, CancellationToken cancellationToken ) { var message = converter.FromEnvelope( envelope ); diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs b/src/Networking.Tests/Helpers/TestServerCallContext.cs similarity index 97% rename from src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs rename to src/Networking.Tests/Helpers/TestServerCallContext.cs index 01212db3..6068ee79 100644 --- a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContext.cs +++ b/src/Networking.Tests/Helpers/TestServerCallContext.cs @@ -1,6 +1,6 @@ using Grpc.Core; -namespace Drift.Networking.PeerStreaming.Tests.Helpers; +namespace Drift.Networking.Tests.Helpers; internal sealed class TestServerCallContext : ServerCallContext { private readonly Metadata _requestHeaders; diff --git a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs b/src/Networking.Tests/Helpers/TestServerCallContextExtensions.cs similarity index 65% rename from src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs rename to src/Networking.Tests/Helpers/TestServerCallContextExtensions.cs index 3129b711..86012438 100644 --- a/src/Networking.PeerStreaming.Tests/Helpers/TestServerCallContextExtensions.cs +++ b/src/Networking.Tests/Helpers/TestServerCallContextExtensions.cs @@ -1,15 +1,15 @@ using Drift.Networking.Grpc.Generated; using Grpc.Core; -namespace Drift.Networking.PeerStreaming.Tests.Helpers; +namespace Drift.Networking.Tests.Helpers; internal static class TestServerCallContextExtensions { public static ( - DuplexStreamEndpoint, IAsyncStreamReader> Client, - DuplexStreamEndpoint, IServerStreamWriter> Server + DuplexStreamEndpoint, IAsyncStreamReader> Client, + DuplexStreamEndpoint, IServerStreamWriter> Server ) CreateDuplexStreams( this TestServerCallContext serverContext ) { - return InMemoryDuplexStreamPair.Create( serverContext ); + return InMemoryDuplexStreamPair.Create( serverContext ); } internal static ( diff --git a/src/Networking.Tests/InboundTests.cs b/src/Networking.Tests/InboundTests.cs new file mode 100644 index 00000000..e2accc94 --- /dev/null +++ b/src/Networking.Tests/InboundTests.cs @@ -0,0 +1,77 @@ +using Drift.Networking.Core; +using Drift.Networking.Core.Messages; +using Drift.Networking.Server; +using Drift.Networking.Tests.Helpers; +using Drift.TestUtilities; + +namespace Drift.Networking.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 MessageResponseCorrelator( logger ); + var envelopeConverter = new MessageEnvelopeConverter(); + var messageStreamManager = new MessageStreamManager( + logger, + null, + new MessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), + new MessagingOptions { StoppingToken = cts.Token } + ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + + var inboundMessageService = new InboundMessageService( messageStreamManager, logger ); + + // Act / Assert + var serverStreams = duplexStreams.Server; + var connectTask = + inboundMessageService.Connect( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + + Assert.That( connectTask.IsCompleted, Is.False ); + + await cts.CancelAsync(); + + await Task.WhenAny( connectTask, Task.Delay( 1000 ) ); + + Assert.That( connectTask.IsCompleted, Is.True ); + } + + [Test] + public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { + // Arrange + using var cts = new CancellationTokenSource(); + var logger = new StringLogger( TestContext.Out ); + var responseCorrelator = new MessageResponseCorrelator( logger ); + var envelopeConverter = new MessageEnvelopeConverter(); + var messageStreamManager = new MessageStreamManager( + logger, + null, + new MessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), + new MessagingOptions { StoppingToken = cts.Token } + ); + var inboundMessageService = new InboundMessageService( messageStreamManager, logger ); + + var callContext = TestServerCallContext.Create(); + callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); + var duplexStreams = callContext.CreateDuplexStreams(); + + // Act + var connectTask = inboundMessageService.Connect( + duplexStreams.Server.RequestStream, + duplexStreams.Server.ResponseStream, + callContext + ); + + // Assert + Assert.That( connectTask.IsCompleted, Is.False ); + + await Task.WhenAny( connectTask, Task.Delay( 1000 ) ); + + Assert.That( connectTask.IsCompleted, Is.False ); + } +} \ No newline at end of file diff --git a/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs b/src/Networking.Tests/MessageStreamManagerTests.cs similarity index 55% rename from src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs rename to src/Networking.Tests/MessageStreamManagerTests.cs index 9e930af8..becf03bf 100644 --- a/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs +++ b/src/Networking.Tests/MessageStreamManagerTests.cs @@ -1,33 +1,33 @@ -using Drift.Networking.PeerStreaming.Core; -using Drift.Networking.PeerStreaming.Core.Messages; -using Drift.Networking.PeerStreaming.Tests.Helpers; +using Drift.Networking.Core; +using Drift.Networking.Core.Messages; +using Drift.Networking.Tests.Helpers; using Drift.TestUtilities; -namespace Drift.Networking.PeerStreaming.Tests; +namespace Drift.Networking.Tests; -internal sealed class PeerStreamManagerTests { +internal sealed class MessageStreamManagerTests { [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( + var envelopeConverter = new MessageEnvelopeConverter(); + var responseCorrelator = new MessageResponseCorrelator( logger ); + var dispatcher = new MessageDispatcher( [testMessageHandler], envelopeConverter, responseCorrelator, logger ); + var messageStreamManager = new MessageStreamManager( logger, null, dispatcher, - new PeerStreamingOptions { StoppingToken = cts.Token } + new MessagingOptions { 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(); + var stream = messageStreamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + var converter = new MessageEnvelopeConverter(); // Act var clientStreams = duplexStreams.Client; diff --git a/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj b/src/Networking.Tests/Networking.Tests.csproj similarity index 62% rename from src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj rename to src/Networking.Tests/Networking.Tests.csproj index 42b50c1e..733c8c6e 100644 --- a/src/Networking.PeerStreaming.Tests/Networking.PeerStreaming.Tests.csproj +++ b/src/Networking.Tests/Networking.Tests.csproj @@ -1,12 +1,12 @@ - + - - + + From 4b22cb3a8ede5b1964659432772c047ac2e2f3d7 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:31:32 +0200 Subject: [PATCH 14/43] wip --- .gitignore | 1 + .../Versioning/VersioningTests.cs | 2 +- build/NukeBuild.Binaries.cs | 2 +- build/NukeBuild.Container.cs | 2 +- build/NukeBuild.Test.cs | 12 +-- build/NukeBuild.TestContainerlab.cs | 53 ++++++++----- containerlab/README.md | 76 +++---------------- containerlab/cooperation-test.clab.yaml | 7 +- containerlab/simple-test.clab.yaml | 4 - .../Subcommands/Start/AgentStartParameters.cs | 1 + 10 files changed, 55 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index 80a95396..8319e2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ obj/ .idea/.idea.Drift/.idea/watcherTasks.xml .idea/.idea.Drift/.idea/encodings.xml .idea/.idea.Drift.Build/.idea/encodings.xml +.air/ *.DotSettings *.received.* artifacts/ diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index d6b0ab51..0d25ec35 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -31,7 +31,7 @@ public async Task DefaultVersioningVersionTest() { [Test] public async Task DefaultVersioningWhenNoReleaseTargets() { // Arrange - var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => NukeBuildWithArbitraryTarget.Arbitrary ); + var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( _ => NukeBuildWithArbitraryTarget.Arbitrary ); // Act var factory = new VersioningStrategyFactory( build ); diff --git a/build/NukeBuild.Binaries.cs b/build/NukeBuild.Binaries.cs index 7a51e37b..a6cfdf36 100644 --- a/build/NukeBuild.Binaries.cs +++ b/build/NukeBuild.Binaries.cs @@ -30,7 +30,7 @@ sealed partial class NukeBuild { Log.Information( "Publishing {Runtime} build to {PublishDir}", Platform, publishDir ); Log.Debug( "Supported runtimes are {SupportedRuntimes}", string.Join( ", ", SupportedRuntimes ) ); DotNetPublish( s => s - .SetProject( Solution.Cli ) + .SetProject( Solution.Cli.Cli ) .SetConfiguration( Configuration ) .SetOutput( publishDir ) .SetSelfContained( true ) diff --git a/build/NukeBuild.Container.cs b/build/NukeBuild.Container.cs index e442ba33..c90d5411 100644 --- a/build/NukeBuild.Container.cs +++ b/build/NukeBuild.Container.cs @@ -45,7 +45,7 @@ partial class NukeBuild { var version = await Versioning.Value.GetVersionAsync(); - _driftImageRef = LocalDriftImage.Qualify( new Tag( $"staging.{Guid.NewGuid().ToString( "N" )}" ) ); + _driftImageRef = LocalDriftImage.Qualify( new Tag( $"staging.{Guid.NewGuid():N}" ) ); Log.Information( "Building container image..." ); // var created = DateTime.UtcNow.ToString( "o", CultureInfo.InvariantCulture ); // o = round-trip format / ISO 8601 diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index 13e15344..d04dbee1 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -22,7 +22,7 @@ sealed partial class NukeBuild { throw new PlatformNotSupportedException(); Target Test => _ => _ - .DependsOn( TestSelf, TestUnit, TestE2E, TestE2E_Clab ); + .DependsOn( TestSelf, TestUnit, TestE2E ); Target TestSelf => _ => _ .Before( BuildInfo ) @@ -70,7 +70,7 @@ sealed partial class NukeBuild { ); Target TestE2E => _ => _ - .DependsOn( TestE2E_General, TestE2E_Binary, TestE2E_Container ); + .DependsOn( TestE2E_General, TestE2E_Binary, TestE2E_Container, TestE2E_Clab ); Target TestE2E_General => _ => _ .DependsOn( Build ) @@ -81,7 +81,7 @@ sealed partial class NukeBuild { Log.Information( "Running general E2E tests" ); DotNetTest( settings => settings - .SetProjectFile( Solution.Cli_E2ETests_General ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_General ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) @@ -105,7 +105,7 @@ sealed partial class NukeBuild { var envVars = new Dictionary { { "DRIFT_BINARY_PATH", driftBinary }, }; DotNetTest( settings => settings - .SetProjectFile( Solution.Cli_E2ETests_Binary ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_Binary ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) @@ -118,7 +118,7 @@ sealed partial class NukeBuild { ); Target TestE2E_Container => _ => _ - .DependsOn( PublishBinaries, BuildContainerImage ) + .DependsOn( BuildContainerImage ) .After( TestUnit ) .OnlyWhenDynamic( () => Platform != DotNetRuntimeIdentifier.win_x64 ) .Executes( async () => { @@ -145,7 +145,7 @@ sealed partial class NukeBuild { } return settings - .SetProjectFile( Solution.Cli_E2ETests_Container ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_Container ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs index c790de52..206e8df7 100644 --- a/build/NukeBuild.TestContainerlab.cs +++ b/build/NukeBuild.TestContainerlab.cs @@ -83,10 +83,7 @@ sealed partial class NukeBuild { } if ( !await IsContainerlabAvailableAsync() ) { - throw new Exception( - "Containerlab does not appear to be installed or in PATH. " + - "See https://containerlab.dev/install/ for installation instructions." - ); + throw new Exception( "Containerlab does not appear to be installed or in PATH." ); } var casesToRun = SelectTestCases(); @@ -103,15 +100,17 @@ sealed partial class NukeBuild { foreach ( var testCase in casesToRun ) { var run = passed + failed + 1; - Log.Information( "━━━ Test case: {Name} ({Run}/{Total}) ━━━", testCase.Name, run, total ); + Log.Information( "---------------------------------------------" ); + Log.Information( "{Name} ({Run}/{Total})", testCase.Name, run, total ); + Log.Information( "---------------------------------------------" ); if ( await RunTestCaseAsync( testCase ) ) { passed++; - Log.Information( "PASS: {Name}", testCase.Name ); + Log.Information( "🟢 PASS: {Name}", testCase.Name ); } else { failed++; - Log.Error( "FAIL: {Name}", testCase.Name ); + Log.Error( "🔴 FAIL: {Name}", testCase.Name ); } } @@ -128,6 +127,8 @@ private ContainerlabTestCase[] SelectTestCases() { return TestCases; } + Log.Warning( "Only selecting test case(s) matching topology '{Topology}'", ClabTopology ); + var selected = TestCases.Where( tc => tc.Name == ClabTopology ).ToArray(); if ( !selected.Any() ) { throw new Exception( @@ -201,8 +202,9 @@ private static async Task DeployTopologyAsync( string topologyFile ) { timeout: TimeSpan.FromMinutes( 5 ) ).AssertZeroExitCode(); - Log.Information( "Waiting for containers to be ready..." ); - await Task.Delay( TimeSpan.FromSeconds( 10 ) ); + // TODO try to disable fixed waiting + // Log.Information( "Waiting for containers to be ready..." ); + // await Task.Delay( TimeSpan.FromSeconds( 10 ) ); } private static void DestroyTopologyIfExists( string topologyFile ) { @@ -267,7 +269,8 @@ private static async Task RunScanAndAssertAsync( AbsolutePath specFile, Containe 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 ) ); + // TODO try without fixed delay + // 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(); @@ -310,17 +313,14 @@ private static void AssertScanOutput( ContainerlabTestCase testCase, IEnumerable 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 ) => + private static IProcess Clab( + string args, + AbsolutePath workDir = null, + TimeSpan? timeout = null, + bool logOutput = true + ) => ProcessTasks.StartProcess( "containerlab", args, workingDirectory: workDir, @@ -329,7 +329,11 @@ private static IProcess Clab( string args, AbsolutePath workDir = null, TimeSpan logger: logOutput ? ClabLogger : null ); - private static IProcess Docker( string args, AbsolutePath workDir = null, TimeSpan? timeout = null ) => + private static IProcess Docker( + string args, + AbsolutePath workDir = null, + TimeSpan? timeout = null + ) => ProcessTasks.StartProcess( "docker", args, workingDirectory: workDir, @@ -337,7 +341,14 @@ private static IProcess Docker( string args, AbsolutePath workDir = null, TimeSp ); } -/// A Containerlab integration test case. +/// +/// A Containerlab-based E2E test case. +/// +/// Test case name +/// A Containerlab topology file +/// A Drift spec file +/// Name of the container hosting the Drift CLI +/// A collection of assertions to be run against the scan output sealed record ContainerlabTestCase( string Name, string TopologyFile, diff --git a/containerlab/README.md b/containerlab/README.md index 6450a9b1..df33876b 100644 --- a/containerlab/README.md +++ b/containerlab/README.md @@ -1,84 +1,30 @@ # Containerlab Integration Testing -This directory contains containerlab topologies for testing Drift's distributed network scanning capabilities. +This directory contains [Containerlab](https://containerlab.dev/) 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) +- Docker, or Podman with Docker CLI shim - Drift Docker image: `localhost:5000/drift:dev` -## Quick Start - -The easiest way to run containerlab integration tests is via Nuke: +## Quick start ```bash -# Run all tests including containerlab integration +# Run ALL tests including Containerlab integration tests dotnet nuke Test # Run only containerlab tests -dotnet nuke TestContainerlab --skip test +dotnet nuke Test_E2EClab # Run a single topology for debugging -dotnet nuke TestContainerlab --skip test --clab-topology simple-test +dotnet nuke Test_E2EClab --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. - +dotnet nuke Test_E2EClab --keep-clab-running ``` -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 +## Agent identity Agents in these topologies use the `--id` flag to set a fixed, predictable agent ID: @@ -93,7 +39,7 @@ The `--id` flag is hidden from the help output and logs a warning when used — In production, agents generate and persist their own ID at `/root/.config/drift/agent/agent-identity.json`. -## Nuke Target Parameters +## NUKE target parameters | Parameter | Description | |---|---| @@ -103,7 +49,7 @@ In production, agents generate and persist their own ID at `/root/.config/drift/ ## 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: +**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 @@ -116,7 +62,7 @@ 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: +**Cannot reach agents** — Verify containers are on same network: ```bash docker network inspect clab ``` diff --git a/containerlab/cooperation-test.clab.yaml b/containerlab/cooperation-test.clab.yaml index e7859935..092c860d 100644 --- a/containerlab/cooperation-test.clab.yaml +++ b/containerlab/cooperation-test.clab.yaml @@ -2,7 +2,7 @@ name: drift-cooperation-test # Multi-agent cooperation topology # -# All nodes share a single flat management network (172.20.20.0/24). +# All nodes share a single flat 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 @@ -13,32 +13,27 @@ name: drift-cooperation-test 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 diff --git a/containerlab/simple-test.clab.yaml b/containerlab/simple-test.clab.yaml index 6c8bb507..e57eee20 100644 --- a/containerlab/simple-test.clab.yaml +++ b/containerlab/simple-test.clab.yaml @@ -1,16 +1,13 @@ 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 @@ -18,7 +15,6 @@ topology: ports: - "5001:5000" - # Single target device target1: kind: linux image: alpine:latest diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs index f93ae929..5b71a364 100644 --- a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -14,6 +14,7 @@ internal static class Options { DefaultValueFactory = _ => Ports.AgentDefault, Description = "Set the port used for communication" }; + // TODO remove after hub-and-spoke transition? internal static readonly Option Id = new("--id") { Description = "Set a specific agent ID (for testing purposes)", Hidden = true }; From 6613599b2e87de6b4374285c56f659a47c251a94 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:38:08 +0200 Subject: [PATCH 15/43] f --- containerlab/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containerlab/README.md b/containerlab/README.md index df33876b..6ebfaafb 100644 --- a/containerlab/README.md +++ b/containerlab/README.md @@ -1,4 +1,4 @@ -# Containerlab Integration Testing +# Containerlab integration testing This directory contains [Containerlab](https://containerlab.dev/) topologies for testing Drift's distributed network scanning capabilities. From cfddb642a5107f23ec75a2856b1a0eeb3133e7de Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:40:35 +0200 Subject: [PATCH 16/43] f --- Drift.sln | 14 ++++++ src/Agent.Host.Tests/Agent.Host.Tests.csproj | 1 - src/Agent.Host.Tests/MessageHandlerTests.cs | 40 +-------------- src/Agent.Host/AgentHostAssemblyMarker.cs | 6 --- src/Agent.Host/ServiceCollectionExtensions.cs | 2 +- src/Coordinator.Host.Tests/AssemblyInfo.cs | 1 + .../Coordinator.Host.Tests.csproj | 19 +++++++ .../MessageHandlerTests.cs | 26 ++++++++++ src/Coordinator.Host/AgentHost.cs | 12 +++++ src/Coordinator.Host/Coordinator.Host.csproj | 4 ++ src/Messaging.Tests/MessageHandlerTests.cs | 50 +++++++++++++++++++ src/Messaging.Tests/Messaging.Tests.csproj | 19 +++++++ src/Networking.Core.Abstractions/Empty.cs | 1 + 13 files changed, 149 insertions(+), 46 deletions(-) delete mode 100644 src/Agent.Host/AgentHostAssemblyMarker.cs create mode 100644 src/Coordinator.Host.Tests/AssemblyInfo.cs create mode 100644 src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj create mode 100644 src/Coordinator.Host.Tests/MessageHandlerTests.cs create mode 100644 src/Coordinator.Host/AgentHost.cs create mode 100644 src/Messaging.Tests/MessageHandlerTests.cs create mode 100644 src/Messaging.Tests/Messaging.Tests.csproj diff --git a/Drift.sln b/Drift.sln index deeebebc..86f9d9ce 100644 --- a/Drift.sln +++ b/Drift.sln @@ -113,6 +113,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Coordinator", "Coordinator" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Cli", "Cli", "{DD31E4C8-7058-4407-AEC4-43EDA0AB06D1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host.Tests", "src\Coordinator.Host.Tests\Coordinator.Host.Tests.csproj", "{CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Tests", "src\Messaging.Tests\Messaging.Tests.csproj", "{87C0FA35-BC55-4B86-AAAD-C425C9A0E050}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -269,6 +273,14 @@ Global {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.Build.0 = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.Build.0 = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} @@ -296,5 +308,7 @@ Global {B75C5C0C-A5A2-419E-B0DD-342A44B1B48D} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} {130363FC-478E-472A-978B-731D39E3E264} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} {76093473-3375-4519-9524-68448A19DE4A} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} EndGlobalSection EndGlobal diff --git a/src/Agent.Host.Tests/Agent.Host.Tests.csproj b/src/Agent.Host.Tests/Agent.Host.Tests.csproj index faa044fe..435d7306 100644 --- a/src/Agent.Host.Tests/Agent.Host.Tests.csproj +++ b/src/Agent.Host.Tests/Agent.Host.Tests.csproj @@ -6,7 +6,6 @@ - diff --git a/src/Agent.Host.Tests/MessageHandlerTests.cs b/src/Agent.Host.Tests/MessageHandlerTests.cs index 82ff605e..e7afe190 100644 --- a/src/Agent.Host.Tests/MessageHandlerTests.cs +++ b/src/Agent.Host.Tests/MessageHandlerTests.cs @@ -1,56 +1,20 @@ using System.Reflection; -using Drift.Messaging.Protocol; using Drift.Networking.Core.Abstractions; namespace Drift.Agent.Host.Tests; +// TODO almost duplicate of MessageHandlerTests in Coordinator.Host.Tests internal sealed class MessageHandlerTests { - private static readonly Assembly MessagesAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly; - private static readonly Assembly HandlersAssembly = typeof(AgentHostAssemblyMarker).Assembly; - private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IRequest<>) ); - private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IResponse) ); + private static readonly Assembly HandlersAssembly = typeof(AgentHost).Assembly; private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); [Test] public void FindMessagesAndHandlers() { 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(IMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! - .GetValue( null ) as string; - - Assert.That( messageTypeValue, Is.Not.Null.And.Not.Empty ); - - var jsonInfoValue = type - .GetProperty( nameof(IMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! - .GetValue( null ); - - Assert.That( jsonInfoValue, Is.Not.Null ); - } - - private static List GetAllConcreteMessageTypes( Type baseType ) { - return MessagesAssembly - .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 HandlersAssembly .GetTypes() diff --git a/src/Agent.Host/AgentHostAssemblyMarker.cs b/src/Agent.Host/AgentHostAssemblyMarker.cs deleted file mode 100644 index e38447cb..00000000 --- a/src/Agent.Host/AgentHostAssemblyMarker.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Drift.Agent.Host; - -// Justification: marker class -#pragma warning disable S2094 -public sealed class AgentHostAssemblyMarker; -#pragma warning restore S2094 \ No newline at end of file diff --git a/src/Agent.Host/ServiceCollectionExtensions.cs b/src/Agent.Host/ServiceCollectionExtensions.cs index f43d65e8..d628abef 100644 --- a/src/Agent.Host/ServiceCollectionExtensions.cs +++ b/src/Agent.Host/ServiceCollectionExtensions.cs @@ -12,4 +12,4 @@ public void AddAgentHandlers() { services.AddScoped(); } } -} +} \ No newline at end of file diff --git a/src/Coordinator.Host.Tests/AssemblyInfo.cs b/src/Coordinator.Host.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..5493e66a --- /dev/null +++ b/src/Coordinator.Host.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Category( "Unit" )] \ No newline at end of file diff --git a/src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj b/src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj new file mode 100644 index 00000000..031d7da3 --- /dev/null +++ b/src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Coordinator.Host.Tests/MessageHandlerTests.cs b/src/Coordinator.Host.Tests/MessageHandlerTests.cs new file mode 100644 index 00000000..c5015716 --- /dev/null +++ b/src/Coordinator.Host.Tests/MessageHandlerTests.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Coordinator.Host.Tests; + +// TODO almost duplicate of MessageHandlerTests in Agent.Host.Tests +internal sealed class MessageHandlerTests { + private static readonly Assembly HandlersAssembly = typeof(CoordinatorHost).Assembly; + private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); + + [Explicit( "Disable until implemented" )] + [Test] + public void FindMessagesAndHandlers() { + using ( Assert.EnterMultipleScope() ) { + Assert.That( HandlerTypes.ToList(), Has.Count.GreaterThan( 1 ), "No handlers found via reflection" ); + } + } + + private static List GetAllConcreteHandlerTypes() { + return HandlersAssembly + .GetTypes() + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => typeof(IMessageHandler).IsAssignableFrom( t ) ) + .ToList(); + } +} \ No newline at end of file diff --git a/src/Coordinator.Host/AgentHost.cs b/src/Coordinator.Host/AgentHost.cs new file mode 100644 index 00000000..065df926 --- /dev/null +++ b/src/Coordinator.Host/AgentHost.cs @@ -0,0 +1,12 @@ +using Drift.Messaging.Protocol; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Drift.Coordinator.Host; + +public static class CoordinatorHost { + // TODO implement +} \ No newline at end of file diff --git a/src/Coordinator.Host/Coordinator.Host.csproj b/src/Coordinator.Host/Coordinator.Host.csproj index 228a23b5..e32b5757 100644 --- a/src/Coordinator.Host/Coordinator.Host.csproj +++ b/src/Coordinator.Host/Coordinator.Host.csproj @@ -1,5 +1,9 @@ + + + + diff --git a/src/Messaging.Tests/MessageHandlerTests.cs b/src/Messaging.Tests/MessageHandlerTests.cs new file mode 100644 index 00000000..71bdd324 --- /dev/null +++ b/src/Messaging.Tests/MessageHandlerTests.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using Drift.Messaging.Protocol; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Messaging.Tests; + +internal sealed class MessageHandlerTests { + private static readonly Assembly MessagesAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly; + private static readonly IEnumerable RequestTypes = GetAllConcreteMessageTypes( typeof(IRequest<>) ); + private static readonly IEnumerable ResponseTypes = GetAllConcreteMessageTypes( typeof(IResponse) ); + + [Test] + public void FindMessagesAndHandlers() { + 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" ); + } + } + + [TestCaseSource( nameof(RequestTypes) )] + [TestCaseSource( nameof(ResponseTypes) )] + public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { + // Arrange / Act + var messageTypeValue = type + .GetProperty( nameof(IMessage.MessageType), BindingFlags.Public | BindingFlags.Static )! + .GetValue( null ) as string; + + var jsonInfoValue = type + .GetProperty( nameof(IMessage.JsonInfo), BindingFlags.Public | BindingFlags.Static )! + .GetValue( null ); + + // Assert + Assert.That( messageTypeValue, Is.Not.Null.And.Not.Empty ); + Assert.That( jsonInfoValue, Is.Not.Null ); + } + + private static List GetAllConcreteMessageTypes( Type baseType ) { + return MessagesAssembly + .GetTypes() + //.Concat( [typeof(Empty)] ) // TODO what about 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(); + } +} \ No newline at end of file diff --git a/src/Messaging.Tests/Messaging.Tests.csproj b/src/Messaging.Tests/Messaging.Tests.csproj new file mode 100644 index 00000000..af3d7890 --- /dev/null +++ b/src/Messaging.Tests/Messaging.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Networking.Core.Abstractions/Empty.cs b/src/Networking.Core.Abstractions/Empty.cs index 0f7ffe3c..995f14e6 100644 --- a/src/Networking.Core.Abstractions/Empty.cs +++ b/src/Networking.Core.Abstractions/Empty.cs @@ -2,6 +2,7 @@ namespace Drift.Networking.Core.Abstractions; +// TODO necessary? public class Empty : IResponse { private Empty() { } From bee0ff73ded875a2e9e88d55afe61b07b583ba15 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:42:03 +0200 Subject: [PATCH 17/43] f --- src/Agent.Host.Tests/MessageHandlerTests.cs | 1 + src/Coordinator.Host.Tests/MessageHandlerTests.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Agent.Host.Tests/MessageHandlerTests.cs b/src/Agent.Host.Tests/MessageHandlerTests.cs index e7afe190..938f569f 100644 --- a/src/Agent.Host.Tests/MessageHandlerTests.cs +++ b/src/Agent.Host.Tests/MessageHandlerTests.cs @@ -3,6 +3,7 @@ namespace Drift.Agent.Host.Tests; +// TODO currently not doing anything useful // TODO almost duplicate of MessageHandlerTests in Coordinator.Host.Tests internal sealed class MessageHandlerTests { private static readonly Assembly HandlersAssembly = typeof(AgentHost).Assembly; diff --git a/src/Coordinator.Host.Tests/MessageHandlerTests.cs b/src/Coordinator.Host.Tests/MessageHandlerTests.cs index c5015716..f5d344da 100644 --- a/src/Coordinator.Host.Tests/MessageHandlerTests.cs +++ b/src/Coordinator.Host.Tests/MessageHandlerTests.cs @@ -3,6 +3,7 @@ namespace Drift.Coordinator.Host.Tests; +// TODO currently not doing anything useful // TODO almost duplicate of MessageHandlerTests in Agent.Host.Tests internal sealed class MessageHandlerTests { private static readonly Assembly HandlersAssembly = typeof(CoordinatorHost).Assembly; From feecd0a734853deefeaa413661f13bdad88e0087 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:53:17 +0200 Subject: [PATCH 18/43] f --- src/Agent.Host/Scan/ScanSubnetRequestHandler.cs | 4 ++-- src/Agent.Host/Subnets/SubnetsRequestHandler.cs | 2 +- src/Networking.Core.Abstractions/MessageHandlerExtensions.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs index 15a2b96e..b699140e 100644 --- a/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs +++ b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs @@ -42,7 +42,7 @@ CancellationToken cancellationToken ); var completeResponse = new ScanSubnetCompleteResponse { Result = result }; - await stream.SendResponseAsync( converter, completeResponse, envelope.CorrelationId ); + await stream.SendAsync( converter, completeResponse, envelope.CorrelationId ); } finally { scanner.ResultUpdated -= policy.Handle; @@ -101,7 +101,7 @@ private void OnResultUpdated( object? sender, SubnetScanResult result ) { ProgressPercentage = progressPercentage, DevicesFound = deviceCount, Status = result.Status.ToString() }; - _stream.SendResponseFireAndForget( _converter, progressUpdate, _envelope.CorrelationId ); + _stream.SendFireAndForget( _converter, progressUpdate, _envelope.CorrelationId ); _logger.LogDebug( "Sent progress update: {Progress}% for {Cidr}", diff --git a/src/Agent.Host/Subnets/SubnetsRequestHandler.cs b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs index f5721f9d..94d7e01d 100644 --- a/src/Agent.Host/Subnets/SubnetsRequestHandler.cs +++ b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs @@ -25,6 +25,6 @@ CancellationToken cancellationToken logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) ); var response = new SubnetsResponse { Subnets = subnets }; - await stream.SendResponseAsync( converter, response, envelope.CorrelationId ); + await stream.SendAsync( converter, response, envelope.CorrelationId ); } } \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs index 6d8b4b16..a543bee3 100644 --- a/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs +++ b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs @@ -5,7 +5,7 @@ public static class MessageHandlerExtensions { /// Helper to send a response with the correct correlation ID set. /// /// The type of the response message to send. - public static async Task SendResponseAsync( + public static async Task SendAsync( this IMessageStream stream, IMessageEnvelopeConverter converter, TResponse response, @@ -21,7 +21,7 @@ string correlationId /// Useful for progress updates that shouldn't block processing. /// /// The type of the response message to send. - public static void SendResponseFireAndForget( + public static void SendFireAndForget( this IMessageStream stream, IMessageEnvelopeConverter converter, TResponse response, From 13318b4b681c7bc892e61df74dfaa739c734985a Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:05:40 +0200 Subject: [PATCH 19/43] f --- src/Agent.Host/Agent.Host.csproj | 1 - src/Coordinator.Host/AgentHost.cs | 2 + .../ServiceCollectionExtensions.cs | 4 +- src/Messaging.Client/AgentClient.cs | 2 - src/Messaging.Tests/MessageHandlerTests.cs | 2 +- .../MessageHandlerExtensions.cs | 42 ------------------- .../MessageStreamExtensions.cs | 42 +++++++++++++++++++ 7 files changed, 48 insertions(+), 47 deletions(-) delete mode 100644 src/Networking.Core.Abstractions/MessageHandlerExtensions.cs create mode 100644 src/Networking.Core.Abstractions/MessageStreamExtensions.cs diff --git a/src/Agent.Host/Agent.Host.csproj b/src/Agent.Host/Agent.Host.csproj index 4f67bdb9..ae98a3fc 100644 --- a/src/Agent.Host/Agent.Host.csproj +++ b/src/Agent.Host/Agent.Host.csproj @@ -10,7 +10,6 @@ - diff --git a/src/Coordinator.Host/AgentHost.cs b/src/Coordinator.Host/AgentHost.cs index 065df926..0ce642fb 100644 --- a/src/Coordinator.Host/AgentHost.cs +++ b/src/Coordinator.Host/AgentHost.cs @@ -7,6 +7,8 @@ namespace Drift.Coordinator.Host; +#pragma warning disable S2094 public static class CoordinatorHost { +#pragma warning restore S2094 // TODO implement } \ No newline at end of file diff --git a/src/Coordinator.Host/ServiceCollectionExtensions.cs b/src/Coordinator.Host/ServiceCollectionExtensions.cs index 08db94f0..fb3fe5f0 100644 --- a/src/Coordinator.Host/ServiceCollectionExtensions.cs +++ b/src/Coordinator.Host/ServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ namespace Drift.Coordinator.Host; public static class ServiceCollectionExtensions { +#pragma warning disable S1186 public static void AddCoordinatorHandlers( this IServiceCollection services ) { +#pragma warning restore S1186 } -} +} \ No newline at end of file diff --git a/src/Messaging.Client/AgentClient.cs b/src/Messaging.Client/AgentClient.cs index 302eb62c..35513ef8 100644 --- a/src/Messaging.Client/AgentClient.cs +++ b/src/Messaging.Client/AgentClient.cs @@ -157,7 +157,6 @@ CancellationToken cancellationToken } } - // All retries exhausted throw new AggregateException( $"Operation failed for agent {agent.Id} after {attempt} attempts", lastException! @@ -165,7 +164,6 @@ CancellationToken cancellationToken } 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 ); } diff --git a/src/Messaging.Tests/MessageHandlerTests.cs b/src/Messaging.Tests/MessageHandlerTests.cs index 71bdd324..a3d7fe1f 100644 --- a/src/Messaging.Tests/MessageHandlerTests.cs +++ b/src/Messaging.Tests/MessageHandlerTests.cs @@ -37,7 +37,7 @@ public void Messages_HaveValidMessageTypeAndJsonInfo( Type type ) { private static List GetAllConcreteMessageTypes( Type baseType ) { return MessagesAssembly .GetTypes() - //.Concat( [typeof(Empty)] ) // TODO what about Empty? + // .Concat( [typeof(Empty)] ) // TODO what about Empty? .Where( t => t is { IsAbstract: false, IsInterface: false } ) .Where( t => // Generic base type diff --git a/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs b/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs deleted file mode 100644 index a543bee3..00000000 --- a/src/Networking.Core.Abstractions/MessageHandlerExtensions.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Drift.Networking.Core.Abstractions; - -public static class MessageHandlerExtensions { - /// - /// Helper to send a response with the correct correlation ID set. - /// - /// The type of the response message to send. - public static async Task SendAsync( - this IMessageStream stream, - IMessageEnvelopeConverter converter, - TResponse response, - string correlationId - ) where TResponse : IMessage { - 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 SendFireAndForget( - this IMessageStream stream, - IMessageEnvelopeConverter converter, - TResponse response, - string correlationId - ) where TResponse : IMessage { - var envelope = converter.ToEnvelope( response ); - envelope.ReplyTo = correlationId; - _ = stream.SendAsync( envelope ).ContinueWith( - t => { - t.Exception?.Handle( ex => { - // TODO: Handle exception - return true; - } ); - }, - TaskContinuationOptions.OnlyOnFaulted - ); - } -} \ No newline at end of file diff --git a/src/Networking.Core.Abstractions/MessageStreamExtensions.cs b/src/Networking.Core.Abstractions/MessageStreamExtensions.cs new file mode 100644 index 00000000..7af7a746 --- /dev/null +++ b/src/Networking.Core.Abstractions/MessageStreamExtensions.cs @@ -0,0 +1,42 @@ +namespace Drift.Networking.Core.Abstractions; + +public static class MessageStreamExtensions { + extension( IMessageStream stream ) { + /// + /// Helper to send a response with the correct correlation ID set. + /// + /// The type of the response message to send. + public async Task SendAsync( + IMessageEnvelopeConverter converter, + TResponse response, + string correlationId + ) where TResponse : IMessage { + 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 void SendFireAndForget( + IMessageEnvelopeConverter converter, + TResponse response, + string correlationId + ) where TResponse : IMessage { + var envelope = converter.ToEnvelope( response ); + envelope.ReplyTo = correlationId; + _ = stream.SendAsync( envelope ).ContinueWith( + t => { + t.Exception?.Handle( ex => { + // TODO: Handle exception + return true; + } ); + }, + TaskContinuationOptions.OnlyOnFaulted + ); + } + } +} \ No newline at end of file From c126c728721c7b66c96af0461a454c4c660a8326 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:15:05 +0200 Subject: [PATCH 20/43] f --- src/ArchTests/ArchTests.csproj | 1 - ...d.txt => AgentCommandTests.SuccessfulLifecycle.verified.txt} | 0 src/Cli.Tests/Commands/AgentCommandTests.cs | 2 +- 3 files changed, 1 insertion(+), 2 deletions(-) rename src/Cli.Tests/Commands/{AgentCommandTests.SuccessfulStartup.verified.txt => AgentCommandTests.SuccessfulLifecycle.verified.txt} (100%) diff --git a/src/ArchTests/ArchTests.csproj b/src/ArchTests/ArchTests.csproj index 73115aac..7d34c1ed 100644 --- a/src/ArchTests/ArchTests.csproj +++ b/src/ArchTests/ArchTests.csproj @@ -2,7 +2,6 @@ - false none diff --git a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt similarity index 100% rename from src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt rename to src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs index 7ebc9c3f..11f913cf 100644 --- a/src/Cli.Tests/Commands/AgentCommandTests.cs +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -20,7 +20,7 @@ public async Task RespectsCancellationToken() { } [Test] - public async Task SuccessfulStartup() { + public async Task SuccessfulLifecycle() { using var tcs = new CancellationTokenSource(); var runningCommand = await DriftTestCli.StartAgentAsync( From 43904e14f0bb20691a91e3631d05c979b7c1150c Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:10:12 +0200 Subject: [PATCH 21/43] f --- .nuke/build.schema.json | 1 + build/NukeBuild.Test.cs | 16 ++++++++++++++- .../ScanCommandTests.Orchestration.cs | 1 - .../Network/Firewall/FirewallEvaluator.cs | 20 ++----------------- .../Utils/Network/Firewall/FirewallRules.cs | 10 ++++++---- src/Common/Network/IpNetworkUtils.cs | 2 +- .../MessageHandlerTests.cs | 2 +- src/Networking.Core/MessageStream.cs | 2 +- 8 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index af8e28ea..33bb7c6a 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -64,6 +64,7 @@ "TestLocal", "TestSelf", "TestUnit", + "TestUnitLocal", "UpdateOui" ] }, diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index d04dbee1..e16f7b3e 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -41,10 +41,24 @@ sealed partial class NukeBuild { Target TestLocal => _ => _ .DependsOn( Test ) + .AssuredAfterFailure() .Executes( () => { var result = ProcessTasks.StartProcess( "dotnet", - "trx --verbosity verbose", + "trx", //"trx --verbosity verbose", + workingDirectory: RootDirectory + ); + result.AssertZeroExitCode(); + } + ); + + Target TestUnitLocal => _ => _ + .DependsOn( TestUnit ) + .AssuredAfterFailure() + .Executes( () => { + var result = ProcessTasks.StartProcess( + "dotnet", + "trx", //"trx --verbosity verbose", workingDirectory: RootDirectory ); result.AssertZeroExitCode(); diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs index 5c83c405..38a2475d 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs @@ -12,7 +12,6 @@ public async Task WithAgents_TwoDisjointSubnets_CombinesResults() { // Arrange var builder = Topologies.TwoAgentsDisjointSubnetsWithCli() .WithFirewall( fw => { - fw.DefaultPolicy = FirewallAction.Deny; // CLI must be able to reach agents to collect their results (control plane = same firewall). // Allow CLI→agent routing; deny-default blocks all other cross-subnet traffic (agent↔agent). fw.Allow( FirewallTarget.Subnet( "cli-subnet" ), FirewallTarget.Subnet( "agent1-subnet" ) ); diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs index 16e46561..c98c5193 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs @@ -19,7 +19,6 @@ public FirewallEvaluator( FirewallRules rules, IReadOnlyList<(string Name, CidrB /// /// Evaluate if traffic is allowed from source to destination. - /// Supports subnet names, IP addresses, CIDR blocks, and wildcards. /// Rules are evaluated in order - first match wins. /// If no rule matches, the default policy from is applied. /// @@ -74,23 +73,8 @@ public bool IsAllowed( FirewallTarget source, FirewallTarget dest ) { /// private static bool IpInCidr( IpV4Address ip, CidrBlock cidr ) { try { - var ipBytes = IPAddress.Parse( ip.Value ).GetAddressBytes(); - var cidrParts = cidr.ToString().Split( '/' ); - if ( cidrParts.Length != 2 ) { - return false; - } - - var networkBytes = System.Net.IPAddress.Parse( cidrParts[0] ).GetAddressBytes(); - if ( !int.TryParse( cidrParts[1], out var prefixLength ) || prefixLength < 0 || prefixLength > 32 ) { - return false; - } - - var mask = ~0u << ( 32 - prefixLength ); - var ipInt = ( (uint) ipBytes[0] << 24 ) | ( (uint) ipBytes[1] << 16 ) | ( (uint) ipBytes[2] << 8 ) | ipBytes[3]; - var networkInt = ( (uint) networkBytes[0] << 24 ) | ( (uint) networkBytes[1] << 16 ) | - ( (uint) networkBytes[2] << 8 ) | networkBytes[3]; - - return ( ipInt & mask ) == ( networkInt & mask ); + var ipAddress = IPAddress.Parse( ip.Value ); + return IPNetwork2.Parse( cidr.ToString() ).Contains( ipAddress ); } catch { return false; diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs index f21b3a78..d2b9b9c6 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs @@ -2,9 +2,8 @@ namespace Drift.Cli.Tests.Utils.Network.Firewall; /// /// Represents firewall rules for controlling network visibility in test topologies. -/// Rules are evaluated in order (first match wins), with a default ALLOW policy. -/// Inspired by OPNsense firewall behavior. -/// Use FirewallEvaluator to evaluate rules with subnet awareness. +/// Rules are evaluated in order (first match wins), with a default DENY policy. +/// Use to evaluate rules with subnet awareness. /// public sealed class FirewallRules { private readonly List _rules = new(); @@ -30,7 +29,10 @@ public void Deny( FirewallTarget source, FirewallTarget destination ) { /// /// Gets or sets the policy applied when no rule matches. Defaults to . /// - public FirewallAction DefaultPolicy { get; set; } = FirewallAction.Allow; + public FirewallAction DefaultPolicy { + get; + set; + } = FirewallAction.Allow; public IReadOnlyList Rules => _rules.AsReadOnly(); } diff --git a/src/Common/Network/IpNetworkUtils.cs b/src/Common/Network/IpNetworkUtils.cs index da06221d..3796a756 100644 --- a/src/Common/Network/IpNetworkUtils.cs +++ b/src/Common/Network/IpNetworkUtils.cs @@ -60,7 +60,7 @@ public static IPAddress GetNetmask( int prefixLength ) { /// 172.16.0.0 to 172.31.255.255 (172.16.0.0/12) /// 192.168.0.0 to 192.168.255.255 (192.168.0.0/16) /// - /// See alsohttps://www.rfc-editor.org/rfc/rfc1918.html#section-3. + /// See also https://www.rfc-editor.org/rfc/rfc1918.html#section-3. /// public static bool IsPrivateIpV4( IPAddress ip ) { if ( ip.AddressFamily != AddressFamily.InterNetwork ) { diff --git a/src/Coordinator.Host.Tests/MessageHandlerTests.cs b/src/Coordinator.Host.Tests/MessageHandlerTests.cs index f5d344da..2812974d 100644 --- a/src/Coordinator.Host.Tests/MessageHandlerTests.cs +++ b/src/Coordinator.Host.Tests/MessageHandlerTests.cs @@ -9,7 +9,7 @@ internal sealed class MessageHandlerTests { private static readonly Assembly HandlersAssembly = typeof(CoordinatorHost).Assembly; private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); - [Explicit( "Disable until implemented" )] + [Ignore( "Disable until implemented" )] [Test] public void FindMessagesAndHandlers() { using ( Assert.EnterMultipleScope() ) { diff --git a/src/Networking.Core/MessageStream.cs b/src/Networking.Core/MessageStream.cs index 09d5d33e..6dba6089 100644 --- a/src/Networking.Core/MessageStream.cs +++ b/src/Networking.Core/MessageStream.cs @@ -8,7 +8,7 @@ namespace Drift.Networking.Core; public sealed class MessageStream : IMessageStream { - private static int _instanceCounter; // Being static is not ideal for testing with multiple instances + private static int _instanceCounter; // Being static is not ideal for testing with multiple Drift instances in the same process private readonly IAsyncStreamReader _reader; private readonly IAsyncStreamWriter _writer; private readonly MessageDispatcher _dispatcher; From 5c925cf8a872821dfbda96c60cc478c4129ef1f3 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:02:26 +0200 Subject: [PATCH 22/43] f --- src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs | 1 + src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs index 38a2475d..5c83c405 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs @@ -12,6 +12,7 @@ public async Task WithAgents_TwoDisjointSubnets_CombinesResults() { // Arrange var builder = Topologies.TwoAgentsDisjointSubnetsWithCli() .WithFirewall( fw => { + fw.DefaultPolicy = FirewallAction.Deny; // CLI must be able to reach agents to collect their results (control plane = same firewall). // Allow CLI→agent routing; deny-default blocks all other cross-subnet traffic (agent↔agent). fw.Allow( FirewallTarget.Subnet( "cli-subnet" ), FirewallTarget.Subnet( "agent1-subnet" ) ); diff --git a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs index d2b9b9c6..1b33b9b5 100644 --- a/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs +++ b/src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs @@ -32,7 +32,7 @@ public void Deny( FirewallTarget source, FirewallTarget destination ) { public FirewallAction DefaultPolicy { get; set; - } = FirewallAction.Allow; + } = FirewallAction.Allow; // TODO change to Deny public IReadOnlyList Rules => _rules.AsReadOnly(); } From e0e2843e6113fb7e3e3ef276eca53d4128037f67 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:06:09 +0200 Subject: [PATCH 23/43] f --- src/Spec/Validation/SpecValidator.cs | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/Spec/Validation/SpecValidator.cs b/src/Spec/Validation/SpecValidator.cs index b0600edd..6f8aef37 100644 --- a/src/Spec/Validation/SpecValidator.cs +++ b/src/Spec/Validation/SpecValidator.cs @@ -21,6 +21,8 @@ public static ValidationResult Validate( string yaml, SpecVersion version ) { var schema = SpecSchemaProvider.Get( version ); return Validate( yaml, schema ); } + // TODO add failed parsing tests + // An (unexpected) parsing error catch ( YamlException ex ) { var errors = new List(); @@ -35,7 +37,6 @@ public static ValidationResult Validate( string yaml, SpecVersion version ) { } private static ValidationResult Validate( string yaml, JsonSchema schema ) { - // try { // Read YAML and convert to JSON var yamlObject = YamlConverter.DeserializeToDto( yaml ); // var yamlLineNumbers = GetYamlLineNumbers( yamlContent ); @@ -51,17 +52,6 @@ private static ValidationResult Validate( string yaml, JsonSchema schema ) { return new ValidationResult { IsValid = validationResults.IsValid, Errors = ExtractErrors( validationResults ).ToList() }; - - // Throw exceptions - /*} - catch ( Exception ex ) { - return new ValidationResult { - IsValid = false, - Errors = [ - new ValidationError { Message = $"Validation failed: {ex.Message}", Path = "/" } - ] - }; - }*/ } private static IEnumerable ExtractErrors( EvaluationResults results ) { @@ -73,7 +63,7 @@ private static IEnumerable ExtractErrors( EvaluationResults res foreach ( var error in results.Errors.Where( e => !ContainerKeywords.Contains( e.Key ) ) ) { yield return new ValidationError { Path = results.InstanceLocation.SegmentCount == 0 ? "/" : results.InstanceLocation.ToString(), - Message = error.Value ?? "Validation error", + Message = error.Value, SchemaPath = results.SchemaLocation.ToString() }; } From e3ca4ddac81e8bcb96704961cd21de2cdf40dfdf Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:15:43 +0200 Subject: [PATCH 24/43] bump grpc deps --- Directory.Packages.props | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ecdbd14f..35a3869d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,11 +10,11 @@ - - - - - + + + + + From 46c1f4994b761f404edde96f46754278536f265f Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:46:00 +0200 Subject: [PATCH 25/43] f --- Drift.sln | 14 +++++++++++++- src/Scanning/Scanners/PingSubnetScannerBase.cs | 16 +++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Drift.sln b/Drift.sln index 86f9d9ce..e28aa864 100644 --- a/Drift.sln +++ b/Drift.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli", "src\Cli\Cli.csproj", "{51BC193B-A7CB-4A3D-8852-AC8A0CE74DF5}" EndProject @@ -117,6 +117,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host.Tests", "s EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Tests", "src\Messaging.Tests\Messaging.Tests.csproj", "{87C0FA35-BC55-4B86-AAAD-C425C9A0E050}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Clab", "Clab", "{BA370A6D-DD96-4E5B-82B4-5CE7B6860F26}" + ProjectSection(SolutionItems) = preProject + containerlab\README.md = containerlab\README.md + containerlab\cooperation-test-spec.yaml = containerlab\cooperation-test-spec.yaml + containerlab\cooperation-test.clab.yaml = containerlab\cooperation-test.clab.yaml + containerlab\simple-test-spec.yaml = containerlab\simple-test-spec.yaml + containerlab\simple-test.clab.yaml = containerlab\simple-test.clab.yaml + containerlab\subnet-isolation-test-spec.yaml = containerlab\subnet-isolation-test-spec.yaml + containerlab\subnet-isolation-test.clab.yaml = containerlab\subnet-isolation-test.clab.yaml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -310,5 +321,6 @@ Global {76093473-3375-4519-9524-68448A19DE4A} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} {87C0FA35-BC55-4B86-AAAD-C425C9A0E050} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {BA370A6D-DD96-4E5B-82B4-5CE7B6860F26} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} EndGlobalSection EndGlobal diff --git a/src/Scanning/Scanners/PingSubnetScannerBase.cs b/src/Scanning/Scanners/PingSubnetScannerBase.cs index 906209c1..fdad5757 100644 --- a/src/Scanning/Scanners/PingSubnetScannerBase.cs +++ b/src/Scanning/Scanners/PingSubnetScannerBase.cs @@ -18,6 +18,10 @@ namespace Drift.Scanning.Scanners; +/// +/// Basic subnet scanner that uses the platform provided ping-command. +/// Inefficient, but "ping" is usually available on all platforms. +/// internal abstract class PingSubnetScannerBase : ISubnetScanner { public event EventHandler? ResultUpdated; @@ -157,8 +161,8 @@ List CreateAddresses( (IPAddress Ip, bool Success, string? Hostn } /// - /// 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 + /// Builds a map of local [unicast] IPv4 addresses to the MAC address of the + /// interface that owns them. Useful for resolving the MAC for the scanner's own /// IP addresses, which never appear in the ARP cache. /// private static Dictionary BuildLocalInterfaceMacTable() { @@ -166,11 +170,13 @@ private static Dictionary BuildLocalInterfaceMacTable() { foreach ( var iface in NetworkInterface.GetAllNetworkInterfaces() ) { var physicalAddress = iface.GetPhysicalAddress(); - if ( physicalAddress.GetAddressBytes().Length == 0 ) { - continue; // loopback and tunnel interfaces have no MAC + var addressBytes = physicalAddress.GetAddressBytes(); + + if ( addressBytes.Length == 0 ) { + continue; // loopback, tunnel and other logical interfaces have no MAC } - var macString = string.Join( "-", physicalAddress.GetAddressBytes().Select( b => b.ToString( "X2" ) ) ); + var macString = string.Join( "-", addressBytes.Select( b => b.ToString( "X2" ) ) ); foreach ( var unicastAddress in iface.GetIPProperties().UnicastAddresses.Select( u => u.Address ) ) { if ( unicastAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ) { From 36edd6e5a8cafbcb12c4c3ebc372411be9a6eb56 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:55:13 +0200 Subject: [PATCH 26/43] f --- src/Networking.Tests/Helpers/TestPeerMessage.cs | 5 +++++ src/Networking.Tests/MessageStreamManagerTests.cs | 15 ++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Networking.Tests/Helpers/TestPeerMessage.cs b/src/Networking.Tests/Helpers/TestPeerMessage.cs index 74564a77..44d4ad7c 100644 --- a/src/Networking.Tests/Helpers/TestPeerMessage.cs +++ b/src/Networking.Tests/Helpers/TestPeerMessage.cs @@ -8,6 +8,11 @@ namespace Drift.Networking.Tests.Helpers; internal sealed class TestPeerMessage : IRequest, IResponse { public static string MessageType => "test-peer-message"; + public string Payload { + get; + init; + } = string.Empty; + public static JsonTypeInfo JsonInfo => TestPeerMessageJsonContext.Default.TestPeerMessage; } diff --git a/src/Networking.Tests/MessageStreamManagerTests.cs b/src/Networking.Tests/MessageStreamManagerTests.cs index becf03bf..9ebe547b 100644 --- a/src/Networking.Tests/MessageStreamManagerTests.cs +++ b/src/Networking.Tests/MessageStreamManagerTests.cs @@ -12,9 +12,12 @@ public async Task IncomingMessageIsDispatchedToHandler() { var cts = new CancellationTokenSource(); var logger = new StringLogger( TestContext.Out ); var testMessageHandler = new TestMessageHandler(); - var envelopeConverter = new MessageEnvelopeConverter(); - var responseCorrelator = new MessageResponseCorrelator( logger ); - var dispatcher = new MessageDispatcher( [testMessageHandler], envelopeConverter, responseCorrelator, logger ); + var dispatcher = new MessageDispatcher( + [testMessageHandler], + new MessageEnvelopeConverter(), + new MessageResponseCorrelator( logger ), + logger + ); var messageStreamManager = new MessageStreamManager( logger, null, @@ -31,14 +34,16 @@ public async Task IncomingMessageIsDispatchedToHandler() { // Act var clientStreams = duplexStreams.Client; - await clientStreams.RequestStream.WriteAsync( converter.ToEnvelope( new TestPeerMessage() ) ); + await clientStreams.RequestStream.WriteAsync( converter.ToEnvelope( new TestPeerMessage { + Payload = "test123" + } ) ); await cts.CancelAsync(); await stream.ReadTask; // Assert Assert.That( testMessageHandler.LastMessage, Is.Not.Null ); - // Assert.That( testMessageHandler.LastMessage.MessageType, Is.EqualTo( "TestMessageType" ) ); + Assert.That( testMessageHandler.LastMessage.Payload, Is.EqualTo( "test123" ) ); cts.Dispose(); } From 7ddf8410de4ab5cae988521c9696d74ea18525bb Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:59:42 +0200 Subject: [PATCH 27/43] f --- src/Networking.Tests/InboundTests.cs | 35 +++++++++++----------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/src/Networking.Tests/InboundTests.cs b/src/Networking.Tests/InboundTests.cs index e2accc94..05e7c4de 100644 --- a/src/Networking.Tests/InboundTests.cs +++ b/src/Networking.Tests/InboundTests.cs @@ -11,22 +11,12 @@ internal sealed class InboundTests { public async Task InboundStreamIsClosedWhenCancelledTest() { // Arrange using var cts = new CancellationTokenSource(); - var logger = new StringLogger( TestContext.Out ); - var responseCorrelator = new MessageResponseCorrelator( logger ); - var envelopeConverter = new MessageEnvelopeConverter(); - var messageStreamManager = new MessageStreamManager( - logger, - null, - new MessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), - new MessagingOptions { StoppingToken = cts.Token } - ); + var inboundMessageService = CreateInboundMessageService( cts ); var callContext = TestServerCallContext.Create(); callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); var duplexStreams = callContext.CreateDuplexStreams(); - var inboundMessageService = new InboundMessageService( messageStreamManager, logger ); - // Act / Assert var serverStreams = duplexStreams.Server; var connectTask = @@ -41,20 +31,12 @@ public async Task InboundStreamIsClosedWhenCancelledTest() { Assert.That( connectTask.IsCompleted, Is.True ); } + [Test] public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { // Arrange using var cts = new CancellationTokenSource(); - var logger = new StringLogger( TestContext.Out ); - var responseCorrelator = new MessageResponseCorrelator( logger ); - var envelopeConverter = new MessageEnvelopeConverter(); - var messageStreamManager = new MessageStreamManager( - logger, - null, - new MessageDispatcher( [], envelopeConverter, responseCorrelator, logger ), - new MessagingOptions { StoppingToken = cts.Token } - ); - var inboundMessageService = new InboundMessageService( messageStreamManager, logger ); + var inboundMessageService = CreateInboundMessageService( cts ); var callContext = TestServerCallContext.Create(); callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); @@ -74,4 +56,15 @@ public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { Assert.That( connectTask.IsCompleted, Is.False ); } + + private static InboundMessageService CreateInboundMessageService( CancellationTokenSource cts ) { + var logger = new StringLogger( TestContext.Out ); + var messageStreamManager = new MessageStreamManager( + logger, + null, + new MessageDispatcher( [], new MessageEnvelopeConverter(), new MessageResponseCorrelator( logger ), logger ), + new MessagingOptions { StoppingToken = cts.Token } + ); + return new InboundMessageService( messageStreamManager, logger ); + } } \ No newline at end of file From b18ecf99a187e3f5302b9bd1f26886c4ad8f54a2 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:02:55 +0200 Subject: [PATCH 28/43] f --- .../MessageStreamManagerTests.cs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Networking.Tests/MessageStreamManagerTests.cs b/src/Networking.Tests/MessageStreamManagerTests.cs index 9ebe547b..b6325075 100644 --- a/src/Networking.Tests/MessageStreamManagerTests.cs +++ b/src/Networking.Tests/MessageStreamManagerTests.cs @@ -11,17 +11,16 @@ public async Task IncomingMessageIsDispatchedToHandler() { // Arrange var cts = new CancellationTokenSource(); var logger = new StringLogger( TestContext.Out ); - var testMessageHandler = new TestMessageHandler(); - var dispatcher = new MessageDispatcher( - [testMessageHandler], - new MessageEnvelopeConverter(), - new MessageResponseCorrelator( logger ), - logger - ); - var messageStreamManager = new MessageStreamManager( + var messageHandler = new TestMessageHandler(); + var streamManager = new MessageStreamManager( logger, null, - dispatcher, + new MessageDispatcher( + [messageHandler], + new MessageEnvelopeConverter(), + new MessageResponseCorrelator( logger ), + logger + ), new MessagingOptions { StoppingToken = cts.Token } ); @@ -29,7 +28,7 @@ public async Task IncomingMessageIsDispatchedToHandler() { callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); var duplexStreams = callContext.CreateDuplexStreams(); var serverStreams = duplexStreams.Server; - var stream = messageStreamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); + var stream = streamManager.Create( serverStreams.RequestStream, serverStreams.ResponseStream, callContext ); var converter = new MessageEnvelopeConverter(); // Act @@ -42,8 +41,8 @@ public async Task IncomingMessageIsDispatchedToHandler() { await stream.ReadTask; // Assert - Assert.That( testMessageHandler.LastMessage, Is.Not.Null ); - Assert.That( testMessageHandler.LastMessage.Payload, Is.EqualTo( "test123" ) ); + Assert.That( messageHandler.LastMessage, Is.Not.Null ); + Assert.That( messageHandler.LastMessage.Payload, Is.EqualTo( "test123" ) ); cts.Dispose(); } From dbc31f82c426e4fbb640694e4ccc4553e7206af0 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:10:01 +0200 Subject: [PATCH 29/43] f --- src/Networking.Server/InboundMessageService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Networking.Server/InboundMessageService.cs b/src/Networking.Server/InboundMessageService.cs index 6341612e..fa5b9209 100644 --- a/src/Networking.Server/InboundMessageService.cs +++ b/src/Networking.Server/InboundMessageService.cs @@ -5,7 +5,9 @@ namespace Drift.Networking.Server; -// Handle incoming connections (AKA server-side). +/// +/// Handles incoming gRPC connections (AKA server-side). +/// internal sealed class InboundMessageService( IMessageStreamManager messageStreamManager, ILogger logger ) : MessagingService.MessagingServiceBase { public override async Task Connect( From f9c11b4953839b59e87a30169b7f202fc14a3542 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:21:05 +0200 Subject: [PATCH 30/43] f --- src/Networking.Core/MessageStream.cs | 5 +-- .../Messages/MessageDispatcher.cs | 2 +- .../Helpers/TestPeerMessage.cs | 7 +++- src/Networking.Tests/InboundTests.cs | 20 +++++++----- .../MessageStreamManagerTests.cs | 32 +++++++++++-------- src/Networking.Tests/Networking.Tests.csproj | 1 + 6 files changed, 42 insertions(+), 25 deletions(-) diff --git a/src/Networking.Core/MessageStream.cs b/src/Networking.Core/MessageStream.cs index 6dba6089..04f8706f 100644 --- a/src/Networking.Core/MessageStream.cs +++ b/src/Networking.Core/MessageStream.cs @@ -8,7 +8,8 @@ namespace Drift.Networking.Core; public sealed class MessageStream : IMessageStream { - private static int _instanceCounter; // Being static is not ideal for testing with multiple Drift instances in the same process + // Being static is not ideal for testing with multiple Drift instances in the same process + private static int _instanceCounter; private readonly IAsyncStreamReader _reader; private readonly IAsyncStreamWriter _writer; private readonly MessageDispatcher _dispatcher; @@ -71,7 +72,7 @@ public async Task SendAsync( Message message ) { } private async Task ReadLoopAsync() { - _logger.LogDebug( "Read loop starting..." ); + _logger.LogDebug( "Read loop starting (waiting)..." ); try { await foreach ( var message in _reader.ReadAllAsync( _cancellationToken ) ) { diff --git a/src/Networking.Core/Messages/MessageDispatcher.cs b/src/Networking.Core/Messages/MessageDispatcher.cs index dc2d2ee4..5e722de6 100644 --- a/src/Networking.Core/Messages/MessageDispatcher.cs +++ b/src/Networking.Core/Messages/MessageDispatcher.cs @@ -43,6 +43,6 @@ ILogger logger return; } - throw new NotImplementedException( "Unknown message type '" + message.MessageType + "'" ); + throw new NotImplementedException( "No handler exist for message type '" + message.MessageType + "'" ); } } \ No newline at end of file diff --git a/src/Networking.Tests/Helpers/TestPeerMessage.cs b/src/Networking.Tests/Helpers/TestPeerMessage.cs index 44d4ad7c..29e204f7 100644 --- a/src/Networking.Tests/Helpers/TestPeerMessage.cs +++ b/src/Networking.Tests/Helpers/TestPeerMessage.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization.Metadata; using Drift.Networking.Core.Abstractions; using Drift.Networking.Grpc.Generated; +using Microsoft.Extensions.Logging; namespace Drift.Networking.Tests.Helpers; @@ -19,7 +20,7 @@ public string Payload { [JsonSerializable( typeof(TestPeerMessage) )] internal sealed partial class TestPeerMessageJsonContext : JsonSerializerContext; -internal sealed class TestMessageHandler : IMessageHandler { +internal sealed class TestMessageHandler( ILogger logger ) : IMessageHandler { public TestPeerMessage? LastMessage { get; private set; @@ -33,9 +34,13 @@ public Task HandleAsync( IMessageStream stream, CancellationToken cancellationToken ) { + logger.LogInformation( "Received message of type '{MessageType}'", MessageType ); + var message = converter.FromEnvelope( envelope ); LastMessage = message; + logger.LogInformation( "Handled message with payload '{Payload}'", message.Payload ); + // For this test handler, we don't send a response return Task.CompletedTask; } diff --git a/src/Networking.Tests/InboundTests.cs b/src/Networking.Tests/InboundTests.cs index 05e7c4de..21e5c254 100644 --- a/src/Networking.Tests/InboundTests.cs +++ b/src/Networking.Tests/InboundTests.cs @@ -1,8 +1,11 @@ +using Drift.Networking.Client; using Drift.Networking.Core; -using Drift.Networking.Core.Messages; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Server; using Drift.Networking.Tests.Helpers; using Drift.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Drift.Networking.Tests; @@ -58,13 +61,14 @@ public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { } private static InboundMessageService CreateInboundMessageService( CancellationTokenSource cts ) { + var serviceCollection = new ServiceCollection(); var logger = new StringLogger( TestContext.Out ); - var messageStreamManager = new MessageStreamManager( - logger, - null, - new MessageDispatcher( [], new MessageEnvelopeConverter(), new MessageResponseCorrelator( logger ), logger ), - new MessagingOptions { StoppingToken = cts.Token } - ); - return new InboundMessageService( messageStreamManager, logger ); + serviceCollection.AddSingleton( logger ); + serviceCollection.AddMessagingCore( new MessagingOptions { StoppingToken = cts.Token } ); + serviceCollection.AddMessagingClient(); + serviceCollection.AddMessagingServer(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var streamManager = serviceProvider.GetRequiredService(); + return new InboundMessageService( streamManager, logger ); } } \ No newline at end of file diff --git a/src/Networking.Tests/MessageStreamManagerTests.cs b/src/Networking.Tests/MessageStreamManagerTests.cs index b6325075..42876336 100644 --- a/src/Networking.Tests/MessageStreamManagerTests.cs +++ b/src/Networking.Tests/MessageStreamManagerTests.cs @@ -1,7 +1,11 @@ +using Drift.Networking.Client; using Drift.Networking.Core; +using Drift.Networking.Core.Abstractions; using Drift.Networking.Core.Messages; using Drift.Networking.Tests.Helpers; using Drift.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Drift.Networking.Tests; @@ -10,19 +14,7 @@ internal sealed class MessageStreamManagerTests { public async Task IncomingMessageIsDispatchedToHandler() { // Arrange var cts = new CancellationTokenSource(); - var logger = new StringLogger( TestContext.Out ); - var messageHandler = new TestMessageHandler(); - var streamManager = new MessageStreamManager( - logger, - null, - new MessageDispatcher( - [messageHandler], - new MessageEnvelopeConverter(), - new MessageResponseCorrelator( logger ), - logger - ), - new MessagingOptions { StoppingToken = cts.Token } - ); + var (streamManager, messageHandler) = CreateStreamManager( cts ); var callContext = TestServerCallContext.Create(); callContext.RequestHeaders.Add( "agent-id", "agentid_test123" ); @@ -46,4 +38,18 @@ public async Task IncomingMessageIsDispatchedToHandler() { cts.Dispose(); } + + private static (IMessageStreamManager, TestMessageHandler messageHandler) CreateStreamManager( + CancellationTokenSource cts + ) { + var serviceCollection = new ServiceCollection(); + var logger = new StringLogger( TestContext.Out ); + var messageHandler = new TestMessageHandler( logger ); + serviceCollection.AddSingleton( logger ); + serviceCollection.AddSingleton( _ => messageHandler ); + serviceCollection.AddMessagingCore( new MessagingOptions { StoppingToken = cts.Token } ); + serviceCollection.AddMessagingClient(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + return ( serviceProvider.GetRequiredService(), messageHandler ); + } } \ No newline at end of file diff --git a/src/Networking.Tests/Networking.Tests.csproj b/src/Networking.Tests/Networking.Tests.csproj index 733c8c6e..5ce2fb02 100644 --- a/src/Networking.Tests/Networking.Tests.csproj +++ b/src/Networking.Tests/Networking.Tests.csproj @@ -6,6 +6,7 @@ + From a43871d6970d41a3e716ba7d7a89bd11885ecafe Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:32:29 +0200 Subject: [PATCH 31/43] f --- src/Networking.Tests/InboundTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Networking.Tests/InboundTests.cs b/src/Networking.Tests/InboundTests.cs index 21e5c254..2474d62b 100644 --- a/src/Networking.Tests/InboundTests.cs +++ b/src/Networking.Tests/InboundTests.cs @@ -34,7 +34,6 @@ public async Task InboundStreamIsClosedWhenCancelledTest() { Assert.That( connectTask.IsCompleted, Is.True ); } - [Test] public async Task InboundStreamRemainsOpenWhenNotCancelledTest() { // Arrange From 99b36005d53134f8ae8309f964b0c9d19713be8f Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:42:37 +0200 Subject: [PATCH 32/43] update agents.md source layout --- AGENTS.md | 40 +++++++++++++++++++++++++--------------- Drift.sln | 1 + 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f22ef0c2..4fcf4ece 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,24 +33,34 @@ dotnet test src/Domain.Tests --filter "FullyQualifiedName~MyTest" The solution is split into focused projects. The main ones: -| Project | Role | -|---|------------------------------------------------------------------------------------------| -| `Cli` | Entry point; commands: `init`, `scan`, `agent start`; AOT-compiled | -| `Cli.Abstractions` | Shared CLI constants: exit codes, env var names, port numbers, file names | -| `Cli.Settings` | User settings file (`~/.config/drift/settings.json`) | -| `Domain` | Core value types: `Network`, `Device`, `Inventory`, `CidrBlock`, `Port`, `Scan`, `AgentId` | -| `Spec` | YAML spec parsing and validation (YamlDotNet + JsonSchema.Net) | -| `Scanning` | Network discovery: ARP, ping, port scanning with rate limiting | -| `Diff` | Compares declared spec state vs. discovered scan state to produce drift report | -| `Networking.PeerStreaming.*` | Abstract P2P streaming protocol + gRPC implementation (`peer.proto`) | -| `Networking.Cluster` | Multi-agent coordination | -| `Agent.Hosting` | Agent runtime (`AgentHost`, `Identity`) | -| `Agent.PeerProtocol` | Agent-specific peer messaging | -| `Serialization` | Cross-module serialization helpers | -| `ArchTests` | ArchUnitNET tests enforcing dependency rules and naming conventions | +| Project | Role | +|---|---| +| `Cli` | Entry point; commands: `init`, `scan`, `agent start`; AOT-compiled | +| `Cli.Abstractions` | Shared CLI constants: exit codes, env var names, port numbers, file names | +| `Cli.Settings` | User settings file (`~/.config/drift/settings.json`) | +| `Domain` | Core value types: `Network`, `Device`, `Inventory`, `CidrBlock`, `Port`, `AgentId` | +| `Spec` | YAML spec parsing and validation into declared-state domain types | +| `Scanning` | Network discovery: ARP, ping, port scanning | +| `Diff` | Compares declared spec state vs. discovered scan state to produce a drift report | +| `Networking.Grpc` | Generated gRPC/protobuf contracts for the messaging transport | +| `Networking.Core.Abstractions` | Interfaces for message streams, handlers, and client factories | +| `Networking.Core` | Message stream/manager implementation built on gRPC | +| `Networking.Client` | Default client factory for opening outbound messaging connections | +| `Networking.Server` | Hosts the inbound gRPC service for messaging endpoints | +| `Messaging.Protocol` | Concrete request/response message contracts (e.g. scan, subnets) | +| `Messaging.Client` | Typed agent client built on top of `Networking.Client`/`Networking.Core` | +| `Agent.Host` | Hosts an agent's messaging/gRPC endpoint (Kestrel/ASP.NET Core) | +| `Coordinator.Host` | Coordinator-side host counterpart to `Agent.Host` (work in progress) | +| `Common` | Shared cross-cutting helpers: IO, logging, network utilities, embedded resources | +| `Common.Schemas` | Shared JSON Schema generation helpers (e.g. lowercase enum naming) | +| `Serialization` | Cross-module serialization helpers | +| `TestUtilities` | Shared test helpers (loggers, Verify/snapshot settings) used by `*.Tests` projects | +| `ArchTests` | ArchUnitNET tests enforcing dependency rules and naming conventions | Schema generators live in `Spec.SchemaGenerator.Cli` and `Cli.Settings.SchemaGenerator.Cli` — they produce JSON Schema from C# types. +`Networking.*` and `Messaging.*` implement the role-agnostic transport layer (see naming rule below); `Agent.Host` and `Coordinator.Host` build role-specific hosting on top of it. + ### Data flow ``` diff --git a/Drift.sln b/Drift.sln index e28aa864..788a1bde 100644 --- a/Drift.sln +++ b/Drift.sln @@ -26,6 +26,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution install.sh = install.sh Directory.Packages.props = Directory.Packages.props Containerfile = Containerfile + AGENTS.md = AGENTS.md EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diff.Tests", "src\Diff.Tests\Diff.Tests.csproj", "{904351B1-CC53-477A-834E-7C3A120EACE9}" From 1e452900c76d07135a904102cbf382724db72f4d Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:27:44 +0200 Subject: [PATCH 33/43] chore(build): add NUKE target to regenerate spec and settings schemas Adds GenerateSchemas (and per-project GenerateSpecSchema/GenerateSettingsSchema targets) that run the existing Spec.SchemaGenerator.Cli and Cli.Settings.SchemaGenerator.Cli tools and write output directly into each project's embedded_resources/schemas directory. Previously this was a manual, undocumented step; the existing SchemaTests staleness checks remain as the CI safety net. --- .nuke/build.schema.json | 3 +++ build/NukeBuild.Schemas.cs | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 build/NukeBuild.Schemas.cs diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 33bb7c6a..653b3b96 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -49,6 +49,9 @@ "CreateRelease", "CreateWindowsArtifacts", "DeleteUntaggedImages", + "GenerateSchemas", + "GenerateSettingsSchema", + "GenerateSpecSchema", "OutputVersion", "PackBinaries", "PublishBinaries", diff --git a/build/NukeBuild.Schemas.cs b/build/NukeBuild.Schemas.cs new file mode 100644 index 00000000..a01fbb4a --- /dev/null +++ b/build/NukeBuild.Schemas.cs @@ -0,0 +1,47 @@ +using Drift.Build.Utilities; +using Nuke.Common.IO; +using Nuke.Common.Tools.DotNet; +using Serilog; +using static Nuke.Common.Tools.DotNet.DotNetTasks; +using Target = Nuke.Common.Target; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + Target GenerateSchemas => _ => _ + .DependsOn( GenerateSpecSchema, GenerateSettingsSchema ); + + Target GenerateSpecSchema => _ => _ + .Executes( () => { + using var _ = new OperationTimer( nameof(GenerateSpecSchema) ); + + RunSchemaGenerator( + Solution.Spec_SchemaGenerator_Cli.Path, + Solution.Spec.Directory / "embedded_resources" / "schemas" + ); + } + ); + + Target GenerateSettingsSchema => _ => _ + .Executes( () => { + using var _ = new OperationTimer( nameof(GenerateSettingsSchema) ); + + RunSchemaGenerator( + Solution.Cli.Cli_Settings_SchemaGenerator_Cli.Path, + Solution.Cli.Cli_Settings.Directory / "embedded_resources" / "schemas" + ); + } + ); + + private void RunSchemaGenerator( AbsolutePath projectFile, AbsolutePath outputDirectory ) { + Log.Information( "Generating schema from {Project} into {OutputDirectory}", projectFile, outputDirectory ); + + DotNetRun( s => s + .SetProjectFile( projectFile ) + .SetConfiguration( Configuration ) + .SetApplicationArguments( outputDirectory ) + ); + } +} \ No newline at end of file From 61031ab3d338c213fef346ed7ed6f82fee4376cb Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:27:50 +0200 Subject: [PATCH 34/43] feat(spec): wire up settings block with unknown_devices and undeclared_connections Connects the previously disconnected Settings/UnknownDevicePolicy DTO stub into DriftSpec, Inventory, and the ToDomain/ToDto mappers. Adds a new UndeclaredConnectionsPolicy (blocked/reachable) setting, distinct from UnknownDevices (allowed/disallowed): the former governs default reachability for connections not covered by any agent policy, the latter governs whether undeclared devices are a spec violation. Regenerates the embedded spec schema and adds validation test coverage for both settings. --- src/Domain/Inventory.cs | 5 ++ src/Domain/Settings.cs | 55 +++++++++++++++++++ src/Spec.Tests/ValidationTests.cs | 30 ++++++++++ src/Spec/Dtos/V1_preview/DriftSpec.cs | 28 ++++++++-- .../V1_preview/Mappers/Mapper.ToDomain.cs | 31 +++++++++++ .../Dtos/V1_preview/Mappers/Mapper.ToDto.cs | 33 ++++++++++- src/Spec/Serialization/YamlStaticContext.cs | 3 + .../schemas/drift-spec-v1-preview.schema.json | 35 ++++++++++++ 8 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 src/Domain/Settings.cs diff --git a/src/Domain/Inventory.cs b/src/Domain/Inventory.cs index 347999be..59cf463c 100644 --- a/src/Domain/Inventory.cs +++ b/src/Domain/Inventory.cs @@ -6,6 +6,11 @@ public required Network Network { init; } + public Settings? Settings { + get; + set; + } + public List Agents { get; set; diff --git a/src/Domain/Settings.cs b/src/Domain/Settings.cs new file mode 100644 index 00000000..eb1553d2 --- /dev/null +++ b/src/Domain/Settings.cs @@ -0,0 +1,55 @@ +namespace Drift.Domain; + +/// +/// Network-wide behavioral settings, distinct from the declared network topology itself. +/// +public record Settings { + /// + /// Gets or sets whether devices discovered on the network but not declared in the spec are + /// considered a spec violation () or + /// tolerated (). + /// + public UnknownDevicePolicy? UnknownDevices { + get; + set; + } + + /// + /// Gets or sets the default policy for connections that are not explicitly declared by any + /// agent's policy block: whether they are expected to be + /// or + /// by default. + /// + public UndeclaredConnectionsPolicy? UndeclaredConnections { + get; + set; + } + + public int? PingThrottling { + get; + set; + } + + public bool? ScanOnlyDeclaredSubnets { + get; + set; + } +} + +/// +/// Governs whether a device discovered on the network but not declared in the spec is treated +/// as adhering to the spec or not. +/// +public enum UnknownDevicePolicy { + Disallowed = 1, + Allowed = 2 +} + +/// +/// Governs whether network connections that are not explicitly declared by any agent's policy +/// block are expected to be reachable or blocked by default. +/// +public enum UndeclaredConnectionsPolicy { + Blocked = 1, + Reachable = 2 +} diff --git a/src/Spec.Tests/ValidationTests.cs b/src/Spec.Tests/ValidationTests.cs index a75398c0..1b2bdb5c 100644 --- a/src/Spec.Tests/ValidationTests.cs +++ b/src/Spec.Tests/ValidationTests.cs @@ -87,6 +87,21 @@ internal sealed class ValidationTests { """, "/version: Expected \"\\u0022v1-preview\\u0022\"" )] + [TestCase( + 8, + """ + version: v1-preview + network: + id: my-network + subnets: + - id: my-subnet + address: 100.100.100.100/16 + settings: + unknown_devices: not-a-valid-value + """, + ": Exception during deserialization", + ": Requested value 'NotAValidValue' was not found." + )] public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) { // Arrange / Act var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); @@ -130,6 +145,21 @@ public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) - id: my-subnet address: 100.100.100.100/16 """ )] + [TestCase( + 4, + """ + version: v1-preview + network: + id: my-network + subnets: + - id: my-subnet + address: 100.100.100.100/16 + settings: + unknown_devices: disallowed + undeclared_connections: blocked + ping_throttling: 100 + scan_only_declared_subnets: true + """ )] public void YamlIsValidTest( int caseNo, string yaml ) { // Arrange / Act var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); diff --git a/src/Spec/Dtos/V1_preview/DriftSpec.cs b/src/Spec/Dtos/V1_preview/DriftSpec.cs index 1dbc1a23..03a4b01e 100644 --- a/src/Spec/Dtos/V1_preview/DriftSpec.cs +++ b/src/Spec/Dtos/V1_preview/DriftSpec.cs @@ -5,7 +5,7 @@ namespace Drift.Spec.Dtos.V1_preview; [Title( "Drift spec schema" )] [Description( "JSON schema for validating Drift specs" )] -[AdditionalProperties( false )] // TODO set to false +[AdditionalProperties( false )] // TODO rename to DriftSpecV1Preview? public record DriftSpec { [property: Const( @@ -27,11 +27,10 @@ public Network Network { set; } - // TODO support settings - /*public Settings? Settings { + public Settings? Settings { get; set; - }*/ + } public List? Agents { get; @@ -40,7 +39,7 @@ public List? Agents { } // [Title( "Network declaration" )] -[AdditionalProperties( false )] // TODO set to false +[AdditionalProperties( false )] public record Network { public List? Subnets { get; @@ -136,11 +135,25 @@ public bool? IsId { [AdditionalProperties( false )] public record Settings { + /// + /// Gets or sets whether a device discovered on the network but not declared in the spec is + /// considered a spec violation (adherence to spec), or tolerated. + /// public UnknownDevicePolicy? UnknownDevices { get; set; } + /// + /// Gets or sets the default policy for connections not explicitly declared by any agent's + /// policy: block: whether such connections are expected to be reachable or blocked by + /// default. + /// + public UndeclaredConnectionsPolicy? UndeclaredConnections { + get; + set; + } + public int? PingThrottling { get; set; @@ -170,4 +183,9 @@ public string Address { public enum UnknownDevicePolicy { Disallowed = 1, Allowed = 2 +} + +public enum UndeclaredConnectionsPolicy { + Blocked = 1, + Reachable = 2 } \ No newline at end of file diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs index 3f53429c..b4d0c366 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs @@ -9,6 +9,10 @@ public static Inventory ToDomain( DriftSpec dto ) { // ArgumentNullException.ThrowIfNull( dto.Address ); var spec = new Inventory { Network = Map( dto.Network ) }; + if ( dto.Settings != null ) { + spec.Settings = Map( dto.Settings ); + } + if ( dto.Agents != null ) { spec.Agents = Map( dto.Agents ); } @@ -16,6 +20,33 @@ public static Inventory ToDomain( DriftSpec dto ) { return spec; } + private static Domain.Settings Map( Settings dto ) { + return new Domain.Settings { + UnknownDevices = Map( dto.UnknownDevices ), + UndeclaredConnections = Map( dto.UndeclaredConnections ), + PingThrottling = dto.PingThrottling, + ScanOnlyDeclaredSubnets = dto.ScanOnlyDeclaredSubnets + }; + } + + private static Domain.UnknownDevicePolicy? Map( UnknownDevicePolicy? dto ) { + return dto switch { + null => null, + UnknownDevicePolicy.Disallowed => Domain.UnknownDevicePolicy.Disallowed, + UnknownDevicePolicy.Allowed => Domain.UnknownDevicePolicy.Allowed, + _ => throw new ArgumentOutOfRangeException( nameof(dto), dto, null ) + }; + } + + private static Domain.UndeclaredConnectionsPolicy? Map( UndeclaredConnectionsPolicy? dto ) { + return dto switch { + null => null, + UndeclaredConnectionsPolicy.Blocked => Domain.UndeclaredConnectionsPolicy.Blocked, + UndeclaredConnectionsPolicy.Reachable => Domain.UndeclaredConnectionsPolicy.Reachable, + _ => throw new ArgumentOutOfRangeException( nameof(dto), dto, null ) + }; + } + private static List Map( List dto ) { return dto.Select( Map ).ToList(); } diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs index 65ab15d9..a9e48240 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs @@ -8,7 +8,38 @@ public static partial class Mapper { internal const string VersionConstant = "v1-preview"; public static DriftSpec ToDto( Inventory domain ) { - return new DriftSpec { Version = VersionConstant, Network = Map( domain.Network ) }; + return new DriftSpec { + Version = VersionConstant, + Network = Map( domain.Network ), + Settings = domain.Settings == null ? null : Map( domain.Settings ) + }; + } + + private static Settings Map( Domain.Settings domain ) { + return new Settings { + UnknownDevices = Map( domain.UnknownDevices ), + UndeclaredConnections = Map( domain.UndeclaredConnections ), + PingThrottling = domain.PingThrottling, + ScanOnlyDeclaredSubnets = domain.ScanOnlyDeclaredSubnets + }; + } + + private static UnknownDevicePolicy? Map( Domain.UnknownDevicePolicy? domain ) { + return domain switch { + null => null, + Domain.UnknownDevicePolicy.Disallowed => UnknownDevicePolicy.Disallowed, + Domain.UnknownDevicePolicy.Allowed => UnknownDevicePolicy.Allowed, + _ => throw new ArgumentOutOfRangeException( nameof(domain), domain, null ) + }; + } + + private static UndeclaredConnectionsPolicy? Map( Domain.UndeclaredConnectionsPolicy? domain ) { + return domain switch { + null => null, + Domain.UndeclaredConnectionsPolicy.Blocked => UndeclaredConnectionsPolicy.Blocked, + Domain.UndeclaredConnectionsPolicy.Reachable => UndeclaredConnectionsPolicy.Reachable, + _ => throw new ArgumentOutOfRangeException( nameof(domain), domain, null ) + }; } private static Network Map( Domain.Network domain ) { diff --git a/src/Spec/Serialization/YamlStaticContext.cs b/src/Spec/Serialization/YamlStaticContext.cs index b06693eb..e32e52d5 100644 --- a/src/Spec/Serialization/YamlStaticContext.cs +++ b/src/Spec/Serialization/YamlStaticContext.cs @@ -11,6 +11,9 @@ namespace Drift.Spec.Serialization; [YamlSerializable( typeof(Device) )] [YamlSerializable( typeof(DeviceState) )] [YamlSerializable( typeof(DeviceAddress) )] +[YamlSerializable( typeof(Dtos.V1_preview.Settings) )] +[YamlSerializable( typeof(UnknownDevicePolicy) )] +[YamlSerializable( typeof(UndeclaredConnectionsPolicy) )] [YamlSerializable( typeof(Agent) )] /*[YamlSerializable( typeof(IpV4Address) )] [YamlSerializable( typeof(HostnameAddress) )] 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 cc11b3d2..ddb225f5 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 @@ -103,6 +103,41 @@ }, "additionalProperties": false }, + "settings": { + "type": [ + "null", + "object" + ], + "properties": { + "unknown_devices": { + "enum": [ + "disallowed", + "allowed", + null + ] + }, + "undeclared_connections": { + "enum": [ + "blocked", + "reachable", + null + ] + }, + "ping_throttling": { + "type": [ + "null", + "integer" + ] + }, + "scan_only_declared_subnets": { + "type": [ + "null", + "boolean" + ] + } + }, + "additionalProperties": false + }, "agents": { "type": [ "null", From 6f4e8e8a00b9e9557ac0bc5da1b3f3215a700c4b Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:39:01 +0200 Subject: [PATCH 35/43] fix(networking-core): propagate cancellation token to TrySetCanceled Pass the linked cts.Token (which combines the caller's ct with the internal timeout) into TrySetCanceled instead of the parameterless overload, so OperationCanceledException.CancellationToken correctly reflects which token triggered the cancellation. Resolves S8949. --- src/Networking.Core/Messages/MessageResponseCorrelator.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Networking.Core/Messages/MessageResponseCorrelator.cs b/src/Networking.Core/Messages/MessageResponseCorrelator.cs index 0e07a2ec..782df799 100644 --- a/src/Networking.Core/Messages/MessageResponseCorrelator.cs +++ b/src/Networking.Core/Messages/MessageResponseCorrelator.cs @@ -21,7 +21,7 @@ public Task WaitForResponseAsync( string correlationId, TimeSpan timeou cts.Token.Register( () => { if ( _pendingRequests.TryRemove( correlationId, out var removed ) ) { - removed.TrySetCanceled(); + removed.TrySetCanceled( cts.Token ); } cts.Dispose(); @@ -52,7 +52,7 @@ CancellationToken ct cts.Token.Register( () => { if ( _streamingRequests.TryRemove( correlationId, out var removed ) ) { - removed.CompletionSource.TrySetCanceled(); + removed.CompletionSource.TrySetCanceled( cts.Token ); } cts.Dispose(); From c02c594f343108d5299f7d99bb24a9f25dae8135 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:16:16 +0200 Subject: [PATCH 36/43] spec evolution --- AGENTS.md | 7 +- ...cWithoutDiscoverySuccess_spec.verified.txt | 27 ++-- ...ratedSpecWithDiscoveryIsValid.verified.txt | 9 +- ...edSpecWithoutDiscoveryIsValid.verified.txt | 27 ++-- .../Scan/Interactive/Ui/ScanLayout.cs | 6 +- src/Domain/Environment.cs | 40 +++++ src/Domain/Inventory.cs | 13 ++ src/Spec.Tests/MapperTests.cs | 135 +++++++++++++++++ src/Spec.Tests/ValidationTests.cs | 96 ++++++++++++ src/Spec.Tests/resources/network1.yaml | 12 +- .../resources/network_single_device_host.yaml | 3 +- src/Spec/Dtos/V1_preview/DriftSpec.cs | 100 ++++++++++++- .../V1_preview/Mappers/Mapper.ToDomain.cs | 76 ++++++++-- .../Dtos/V1_preview/Mappers/Mapper.ToDto.cs | 50 +++++-- src/Spec/Serialization/YamlStaticContext.cs | 4 +- .../schemas/drift-spec-v1-preview.schema.json | 140 +++++++++++++++--- 16 files changed, 631 insertions(+), 114 deletions(-) create mode 100644 src/Spec.Tests/MapperTests.cs diff --git a/AGENTS.md b/AGENTS.md index 4fcf4ece..3100b41e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ dotnet nuke Build # Restore + compile dotnet nuke TestUnit # Unit tests only (fast) dotnet nuke Test # All tests (unit + E2E) dotnet nuke TestE2E # E2E tests (General, Binary, Container image, Container network topologies using Containerlab) -dotnet nuke PublishBinaries # Self-contained binary ( +dotnet nuke PublishBinaries # Self-contained binary for the current platform dotnet nuke BuildContainerImage ``` @@ -86,11 +86,10 @@ Agents (remote Drift instances) report discovered state back to the coordinator ## Testing - **Unit tests**: `*.Tests` projects using NUnit 4 and NSubstitute for mocking. -- **E2E tests**: `Cli.E2ETests.*` projects — General (Testcontainers), Binary (published binary), Container (Docker image), Containerlab (real multi-node topologies). +- **E2E tests**: `Cli.E2ETests.*` projects (`General` install scripts and schemas, `Binary` against the published binary, `Container` against the container image). +- **Containerlab tests**: driven directly by the NUKE build (`build/NukeBuild.TestContainerlab.cs`, target `TestE2E_Clab`) against multi-node topologies — not a `Cli.E2ETests.*` project. Requires Containerlab installed and uses topology files in `containerlab/`. - **Architecture tests**: `ArchTests` project validates project dependency graph and naming rules. -Containerlab tests require Containerlab installed and use topology files in `containerlab/`. - ## Terminology (from domain model) - **Spec**: declarative YAML definition of desired network state diff --git a/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithoutDiscoverySuccess_spec.verified.txt b/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithoutDiscoverySuccess_spec.verified.txt index db8b24dd..0a53f6f8 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithoutDiscoverySuccess_spec.verified.txt +++ b/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithoutDiscoverySuccess_spec.verified.txt @@ -10,37 +10,28 @@ network: devices: - id: router addresses: - - type: ip-v4 - value: 192.168.1.10 + ipv4: 192.168.1.10 - id: nas addresses: - - type: ip-v4 - value: 192.168.1.20 + ipv4: 192.168.1.20 - id: server addresses: - - type: ip-v4 - value: 192.168.1.30 + ipv4: 192.168.1.30 - id: desktop addresses: - - type: ip-v4 - value: 192.168.1.40 + ipv4: 192.168.1.40 - id: laptop addresses: - - type: ip-v4 - value: 192.168.1.50 + ipv4: 192.168.1.50 - id: smart-tv addresses: - - type: ip-v4 - value: 192.168.100.10 + ipv4: 192.168.100.10 - id: security-camera addresses: - - type: ip-v4 - value: 192.168.100.20 + ipv4: 192.168.100.20 - id: smart-switch addresses: - - type: ip-v4 - value: 192.168.100.30 + ipv4: 192.168.100.30 - id: guest-device addresses: - - type: ip-v4 - value: 192.168.200.100 + ipv4: 192.168.200.100 diff --git a/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithDiscoveryIsValid.verified.txt b/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithDiscoveryIsValid.verified.txt index ea6c0865..4cbad69b 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithDiscoveryIsValid.verified.txt +++ b/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithDiscoveryIsValid.verified.txt @@ -5,13 +5,10 @@ network: devices: - id: device-1 addresses: - - type: ip-v4 - value: 192.168.0.10 + ipv4: 192.168.0.10 - id: device-2 addresses: - - type: ip-v4 - value: 192.168.0.11 + ipv4: 192.168.0.11 - id: device-3 addresses: - - type: ip-v4 - value: 192.168.0.12 + ipv4: 192.168.0.12 diff --git a/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithoutDiscoveryIsValid.verified.txt b/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithoutDiscoveryIsValid.verified.txt index db8b24dd..0a53f6f8 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithoutDiscoveryIsValid.verified.txt +++ b/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithoutDiscoveryIsValid.verified.txt @@ -10,37 +10,28 @@ network: devices: - id: router addresses: - - type: ip-v4 - value: 192.168.1.10 + ipv4: 192.168.1.10 - id: nas addresses: - - type: ip-v4 - value: 192.168.1.20 + ipv4: 192.168.1.20 - id: server addresses: - - type: ip-v4 - value: 192.168.1.30 + ipv4: 192.168.1.30 - id: desktop addresses: - - type: ip-v4 - value: 192.168.1.40 + ipv4: 192.168.1.40 - id: laptop addresses: - - type: ip-v4 - value: 192.168.1.50 + ipv4: 192.168.1.50 - id: smart-tv addresses: - - type: ip-v4 - value: 192.168.100.10 + ipv4: 192.168.100.10 - id: security-camera addresses: - - type: ip-v4 - value: 192.168.100.20 + ipv4: 192.168.100.20 - id: smart-switch addresses: - - type: ip-v4 - value: 192.168.100.30 + ipv4: 192.168.100.30 - id: guest-device addresses: - - type: ip-v4 - value: 192.168.200.100 + ipv4: 192.168.200.100 diff --git a/src/Cli/Commands/Scan/Interactive/Ui/ScanLayout.cs b/src/Cli/Commands/Scan/Interactive/Ui/ScanLayout.cs index c75f0539..95e8f518 100644 --- a/src/Cli/Commands/Scan/Interactive/Ui/ScanLayout.cs +++ b/src/Cli/Commands/Scan/Interactive/Ui/ScanLayout.cs @@ -103,7 +103,9 @@ private static Layout BuildProgressBar( Percentage progress ) { ); } - private static Markup BuildFooter() { + private static Markup BuildFooter( /*int scroll, int maxScroll, int selectedIndex, List subnets */ ) { + const string keyColor = "bold"; + var keyActions = new Dictionary { { "q", "quit" }, { "r", "restart" }, @@ -118,7 +120,7 @@ private static Markup BuildFooter() { var footerParts = new List(); foreach ( var kvp in keyActions ) { - footerParts.Add( $"[bold]{kvp.Key}[/] {kvp.Value}" ); + footerParts.Add( $"[{keyColor}]{kvp.Key}[/] {kvp.Value}" ); } // footerParts.Add( $"[grey]Scroll: {scroll}/{maxScroll}[/]" ); diff --git a/src/Domain/Environment.cs b/src/Domain/Environment.cs index e56727f2..e171f648 100644 --- a/src/Domain/Environment.cs +++ b/src/Domain/Environment.cs @@ -48,6 +48,46 @@ public AgentAuthentication Authentication { get; set; } + + public List? Policy { + get; + set; + } +} + +/// +/// A single policy assertion executed by an agent against a target. Not yet executed. +/// +public record Policy { + public required List To { + get; + init; + } + + public required string Expect { + get; + init; + } + + public List? Port { + get; + init; + } + + public string? Protocol { + get; + init; + } + + public List? Probe { + get; + init; + } + + public string? Fallback { + get; + init; + } } public record AgentAuthentication { diff --git a/src/Domain/Inventory.cs b/src/Domain/Inventory.cs index 59cf463c..5e5c8093 100644 --- a/src/Domain/Inventory.cs +++ b/src/Domain/Inventory.cs @@ -6,6 +6,11 @@ public required Network Network { init; } + public Server? Server { + get; + set; + } + public Settings? Settings { get; set; @@ -15,4 +20,12 @@ public List Agents { get; set; } = []; +} + +public record Server { + // TODO Use Uri type + public required string Address { + get; + init; + } } \ No newline at end of file diff --git a/src/Spec.Tests/MapperTests.cs b/src/Spec.Tests/MapperTests.cs new file mode 100644 index 00000000..74ff7d17 --- /dev/null +++ b/src/Spec.Tests/MapperTests.cs @@ -0,0 +1,135 @@ +using Drift.Domain.Device.Declared; +using Drift.Spec.Dtos.V1_preview; +using Drift.Spec.Dtos.V1_preview.Mappers; + +namespace Drift.Spec.Tests; + +internal sealed class MapperTests { + [Test] + public void Device_WithoutState_DefaultsToUp() { + // Arrange + var dto = new DriftSpec { + Version = "v1-preview", + Network = new Network { + Devices = [ + new Device { Addresses = new Addresses { Ipv4 = "192.168.1.10" } } + ] + } + }; + + // Act + var domain = Mapper.ToDomain( dto ); + + // Assert + Assert.That( domain.Network.Devices, Has.Count.EqualTo( 1 ) ); + Assert.That( domain.Network.Devices[0].State, Is.EqualTo( DeclaredDeviceState.Up ) ); + } + + [Test] + public void Device_WithExplicitDownState_IsMapped() { + // Arrange + var dto = new DriftSpec { + Version = "v1-preview", + Network = new Network { + Devices = [ + new Device { Addresses = new Addresses { Ipv4 = "192.168.1.10" }, State = DeviceState.Down } + ] + } + }; + + // Act + var domain = Mapper.ToDomain( dto ); + + // Assert + Assert.That( domain.Network.Devices[0].State, Is.EqualTo( DeclaredDeviceState.Down ) ); + } + + [Test] + public void Device_InfoAddresses_AreMappedAsNonIdentity() { + // Arrange + var dto = new DriftSpec { + Version = "v1-preview", + Network = new Network { + Devices = [ + new Device { + Addresses = new Addresses { Ipv4 = "192.168.1.10" }, Info = new Addresses { Hostname = "nas.local" } + } + ] + } + }; + + // Act + var domain = Mapper.ToDomain( dto ); + + // Assert + var addresses = domain.Network.Devices[0].Addresses; + Assert.That( addresses, Has.Count.EqualTo( 2 ) ); + Assert.That( addresses.Single( a => a.Value == "192.168.1.10" ).IsId, Is.True ); + Assert.That( addresses.Single( a => a.Value == "nas.local" ).IsId, Is.False ); + + // Round-trip: info address must come back under `info:`, not `addresses:` + var roundTripped = Mapper.ToDto( domain ); + var device = roundTripped.Network.Devices![0]; + Assert.That( device.Addresses.Ipv4, Is.EqualTo( "192.168.1.10" ) ); + Assert.That( device.Addresses.Hostname, Is.Null ); + Assert.That( device.Info?.Hostname, Is.EqualTo( "nas.local" ) ); + } + + [Test] + public void Server_IsMappedToDomainAndBack() { + // Arrange + var dto = new DriftSpec { + Version = "v1-preview", Network = new Network(), Server = new Server { Address = "http://server:5000" } + }; + + // Act + var domain = Mapper.ToDomain( dto ); + var roundTripped = Mapper.ToDto( domain ); + + // Assert + Assert.That( domain.Server?.Address, Is.EqualTo( "http://server:5000" ) ); + Assert.That( roundTripped.Server?.Address, Is.EqualTo( "http://server:5000" ) ); + } + + [Test] + public void AgentPolicy_IsMappedToDomainAndBack() { + // Arrange + var dto = new DriftSpec { + Version = "v1-preview", + Network = new Network(), + Agents = [ + new Agent { + Id = "agentid_test1", + Address = "http://agent1:5000", + Policy = [ + new Policy { + To = ["router"], + Expect = "reachable", + Port = [443], + Protocol = "tcp", + Probe = ["tls"], + Fallback = "gateway" + } + ] + } + ] + }; + + // Act + var domain = Mapper.ToDomain( dto ); + var roundTripped = Mapper.ToDto( domain ); + + // Assert + var domainPolicy = domain.Agents[0].Policy?[0]; + Assert.That( domainPolicy?.To, Is.EquivalentTo( new[] { "router" } ) ); + Assert.That( domainPolicy?.Expect, Is.EqualTo( "reachable" ) ); + Assert.That( domainPolicy?.Port, Is.EquivalentTo( new[] { 443 } ) ); + Assert.That( domainPolicy?.Protocol, Is.EqualTo( "tcp" ) ); + Assert.That( domainPolicy?.Probe, Is.EquivalentTo( new[] { "tls" } ) ); + Assert.That( domainPolicy?.Fallback, Is.EqualTo( "gateway" ) ); + + var roundTrippedPolicy = roundTripped.Agents?[0].Policy?[0]; + Assert.That( roundTrippedPolicy?.To, Is.EquivalentTo( new[] { "router" } ) ); + Assert.That( roundTrippedPolicy?.Expect, Is.EqualTo( "reachable" ) ); + } +} \ No newline at end of file diff --git a/src/Spec.Tests/ValidationTests.cs b/src/Spec.Tests/ValidationTests.cs index 1b2bdb5c..38390ddf 100644 --- a/src/Spec.Tests/ValidationTests.cs +++ b/src/Spec.Tests/ValidationTests.cs @@ -102,6 +102,51 @@ internal sealed class ValidationTests { ": Exception during deserialization", ": Requested value 'NotAValidValue' was not found." )] + [TestCase( + 9, + """ + version: v1-preview + network: + id: my-network + subnets: + - id: my-subnet + address: 100.100.100.100/16 + server: + foo: bar + """, + "/server: Required properties [\"address\"] are not present" + )] + [TestCase( + 10, + """ + version: v1-preview + network: + id: my-network + subnets: + - id: my-subnet + address: 100.100.100.100/16 + agents: + - id: agentid_test1 + address: http://agent1:5000 + policy: + - expect: reachable + """, + "/agents/0/policy/0: Required properties [\"to\"] are not present" + )] + [TestCase( + 11, + """ + version: v1-preview + network: + id: my-network + subnets: + - id: my-subnet + address: 100.100.100.100/16 + devices: + - id: my-device + """, + "/network/devices/0: Required properties [\"addresses\"] are not present" + )] public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) { // Arrange / Act var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); @@ -160,6 +205,57 @@ public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) ping_throttling: 100 scan_only_declared_subnets: true """ )] + [TestCase( + 5, + """ + version: v1-preview + network: + id: my-network + subnets: + - id: my-subnet + address: 100.100.100.100/16 + server: + address: http://192.168.1.10:5000 + """ )] + [TestCase( + 6, + """ + version: v1-preview + network: + id: my-network + subnets: + - id: my-subnet + address: 100.100.100.100/16 + agents: + - id: agentid_test1 + address: http://agent1:5000 + policy: + - to: [internet, servers] + expect: reachable + - to: [router] + port: [443] + protocol: tcp + probe: [tls] + expect: reachable + fallback: gateway + """ )] + [TestCase( + 7, + """ + version: v1-preview + network: + id: my-network + subnets: + - id: my-subnet + address: 100.100.100.100/16 + devices: + - id: nas + addresses: + ipv4: 192.168.10.5 + mac: b8:27:eb:12:34:56 + info: + hostname: nas.local + """ )] public void YamlIsValidTest( int caseNo, string yaml ) { // Arrange / Act var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); diff --git a/src/Spec.Tests/resources/network1.yaml b/src/Spec.Tests/resources/network1.yaml index fcee4a4d..ef074736 100644 --- a/src/Spec.Tests/resources/network1.yaml +++ b/src/Spec.Tests/resources/network1.yaml @@ -24,8 +24,7 @@ network: - id: switch1 type: switch addresses: - - type: "ip-v4" - value: 192.168.1.1 + ipv4: 192.168.1.1 interfaces: - id: eth0 vlan: 10 @@ -37,8 +36,7 @@ network: - id: switch2 type: switch addresses: - - type: "ip-v4" - value: 192.168.1.2 + ipv4: 192.168.1.2 interfaces: - id: eth0 vlan: 10 @@ -50,8 +48,7 @@ network: - id: client1 type: host addresses: - - type: "ip-v4" - value: 192.168.1.101 + ipv4: 192.168.1.101 vlan: 20 connected_to: switch1-eth1 tests: @@ -62,8 +59,7 @@ network: - id: client2 type: host addresses: - - type: "ip-v4" - value: 192.168.3.1 + ipv4: 192.168.3.1 vlan: 20 connected_to: switch2-eth1 tests: diff --git a/src/Spec.Tests/resources/network_single_device_host.yaml b/src/Spec.Tests/resources/network_single_device_host.yaml index d73b1ab0..cc41146f 100644 --- a/src/Spec.Tests/resources/network_single_device_host.yaml +++ b/src/Spec.Tests/resources/network_single_device_host.yaml @@ -3,8 +3,7 @@ network: - id: client1 type: host addresses: - - type: ip-v4 - value: 192.168.1.101 + ipv4: 192.168.1.101 vlan: 20 tests: - "connectivity" diff --git a/src/Spec/Dtos/V1_preview/DriftSpec.cs b/src/Spec/Dtos/V1_preview/DriftSpec.cs index 03a4b01e..a5cac51d 100644 --- a/src/Spec/Dtos/V1_preview/DriftSpec.cs +++ b/src/Spec/Dtos/V1_preview/DriftSpec.cs @@ -27,6 +27,11 @@ public Network Network { set; } + public Server? Server { + get; + set; + } + public Settings? Settings { get; set; @@ -52,6 +57,19 @@ public List? Devices { } } +/// +/// Declares where the coordinating server for this network is meant to run. +/// +[AdditionalProperties( false )] +public record Server { + [Required] + // TODO Use Uri type + public string Address { + get; + set; + } +} + [AdditionalProperties( false )] public record Subnet { public string? Id { @@ -78,8 +96,20 @@ public string? Id { set; } + /// + /// Gets or sets the addresses used to identify this device when matching declared vs. + /// discovered state. + /// [Required] - public List Addresses { + public Addresses Addresses { + get; + set; + } + + /// + /// Gets or sets additional descriptive addresses that are not used for device identity. + /// + public Addresses? Info { get; set; } @@ -112,22 +142,23 @@ public enum DeviceState { Down = 3 } -// TODO add patterns +/// +/// A set of addresses for a device. +/// +// TODO enforce "at least one property set" [AdditionalProperties( false )] -public record DeviceAddress { - [Required] - public string Type { +public record Addresses { + public string? Ipv4 { get; set; } - [Required] - public string Value { + public string? Mac { get; set; } - public bool? IsId { + public string? Hostname { get; set; } @@ -178,6 +209,59 @@ public string Address { get; set; } + + public List? Policy { + get; + set; + } +} + +/// +/// A single policy assertion executed by an agent against a target. +/// +// TODO extend `To`/`Port`/`Probe` to accept either a single value or an array +[AdditionalProperties( false )] +public record Policy { + /// + /// Gets or sets the target(s) of this policy assertion: a subnet id, device id, a + /// well-known target (internet, peers, rfc1918), or a list thereof. + /// + [Required] + public List To { + get; + set; + } + + // TODO lock down to an enum once the full set of probe types and their possible outcomes is + // pinned down (e.g. "reachable"/"unreachable" for ICMP/TCP, "valid"/"invalid" for TLS). + [Required] + public string Expect { + get; + set; + } + + public List? Port { + get; + set; + } + + // TODO lock down to an enum (e.g. tcp/udp/icmp) once probe execution is built. + public string? Protocol { + get; + set; + } + + // TODO lock down to an enum once extended probe types are defined. + public List? Probe { + get; + set; + } + + // TODO lock down to an enum (currently only "gateway" is a known value). + public string? Fallback { + get; + set; + } } public enum UnknownDevicePolicy { diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs index b4d0c366..7955b2d2 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs @@ -9,6 +9,10 @@ public static Inventory ToDomain( DriftSpec dto ) { // ArgumentNullException.ThrowIfNull( dto.Address ); var spec = new Inventory { Network = Map( dto.Network ) }; + if ( dto.Server != null ) { + spec.Server = Map( dto.Server ); + } + if ( dto.Settings != null ) { spec.Settings = Map( dto.Settings ); } @@ -20,6 +24,10 @@ public static Inventory ToDomain( DriftSpec dto ) { return spec; } + private static Domain.Server Map( Server dto ) { + return new Domain.Server { Address = dto.Address }; + } + private static Domain.Settings Map( Settings dto ) { return new Domain.Settings { UnknownDevices = Map( dto.UnknownDevices ), @@ -57,9 +65,24 @@ private static Domain.Agent Map( Agent dto ) { agent.Id = dto.Id; agent.Address = dto.Address; + if ( dto.Policy != null ) { + agent.Policy = dto.Policy.Select( Map ).ToList(); + } + return agent; } + private static Domain.Policy Map( Policy dto ) { + return new Domain.Policy { + To = dto.To, + Expect = dto.Expect, + Port = dto.Port, + Protocol = dto.Protocol, + Probe = dto.Probe, + Fallback = dto.Fallback + }; + } + private static Domain.Network Map( Network dto ) { var network = new Domain.Network(); @@ -101,7 +124,13 @@ private static List Map( List dto ) { private static DeclaredDevice Map( Device dto ) { // ArgumentNullException.ThrowIfNull( dto.Addresses ); - var declaredDevice = new DeclaredDevice { Addresses = dto.Addresses.Select( Map ).ToList() }; + var addresses = MapIdentityAddresses( dto.Addresses ); + + if ( dto.Info != null ) { + addresses.AddRange( MapInfoAddresses( dto.Info ) ); + } + + var declaredDevice = new DeclaredDevice { Addresses = addresses }; if ( dto.Id != null ) { declaredDevice.Id = dto.Id; @@ -118,15 +147,6 @@ private static DeclaredDevice Map( Device dto ) { return declaredDevice; } - 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 ), - "hostname" => new Domain.Device.Addresses.HostnameAddress( dto.Value, dto.IsId ?? true ), - _ => throw new ArgumentOutOfRangeException( nameof(dto), dto.Type, "Unknown address type" ) - }; - } - private static DeclaredDeviceState? Map( DeviceState? dto ) { return dto switch { null => null, @@ -136,4 +156,40 @@ private static IDeviceAddress Map( DeviceAddress dto ) { _ => throw new ArgumentOutOfRangeException( nameof(dto), dto, null ) }; } + + private static List MapIdentityAddresses( Addresses dto ) { + var list = new List(); + + if ( dto.Ipv4 != null ) { + list.Add( new IpV4Address( dto.Ipv4, true ) ); + } + + if ( dto.Mac != null ) { + list.Add( new MacAddress( dto.Mac, true ) ); + } + + if ( dto.Hostname != null ) { + list.Add( new HostnameAddress( dto.Hostname, true ) ); + } + + return list; + } + + private static List MapInfoAddresses( Addresses dto ) { + var list = new List(); + + if ( dto.Ipv4 != null ) { + list.Add( new IpV4Address( dto.Ipv4, false ) ); + } + + if ( dto.Mac != null ) { + list.Add( new MacAddress( dto.Mac, false ) ); + } + + if ( dto.Hostname != null ) { + list.Add( new HostnameAddress( dto.Hostname, false ) ); + } + + return list; + } } \ No newline at end of file diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs index a9e48240..f0896b3a 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs @@ -11,7 +11,28 @@ public static DriftSpec ToDto( Inventory domain ) { return new DriftSpec { Version = VersionConstant, Network = Map( domain.Network ), - Settings = domain.Settings == null ? null : Map( domain.Settings ) + Server = domain.Server == null ? null : Map( domain.Server ), + Settings = domain.Settings == null ? null : Map( domain.Settings ), + Agents = domain.Agents.Count == 0 ? null : domain.Agents.Select( Map ).ToList() + }; + } + + private static Server Map( Domain.Server domain ) { + return new Server { Address = domain.Address }; + } + + private static Agent Map( Domain.Agent domain ) { + return new Agent { Id = domain.Id, Address = domain.Address, Policy = domain.Policy?.Select( Map ).ToList() }; + } + + private static Policy Map( Domain.Policy domain ) { + return new Policy { + To = domain.To, + Expect = domain.Expect, + Port = domain.Port, + Protocol = domain.Protocol, + Probe = domain.Probe, + Fallback = domain.Fallback }; } @@ -53,27 +74,18 @@ private static Subnet Map( DeclaredSubnet domain ) { } private static Device Map( DeclaredDevice domain ) { + var identity = domain.Addresses.Where( a => a.IsId != false ).ToList(); + var info = domain.Addresses.Where( a => a.IsId == false ).ToList(); + return new Device { Id = domain.Id, - Addresses = domain.Addresses.Select( Map ).ToList(), + Addresses = MapAddresses( identity ), + Info = info.Count == 0 ? null : MapAddresses( info ), State = Map( domain.State ), Enabled = domain.Enabled }; } - private static DeviceAddress Map( IDeviceAddress domain ) { - return new DeviceAddress { Value = domain.Value, Type = Map( domain.Type ), IsId = domain.IsId }; - } - - private static string Map( AddressType addressType ) { - return addressType switch { - AddressType.IpV4 => "ip-v4", - AddressType.Mac => "mac", - AddressType.Hostname => "hostname", - _ => throw new ArgumentOutOfRangeException( nameof(addressType), addressType, null ) - }; - } - private static DeviceState? Map( DeclaredDeviceState? domain ) { return domain switch { null => null, @@ -83,4 +95,12 @@ private static string Map( AddressType addressType ) { _ => throw new ArgumentOutOfRangeException( nameof(domain), domain, null ) }; } + + private static Addresses MapAddresses( List domain ) { + return new Addresses { + Ipv4 = domain.OfType().Cast().FirstOrDefault()?.Value, + Mac = domain.OfType().Cast().FirstOrDefault()?.Value, + Hostname = domain.OfType().Cast().FirstOrDefault()?.Value + }; + } } \ No newline at end of file diff --git a/src/Spec/Serialization/YamlStaticContext.cs b/src/Spec/Serialization/YamlStaticContext.cs index e32e52d5..287fceb6 100644 --- a/src/Spec/Serialization/YamlStaticContext.cs +++ b/src/Spec/Serialization/YamlStaticContext.cs @@ -10,10 +10,12 @@ namespace Drift.Spec.Serialization; [YamlSerializable( typeof(Subnet) )] [YamlSerializable( typeof(Device) )] [YamlSerializable( typeof(DeviceState) )] -[YamlSerializable( typeof(DeviceAddress) )] +[YamlSerializable( typeof(Addresses) )] +[YamlSerializable( typeof(Policy) )] [YamlSerializable( typeof(Dtos.V1_preview.Settings) )] [YamlSerializable( typeof(UnknownDevicePolicy) )] [YamlSerializable( typeof(UndeclaredConnectionsPolicy) )] +[YamlSerializable( typeof(Server) )] [YamlSerializable( typeof(Agent) )] /*[YamlSerializable( typeof(IpV4Address) )] [YamlSerializable( typeof(HostnameAddress) )] 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 ddb225f5..5b499950 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 @@ -55,29 +55,55 @@ ] }, "addresses": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "value": { - "type": "string" - }, - "is_id": { - "type": [ - "null", - "boolean" - ] - } + "type": "object", + "properties": { + "ipv4": { + "type": [ + "null", + "string" + ] }, - "required": [ - "type", - "value" - ], - "additionalProperties": false - } + "mac": { + "type": [ + "null", + "string" + ] + }, + "hostname": { + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "info": { + "type": [ + "null", + "object" + ], + "properties": { + "ipv4": { + "type": [ + "null", + "string" + ] + }, + "mac": { + "type": [ + "null", + "string" + ] + }, + "hostname": { + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false }, "state": { "enum": [ @@ -103,6 +129,21 @@ }, "additionalProperties": false }, + "server": { + "type": [ + "null", + "object" + ], + "properties": { + "address": { + "type": "string" + } + }, + "required": [ + "address" + ], + "additionalProperties": false + }, "settings": { "type": [ "null", @@ -151,6 +192,61 @@ }, "address": { "type": "string" + }, + "policy": { + "type": [ + "null", + "array" + ], + "items": { + "type": "object", + "properties": { + "to": { + "type": "array", + "items": { + "type": "string" + } + }, + "expect": { + "type": "string" + }, + "port": { + "type": [ + "null", + "array" + ], + "items": { + "type": "integer" + } + }, + "protocol": { + "type": [ + "null", + "string" + ] + }, + "probe": { + "type": [ + "null", + "array" + ], + "items": { + "type": "string" + } + }, + "fallback": { + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "to", + "expect" + ], + "additionalProperties": false + } } }, "required": [ From 104537b92c95bfe0190387d8ae31f8b65c7567c9 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:43:40 +0200 Subject: [PATCH 37/43] agent status message --- src/Agent.Host/AgentHost.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Agent.Host/AgentHost.cs b/src/Agent.Host/AgentHost.cs index e86bb9ee..d5c05292 100644 --- a/src/Agent.Host/AgentHost.cs +++ b/src/Agent.Host/AgentHost.cs @@ -44,9 +44,7 @@ private static WebApplication Build( 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 - } ); + options.ListenAnyIP( port ); } ); var app = builder.Build(); @@ -55,6 +53,17 @@ private static WebApplication Build( // will get CancellationToken.None. messagingOptions.StoppingToken = app.Lifetime.ApplicationStopping; + app.MapGet( "/", () => + // TODO Render figlet using same flf as in the help command + """ + ___ _ __ _ + | \ _ _ (_) / _| | |_ + | |) | | '_| | | | _| | _| + |___/ |_| |_| |_| \__| + """ + + + "\n\nAgent" + ); app.MapMessagingServerEndpoints(); app.Lifetime.ApplicationStarted.Register( () => { From ef556ddd6aa9424e3643dfb658278ce18acdee61 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:17:09 +0200 Subject: [PATCH 38/43] add server command --- src/Agent.Host/Agent.Host.csproj | 2 +- src/Agent.Host/AgentHost.cs | 1 - ...tCommandTests.cs => ServerCommandTests.cs} | 2 +- src/Cli/Cli.csproj | 1 + src/Cli/Commands/Server/ServerCommand.cs | 10 +++ .../Subcommands/Start/ServerStartCommand.cs | 81 +++++++++++++++++ .../Start/ServerStartParameters.cs | 36 ++++++++ src/Cli/Infrastructure/RootCommandFactory.cs | 7 +- src/Coordinator.Host/AgentHost.cs | 14 --- src/Coordinator.Host/Coordinator.Host.csproj | 7 +- src/Coordinator.Host/CoordinatorHost.cs | 88 +++++++++++++++++++ 11 files changed, 229 insertions(+), 20 deletions(-) rename src/Cli.Tests/Commands/{AgentCommandTests.cs => ServerCommandTests.cs} (96%) create mode 100644 src/Cli/Commands/Server/ServerCommand.cs create mode 100644 src/Cli/Commands/Server/Subcommands/Start/ServerStartCommand.cs create mode 100644 src/Cli/Commands/Server/Subcommands/Start/ServerStartParameters.cs delete mode 100644 src/Coordinator.Host/AgentHost.cs create mode 100644 src/Coordinator.Host/CoordinatorHost.cs diff --git a/src/Agent.Host/Agent.Host.csproj b/src/Agent.Host/Agent.Host.csproj index ae98a3fc..a9486bea 100644 --- a/src/Agent.Host/Agent.Host.csproj +++ b/src/Agent.Host/Agent.Host.csproj @@ -12,4 +12,4 @@ - + \ No newline at end of file diff --git a/src/Agent.Host/AgentHost.cs b/src/Agent.Host/AgentHost.cs index d5c05292..9663ae17 100644 --- a/src/Agent.Host/AgentHost.cs +++ b/src/Agent.Host/AgentHost.cs @@ -4,7 +4,6 @@ using Drift.Networking.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; diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/ServerCommandTests.cs similarity index 96% rename from src/Cli.Tests/Commands/AgentCommandTests.cs rename to src/Cli.Tests/Commands/ServerCommandTests.cs index 11f913cf..f9222cab 100644 --- a/src/Cli.Tests/Commands/AgentCommandTests.cs +++ b/src/Cli.Tests/Commands/ServerCommandTests.cs @@ -3,7 +3,7 @@ namespace Drift.Cli.Tests.Commands; -internal sealed class AgentCommandTests { +internal sealed class ServerCommandTests { [CancelAfter( 3000 )] [Test] public async Task RespectsCancellationToken() { diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj index 42f9985d..6e3ae1fb 100644 --- a/src/Cli/Cli.csproj +++ b/src/Cli/Cli.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Cli/Commands/Server/ServerCommand.cs b/src/Cli/Commands/Server/ServerCommand.cs new file mode 100644 index 00000000..a6d9e180 --- /dev/null +++ b/src/Cli/Commands/Server/ServerCommand.cs @@ -0,0 +1,10 @@ +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Server.Subcommands.Start; + +namespace Drift.Cli.Commands.Server; + +internal class ServerCommand : ContainerCommandBase { + internal ServerCommand( IServiceProvider provider ) : base( "server", "Manage the local Drift server" ) { + Subcommands.Add( new ServerStartCommand( provider ) ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Server/Subcommands/Start/ServerStartCommand.cs b/src/Cli/Commands/Server/Subcommands/Start/ServerStartCommand.cs new file mode 100644 index 00000000..60b7c704 --- /dev/null +++ b/src/Cli/Commands/Server/Subcommands/Start/ServerStartCommand.cs @@ -0,0 +1,81 @@ +using System.CommandLine; +using Drift.Agent.Host; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Agent.Subcommands; +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.Coordinator.Host; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Commands.Server.Subcommands.Start; + +// TODO No spec should be provided - do not inherit from CommandBase - or CommandBase should not include it +internal class ServerStartCommand : CommandBase { + internal ServerStartCommand( IServiceProvider provider ) : base( "start", "Start a local Drift server", provider ) { + Options.Add( ServerStartParameters.Options.PortS ); + Options.Add( ServerStartParameters.Options.PortAgent ); + } + + protected override ServerStartParameters CreateParameters( ParseResult result ) { + return new ServerStartParameters( result ); + } +} + +internal class ServerStartCommandHandler( + IOutputManager output, + AgentLifetime? agentLifetime = null, + Action? configureServicesOverride = null +) + : ICommandHandler { + public async Task Invoke( ServerStartParameters parameters, CancellationToken cancellationToken ) { + output.Log.LogDebug( "Running 'server start' command" ); + + output.WarnAgentPreview(); + + var logger = output.GetLogger(); + + logger.LogInformation( "Server starting..." ); + + /*Inventory? inventory; + + try { + inventory = await specProvider.GetDeserializedAsync( parameters.SpecFile ); + } + catch ( FileNotFoundException ) { + return ExitCodes.GeneralError; + }*/ + + output.Log.LogDebug( "Starting server..." ); + + try { + await CoordinatorHost.Run( + parameters.PortS, + logger, + ConfigureServices, + cancellationToken, + agentLifetime?.Ready + ); + } + catch ( OperationCanceledException ) when ( cancellationToken.IsCancellationRequested ) { + // Graceful shutdown via cancellation + } + + 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.AddAgentHandlers(); + + // Allow test overrides + configureServicesOverride?.Invoke( services ); + } + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Server/Subcommands/Start/ServerStartParameters.cs b/src/Cli/Commands/Server/Subcommands/Start/ServerStartParameters.cs new file mode 100644 index 00000000..d6fa4da4 --- /dev/null +++ b/src/Cli/Commands/Server/Subcommands/Start/ServerStartParameters.cs @@ -0,0 +1,36 @@ +using System.CommandLine; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Parameters; + +namespace Drift.Cli.Commands.Server.Subcommands.Start; + +internal record ServerStartParameters : BaseParameters { + internal static class Options { + internal static readonly Option Daemon = new("--daemon", "-d") { + Description = "Run the server as a background daemon" + }; + + internal static readonly Option PortS = new("--port", "-p") { + DefaultValueFactory = _ => Ports.AgentDefault - 5, Description = "Set the port used for communication" + }; + + internal static readonly Option PortAgent = new("--port-agent", "-pa") { + DefaultValueFactory = _ => Ports.AgentDefault, Description = "Set the port used for agent communication" + }; + } + + internal ServerStartParameters( ParseResult parseResult ) : base( parseResult ) { + PortS = parseResult.GetValue( Options.PortS ); + PortAgent = parseResult.GetValue( Options.PortAgent ); + } + + public ushort PortS { + get; + set; + } + + public ushort PortAgent { + get; + set; + } +} \ No newline at end of file diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs index 24e91f0f..13ee7c1c 100644 --- a/src/Cli/Infrastructure/RootCommandFactory.cs +++ b/src/Cli/Infrastructure/RootCommandFactory.cs @@ -10,6 +10,8 @@ using Drift.Cli.Commands.Lint; using Drift.Cli.Commands.Scan; using Drift.Cli.Commands.Scan.Interactive.Input; +using Drift.Cli.Commands.Server; +using Drift.Cli.Commands.Server.Subcommands.Start; using Drift.Cli.Presentation.Console; using Drift.Cli.Presentation.Console.Logging; using Drift.Cli.Presentation.Console.Managers.Abstractions; @@ -79,7 +81,6 @@ private static void ConfigureDefaults( IServiceCollection services, bool toConso ConfigureSubnetProvider( services ); ConfigureNetworkScanner( services ); ConfigureInteractiveServices( services ); - ConfigureAgentCluster( services ); ConfigureAgentClient( services ); } @@ -102,7 +103,8 @@ private static RootCommand CreateRootCommand( IServiceProvider provider ) { new InitCommand( provider ), new ScanCommand( provider ), new LintCommand( provider ), - new AgentCommand( provider ) + new AgentCommand( provider ), + new ServerCommand( provider ) }; rootCommand.TreatUnmatchedTokensAsErrors = true; @@ -138,6 +140,7 @@ private static void ConfigureBuiltInCommandHandlers( IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void ConfigureInteractiveServices( IServiceCollection services ) { diff --git a/src/Coordinator.Host/AgentHost.cs b/src/Coordinator.Host/AgentHost.cs deleted file mode 100644 index 0ce642fb..00000000 --- a/src/Coordinator.Host/AgentHost.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Drift.Messaging.Protocol; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Drift.Coordinator.Host; - -#pragma warning disable S2094 -public static class CoordinatorHost { -#pragma warning restore S2094 - // TODO implement -} \ No newline at end of file diff --git a/src/Coordinator.Host/Coordinator.Host.csproj b/src/Coordinator.Host/Coordinator.Host.csproj index e32b5757..26161a79 100644 --- a/src/Coordinator.Host/Coordinator.Host.csproj +++ b/src/Coordinator.Host/Coordinator.Host.csproj @@ -7,6 +7,11 @@ + + + + + - + \ No newline at end of file diff --git a/src/Coordinator.Host/CoordinatorHost.cs b/src/Coordinator.Host/CoordinatorHost.cs new file mode 100644 index 00000000..7f8eada8 --- /dev/null +++ b/src/Coordinator.Host/CoordinatorHost.cs @@ -0,0 +1,88 @@ +using Drift.Messaging.Protocol; +using Drift.Networking.Client; +using Drift.Networking.Core; +using Drift.Networking.Server; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Drift.Coordinator.Host; + +// TODO mostly a duplicate of AgentHost +public static class CoordinatorHost { + 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 ); + } + + public static WebApplication Build( + ushort port, + ILogger logger, + Action? configureServices = null, + TaskCompletionSource? ready = null + ) { + var builder = WebApplication.CreateSlimBuilder(); + + builder.Logging.ClearProviders(); + builder.Services.AddSingleton( logger ); + // TODO consolidate all the addmessaging* into single configurable extension that can be used for all roles + // (CLI, Agent, Coordinator) with different config flags. Should be high-level (domain preferred) + builder.Services.AddMessagingServer( options => { + options.EnableDetailedErrors = true; + } ); + builder.Services.AddMessagingClient(); + var messagingOptions = new MessagingOptions { MessageAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly }; + builder.Services.AddMessagingCore( messagingOptions ); + configureServices?.Invoke( builder.Services ); + + builder.WebHost.ConfigureKestrel( options => { + options.ListenAnyIP( port ); + } ); + + AddServerStuff( builder ); + + var app = builder.Build(); + + // Note: a service reading StoppingToken during initialization (really, any code run before this point) + // will get CancellationToken.None. + messagingOptions.StoppingToken = app.Lifetime.ApplicationStopping; + + app.MapGet( "/", () => + // TODO Render figlet using same flf as in the help command + """ + ___ _ __ _ + | \ _ _ (_) / _| | |_ + | |) | | '_| | | | _| | _| + |___/ |_| |_| |_| \__| + """ + + + "\n\nServer" + ); + app.MapMessagingServerEndpoints(); + + app.Lifetime.ApplicationStarted.Register( () => { + logger.LogInformation( "Listening for incoming connections on port {Port}", port ); + logger.LogInformation( "Server started" ); + ready?.TrySetResult(); + } ); + app.Lifetime.ApplicationStopping.Register( () => { + logger.LogInformation( "Server stopping..." ); + } ); + app.Lifetime.ApplicationStopped.Register( () => { + logger.LogInformation( "Server stopped" ); + } ); + + return app; + } + + private static void AddServerStuff( WebApplicationBuilder app ) { + } +} \ No newline at end of file From 6c55870dbb43dcd14742dbd1441e06ff0d78eaef Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:58:17 +0200 Subject: [PATCH 39/43] add env commands --- .editorconfig | 1 + src/Agent.Host/AgentHost.cs | 33 +++-- ...lizationTests.DefaultContents.verified.txt | 4 +- src/Cli.Settings.Tests/SerializationTests.cs | 30 +++++ .../DefaultSettingsLocationProvider.cs | 2 +- .../V1_preview/CliSettings.Serialization.cs | 2 +- src/Cli.Settings/V1_preview/CliSettings.cs | 11 ++ .../Environments/CliSettingsExtensions.cs | 14 ++ .../Environments/EnvironmentSetting.cs | 3 + .../drift-settings-v1-preview.schema.json | 20 +++ src/Cli.Tests/Commands/EnvCommandTests.Add.cs | 82 +++++++++++ ..._DuplicateName_FailsWithError.verified.txt | 1 + ...dd_MissingName_FailsWithError.verified.txt | 1 + ...nments_OnlyFirstBecomesActive.verified.txt | 1 + ..._AddsEnvironmentAndSetsActive.verified.txt | 1 + ...Tests.EnvList_ListAlias_Works.verified.txt | 1 + ...thOneActive_DisplaysCorrectly.verified.txt | 3 + ...veEnvironment_DisplaysWarning.verified.txt | 5 + ...ronments_DisplaysEmptyMessage.verified.txt | 2 + ...ysWithActive_commandName=list.verified.txt | 1 + ...laysWithActive_commandName=ls.verified.txt | 1 + ...NoEnvironments_FailsWithError.verified.txt | 1 + ...entEnvironment_FailsWithError.verified.txt | 1 + ...earsActive_AdvisesCreatingOne.verified.txt | 3 + ...ctiveEnvironment_ClearsActive.verified.txt | 3 + ...ve_Success_RemovesEnvironment.verified.txt | 3 + ...NoEnvironments_FailsWithError.verified.txt | 1 + ...entEnvironment_FailsWithError.verified.txt | 1 + ...Success_SetsActiveEnvironment.verified.txt | 1 + ...ow_AddMultipleAndManage_Works.verified.txt | 2 + .../Commands/EnvCommandTests.Integration.cs | 31 +++++ .../Commands/EnvCommandTests.List.cs | 70 ++++++++++ .../Commands/EnvCommandTests.Remove.cs | 127 ++++++++++++++++++ src/Cli.Tests/Commands/EnvCommandTests.Use.cs | 88 ++++++++++++ src/Cli.Tests/Commands/EnvCommandTests.cs | 49 +++++++ ...emCommandLineDefaultErrorTest.verified.txt | 2 + src/Cli.Tests/Utils/DriftTestCli.cs | 16 ++- .../Commands/Common/Commands/CommandBase.cs | 27 +++- src/Cli/Commands/Env/EnvCommand.cs | 23 ++++ .../Commands/Env/Subcommands/EnvAddCommand.cs | 78 +++++++++++ .../Env/Subcommands/EnvListCommand.cs | 76 +++++++++++ .../Env/Subcommands/EnvRemoveCommand.cs | 82 +++++++++++ .../Commands/Env/Subcommands/EnvUseCommand.cs | 65 +++++++++ src/Cli/Commands/Init/InitCommand.cs | 2 +- src/Cli/Commands/Lint/LintCommand.cs | 3 +- .../Lint/Presentation/NormalLintRenderer.cs | 11 +- src/Cli/Commands/Preview/EnvCommand.cs | 62 --------- src/Cli/Infrastructure/RootCommandFactory.cs | 11 +- .../Logging/OutputManagerExtensions.cs | 9 ++ .../Managers/Abstractions/INormalOutput.cs | 16 +-- .../Console/Managers/NullOutputManager.cs | 16 +-- .../Managers/Outputs/NormalOutput.Verbose.cs | 12 +- .../Outputs/NormalOutput.VeryVerbose.cs | 12 +- .../Rendering/NormalOutputExtensions.cs | 23 ++-- src/Cli/SpecFile/FileSystemSpecProvider.cs | 12 +- src/Coordinator.Host/CoordinatorHost.cs | 33 +++-- 56 files changed, 1034 insertions(+), 157 deletions(-) create mode 100644 src/Cli.Settings/V1_preview/Environments/CliSettingsExtensions.cs create mode 100644 src/Cli.Settings/V1_preview/Environments/EnvironmentSetting.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.Add.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_DuplicateName_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MissingName_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_Success_AddsEnvironmentAndSetsActive.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_ListAlias_Works.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoActiveEnvironment_DisplaysWarning.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysEmptyMessage.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.Integration.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.List.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.Remove.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.Use.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.cs create mode 100644 src/Cli/Commands/Env/EnvCommand.cs create mode 100644 src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs create mode 100644 src/Cli/Commands/Env/Subcommands/EnvListCommand.cs create mode 100644 src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs create mode 100644 src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs delete mode 100644 src/Cli/Commands/Preview/EnvCommand.cs diff --git a/.editorconfig b/.editorconfig index c21b764c..63315da4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -159,6 +159,7 @@ dotnet_diagnostic.CA1848.severity = suggestion dotnet_diagnostic.CA2007.severity = suggestion dotnet_diagnostic.CA1303.severity = none dotnet_diagnostic.CA1848.severity = none +dotnet_diagnostic.CA1068.severity = warning # Elements should be documented dotnet_diagnostic.SA1600.severity = none # Braces for multi-line statements should not share line diff --git a/src/Agent.Host/AgentHost.cs b/src/Agent.Host/AgentHost.cs index 9663ae17..a0d8fbe7 100644 --- a/src/Agent.Host/AgentHost.cs +++ b/src/Agent.Host/AgentHost.cs @@ -4,6 +4,7 @@ using Drift.Networking.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; @@ -43,7 +44,9 @@ private static WebApplication Build( configureServices?.Invoke( builder.Services ); builder.WebHost.ConfigureKestrel( options => { - options.ListenAnyIP( port ); + options.ListenAnyIP( port, o => { + o.Protocols = HttpProtocols.Http2; // gRPC requires HTTP/2 + } ); } ); var app = builder.Build(); @@ -52,17 +55,23 @@ private static WebApplication Build( // will get CancellationToken.None. messagingOptions.StoppingToken = app.Lifetime.ApplicationStopping; - app.MapGet( "/", () => - // TODO Render figlet using same flf as in the help command - """ - ___ _ __ _ - | \ _ _ (_) / _| | |_ - | |) | | '_| | | | _| | _| - |___/ |_| |_| |_| \__| - """ - + - "\n\nAgent" - ); + // Unreachable while Kestrel ListenOptions.Protocols is HTTP/2-only (browsers can't speak HTTP/2 without TLS), + // but kept here for when this is moved to its own HTTP/1.1 port. + // Setting it to Http1AndHttp2 is not an option since that degrades ALL connections, including gRPC + // calls, to HTTP/1.1, which then fail against gRPC's HTTP/2-only endpoints with HTTP_1_1_REQUIRED. + // See https://github.com/grpc/grpc-dotnet/issues/979. So this must stay HTTP/2-only until either + // TLS is added or the friendly "/" page below is moved to its own HTTP/1.1-only port. + // app.MapGet( "/", () => + // // TODO Render figlet using same flf as in the help command + // """ + // ___ _ __ _ + // | \ _ _ (_) / _| | |_ + // | |) | | '_| | | | _| | _| + // |___/ |_| |_| |_| \__| + // """ + // + + // "\n\nAgent" + // ); app.MapMessagingServerEndpoints(); app.Lifetime.ApplicationStarted.Register( () => { diff --git a/src/Cli.Settings.Tests/SerializationTests.DefaultContents.verified.txt b/src/Cli.Settings.Tests/SerializationTests.DefaultContents.verified.txt index 5e469f66..baeb954b 100644 --- a/src/Cli.Settings.Tests/SerializationTests.DefaultContents.verified.txt +++ b/src/Cli.Settings.Tests/SerializationTests.DefaultContents.verified.txt @@ -2,5 +2,7 @@ "features": [], "appearance": { "output": "default" - } + }, + "environments": [], + "activeEnvironment": null } \ No newline at end of file diff --git a/src/Cli.Settings.Tests/SerializationTests.cs b/src/Cli.Settings.Tests/SerializationTests.cs index bb4f73f2..604abdbf 100644 --- a/src/Cli.Settings.Tests/SerializationTests.cs +++ b/src/Cli.Settings.Tests/SerializationTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Drift.Cli.Settings.Serialization; using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.Environments; using Drift.Cli.Settings.V1_preview.FeatureFlags; using Microsoft.Extensions.Logging.Abstractions; @@ -49,6 +50,35 @@ public void WriteAndReadRoundtrip() { Directory.Delete( location.GetDirectory(), true ); } + [Test] + public void EnvironmentsWriteAndReadRoundtrip() { + // Arrange + ISettingsLocationProvider location = new TemporarySettingsLocationProvider(); + var logger = NullLogger.Instance; + var original = new CliSettings { + Environments = { + new EnvironmentSetting( "main-site", "192.168.1.10:51515" ), + new EnvironmentSetting( "backup-site", "192.168.2.10:51515" ) + }, + ActiveEnvironment = "main-site" + }; + + // Act + original.Write( logger, location ); + var reloaded = CliSettings.Read( logger, location ); + + // Assert + using ( Assert.EnterMultipleScope() ) { + Assert.That( reloaded.Environments, Has.Count.EqualTo( 2 ) ); + Assert.That( reloaded.Environments[0].Name, Is.EqualTo( "main-site" ) ); + Assert.That( reloaded.Environments[0].Address, Is.EqualTo( "192.168.1.10:51515" ) ); + Assert.That( reloaded.ActiveEnvironment, Is.EqualTo( "main-site" ) ); + Assert.That( reloaded.GetActiveEnvironment(), Is.EqualTo( original.Environments[0] ) ); + } + + Directory.Delete( location.GetDirectory(), true ); + } + [Test] public async Task LoadsDefaultsWhenBadJson() { // Arrange diff --git a/src/Cli.Settings/Serialization/DefaultSettingsLocationProvider.cs b/src/Cli.Settings/Serialization/DefaultSettingsLocationProvider.cs index c46e460c..be0d07c9 100644 --- a/src/Cli.Settings/Serialization/DefaultSettingsLocationProvider.cs +++ b/src/Cli.Settings/Serialization/DefaultSettingsLocationProvider.cs @@ -3,7 +3,7 @@ namespace Drift.Cli.Settings.Serialization; -internal sealed class DefaultSettingsLocationProvider : ISettingsLocationProvider { +public sealed class DefaultSettingsLocationProvider : ISettingsLocationProvider { public string GetDirectory() { var configDirOverride = Environment.GetEnvironmentVariable( nameof(EnvVar.Drift_ConfigDir) ); diff --git a/src/Cli.Settings/V1_preview/CliSettings.Serialization.cs b/src/Cli.Settings/V1_preview/CliSettings.Serialization.cs index ef0e647c..95f19ccc 100644 --- a/src/Cli.Settings/V1_preview/CliSettings.Serialization.cs +++ b/src/Cli.Settings/V1_preview/CliSettings.Serialization.cs @@ -14,7 +14,7 @@ public static CliSettings Read( ILogger? logger = null, ISettingsLocationProvide logger?.LogTrace( "Reading settings from file: {Path}", location.GetFile() ); if ( !File.Exists( location.GetFile() ) ) { - logger?.LogInformation( "Settings file not found. Using defaults." ); + logger?.LogDebug( "Settings file not found. Using defaults." ); return new CliSettings(); } diff --git a/src/Cli.Settings/V1_preview/CliSettings.cs b/src/Cli.Settings/V1_preview/CliSettings.cs index eafbc1fd..0402d421 100644 --- a/src/Cli.Settings/V1_preview/CliSettings.cs +++ b/src/Cli.Settings/V1_preview/CliSettings.cs @@ -1,4 +1,5 @@ using Drift.Cli.Settings.V1_preview.Appearance; +using Drift.Cli.Settings.V1_preview.Environments; using Drift.Cli.Settings.V1_preview.FeatureFlags; namespace Drift.Cli.Settings.V1_preview; @@ -13,4 +14,14 @@ public AppearanceSettings Appearance { get; set; } = new(OutputFormatSetting.Default); + + public List Environments { + get; + set; + } = []; + + public string? ActiveEnvironment { + get; + set; + } } \ No newline at end of file diff --git a/src/Cli.Settings/V1_preview/Environments/CliSettingsExtensions.cs b/src/Cli.Settings/V1_preview/Environments/CliSettingsExtensions.cs new file mode 100644 index 00000000..626b2f8e --- /dev/null +++ b/src/Cli.Settings/V1_preview/Environments/CliSettingsExtensions.cs @@ -0,0 +1,14 @@ +namespace Drift.Cli.Settings.V1_preview.Environments; + +public static class CliSettingsExtensions { + public static bool TryGetEnvironment( this CliSettings settings, string name, out EnvironmentSetting? environment ) { + environment = settings.Environments.Find( e => e.Name == name ); + return environment != null; + } + + public static EnvironmentSetting? GetActiveEnvironment( this CliSettings settings ) { + return settings.ActiveEnvironment == null + ? null + : settings.Environments.Find( e => e.Name == settings.ActiveEnvironment ); + } +} \ No newline at end of file diff --git a/src/Cli.Settings/V1_preview/Environments/EnvironmentSetting.cs b/src/Cli.Settings/V1_preview/Environments/EnvironmentSetting.cs new file mode 100644 index 00000000..3d19fa50 --- /dev/null +++ b/src/Cli.Settings/V1_preview/Environments/EnvironmentSetting.cs @@ -0,0 +1,3 @@ +namespace Drift.Cli.Settings.V1_preview.Environments; + +public record EnvironmentSetting( string Name, string Address ); \ No newline at end of file diff --git a/src/Cli.Settings/embedded_resources/schemas/drift-settings-v1-preview.schema.json b/src/Cli.Settings/embedded_resources/schemas/drift-settings-v1-preview.schema.json index 4c3eaa18..b3ea85da 100644 --- a/src/Cli.Settings/embedded_resources/schemas/drift-settings-v1-preview.schema.json +++ b/src/Cli.Settings/embedded_resources/schemas/drift-settings-v1-preview.schema.json @@ -33,6 +33,26 @@ ] } } + }, + "environments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "address": { + "type": "string" + } + } + } + }, + "activeEnvironment": { + "type": [ + "null", + "string" + ] } } } \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Add.cs b/src/Cli.Tests/Commands/EnvCommandTests.Add.cs new file mode 100644 index 00000000..e874f538 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Add.cs @@ -0,0 +1,82 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Settings.V1_preview; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvAdd_Success_AddsEnvironmentAndSetsActive() { + // Arrange / Act + WriteSettings( new CliSettings() ); + + var (exitCode, output, error) = await InvokeAsync( + "env add myenv localhost:5000" + ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + Assert.That( settings.Environments[0].Name, Is.EqualTo( "myenv" ) ); + Assert.That( settings.Environments[0].Address, Is.EqualTo( "localhost:5000" ) ); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "myenv" ) ); + } + + [Test] + public async Task EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env add env2 host2:5000" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 2 ) ); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env1" ) ); + } + + [Test] + public async Task EnvAdd_DuplicateName_FailsWithError() { + // Arrange + CreateInitialEnvironment( "myenv", "localhost:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( + "env add myenv localhost:5001" + ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + Assert.That( settings.Environments[0].Address, Is.EqualTo( "localhost:5000" ) ); + } + + [Test] + public async Task EnvAdd_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env add localhost:5000" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'add'." ) ); + } + + [Test] + public async Task EnvAdd_MissingUri_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env add myenv" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'add'." ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_DuplicateName_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_DuplicateName_FailsWithError.verified.txt new file mode 100644 index 00000000..7da57788 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_DuplicateName_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'myenv' already exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MissingName_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MissingName_FailsWithError.verified.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MissingName_FailsWithError.verified.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive.verified.txt new file mode 100644 index 00000000..3c5d5346 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive.verified.txt @@ -0,0 +1 @@ +✓ Added 'env2@host2:5000' diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_Success_AddsEnvironmentAndSetsActive.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_Success_AddsEnvironmentAndSetsActive.verified.txt new file mode 100644 index 00000000..d622f929 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_Success_AddsEnvironmentAndSetsActive.verified.txt @@ -0,0 +1 @@ +✓ Added 'myenv@localhost:5000' diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_ListAlias_Works.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_ListAlias_Works.verified.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_ListAlias_Works.verified.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt new file mode 100644 index 00000000..dde91fc3 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt @@ -0,0 +1,3 @@ + env1 @ host1:5000 +* env2 @ host2:5000 + env3 @ host3:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoActiveEnvironment_DisplaysWarning.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoActiveEnvironment_DisplaysWarning.verified.txt new file mode 100644 index 00000000..6a711962 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoActiveEnvironment_DisplaysWarning.verified.txt @@ -0,0 +1,5 @@ + env1 @ host1:5000 + env2 @ host2:5000 + +No environment is active. +💡️ Set one with drift env use diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysEmptyMessage.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysEmptyMessage.verified.txt new file mode 100644 index 00000000..5500d3c2 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysEmptyMessage.verified.txt @@ -0,0 +1,2 @@ +No environments configured. +💡️ Add one with drift env add
diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt new file mode 100644 index 00000000..c0d67a68 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt @@ -0,0 +1 @@ +* myenv @ localhost:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt new file mode 100644 index 00000000..c0d67a68 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt @@ -0,0 +1 @@ +* myenv @ localhost:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt new file mode 100644 index 00000000..0142b7ab --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'myenv' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt new file mode 100644 index 00000000..02b39abe --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'nonexistent' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt new file mode 100644 index 00000000..049bd2ed --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt @@ -0,0 +1,3 @@ +✓ Removed 'env1' +No environments are configured. +💡️ Add one with drift env add
diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt new file mode 100644 index 00000000..ee54af10 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt @@ -0,0 +1,3 @@ +✓ Removed 'env1' +No environment is active. +💡️ Set one with drift env use diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt new file mode 100644 index 00000000..ee54af10 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt @@ -0,0 +1,3 @@ +✓ Removed 'env1' +No environment is active. +💡️ Set one with drift env use diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt new file mode 100644 index 00000000..0142b7ab --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'myenv' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt new file mode 100644 index 00000000..02b39abe --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'nonexistent' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt new file mode 100644 index 00000000..25887a0f --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt @@ -0,0 +1 @@ +✓ 'env2' is active diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt new file mode 100644 index 00000000..b9118f12 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt @@ -0,0 +1,2 @@ + env1 @ host1:5000 +* env2 @ host2:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs b/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs new file mode 100644 index 00000000..a193645b --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs @@ -0,0 +1,31 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvWorkflow_AddMultipleAndManage_Works() { + // Arrange / Act - add env1 + var (exitCode1, _, _) = await InvokeAsync( "env add env1 host1:5000" ); + Assert.That( exitCode1, Is.EqualTo( ExitCodes.Success ) ); + + // Act - add env2 + var (exitCode2, _, _) = await InvokeAsync( "env add env2 host2:5000" ); + Assert.That( exitCode2, Is.EqualTo( ExitCodes.Success ) ); + + // Act - switch to env2 + var (exitCode3, _, _) = await InvokeAsync( "env use env2" ); + Assert.That( exitCode3, Is.EqualTo( ExitCodes.Success ) ); + + // Act - list + var (exitCode4, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode4, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 2 ) ); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.List.cs b/src/Cli.Tests/Commands/EnvCommandTests.List.cs new file mode 100644 index 00000000..ea96bb60 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.List.cs @@ -0,0 +1,70 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Settings.V1_preview; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvList_NoEnvironments_DisplaysEmptyMessage() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_SingleEnvironment_DisplaysWithActive( [Values( "list", "ls" )] string commandName ) { + // Arrange + CreateInitialEnvironment( "myenv", "localhost:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( $"env {commandName}" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env2"; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_NoActiveEnvironment_DisplaysWarning() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = null; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs b/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs new file mode 100644 index 00000000..b5129884 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs @@ -0,0 +1,127 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Settings.V1_preview; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvRemove_Success_RemovesEnvironment() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + Assert.That( settings.Environments[0].Name, Is.EqualTo( "env2" ) ); + } + + [Test] + public async Task EnvRemove_RemovesActiveEnvironment_ClearsActive() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env1"; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.Null ); + Assert.That( updatedSettings.Environments, Has.Count.EqualTo( 1 ) ); + } + + [Test] + public async Task EnvRemove_RemovesNonActiveEnvironment_KeepsActiveUnchanged() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env2"; + WriteSettings( settings ); + + // Act + var (exitCode, _, _) = await InvokeAsync( "env remove env1" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + Assert.That( updatedSettings.Environments, Has.Count.EqualTo( 2 ) ); + } + + [Test] + public async Task EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Is.Empty ); + Assert.That( settings.ActiveEnvironment, Is.Null ); + } + + [Test] + public async Task EnvRemove_NonExistentEnvironment_FailsWithError() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + } + + [Test] + public async Task EnvRemove_NoEnvironments_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env remove myenv" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + } + + [Test] + public async Task EnvRemove_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env remove" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'remove'." ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Use.cs b/src/Cli.Tests/Commands/EnvCommandTests.Use.cs new file mode 100644 index 00000000..c4547ccf --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Use.cs @@ -0,0 +1,88 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Settings.V1_preview; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvUse_Success_SetsActiveEnvironment() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use env2" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + } + + [Test] + public async Task EnvUse_NonExistentEnvironment_FailsWithError() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env1" ) ); + } + + [Test] + public async Task EnvUse_NoEnvironments_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env use myenv" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + } + + [Test] + public async Task EnvUse_SwitchBetweenEnvironments_Works() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env1"; + WriteSettings( settings ); + + // Act - switch to env2 + var (exitCode1, _, _) = await InvokeAsync( "env use env2" ); + Assert.That( exitCode1, Is.EqualTo( ExitCodes.Success ) ); + + // Act - switch to env3 + var (exitCode2, _, _) = await InvokeAsync( "env use env3" ); + + // Assert + Assert.That( exitCode2, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.EqualTo( "env3" ) ); + } + + [Test] + public async Task EnvUse_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env use" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'use'." ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.cs b/src/Cli.Tests/Commands/EnvCommandTests.cs new file mode 100644 index 00000000..d05352d7 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.cs @@ -0,0 +1,49 @@ +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.Tests; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.Environments; +using Drift.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + private ISettingsLocationProvider SettingsLocation { + get; + } = new TemporarySettingsLocationProvider(); + + private Task InvokeAsync( string args ) { + return DriftTestCli.InvokeAsync( args, settingsLocation: SettingsLocation ); + } + + private CliSettings ReadSettings() { + return CliSettings.Read( location: SettingsLocation ); + } + + private void WriteSettings( CliSettings settings ) { + settings.Write( NullLogger.Instance, location: SettingsLocation ); + } + + [TearDown] + public void TearDown() { + var settingsDir = SettingsLocation.GetDirectory(); + if ( Directory.Exists( settingsDir ) ) { + Directory.Delete( settingsDir, true ); + } + } + + private void CreateInitialEnvironment( string name, string address ) { + CreateInitialEnvironments( ( name, address ) ); + } + + private void CreateInitialEnvironments( params (string name, string address)[] environments ) { + var settings = new CliSettings { + Environments = environments + .Select( e => new EnvironmentSetting( e.name, e.address ) ) + .ToList(), + ActiveEnvironment = environments.Length > 0 ? environments[0].name : null + }; + + WriteSettings( settings ); + } +} \ 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 bf9563ca..776d04d0 100644 --- a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt +++ b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt @@ -19,6 +19,8 @@ Commands: scan Scan the network and detect drift lint Validate a network spec agent Manage the local Drift agent + server Manage the local Drift server + env Manage Drift environments Required command was not provided. Unrecognized command or argument 'nonexisting'. diff --git a/src/Cli.Tests/Utils/DriftTestCli.cs b/src/Cli.Tests/Utils/DriftTestCli.cs index 162f0163..fb8e0b7a 100644 --- a/src/Cli.Tests/Utils/DriftTestCli.cs +++ b/src/Cli.Tests/Utils/DriftTestCli.cs @@ -2,6 +2,8 @@ using System.CommandLine.Parsing; using Drift.Cli.Commands.Agent.Subcommands; using Drift.Cli.Infrastructure; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.Tests; using Microsoft.Extensions.DependencyInjection; namespace Drift.Cli.Tests.Utils; @@ -17,8 +19,10 @@ internal static async Task InvokeAsync( Action? configureServices = null, RootCommandFactory.CommandRegistration[]? customCommands = null, CancellationToken cancellationToken = default, - bool redirectConsole = true + bool redirectConsole = true, + ISettingsLocationProvider? settingsLocation = null ) { + settingsLocation ??= new TemporarySettingsLocationProvider(); var token = cancellationToken; CancellationTokenSource? cancellationTokenSource = null; @@ -35,6 +39,12 @@ void ConfigureInvocation( InvocationConfiguration config ) { config.Error = error; } + // Register test settings + Action wrappedConfigure = services => { + services.AddSingleton( settingsLocation ); + configureServices?.Invoke( services ); + }; + /* * Most output is written to the InvocationConfiguration's TextWriters, but a few errors may be written to * Console.Out/Error when DI is not yet available. @@ -57,7 +67,7 @@ void ConfigureInvocation( InvocationConfiguration config ) { CommandLineParser.SplitCommandLine( args ).ToArray(), false, true, - configureServices, + wrappedConfigure, customCommands, ConfigureInvocation, token @@ -78,7 +88,7 @@ void ConfigureInvocation( InvocationConfiguration config ) { CommandLineParser.SplitCommandLine( args ).ToArray(), false, true, - configureServices, + wrappedConfigure, customCommands, ConfigureInvocation, token diff --git a/src/Cli/Commands/Common/Commands/CommandBase.cs b/src/Cli/Commands/Common/Commands/CommandBase.cs index e7862574..10cf6a37 100644 --- a/src/Cli/Commands/Common/Commands/CommandBase.cs +++ b/src/Cli/Commands/Common/Commands/CommandBase.cs @@ -1,20 +1,33 @@ using System.CommandLine; using Drift.Cli.Abstractions; using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Presentation.Console.Logging; using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.V1_preview; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; namespace Drift.Cli.Commands.Common.Commands; internal abstract class CommandBase : Command where TParameters : BaseParameters where THandler : ICommandHandler { - protected CommandBase( string name, string description, IServiceProvider provider ) : base( name, description ) { + protected CommandBase( + string name, + string description, + IServiceProvider provider, + bool includeSpecArgument = true + ) : base( name, description ) { Add( CommonParameters.Options.Verbose ); // TODO re-intro when fixed // AddOption( GlobalParameters.Options.VeryVerbose ); Add( CommonParameters.Options.OutputFormat ); - Add( CommonParameters.Arguments.Spec ); + + // TODO hack + if ( includeSpecArgument ) { + Add( CommonParameters.Arguments.Spec ); + } SetAction( async ( parseResult, cancellationToken ) => { await using var scope = provider.CreateAsyncScope(); @@ -22,6 +35,14 @@ protected CommandBase( string name, string description, IServiceProvider provide serviceProvider.GetRequiredService().ParseResult = parseResult; + var output = serviceProvider.GetRequiredService(); + // Uses a null logger: the --output option's default value factory already reads (and logs errors for) + // the settings file once per invocation; avoid surfacing the same error a second time here. + // TODO read cached settings instead? + var settingsLocation = serviceProvider.GetRequiredService(); + var activeEnvironment = CliSettings.Read( NullLogger.Instance, settingsLocation ).ActiveEnvironment; + output.WriteEnvironmentHeader( activeEnvironment ); + var handler = serviceProvider.GetRequiredService(); TParameters parameters; @@ -29,7 +50,7 @@ protected CommandBase( string name, string description, IServiceProvider provide parameters = CreateParameters( parseResult ); } catch ( ArgumentException e ) { - serviceProvider.GetRequiredService().Normal.WriteLineError( $"✗ {e.Message}" ); + output.Normal.WriteLineError( $"✗ {e.Message}" ); return ExitCodes.GeneralError; } diff --git a/src/Cli/Commands/Env/EnvCommand.cs b/src/Cli/Commands/Env/EnvCommand.cs new file mode 100644 index 00000000..ca090f39 --- /dev/null +++ b/src/Cli/Commands/Env/EnvCommand.cs @@ -0,0 +1,23 @@ +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Env.Subcommands; + +namespace Drift.Cli.Commands.Env; + +internal class EnvCommand : ContainerCommandBase { + internal EnvCommand( IServiceProvider provider ) : base( "env", "Manage Drift environments" ) { + /* TODO add status command + Environment/cluster: main-site [Active] + Agent: Agent-01 [Running, Healthy] + Agent: Agent-02 [Stopped, No Auth] + Agent: Agent-03 [Running, Healthy] + + Other agents: + Agent: Agent-04 [Running, Pending Adoption] + Agent: Agent-04 [Running, Unknown] + */ + Subcommands.Add( new EnvAddCommand( provider ) ); + Subcommands.Add( new EnvListCommand( provider ) ); + Subcommands.Add( new EnvUseCommand( provider ) ); + Subcommands.Add( new EnvRemoveCommand( provider ) ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs b/src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs new file mode 100644 index 00000000..65d36ae3 --- /dev/null +++ b/src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs @@ -0,0 +1,78 @@ +using System.CommandLine; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Presentation.Console.Logging; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Cli.Presentation.Rendering; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.Environments; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Commands.Env.Subcommands; + +internal class EnvAddCommand : CommandBase { + internal EnvAddCommand( IServiceProvider provider ) : base( + "add", + "Add a new Drift environment", + provider, + includeSpecArgument: false + ) { + Arguments.Add( EnvAddParameters.Arguments.Name ); + Arguments.Add( EnvAddParameters.Arguments.Uri ); + } + + protected override EnvAddParameters CreateParameters( ParseResult result ) { + return new EnvAddParameters( result ); + } +} + +internal record EnvAddParameters : BaseParameters { + internal static class Arguments { + internal static readonly Argument Name = new("name") { Description = "The environment name" }; + + internal static readonly Argument Uri = new("uri") { Description = "The agent address, e.g. host:port" }; + } + + internal EnvAddParameters( ParseResult parseResult ) : base( parseResult ) { + Name = parseResult.GetValue( Arguments.Name )!; + Address = parseResult.GetValue( Arguments.Uri )!; + } + + internal string Name { + get; + } + + internal string Address { + get; + } +} + +internal class EnvAddCommandHandler( + IOutputManager output, + ISettingsLocationProvider settingsLocation +) : ICommandHandler { + public Task Invoke( EnvAddParameters parameters, CancellationToken cancellationToken ) { + output.Log.LogDebug( "Running 'env add' command" ); + + var settings = CliSettings.Read( output.GetLogger(), settingsLocation ); + + if ( settings.TryGetEnvironment( parameters.Name, out _ ) ) { + output.Normal.WriteLineFailure( $"'{parameters.Name}' already exist" ); + return Task.FromResult( ExitCodes.GeneralError ); + } + + settings.Environments.Add( new EnvironmentSetting( parameters.Name, parameters.Address ) ); + + if ( settings.Environments.Count == 1 ) { + settings.ActiveEnvironment = parameters.Name; + } + + settings.Write( output.GetLogger(), settingsLocation ); + + output.Normal.WriteLineSuccess( $"Added '{parameters.Name}@{parameters.Address}'" ); + + return Task.FromResult( ExitCodes.Success ); + } +} diff --git a/src/Cli/Commands/Env/Subcommands/EnvListCommand.cs b/src/Cli/Commands/Env/Subcommands/EnvListCommand.cs new file mode 100644 index 00000000..7d1ddae4 --- /dev/null +++ b/src/Cli/Commands/Env/Subcommands/EnvListCommand.cs @@ -0,0 +1,76 @@ +using System.CommandLine; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Presentation.Console.Logging; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Cli.Presentation.Rendering; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.V1_preview; +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace Drift.Cli.Commands.Env.Subcommands; + +internal class EnvListCommand : CommandBase { + internal EnvListCommand( IServiceProvider provider ) : base( + "list", + "List Drift environments", + provider, + includeSpecArgument: false + ) { + Aliases.Add( "ls" ); + } + + protected override EnvListParameters CreateParameters( ParseResult result ) { + return new EnvListParameters( result ); + } +} + +internal record EnvListParameters : BaseParameters { + internal EnvListParameters( ParseResult parseResult ) : base( parseResult ) { + } +} + +internal class EnvListCommandHandler( + IOutputManager output, + ISettingsLocationProvider settingsLocation +) : ICommandHandler { + public Task Invoke( EnvListParameters parameters, CancellationToken cancellationToken ) { + output.Log.LogDebug( "Running 'env list' command" ); + + var settings = CliSettings.Read( output.GetLogger(), settingsLocation ); + + if ( settings.Environments.Count == 0 ) { + output.Normal.WriteLineWarning( "No environments configured." ); + output.Normal.WriteLineCTA( "Add one with", "drift env add
" ); + return Task.FromResult( ExitCodes.Success ); + } + + var table = new Table { Border = new NoTableBorder(), ShowHeaders = false }; + table.AddColumn( new TableColumn( "Status" ) { Width = 1 } ); + table.AddColumn( new TableColumn( "Name" ) ); + table.AddColumn( new TableColumn( "Address" ) ); + + foreach ( var environment in settings.Environments ) { + var isActive = environment.Name == settings.ActiveEnvironment; + + table.AddRow( + isActive ? "[bold][green]*[/][/]" : " ", + isActive ? "[bold]" + environment.Name + "[/]" : environment.Name, + isActive ? "[bold]" + "@ " + environment.Address + "[/]" : "@ " + environment.Address + ); + } + + output.Normal.GetAnsiConsole().Write( table ); + + if ( settings.ActiveEnvironment == null && settings.Environments.Count > 0 ) { + output.Normal.WriteLineWarning(); + output.Normal.WriteLineWarning( "No environment is active." ); + output.Normal.WriteLineCTA( "Set one with", "drift env use " ); + } + + return Task.FromResult( ExitCodes.Success ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs b/src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs new file mode 100644 index 00000000..c8eb929f --- /dev/null +++ b/src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs @@ -0,0 +1,82 @@ +using System.CommandLine; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Presentation.Console.Logging; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Cli.Presentation.Rendering; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.Environments; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Commands.Env.Subcommands; + +internal class EnvRemoveCommand : CommandBase { + internal EnvRemoveCommand( IServiceProvider provider ) : base( + "remove", + "Remove a Drift environment", + provider, + includeSpecArgument: false + ) { + Arguments.Add( EnvRemoveParameters.Arguments.Name ); + } + + protected override EnvRemoveParameters CreateParameters( ParseResult result ) { + return new EnvRemoveParameters( result ); + } +} + +internal record EnvRemoveParameters : BaseParameters { + internal static class Arguments { + internal static readonly Argument Name = new("name") { Description = "The environment name" }; + } + + internal EnvRemoveParameters( ParseResult parseResult ) : base( parseResult ) { + Name = parseResult.GetValue( Arguments.Name )!; + } + + internal string Name { + get; + } +} + +internal class EnvRemoveCommandHandler( + IOutputManager output, + ISettingsLocationProvider settingsLocation +) : ICommandHandler { + public Task Invoke( EnvRemoveParameters parameters, CancellationToken cancellationToken ) { + output.Log.LogDebug( "Running 'env remove' command" ); + + var settings = CliSettings.Read( output.GetLogger(), settingsLocation ); + + if ( !settings.TryGetEnvironment( parameters.Name, out _ ) ) { + output.Normal.WriteLineFailure( $"'{parameters.Name}' does not exist" ); + return Task.FromResult( ExitCodes.GeneralError ); + } + + var removedActiveEnvironment = settings.ActiveEnvironment == parameters.Name; + settings.Environments.RemoveAll( e => e.Name == parameters.Name ); + + if ( removedActiveEnvironment ) { + settings.ActiveEnvironment = null; + } + + settings.Write( output.GetLogger(), settingsLocation ); + + output.Normal.WriteLineSuccess( $"Removed '{parameters.Name}'" ); + + if ( removedActiveEnvironment ) { + if ( settings.Environments.Count > 0 ) { + output.Normal.WriteLineWarning( "No environment is active." ); + output.Normal.WriteLineCTA( "Set one with", "drift env use " ); + } + else { + output.Normal.WriteLineWarning( "No environments are configured." ); + output.Normal.WriteLineCTA( "Add one with", "drift env add
" ); + } + } + + return Task.FromResult( ExitCodes.Success ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs b/src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs new file mode 100644 index 00000000..7bdada46 --- /dev/null +++ b/src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs @@ -0,0 +1,65 @@ +using System.CommandLine; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Presentation.Console.Logging; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Cli.Presentation.Rendering; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.Environments; +using Microsoft.Extensions.Logging; + +namespace Drift.Cli.Commands.Env.Subcommands; + +internal class EnvUseCommand : CommandBase { + internal EnvUseCommand( IServiceProvider provider ) : base( + "use", + "Switch the active Drift environment", + provider, + includeSpecArgument: false + ) { + Arguments.Add( EnvUseParameters.Arguments.Name ); + } + + protected override EnvUseParameters CreateParameters( ParseResult result ) { + return new EnvUseParameters( result ); + } +} + +internal record EnvUseParameters : BaseParameters { + internal static class Arguments { + internal static readonly Argument Name = new("name") { Description = "The environment name" }; + } + + internal EnvUseParameters( ParseResult parseResult ) : base( parseResult ) { + Name = parseResult.GetValue( Arguments.Name )!; + } + + internal string Name { + get; + } +} + +internal class EnvUseCommandHandler( + IOutputManager output, + ISettingsLocationProvider settingsLocation +) : ICommandHandler { + public Task Invoke( EnvUseParameters parameters, CancellationToken cancellationToken ) { + output.Log.LogDebug( "Running 'env use' command" ); + + var settings = CliSettings.Read( output.GetLogger(), settingsLocation ); + + if ( !settings.TryGetEnvironment( parameters.Name, out _ ) ) { + output.Normal.WriteLineFailure( $"'{parameters.Name}' does not exist" ); + return Task.FromResult( ExitCodes.GeneralError ); + } + + settings.ActiveEnvironment = parameters.Name; + settings.Write( output.GetLogger(), settingsLocation ); + + output.Normal.WriteLineSuccess( $"'{parameters.Name}' is active" ); + + return Task.FromResult( ExitCodes.Success ); + } +} diff --git a/src/Cli/Commands/Init/InitCommand.cs b/src/Cli/Commands/Init/InitCommand.cs index c72b080e..e7e8212c 100644 --- a/src/Cli/Commands/Init/InitCommand.cs +++ b/src/Cli/Commands/Init/InitCommand.cs @@ -95,7 +95,7 @@ public async Task Invoke( InitParameters parameters, CancellationToken canc if ( isInteractive ) { output.Normal.WriteLine(); - output.Normal.WriteLineCTA( $"{Chars.Bulb} Next step: Run", $"drift scan {initOptions.Name}" ); + output.Normal.WriteLineCTA( "Next step: Run", $"drift scan {initOptions.Name}" ); } output.Log.LogDebug( "init command completed" ); diff --git a/src/Cli/Commands/Lint/LintCommand.cs b/src/Cli/Commands/Lint/LintCommand.cs index 0f822178..11f2809f 100644 --- a/src/Cli/Commands/Lint/LintCommand.cs +++ b/src/Cli/Commands/Lint/LintCommand.cs @@ -44,8 +44,7 @@ public async Task Invoke( LintParameters parameters, CancellationToken canc } output.Log.LogInformation( "Validating network spec: {Spec}", filePath ); - output.Normal.Write( "Validating " ); - output.Normal.WriteLine( $"{filePath} ", ConsoleColor.Cyan ); + output.Normal.WriteLineVerbose( "Validating..." ); var yamlContent = await File.ReadAllTextAsync( filePath!.FullName, cancellationToken ); diff --git a/src/Cli/Commands/Lint/Presentation/NormalLintRenderer.cs b/src/Cli/Commands/Lint/Presentation/NormalLintRenderer.cs index 16e32073..a7ac0d37 100644 --- a/src/Cli/Commands/Lint/Presentation/NormalLintRenderer.cs +++ b/src/Cli/Commands/Lint/Presentation/NormalLintRenderer.cs @@ -6,12 +6,13 @@ namespace Drift.Cli.Commands.Lint.Presentation; internal class NormalLintRenderer( INormalOutput output ) : IRenderer { public void Render( ValidationResult result ) { - output.WriteLineValidity( result.IsValid ); + if ( result.IsValid ) { + output.WriteLineSuccess( "Valid" ); + return; + } - if ( !result.IsValid ) { - foreach ( var error in result.Errors ) { - output.WriteLineError( $"• {error}" ); - } + foreach ( var error in result.Errors ) { + output.WriteLineError( $"{error}" ); } } } \ No newline at end of file diff --git a/src/Cli/Commands/Preview/EnvCommand.cs b/src/Cli/Commands/Preview/EnvCommand.cs deleted file mode 100644 index 14c531b2..00000000 --- a/src/Cli/Commands/Preview/EnvCommand.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.CommandLine; -using Spectre.Console; - -#pragma warning disable S1481 // Justification: Draft code - -// using Environment = Drift.Domain.Environment; - -namespace Drift.Cli.Commands.Preview; - -/* - * Environment/cluster: main-site [Active] - Agent: Agent-01 [Running, Healthy] - Agent: Agent-02 [Stopped, No Auth] - Agent: Agent-03 [Running, Healthy] - - Other agents: - Agent: Agent-04 [Running, Pending Adoption] - Agent: Agent-04 [Running, Unknown] - */ -// TODO or ClusterCommand? -internal class EnvCommand : Command { - internal EnvCommand() : base( "env", "Manage agent clusters environments" ) { - var tokenCmd = new Command( "token", "Create a short-lived JWT that allows an agent to join an environment." ); - tokenCmd.Arguments.Add( new Argument( "cluster-name" ) { Description = "The cluster name" } ); - Subcommands.Add( tokenCmd ); - - var statusCmd = new Command( "status", "Real-time status of environments (heartbeat-like check)" ); - statusCmd.Arguments.Add( new Argument( "cluster-name" ) { Description = "The cluster name" } ); - Subcommands.Add( statusCmd ); - - var listEnvs = new Command( "list", "List environments." ); // env list my-agent-cluster - listEnvs.Arguments.Add( new Argument( "cluster-name" ) { Description = "The cluster name" } ); - listEnvs.SetAction( _ => { - var envConfigPath = "............ drift-env.json"; - var json = File.ReadAllText( envConfigPath ); - // var environment = JsonConverter.Deserialize( json ); - - // Print - var table = new Table().ShowRowSeparators(); - - // table.AddColumn( new TableColumn( nameof(Environment.Name) ).Centered() ); - table.AddColumn( new TableColumn( "Agents" ).Centered() ); - // table.AddColumn( new TableColumn( "" ).Centered() ); - - /*table.AddRow( - environment.Name + ( environment.Active ? "\u26a1 [green]Active[/]" : string.Empty ), - string.Join( "\n", environment.Agents.Select( a => - a.Address + " " + ( a.Authentication.Type == AuthType.None - ? "[red]No auth[/]" - : "\ud83d\udd12" ) - ) - ) - );*/ - -#pragma warning disable RS0030 // TODO: inject IOutputManager and use output.Normal.GetAnsiConsole() - AnsiConsole.Write( table ); // TODO: use output.Normal.GetAnsiConsole().Write(table) once IOutputManager is injected -#pragma warning restore RS0030 - // Ui.WriteLine("config: "+ envConfigPath,ConsoleColor.DarkGray); - } ); - Subcommands.Add( listEnvs ); - } -} \ No newline at end of file diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs index 13ee7c1c..807e6b6c 100644 --- a/src/Cli/Infrastructure/RootCommandFactory.cs +++ b/src/Cli/Infrastructure/RootCommandFactory.cs @@ -5,6 +5,8 @@ using Drift.Cli.Commands.Agent; using Drift.Cli.Commands.Agent.Subcommands.Start; using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Env; +using Drift.Cli.Commands.Env.Subcommands; using Drift.Cli.Commands.Help; using Drift.Cli.Commands.Init; using Drift.Cli.Commands.Lint; @@ -16,6 +18,7 @@ using Drift.Cli.Presentation.Console.Logging; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Rendering; +using Drift.Cli.Settings.Serialization; using Drift.Cli.SpecFile; using Drift.Domain.ExecutionEnvironment; using Drift.Domain.Scan; @@ -74,6 +77,7 @@ internal static RootCommand Create( } private static void ConfigureDefaults( IServiceCollection services, bool toConsole, bool plainConsole ) { + services.AddSingleton(); services.AddScoped(); ConfigureExecutionEnvironment( services ); ConfigureOutput( services, toConsole, plainConsole ); @@ -104,7 +108,8 @@ private static RootCommand CreateRootCommand( IServiceProvider provider ) { new ScanCommand( provider ), new LintCommand( provider ), new AgentCommand( provider ), - new ServerCommand( provider ) + new ServerCommand( provider ), + new EnvCommand( provider ) }; rootCommand.TreatUnmatchedTokensAsErrors = true; @@ -141,6 +146,10 @@ private static void ConfigureBuiltInCommandHandlers( IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void ConfigureInteractiveServices( IServiceCollection services ) { diff --git a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs index 96e47dd4..a1c1b3b5 100644 --- a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs +++ b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs @@ -25,4 +25,13 @@ public static void WarnAgentPreview( this IOutputManager outputManager ) { logger.LogWarning( "Agents run without authentication. Any client that can reach the agent port can connect." ); logger.LogWarning( "----------------------------------------------------------------------------------------" ); } + + /// + /// Writes a one-line header showing the active Drift environment (see 'drift env'), if any is set. + /// + public static void WriteEnvironmentHeader( this IOutputManager outputManager, string? activeEnvironmentName ) { + if ( outputManager.Is( OutputFormat.Normal ) && !string.IsNullOrWhiteSpace( activeEnvironmentName ) ) { + outputManager.Normal.WriteLineVerbose( $"Environment: {activeEnvironmentName}" ); + } + } } \ No newline at end of file diff --git a/src/Cli/Presentation/Console/Managers/Abstractions/INormalOutput.cs b/src/Cli/Presentation/Console/Managers/Abstractions/INormalOutput.cs index ff2bef74..7917e3f0 100644 --- a/src/Cli/Presentation/Console/Managers/Abstractions/INormalOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Abstractions/INormalOutput.cs @@ -4,31 +4,23 @@ namespace Drift.Cli.Presentation.Console.Managers.Abstractions; internal interface INormalOutput { public void WriteVeryVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ); public void WriteLineVeryVerbose(); public void WriteLineVeryVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ); public void WriteVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ); public void WriteLineVerbose(); public void WriteLineVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ); public void Write( diff --git a/src/Cli/Presentation/Console/Managers/NullOutputManager.cs b/src/Cli/Presentation/Console/Managers/NullOutputManager.cs index 7ac407f5..538b2e7f 100644 --- a/src/Cli/Presentation/Console/Managers/NullOutputManager.cs +++ b/src/Cli/Presentation/Console/Managers/NullOutputManager.cs @@ -37,9 +37,7 @@ public bool Is( OutputFormat format ) { internal class NullOutput : INormalOutput, ILogOutput, IJsonOutput { public void WriteVeryVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ) { // No-op } @@ -49,17 +47,13 @@ public void WriteLineVeryVerbose() { } public void WriteLineVeryVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ) { // No-op } public void WriteVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ) { // No-op } @@ -69,9 +63,7 @@ public void WriteLineVerbose() { } public void WriteLineVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ) { // No-op } diff --git a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.Verbose.cs b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.Verbose.cs index d91924e2..6624aec0 100644 --- a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.Verbose.cs +++ b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.Verbose.cs @@ -2,12 +2,10 @@ namespace Drift.Cli.Presentation.Console.Managers.Outputs; internal partial class NormalOutput { public void WriteVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ) { if ( verbose ) { - WriteInternal( stdOut, 0, text, foreground, background ); + WriteInternal( stdOut, 0, text, ConsoleColor.DarkGray ); } } @@ -18,12 +16,10 @@ public void WriteLineVerbose() { } public void WriteLineVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ) { if ( verbose ) { - WriteLineInternal( stdOut, 0, text, foreground, background ); + WriteLineInternal( stdOut, 0, text, ConsoleColor.DarkGray ); } } } \ No newline at end of file diff --git a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.VeryVerbose.cs b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.VeryVerbose.cs index 2d7bbd98..18d32eba 100644 --- a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.VeryVerbose.cs +++ b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.VeryVerbose.cs @@ -2,12 +2,10 @@ namespace Drift.Cli.Presentation.Console.Managers.Outputs; internal partial class NormalOutput { public void WriteVeryVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ) { if ( veryVerbose ) { - WriteInternal( stdOut, 0, text, foreground, background ); + WriteInternal( stdOut, 0, text, ConsoleColor.DarkGray ); } } @@ -18,12 +16,10 @@ public void WriteLineVeryVerbose() { } public void WriteLineVeryVerbose( - string text, - ConsoleColor? foreground = ConsoleColor.DarkGray, - ConsoleColor? background = null + string text ) { if ( veryVerbose ) { - WriteLineInternal( stdOut, 0, text, foreground, background ); + WriteLineInternal( stdOut, 0, text, ConsoleColor.DarkGray ); } } } \ No newline at end of file diff --git a/src/Cli/Presentation/Rendering/NormalOutputExtensions.cs b/src/Cli/Presentation/Rendering/NormalOutputExtensions.cs index e8fe13e2..2b9fb4aa 100644 --- a/src/Cli/Presentation/Rendering/NormalOutputExtensions.cs +++ b/src/Cli/Presentation/Rendering/NormalOutputExtensions.cs @@ -4,20 +4,25 @@ namespace Drift.Cli.Presentation.Rendering; internal static class NormalOutputExtensions { - internal static void WriteLineValidity( this INormalOutput output, bool isValid ) { - if ( isValid ) { - output.WriteLine( $"{Chars.Checkmark} Valid", ConsoleColor.Green ); - } - else { - output.WriteLineError( $"{Chars.Cross} Validation failed" ); - } - } + private const bool Emoji = true; /// /// Action here meaning a command. /// internal static void WriteLineCTA( this INormalOutput output, string description, string command ) { - output.GetAnsiConsole().MarkupLine( $"{description} [bold][green]{command}[/][/]" ); + output.GetAnsiConsole().MarkupLine( $"{Chars.Bulb} {description} [bold][green]{command}[/][/]" ); + } + + internal static void WriteLineSuccess( this INormalOutput output, string message ) { + output.GetAnsiConsole().MarkupLine( Emoji ? $"[green]{Chars.Checkmark}[/] {message}" : $"[green]{message}[/]" ); + } + + internal static void WriteLineFailure( this INormalOutput output, string message ) { + output.GetAnsiConsole().MarkupLine( Emoji ? $"[red]{Chars.Cross}[/] {message}" : $"[red]{message}[/]" ); + } + + internal static void WriteAnsiMarkup( this INormalOutput output, string message ) { + output.GetAnsiConsole().MarkupLine( message ); } /* /// diff --git a/src/Cli/SpecFile/FileSystemSpecProvider.cs b/src/Cli/SpecFile/FileSystemSpecProvider.cs index 22e138ad..6321c866 100644 --- a/src/Cli/SpecFile/FileSystemSpecProvider.cs +++ b/src/Cli/SpecFile/FileSystemSpecProvider.cs @@ -44,9 +44,14 @@ internal class FileSystemSpecProvider( IOutputManager output ) : ISpecFileProvid specFileContents = await sr.ReadToEndAsync(); } - var valid = SpecValidator.Validate( specFileContents, SpecVersion.V1_preview ).IsValid; + var isValid = SpecValidator.Validate( specFileContents, SpecVersion.V1_preview ).IsValid; - output.Normal.WriteLineValidity( valid ); + if ( isValid ) { + output.Normal.WriteLineSuccess( "Valid" ); + } + else { + output.Normal.WriteLineError( "Validation failed" ); + } // output.Normal.WriteLine(); @@ -54,8 +59,7 @@ internal class FileSystemSpecProvider( IOutputManager output ) : ISpecFileProvid spec.Network.Id = GetNetworkId( filePath ); output.Log.LogDebug( "Network ID: {ID}", spec.Network.Id ); - output.Normal.WriteVerbose( "Network ID: " ); - output.Normal.WriteLineVerbose( $"{spec.Network.Id}", ConsoleColor.Cyan ); + output.Normal.WriteLineVerbose( $"Network ID: {spec.Network.Id}" ); output.Normal.WriteLine(); } diff --git a/src/Coordinator.Host/CoordinatorHost.cs b/src/Coordinator.Host/CoordinatorHost.cs index 7f8eada8..069f10b3 100644 --- a/src/Coordinator.Host/CoordinatorHost.cs +++ b/src/Coordinator.Host/CoordinatorHost.cs @@ -4,6 +4,7 @@ using Drift.Networking.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; @@ -44,7 +45,9 @@ public static WebApplication Build( configureServices?.Invoke( builder.Services ); builder.WebHost.ConfigureKestrel( options => { - options.ListenAnyIP( port ); + options.ListenAnyIP( port, o => { + o.Protocols = HttpProtocols.Http2; // gRPC requires HTTP/2 + } ); } ); AddServerStuff( builder ); @@ -55,17 +58,23 @@ public static WebApplication Build( // will get CancellationToken.None. messagingOptions.StoppingToken = app.Lifetime.ApplicationStopping; - app.MapGet( "/", () => - // TODO Render figlet using same flf as in the help command - """ - ___ _ __ _ - | \ _ _ (_) / _| | |_ - | |) | | '_| | | | _| | _| - |___/ |_| |_| |_| \__| - """ - + - "\n\nServer" - ); + // Unreachable while Kestrel ListenOptions.Protocols is HTTP/2-only (browsers can't speak HTTP/2 without TLS), + // but kept here for when this is moved to its own HTTP/1.1 port. + // Setting it to Http1AndHttp2 is not an option since that degrades ALL connections, including gRPC + // calls, to HTTP/1.1, which then fail against gRPC's HTTP/2-only endpoints with HTTP_1_1_REQUIRED. + // See https://github.com/grpc/grpc-dotnet/issues/979. So this must stay HTTP/2-only until either + // TLS is added or the friendly "/" page below is moved to its own HTTP/1.1-only port. + // app.MapGet( "/", () => + // // TODO Render figlet using same flf as in the help command + // """ + // ___ _ __ _ + // | \ _ _ (_) / _| | |_ + // | |) | | '_| | | | _| | _| + // |___/ |_| |_| |_| \__| + // """ + // + + // "\n\Server" + // ); app.MapMessagingServerEndpoints(); app.Lifetime.ApplicationStarted.Register( () => { From 7751cdcfa44abb8ced345d0477d657f6e845adbd Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:52:32 +0200 Subject: [PATCH 40/43] update snapshots --- ...tingsFile_FallsBackToDefault_platform=Linux.verified.txt | 5 +---- ...me=network_single_device_host_outputFormat=.verified.txt | 6 ++---- ...network_single_device_host_outputFormat=log.verified.txt | 2 +- ...work_single_device_host_outputFormat=normal.verified.txt | 6 ++---- ...pecName=network_single_subnet_outputFormat=.verified.txt | 3 +-- ...Name=network_single_subnet_outputFormat=log.verified.txt | 2 +- ...e=network_single_subnet_outputFormat=normal.verified.txt | 3 +-- src/Cli/Commands/Lint/LintCommand.cs | 4 ++-- 8 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/Cli.Tests/BadSettingsFileTests.BadSettingsFile_FallsBackToDefault_platform=Linux.verified.txt b/src/Cli.Tests/BadSettingsFileTests.BadSettingsFile_FallsBackToDefault_platform=Linux.verified.txt index d86240c4..90893060 100644 --- a/src/Cli.Tests/BadSettingsFileTests.BadSettingsFile_FallsBackToDefault_platform=Linux.verified.txt +++ b/src/Cli.Tests/BadSettingsFileTests.BadSettingsFile_FallsBackToDefault_platform=Linux.verified.txt @@ -1,4 +1 @@ -Validating {SolutionDirectory}src/Spec.Tests/resources/network_single_subnet.yaml -✓ Valid -Error reading settings file: {TempPath}/settings.json. Using defaults. -'g' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0. +✓ Valid diff --git a/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=.verified.txt b/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=.verified.txt index 810c1d7e..51e01e1d 100644 --- a/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=.verified.txt +++ b/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=.verified.txt @@ -1,4 +1,2 @@ -Validating {SolutionDirectory}src/Spec.Tests/resources/network_single_device_host.yaml -✕ Validation failed -• /: Required properties ["version"] are not present -• /network/subnets/0: Required properties ["address"] are not present +/: Required properties ["version"] are not present +/network/subnets/0: Required properties ["address"] are not present diff --git a/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=log.verified.txt b/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=log.verified.txt index fee297e1..1e9eaaab 100644 --- a/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=log.verified.txt +++ b/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=log.verified.txt @@ -1,4 +1,4 @@ -[ internal static class Options { + internal const string OutputFormatName = "--output"; + /// /// Enable detailed output, corresponding to log level. /// @@ -39,11 +42,13 @@ internal static class Options { Description = "Very verbose output", Arity = ArgumentArity.Zero, Hidden = true }; - internal static readonly Option OutputFormat = - new("--output", "-o") { + internal static Option CreateOutputFormat( ISettingsLocationProvider settingsLocation ) => + new(OutputFormatName, "-o") { DefaultValueFactory = _ => - CliSettings.Read( new FixedLevelConsoleLogger( LogLevel.Warning, includeStackTrace: false ) ) - .Appearance.Output.ToOutputFormat(), + CliSettings.Read( + settingsLocation, + new FixedLevelConsoleLogger( LogLevel.Warning, includeStackTrace: false ) + ).Appearance.Output.ToOutputFormat(), Description = "Output format", Required = false, Arity = ArgumentArity.ExactlyOne diff --git a/src/Cli/Commands/Common/Parameters/BaseParameters.cs b/src/Cli/Commands/Common/Parameters/BaseParameters.cs index 07e4338e..6652156b 100644 --- a/src/Cli/Commands/Common/Parameters/BaseParameters.cs +++ b/src/Cli/Commands/Common/Parameters/BaseParameters.cs @@ -5,7 +5,7 @@ namespace Drift.Cli.Commands.Common.Parameters; internal abstract record BaseParameters { protected BaseParameters( ParseResult parseResult ) { - OutputFormat = parseResult.GetValue( CommonParameters.Options.OutputFormat ); + OutputFormat = parseResult.GetValue( CommonParameters.Options.OutputFormatName ); } internal OutputFormat OutputFormat { diff --git a/src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs b/src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs index 65d36ae3..c705fa1a 100644 --- a/src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs +++ b/src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs @@ -56,7 +56,7 @@ ISettingsLocationProvider settingsLocation public Task Invoke( EnvAddParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running 'env add' command" ); - var settings = CliSettings.Read( output.GetLogger(), settingsLocation ); + var settings = CliSettings.Read( settingsLocation, output.GetLogger() ); if ( settings.TryGetEnvironment( parameters.Name, out _ ) ) { output.Normal.WriteLineFailure( $"'{parameters.Name}' already exist" ); @@ -75,4 +75,4 @@ public Task Invoke( EnvAddParameters parameters, CancellationToken cancella return Task.FromResult( ExitCodes.Success ); } -} +} \ No newline at end of file diff --git a/src/Cli/Commands/Env/Subcommands/EnvListCommand.cs b/src/Cli/Commands/Env/Subcommands/EnvListCommand.cs index 7d1ddae4..52a295e0 100644 --- a/src/Cli/Commands/Env/Subcommands/EnvListCommand.cs +++ b/src/Cli/Commands/Env/Subcommands/EnvListCommand.cs @@ -40,7 +40,7 @@ ISettingsLocationProvider settingsLocation public Task Invoke( EnvListParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running 'env list' command" ); - var settings = CliSettings.Read( output.GetLogger(), settingsLocation ); + var settings = CliSettings.Read( settingsLocation, output.GetLogger() ); if ( settings.Environments.Count == 0 ) { output.Normal.WriteLineWarning( "No environments configured." ); diff --git a/src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs b/src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs index c8eb929f..3b31fc41 100644 --- a/src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs +++ b/src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs @@ -48,7 +48,7 @@ ISettingsLocationProvider settingsLocation public Task Invoke( EnvRemoveParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running 'env remove' command" ); - var settings = CliSettings.Read( output.GetLogger(), settingsLocation ); + var settings = CliSettings.Read( settingsLocation, output.GetLogger() ); if ( !settings.TryGetEnvironment( parameters.Name, out _ ) ) { output.Normal.WriteLineFailure( $"'{parameters.Name}' does not exist" ); diff --git a/src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs b/src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs index 7bdada46..254c192d 100644 --- a/src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs +++ b/src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs @@ -48,7 +48,7 @@ ISettingsLocationProvider settingsLocation public Task Invoke( EnvUseParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running 'env use' command" ); - var settings = CliSettings.Read( output.GetLogger(), settingsLocation ); + var settings = CliSettings.Read( settingsLocation, output.GetLogger() ); if ( !settings.TryGetEnvironment( parameters.Name, out _ ) ) { output.Normal.WriteLineFailure( $"'{parameters.Name}' does not exist" ); @@ -62,4 +62,4 @@ public Task Invoke( EnvUseParameters parameters, CancellationToken cancella return Task.FromResult( ExitCodes.Success ); } -} +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/ScanCommand.cs b/src/Cli/Commands/Scan/ScanCommand.cs index 5553c33a..14bd5bb1 100644 --- a/src/Cli/Commands/Scan/ScanCommand.cs +++ b/src/Cli/Commands/Scan/ScanCommand.cs @@ -88,15 +88,18 @@ public async Task Invoke( ScanParameters parameters, CancellationToken canc 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); + return ( inventory, false ); } catch ( FileNotFoundException ) { // Spec file was explicitly provided but not found - this is an error - return (new Inventory { Network = new Network(), Agents = [] }, true); + return ( new Inventory { Network = new Network(), Agents = [] }, true ); } } - private async Task> ResolveSubnetsAsync( Inventory inventory, CancellationToken cancellationToken ) { + private async Task> ResolveSubnetsAsync( + Inventory inventory, + CancellationToken cancellationToken + ) { var subnetProviders = BuildSubnetProviders( inventory, cancellationToken ); var subnetProvider = new CompositeSubnetProvider( subnetProviders ); @@ -134,7 +137,11 @@ private static NetworkScanOptions BuildScanRequest( List resolve return new NetworkScanOptions { Cidrs = uniqueCidrs }; } - private void PrintScanSummary( List resolvedSubnets, NetworkScanOptions scanRequest, bool hasAgents ) { + 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() } ) @@ -182,7 +189,12 @@ private INetworkScanner CreateScanner( Inventory inventory, List ); } - private Task StartUi( ScanParameters parameters, Inventory inventory, INetworkScanner scanner, NetworkScanOptions scanRequest ) { + private Task StartUi( + ScanParameters parameters, + Inventory inventory, + INetworkScanner scanner, + NetworkScanOptions scanRequest + ) { if ( parameters.Interactive ) { var ui = new InteractiveUi( output, diff --git a/src/Cli/Commands/Scan/ScanParameters.cs b/src/Cli/Commands/Scan/ScanParameters.cs index 63235d26..ee27dcf9 100644 --- a/src/Cli/Commands/Scan/ScanParameters.cs +++ b/src/Cli/Commands/Scan/ScanParameters.cs @@ -14,7 +14,7 @@ internal static class Options { internal ScanParameters( ParseResult parseResult ) : base( parseResult ) { Interactive = parseResult.GetValue( Options.Interactive ); ShowLogPanel = parseResult.GetValue( CommonParameters.Options.Verbose ) || - parseResult.GetValue( CommonParameters.Options.VeryVerbose ); + parseResult.GetValue( CommonParameters.Options.VeryVerbose ); } internal bool Interactive { diff --git a/src/Cli/Presentation/Console/OutputManagerFactory.cs b/src/Cli/Presentation/Console/OutputManagerFactory.cs index 5556d6aa..59a924b2 100644 --- a/src/Cli/Presentation/Console/OutputManagerFactory.cs +++ b/src/Cli/Presentation/Console/OutputManagerFactory.cs @@ -29,7 +29,7 @@ internal class OutputManagerFactory( bool toConsole = true ) : IOutputManagerFactory { public IOutputManager Create( ParseResult result, bool plainConsole ) { - var outputFormat = result.GetValue( CommonParameters.Options.OutputFormat ); + var outputFormat = result.GetValue( CommonParameters.Options.OutputFormatName ); var verbose = result.GetValue( CommonParameters.Options.Verbose ); var veryVerbose = result.GetValue( CommonParameters.Options.VeryVerbose ); From 11afba571e818eacd8c74e0c639143f3321186a6 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:06:40 +0200 Subject: [PATCH 42/43] fic accidental rename --- .../Commands/{ServerCommandTests.cs => AgentCommandTests.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/Cli.Tests/Commands/{ServerCommandTests.cs => AgentCommandTests.cs} (96%) diff --git a/src/Cli.Tests/Commands/ServerCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs similarity index 96% rename from src/Cli.Tests/Commands/ServerCommandTests.cs rename to src/Cli.Tests/Commands/AgentCommandTests.cs index f9222cab..11f913cf 100644 --- a/src/Cli.Tests/Commands/ServerCommandTests.cs +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -3,7 +3,7 @@ namespace Drift.Cli.Tests.Commands; -internal sealed class ServerCommandTests { +internal sealed class AgentCommandTests { [CancelAfter( 3000 )] [Test] public async Task RespectsCancellationToken() { From e7f15c4a9d661653aed5666ee884393f361b2ee1 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:16:41 +0200 Subject: [PATCH 43/43] cancelable non-interactive scan --- src/Cli.Abstractions/ExitCodes.cs | 9 +++++++-- .../Commands/ScanInteractiveTests.cs | 2 +- src/Cli/Commands/Init/InitCommand.cs | 15 ++++++++------ .../Scan/NonInteractive/NonInteractiveUi.cs | 20 ++++++++++++++----- src/Cli/Commands/Scan/ScanCommand.cs | 20 +++++++++++-------- src/Domain/Scan/INetworkScanner.cs | 2 +- .../Scanners/PingSubnetScannerBase.cs | 7 +++++-- 7 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/Cli.Abstractions/ExitCodes.cs b/src/Cli.Abstractions/ExitCodes.cs index 091250d2..7100c545 100644 --- a/src/Cli.Abstractions/ExitCodes.cs +++ b/src/Cli.Abstractions/ExitCodes.cs @@ -24,13 +24,18 @@ public static class ExitCodes { /// public const int GeneralError = 2; + /// + /// Indicates that the operation was canceled. + /// + public const int Canceled = 3; + /// /// Indicates that a command timed out. /// - public const int TimeOutError = 3; + public const int TimeOutError = 4; /// /// Indicates that a spec validation error occurred. /// - public const int SpecValidationError = 4; + public const int SpecValidationError = 5; } \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanInteractiveTests.cs b/src/Cli.Tests/Commands/ScanInteractiveTests.cs index ebd8fe3f..f15340f1 100644 --- a/src/Cli.Tests/Commands/ScanInteractiveTests.cs +++ b/src/Cli.Tests/Commands/ScanInteractiveTests.cs @@ -27,7 +27,7 @@ public async Task Interactive_QuitKey_ExitsSuccessfully() { ); // Act - var (exitCode, _, error) = await DriftTestCli.InvokeFromTestAsync( "scan -i", services ); + var (exitCode, _, error) = await DriftTestCli.InvokeAsync( "scan -i", services ); // Assert using ( Assert.EnterMultipleScope() ) { diff --git a/src/Cli/Commands/Init/InitCommand.cs b/src/Cli/Commands/Init/InitCommand.cs index e7e8212c..7c874cde 100644 --- a/src/Cli/Commands/Init/InitCommand.cs +++ b/src/Cli/Commands/Init/InitCommand.cs @@ -87,7 +87,7 @@ public async Task Invoke( InitParameters parameters, CancellationToken canc return ExitCodes.GeneralError; } - var success = await Initialize( initOptions ); + var success = await Initialize( initOptions, cancellationToken ); if ( !success ) { return ExitCodes.GeneralError; @@ -170,7 +170,7 @@ INormalOutput console private static string GetEnvPath( string name ) => Path.GetFullPath( $"{name}.env.yaml" ); - private async Task Initialize( InitOptions options ) { + private async Task Initialize( InitOptions options, CancellationToken cancellationToken ) { try { var specPath = GetSpecPath( options.Name ); // var envPath = GetEnvPath( options.Name ); @@ -186,7 +186,7 @@ private async Task Initialize( InitOptions options ) { LogSubnetDetails( scanOptions ); if ( options.Discover ) { - var result = await PerformScanAsync( scanOptions ); + var result = await PerformScanAsync( scanOptions, cancellationToken ); output.Log.LogInformation( "Scan completed" ); output.Log.LogDebug( @@ -221,11 +221,14 @@ private async Task Initialize( InitOptions options ) { #pragma warning restore S2139 } - private async Task PerformScanAsync( NetworkScanOptions request ) { + private async Task PerformScanAsync( + NetworkScanOptions request, + CancellationToken cancellationToken + ) { if ( output.Is( OutputFormat.Normal ) ) { return await output.Normal.GetAnsiConsole().Status().StartAsync( "Scanning network ...", - async _ => await scanner.ScanAsync( request, output.GetLogger() ) + async _ => await scanner.ScanAsync( request, output.GetLogger(), cancellationToken ) ); } @@ -244,7 +247,7 @@ ref lastLogTime try { scanner.ResultUpdated += LogProgress; - return await scanner.ScanAsync( request, output.GetLogger() ); + return await scanner.ScanAsync( request, output.GetLogger(), cancellationToken ); } finally { scanner.ResultUpdated -= LogProgress; diff --git a/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs b/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs index 05a51ed9..0cad132c 100644 --- a/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs +++ b/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs @@ -37,9 +37,16 @@ ref DateTime lastLogTime internal async Task RunAsync( NetworkScanOptions scanRequest, Network? network, - OutputFormat outputFormat + OutputFormat outputFormat, + CancellationToken cancellationToken ) { - var result = await PerformScanAsync( scanRequest ); + var result = await PerformScanAsync( scanRequest, cancellationToken ); + + if ( cancellationToken.IsCancellationRequested ) { + output.Normal.WriteLineWarning( "Scan canceled" ); + output.Log.LogWarning( "Scan canceled" ); + return ExitCodes.Canceled; + } output.Log.LogInformation( "Scan completed" ); @@ -61,7 +68,10 @@ OutputFormat outputFormat return ExitCodes.Success; } - private async Task PerformScanAsync( NetworkScanOptions request ) { + private async Task PerformScanAsync( + NetworkScanOptions request, + CancellationToken cancellationToken + ) { if ( output.Is( OutputFormat.Normal ) ) { var dCol = new TaskDescriptionColumn { Alignment = Justify.Right }; var pCol = new PercentageColumn { Style = new Style( Color.Cyan1 ), CompletedStyle = new Style( Color.Green1 ) }; @@ -78,7 +88,7 @@ private async Task PerformScanAsync( NetworkScanOptions reque try { scanner.ResultUpdated += updater; - return await scanner.ScanAsync( request, output.GetLogger() ); + return await scanner.ScanAsync( request, output.GetLogger(), cancellationToken ); } finally { scanner.ResultUpdated -= updater; @@ -101,7 +111,7 @@ ref lastLogTime try { scanner.ResultUpdated += updater; - return await scanner.ScanAsync( request, output.GetLogger() ); + return await scanner.ScanAsync( request, output.GetLogger(), cancellationToken ); } finally { scanner.ResultUpdated -= updater; diff --git a/src/Cli/Commands/Scan/ScanCommand.cs b/src/Cli/Commands/Scan/ScanCommand.cs index 14bd5bb1..919c55ba 100644 --- a/src/Cli/Commands/Scan/ScanCommand.cs +++ b/src/Cli/Commands/Scan/ScanCommand.cs @@ -60,7 +60,8 @@ internal class ScanCommandHandler( IInterfaceSubnetProvider interfaceSubnetProvider, ISpecFileProvider specProvider, IAgentClient agentClient, - IMessageEnvelopeConverter converter + IMessageEnvelopeConverter converter, + IServiceProvider serviceProvider ) : ICommandHandler { public async Task Invoke( ScanParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running scan command" ); @@ -76,11 +77,11 @@ public async Task Invoke( ScanParameters parameters, CancellationToken canc PrintScanSummary( resolvedSubnets, scanRequest, inventory.Agents.Any() ); var scanner = CreateScanner( inventory, resolvedSubnets ); - var uiTask = StartUi( parameters, inventory, scanner, scanRequest ); + var exitCode = await StartUi( parameters, inventory, scanner, scanRequest, cancellationToken ); output.Log.LogDebug( "scan command completed" ); - return ExitCodes.Success; + return exitCode; } private async Task<(Inventory inventory, bool loadFailed)> LoadInventoryAsync( FileInfo? specFile ) { @@ -193,7 +194,8 @@ private Task StartUi( ScanParameters parameters, Inventory inventory, INetworkScanner scanner, - NetworkScanOptions scanRequest + NetworkScanOptions scanRequest, + CancellationToken cancellationToken ) { if ( parameters.Interactive ) { var ui = new InteractiveUi( @@ -202,13 +204,15 @@ NetworkScanOptions scanRequest scanner, scanRequest, new DefaultKeyMap(), - parameters.ShowLogPanel + parameters.ShowLogPanel, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService() ); - return ui.RunAsync(); + return ui.RunAsync( cancellationToken ); } else { var ui = new NonInteractiveUi( output, scanner ); - return ui.RunAsync( scanRequest, inventory.Network, parameters.OutputFormat ); + return ui.RunAsync( scanRequest, inventory.Network, parameters.OutputFormat, cancellationToken ); } } -} +} \ No newline at end of file diff --git a/src/Domain/Scan/INetworkScanner.cs b/src/Domain/Scan/INetworkScanner.cs index 05b76bcf..992673ee 100644 --- a/src/Domain/Scan/INetworkScanner.cs +++ b/src/Domain/Scan/INetworkScanner.cs @@ -9,6 +9,6 @@ public interface INetworkScanner { Task ScanAsync( NetworkScanOptions options, ILogger logger, - CancellationToken cancellationToken = default + CancellationToken cancellationToken ); } \ No newline at end of file diff --git a/src/Scanning/Scanners/PingSubnetScannerBase.cs b/src/Scanning/Scanners/PingSubnetScannerBase.cs index fdad5757..0fb53776 100644 --- a/src/Scanning/Scanners/PingSubnetScannerBase.cs +++ b/src/Scanning/Scanners/PingSubnetScannerBase.cs @@ -92,6 +92,9 @@ public async Task ScanAsync( ResultUpdated?.Invoke( this, intermediateResult ); } + catch ( OperationCanceledException ) when ( cancellationToken.IsCancellationRequested ) { + // Graceful shutdown via cancellation + } catch ( Exception e ) { logger.LogError( e, "Error while pinging {Ip}", ip ); } @@ -111,7 +114,7 @@ public async Task ScanAsync( var result = new SubnetScanResult { Metadata = new Metadata { StartedAt = startedAt, EndedAt = endedAt }, - Status = ScanResultStatus.Success, + Status = cancellationToken.IsCancellationRequested ? ScanResultStatus.Canceled : ScanResultStatus.Success, DiscoveredDevices = ToDiscoveredDevices( pingReplies, arpTable ), DiscoveryAttempts = ToDiscoveryAttempts( ipRange, (uint) ipRange.Count ), Progress = Percentage.Hundred, @@ -203,4 +206,4 @@ private static Dictionary BuildLocalInterfaceMacTable() { return null; } } -} +} \ No newline at end of file