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/Directory.Packages.props b/Directory.Packages.props index 7bb4a047..9228f6e5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,12 @@ true + + + + + + @@ -13,6 +19,7 @@ + @@ -35,6 +42,7 @@ + diff --git a/Drift.sln b/Drift.sln index d29c2422..0033f7ad 100644 --- a/Drift.sln +++ b/Drift.sln @@ -73,6 +73,32 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cli.Settings.SchemaGenerato EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Common.Schemas\Common.Schemas.csproj", "{BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Grpc", "src\Networking.PeerStreaming.Grpc\Networking.PeerStreaming.Grpc.csproj", "{8ED3FF22-90D2-4F08-A079-55FE7127D1C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Cluster", "src\Networking.Cluster\Networking.Cluster.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core", "src\Networking.PeerStreaming.Core\Networking.PeerStreaming.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Hosting", "src\Agent.Hosting\Agent.Hosting.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Server", "src\Networking.PeerStreaming.Server\Networking.PeerStreaming.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Client", "src\Networking.PeerStreaming.Client\Networking.PeerStreaming.Client.csproj", "{E69772D3-8A07-414F-8F9A-30370D81A972}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Networking", "Networking", "{75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Core.Abstractions", "src\Networking.PeerStreaming.Core.Abstractions\Networking.PeerStreaming.Core.Abstractions.csproj", "{ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.PeerStreaming.Tests", "src\Networking.PeerStreaming.Tests\Networking.PeerStreaming.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol", "src\Agent.PeerProtocol\Agent.PeerProtocol.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FeatureFlagsDELETE", "src\FeatureFlagsDELETE\FeatureFlagsDELETE.csproj", "{31593F51-0B46-4A2B-AA60-88A10D3195F3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FeatureFlagsDELETE.Tests", "src\FeatureFlagsDELETE.Tests\FeatureFlagsDELETE.Tests.csproj", "{24EA16E4-A73B-42F5-B315-FB9AE1B3F9AF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.PeerProtocol.Tests", "src\Agent.PeerProtocol.Tests\Agent.PeerProtocol.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -177,11 +203,66 @@ 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 + {31593F51-0B46-4A2B-AA60-88A10D3195F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31593F51-0B46-4A2B-AA60-88A10D3195F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31593F51-0B46-4A2B-AA60-88A10D3195F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31593F51-0B46-4A2B-AA60-88A10D3195F3}.Release|Any CPU.Build.0 = Release|Any CPU + {24EA16E4-A73B-42F5-B315-FB9AE1B3F9AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24EA16E4-A73B-42F5-B315-FB9AE1B3F9AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24EA16E4-A73B-42F5-B315-FB9AE1B3F9AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24EA16E4-A73B-42F5-B315-FB9AE1B3F9AF}.Release|Any CPU.Build.0 = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {FEA2FBBE-785F-4187-8242-FD348F9E78AF} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {272166CF-E425-45F8-984F-FAFD3CE953C9} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {DD70FBC7-8367-45B3-8D3D-757F1CDF6531} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} + {091D3DCE-F062-4D40-A8F6-5B6F123ED713} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {80445644-7342-4C6D-88E5-BF27126FE9A2} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {A26B4527-6EBF-4A20-8E75-945CCD59016B} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {E69772D3-8A07-414F-8F9A-30370D81A972} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {9DFDD692-22F8-4F9A-8808-94E318863D23} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} EndGlobalSection EndGlobal diff --git a/README_dev.md b/README_dev.md index 7a89c5ed..f58d70a3 100644 --- a/README_dev.md +++ b/README_dev.md @@ -35,6 +35,10 @@ One or more addresses (MAC, IPv4, IPv6, and/or hostname) that together serve as a unique identifier for a network device. +- **Agent** + A running instance of Drift in agent mode that reports network state to other Drift peers. + Agents help ensure full network visibility by uncovering state that's only observable when scanning from specific subnets. + ## Concepts ### Device ID diff --git a/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj b/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj index 2a3d630e..56d8698c 100644 --- a/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj +++ b/build-utils/Build.Utilities.Tests/Build.Utilities.Tests.csproj @@ -1,11 +1,11 @@  - + - + diff --git a/build/_build.csproj b/build/_build.csproj index 6b7e5a43..fc36a1e0 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -13,14 +13,15 @@ + + + + + - - - - diff --git a/containerlab/blog.txt b/containerlab/blog.txt new file mode 100644 index 00000000..6f33cbe4 --- /dev/null +++ b/containerlab/blog.txt @@ -0,0 +1,3 @@ +install + +create topology \ No newline at end of file diff --git a/containerlab/topo1.clab.yaml b/containerlab/topo1.clab.yaml new file mode 100644 index 00000000..a5140b1b --- /dev/null +++ b/containerlab/topo1.clab.yaml @@ -0,0 +1,23 @@ +name: drift-topo1 +topology: + nodes: + switch|bp1: + kind: bridge + network-mode: container:bp1 + bp1: + kind: linux + image: alpine:latest + pc1: + kind: linux + image: hojmark/drift + cmd: scan -i + pc2: + kind: linux + image: agent start --adoptable + pc3: + kind: linux + image: agent start --adoptable + links: + - endpoints: [ "pc1:eth1", "switch|bp1:eth1" ] + - endpoints: [ "pc2:eth1", "switch|bp1:eth2" ] + - endpoints: [ "pc3:eth1", "switch|bp1:eth3" ] \ No newline at end of file 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..9b1444e6 --- /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.ListenLocalhost( 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.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..90aa24d9 --- /dev/null +++ b/src/Agent.PeerProtocol.Tests/PeerMessageHandlerTests.cs @@ -0,0 +1,122 @@ +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 var _ = 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" ); + } + + [Test] + public void AllRequestMessagesHaveHandlers_AndNoExtraHandlers() { + var handledRequestTypes = HandlerTypes + .Select( t => t.GetInterfaces() + .First( i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IPeerMessageHandler<,>) ) + .GetGenericArguments()[0] // 1st generic parameter = TRequest + ) + .ToList(); + + var requestsWithoutHandler = RequestTypes + .Except( handledRequestTypes ) + .Select( t => t.Name ) + .ToList(); + + var extraHandlers = handledRequestTypes + .Except( RequestTypes ) + .Select( t => t.Name ) + .ToList(); + + Assert.That( + requestsWithoutHandler, + Is.Empty, + "Request messages without a handler: " + string.Join( ", ", requestsWithoutHandler ) + ); + + Assert.That( + extraHandlers, + Is.Empty, + "Handlers for unknown request messages: " + string.Join( ", ", extraHandlers ) + ); + } + + [Test] + public void AllResponseMessagesHaveHandlers_AndNoExtraHandlers() { + var handledResponseTypes = HandlerTypes + .Select( t => t.GetInterfaces() + .First( i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IPeerMessageHandler<,>) ) + .GetGenericArguments()[1] ) // 2nd generic parameter = TResponse + .ToList(); + + var responsesWithoutHandler = ResponseTypes + .Except( handledResponseTypes ) + .Select( t => t.Name ) + .ToList(); + + var extraHandlers = handledResponseTypes + .Except( ResponseTypes ) + .Select( t => t.Name ) + .ToList(); + + Assert.That( + responsesWithoutHandler, + Is.Empty, + "Response messages without a handler: " + string.Join( ", ", responsesWithoutHandler ) + ); + + Assert.That( + extraHandlers, + Is.Empty, + "Handlers for unknown response messages: " + string.Join( ", ", extraHandlers ) + ); + } + + [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 => t.GetInterfaces() + .Any( i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IPeerMessageHandler<,>) ) ) + .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..6f74e318 --- /dev/null +++ b/src/Agent.PeerProtocol/Adopt/AdoptRequestHandler.cs @@ -0,0 +1,16 @@ +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 async Task HandleAsync( AdoptRequestPayload message, + CancellationToken cancellationToken = default ) { + _logger.LogInformation( $"[AdoptRequest] Controller: {message.ControllerId}" ); + return IPeerResponse.Empty; + } +} \ 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..2c6b781f --- /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 string Jwt { + get; + set; + } + + public string ControllerId { + get; + set; + } + + public static JsonTypeInfo JsonInfo { + get; + } +} \ 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..6441cfcf --- /dev/null +++ b/src/Agent.PeerProtocol/Adopt/AdoptResponsePayload.cs @@ -0,0 +1,18 @@ +namespace Drift.Agent.PeerProtocol.Adopt; + +internal sealed class AdoptResponsePayload { + public string Status { + get; + set; + } // "accepted" or "rejected" + + public string AgentId { + get; + set; + } // Only with "accepted" + + public string Reason { + get; + set; + } // Only with "rejected" +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj new file mode 100644 index 00000000..fd5ef1e8 --- /dev/null +++ b/src/Agent.PeerProtocol/Agent.PeerProtocol.csproj @@ -0,0 +1,9 @@ + + + + + + + + + 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/ServiceCollectionExtensions.cs b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..41baf7a1 --- /dev/null +++ b/src/Agent.PeerProtocol/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +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(); + } + } +} \ 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..0b12901c --- /dev/null +++ b/src/Agent.PeerProtocol/Subnets/SubnetsRequestHandler.cs @@ -0,0 +1,25 @@ +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( + SubnetsRequest message, + CancellationToken cancellationToken = default + ) { + logger.LogInformation( "Handling subnet request" ); + + var subnets = ( await interfaceSubnetProvider.GetAsync() ).Select( s => s.Cidr ).ToList(); + + logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) ); + + return new SubnetsResponse { Subnets = subnets }; + } +} \ No newline at end of file diff --git a/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs b/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs new file mode 100644 index 00000000..f2402b6c --- /dev/null +++ b/src/Agent.PeerProtocol/Subnets/SubnetsResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Drift.Domain; +using Drift.Networking.PeerStreaming.Core.Abstractions; +using Drift.Serialization.Converters; + +namespace Drift.Agent.PeerProtocol.Subnets; + +public sealed class SubnetsResponse : IPeerResponse { + public static string MessageType => "subnets-response"; + + public required IReadOnlyList Subnets { + get; + init; + } + + public static JsonTypeInfo JsonInfo => SubnetsResponseJsonContext.Default.SubnetsResponse; +} + +[JsonSourceGenerationOptions( + Converters = [typeof(CidrBlockConverter), typeof(IpAddressConverter)], + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable( typeof(SubnetsResponse) )] +internal sealed partial class SubnetsResponseJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/src/ArchTests/SanityTests.cs b/src/ArchTests/SanityTests.cs index 3bac161e..b3268160 100644 --- a/src/ArchTests/SanityTests.cs +++ b/src/ArchTests/SanityTests.cs @@ -3,8 +3,8 @@ namespace Drift.ArchTests; internal sealed class SanityTests : DriftArchitectureFixture { - private const uint ExpectedAssemblyCount = 20; - private const uint ExpectedAssemblyCountTolerance = 3; + private const uint ExpectedAssemblyCount = 30; + private const uint ExpectedAssemblyCountTolerance = 10; [Test] public void FindManyAssemblies() { diff --git a/src/Cli.Settings.Tests/TemporarySettingsLocationProvider.cs b/src/Cli.Settings.Tests/TemporarySettingsLocationProvider.cs index 6b9777ce..5083187a 100644 --- a/src/Cli.Settings.Tests/TemporarySettingsLocationProvider.cs +++ b/src/Cli.Settings.Tests/TemporarySettingsLocationProvider.cs @@ -2,7 +2,7 @@ namespace Drift.Cli.Settings.Tests; -internal sealed class TemporarySettingsLocationProvider : ISettingsLocationProvider { +public sealed class TemporarySettingsLocationProvider : ISettingsLocationProvider { private readonly string _directory = Path.Combine( Path.GetTempPath(), Guid.NewGuid().ToString() ); public string GetDirectory() => _directory; diff --git a/src/Cli.Tests/Cli.Tests.csproj b/src/Cli.Tests/Cli.Tests.csproj index 53d4e722..5cfe4b53 100644 --- a/src/Cli.Tests/Cli.Tests.csproj +++ b/src/Cli.Tests/Cli.Tests.csproj @@ -5,6 +5,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..e8825647 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.MissingOption.verified.txt @@ -0,0 +1 @@ +✗ Either --adoptable or --join must be specified. \ No newline at end of file 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..1cab4e3a --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulStartup.verified.txt @@ -0,0 +1,6 @@ +Agent starting.. +Agent cluster enrollment method is Adoption +Listening for incoming connections on port 51515 +Agent started +Agent stopping... +Agent stopped diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs new file mode 100644 index 00000000..18b2a018 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -0,0 +1,51 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; + +namespace Drift.Cli.Tests.Commands; + +internal sealed class AgentCommandTests { + [CancelAfter( 3000 )] + [Test] + public async Task RespectsCancellationToken() { + using var tcs = new CancellationTokenSource( TimeSpan.FromMilliseconds( 2000 ) ); + + var (exitCode, output, _) = await DriftTestCli.InvokeAsync( + "agent start --adoptable", + cancellationToken: tcs.Token + ); + + Console.WriteLine( output ); + + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task SuccessfulStartup() { + using var tcs = new CancellationTokenSource(); + + var runningCommand = await DriftTestCli.StartAgentAsync( + "--adoptable", + cancellationToken: tcs.Token + ); + + await tcs.CancelAsync(); + + var (exitCode, output, error) = await runningCommand.Completion; + + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + await Verify( output.ToString() ); + Assert.That( error.ToString(), Is.Empty ); + } + } + + [Test] + public async Task MissingOption() { + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "agent start" ); + + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + await Verify( output.ToString() + error ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/InitCommandTests.cs b/src/Cli.Tests/Commands/InitCommandTests.cs index 3a9a27bb..b502d1a1 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.cs +++ b/src/Cli.Tests/Commands/InitCommandTests.cs @@ -78,7 +78,7 @@ public void TearDown() { [Test] public async Task MissingNameOption() { // Arrange / Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "init --overwrite" ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "init --overwrite" ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -95,7 +95,7 @@ public async Task CancellationIsRespected() { try { // Act - var (exitCode, _, _) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, _, _) = await DriftTestCli.InvokeAsync( "init", cancellationToken: cancellationTokenSource.Token ); @@ -126,7 +126,7 @@ public async Task GenerateSpecWithDiscoverySuccess( }; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"init {SpecNameWithDiscovery} --discover {outputFormat} {verbose}", serviceConfig ); @@ -155,7 +155,7 @@ public async Task GenerateSpecWithoutDiscoverySuccess() { }; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"init {SpecNameWithoutDiscovery}", serviceConfig ); diff --git a/src/Cli.Tests/Commands/LintCommandTests.cs b/src/Cli.Tests/Commands/LintCommandTests.cs index 96bd313e..c57fda8f 100644 --- a/src/Cli.Tests/Commands/LintCommandTests.cs +++ b/src/Cli.Tests/Commands/LintCommandTests.cs @@ -14,7 +14,7 @@ public async Task LintValidSpec( var outputOption = string.IsNullOrWhiteSpace( outputFormat ) ? string.Empty : $" -o {outputFormat}"; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"lint ../../../../Spec.Tests/resources/{specName}.yaml" + outputOption ); @@ -36,7 +36,7 @@ public async Task LintInvalidSpec( var outputOption = string.IsNullOrWhiteSpace( outputFormat ) ? string.Empty : $" -o {outputFormat}"; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"lint ../../../../Spec.Tests/resources/{specName}.yaml" + outputOption ); @@ -51,7 +51,7 @@ await Verify( output.ToString() + error ) [Test] public async Task LintMissingSpec() { // Arrange / Act - var (exitCode, _, _) = await DriftTestCli.InvokeFromTestAsync( "lint" ); + var (exitCode, _, _) = await DriftTestCli.InvokeAsync( "lint" ); // Assert Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt b/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt new file mode 100644 index 00000000..538f05f0 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt @@ -0,0 +1,18 @@ +Requesting subnets from agent local1 +Received subnet(s) from agent local1: 192.168.10.0/24 +Requesting subnets from agent local2 +Received subnet(s) from agent local2: 192.168.20.0/24 +Scanning 3 subnets + 192.168.0.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via local + 192.168.10.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local1 + 192.168.20.0/24 (254 addresses, estimated scan time is 00:00:05.0800000) via agentid_local2 + +192.168.0.0/24 (1 devices) +└── 192.168.0.100 11-11-11-11-11-11 Online (unknown device) + +192.168.10.0/24 (1 devices) +└── 192.168.10.100 22-22-22-22-22-22 Online (unknown device) +└── 192.168.10.101 21-21-21-21-21-21 Online (unknown device) + +192.168.20.0/24 (1 devices) +└── 192.168.20.100 33-33-33-33-33-33 Online (unknown device) \ No newline at end of file diff --git a/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs new file mode 100644 index 00000000..edf40259 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.Remote.cs @@ -0,0 +1,81 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; +using Drift.Domain; +using Drift.Domain.Device.Addresses; + +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 = "local1", Address = "http://localhost:51515" }, + new Domain.Agent { Id = "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 ); + } +} \ 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..538f05f0 --- /dev/null +++ b/src/Cli.Tests/Commands/ScanCommandTests.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.cs b/src/Cli.Tests/Commands/ScanCommandTests.cs index 5e77b568..f06af80e 100644 --- a/src/Cli.Tests/Commands/ScanCommandTests.cs +++ b/src/Cli.Tests/Commands/ScanCommandTests.cs @@ -14,11 +14,14 @@ 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 +173,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 +205,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 +219,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 +228,36 @@ public async Task NonExistingSpecOption() { } } + 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 } } ) ); } diff --git a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt index 1756febb..bf9563ca 100644 --- a/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt +++ b/src/Cli.Tests/ExitCodeTests.NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest.verified.txt @@ -18,6 +18,7 @@ Commands: init Create a network spec scan Scan the network and detect drift lint Validate a network spec + agent Manage the local Drift agent Required command was not provided. Unrecognized command or argument 'nonexisting'. diff --git a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt index f1d8cd7a..6a514c74 100644 --- a/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt +++ b/src/Cli.Tests/ExitCodeTests.UnhandledExceptionReturnsUnknownErrorTest.verified.txt @@ -1,6 +1,8 @@ ✗ This exception was thrown from ExceptionCommandHandler - at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DefaultParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 104 - at Drift.Cli.Commands.Common.CommandBase`2.<>c__DisplayClass0_0.<.ctor>b__0(ParseResult parseResult, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/Commands/Common/CommandBase.cs:line 25 - at System.CommandLine.Invocation.AnonymousAsynchronousCommandLineAction.InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) + at Drift.Cli.Tests.ExitCodeTests.ExceptionCommandHandler.Invoke(DummyParameters parameters, CancellationToken cancellationToken) in {ProjectDirectory}ExitCodeTests.cs:line 105 + at Drift.Cli.Commands.Common.Commands.CommandBase`2.<>c__DisplayClass0_0.<<-ctor>b__0>d.MoveNext() in {SolutionDirectory}src/Cli/Commands/Common/Commands/CommandBase.cs:line 25 +--- End of stack trace from previous location --- + at Drift.Cli.Commands.Common.Commands.CommandBase`2.<>c__DisplayClass0_0.<<-ctor>b__0>d.MoveNext() in {SolutionDirectory}src/Cli/Commands/Common/Commands/CommandBase.cs:line 25 +--- End of stack trace from previous location --- at System.CommandLine.Invocation.InvocationPipeline.InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken) - at Drift.Cli.DriftCli.InvokeAsync(String[] args, Boolean toConsole, Boolean plainConsole, Action`1 configureServices, CommandRegistration[] customCommands, Action`1 configureInvocation, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/DriftCli.cs:line 41 + at Drift.Cli.DriftCli.InvokeAsync(String[] args, Boolean toConsole, Boolean plainConsole, Action`1 configureServices, CommandRegistration[] customCommands, Action`1 configureInvocation, CancellationToken cancellationToken) in {SolutionDirectory}src/Cli/DriftCli.cs:line 40 diff --git a/src/Cli.Tests/ExitCodeTests.cs b/src/Cli.Tests/ExitCodeTests.cs index 3d588492..40d5fd6e 100644 --- a/src/Cli.Tests/ExitCodeTests.cs +++ b/src/Cli.Tests/ExitCodeTests.cs @@ -1,7 +1,8 @@ using System.CommandLine; using System.Text.RegularExpressions; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; using Drift.Cli.Infrastructure; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Tests.Utils; @@ -25,7 +26,7 @@ public async Task ExitCodeIsReturnedFromCommandHandlerTest() { // Act var (exitCode, output, error) = - await DriftTestCli.InvokeFromTestAsync( ExitCodeCommand, customCommands: customCommands ); + await DriftTestCli.InvokeAsync( ExitCodeCommand, customCommands: customCommands ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -37,7 +38,7 @@ public async Task ExitCodeIsReturnedFromCommandHandlerTest() { [Test] public async Task NonExistingCommand_ReturnsSystemCommandLineDefaultErrorTest() { // Arrange - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( NonExistingCommand ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( NonExistingCommand ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -61,7 +62,7 @@ public async Task UnhandledExceptionReturnsUnknownErrorTest() { // Act var (exitCode, output, error) = - await DriftTestCli.InvokeFromTestAsync( ExceptionThrowingCommand, customCommands: customCommands ); + await DriftTestCli.InvokeAsync( ExceptionThrowingCommand, customCommands: customCommands ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -71,37 +72,42 @@ public async Task UnhandledExceptionReturnsUnknownErrorTest() { } private sealed class ExitCodeTestCommand( IServiceProvider provider ) - : CommandBase( + : CommandBase( ExitCodeCommand, "Command that returns a specific exit code", provider ) { - protected override DefaultParameters CreateParameters( ParseResult result ) { - return new DefaultParameters( result ); + protected override DummyParameters CreateParameters( ParseResult result ) { + return new DummyParameters( result ); } } - private sealed class ExitCodeCommandHandler( IOutputManager output ) : ICommandHandler { - public Task Invoke( DefaultParameters parameters, CancellationToken cancellationToken ) { + private sealed class ExitCodeCommandHandler( IOutputManager output ) : ICommandHandler { + public Task Invoke( DummyParameters parameters, CancellationToken cancellationToken ) { output.Normal.Write( $"Output from command '{ExitCodeCommand}'" ); return Task.FromResult( ExitCodeCommandExitCode ); } } private sealed class ExceptionTestCommand( IServiceProvider provider ) - : CommandBase( + : CommandBase( ExceptionThrowingCommand, "Command that throws an exception", provider ) { - protected override DefaultParameters CreateParameters( ParseResult result ) { - return new DefaultParameters( result ); + protected override DummyParameters CreateParameters( ParseResult result ) { + return new DummyParameters( result ); } } - private sealed class ExceptionCommandHandler : ICommandHandler { - public Task Invoke( DefaultParameters parameters, CancellationToken cancellationToken ) { + private sealed class ExceptionCommandHandler : ICommandHandler { + public Task Invoke( DummyParameters parameters, CancellationToken cancellationToken ) { throw new Exception( $"This exception was thrown from {nameof(ExceptionCommandHandler)}" ); } } + + private sealed record DummyParameters : BaseParameters { + public DummyParameters( ParseResult parseResult ) : base( parseResult ) { + } + } } \ No newline at end of file diff --git a/src/Cli.Tests/FeatureFlagTest.cs b/src/Cli.Tests/FeatureFlagTest.cs new file mode 100644 index 00000000..cfba9f51 --- /dev/null +++ b/src/Cli.Tests/FeatureFlagTest.cs @@ -0,0 +1,80 @@ +using System.CommandLine; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Commands.Common.Parameters; +using Drift.Cli.Infrastructure; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.Tests; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.FeatureFlags; +using Drift.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Cli.Tests; + +internal sealed class FeatureFlagTest { + private const string DummyCodeCommand = "dummy"; + private const int DummyCommandExitCode = 1337; + private static readonly FeatureFlag MyFeature = new("myFeature"); + private static readonly ISettingsLocationProvider SettingsLocationProvider = new TemporarySettingsLocationProvider(); + + [Test] + public async Task SettingsControlFlag( [Values( false, true, null )] bool? featureEnabled ) { + // Arrange + if ( Directory.Exists( SettingsLocationProvider.GetDirectory() ) ) { + Directory.Delete( SettingsLocationProvider.GetDirectory(), true ); + } + + var settings = new CliSettings(); + if ( featureEnabled != null ) { + settings.Features = [new FeatureFlagSetting( MyFeature, featureEnabled.Value )]; + } + + settings.Save( logger: NullLogger.Instance, location: SettingsLocationProvider ); + + RootCommandFactory.CommandRegistration[] customCommands = [ + new(typeof(DummyTestCommandHandler), sp => new DummyTestCommand( sp )) + ]; + + // Act + var result = await DriftTestCli.InvokeAsync( $"{DummyCodeCommand}", customCommands: customCommands ); + + // Assert + using ( Assert.EnterMultipleScope() ) { + Assert.That( result.ExitCode, Is.EqualTo( DummyCommandExitCode ) ); + var expectedOutput = featureEnabled == true ? "Feature is enabled" : "Feature is disabled"; + Assert.That( result.Output.ToString(), Is.EqualTo( expectedOutput ) ); + Assert.That( result.Error.ToString(), Is.Empty ); + } + + Console.WriteLine( result.Output.ToString() ); + } + + private sealed class DummyTestCommand( IServiceProvider provider ) + : CommandBase( + DummyCodeCommand, + "Command that switches behavior using a feature flag", + provider + ) { + protected override DummyTestParameters CreateParameters( ParseResult result ) { + return new DummyTestParameters( result ); + } + } + + private sealed class DummyTestCommandHandler( IOutputManager output ) : ICommandHandler { + public Task Invoke( DummyTestParameters parameters, CancellationToken cancellationToken ) { + output.Normal.Write( + CliSettings.Load( location: SettingsLocationProvider ).IsFeatureEnabled( MyFeature ) + ? "Feature is enabled" + : "Feature is disabled" + ); + + return Task.FromResult( DummyCommandExitCode ); + } + } + + private sealed record DummyTestParameters : BaseParameters { + public DummyTestParameters( ParseResult parseResult ) : base( parseResult ) { + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/SpecFilePathResolverTests.cs b/src/Cli.Tests/SpecFilePathResolverTests.cs index 6f3aebc6..0d23bb41 100644 --- a/src/Cli.Tests/SpecFilePathResolverTests.cs +++ b/src/Cli.Tests/SpecFilePathResolverTests.cs @@ -18,7 +18,7 @@ internal sealed class SpecFilePathResolverTests { [OneTimeSetUp] public void SetupHomeDir() { - _tempHome = Path.Combine( Path.GetTempPath(), "fake-home-" + Guid.NewGuid().ToString() ); + _tempHome = Path.Combine( Path.GetTempPath(), "fake-home-" + Guid.NewGuid() ); Directory.CreateDirectory( _tempHome ); _originalHome = Environment.GetEnvironmentVariable( HomeEnvVar ); diff --git a/src/Cli.Tests/Utils/CliCommandResult.cs b/src/Cli.Tests/Utils/CliCommandResult.cs new file mode 100644 index 00000000..9665fafc --- /dev/null +++ b/src/Cli.Tests/Utils/CliCommandResult.cs @@ -0,0 +1,24 @@ +namespace Drift.Cli.Tests.Utils; + +internal sealed class CliCommandResult { + internal required int ExitCode { + get; + init; + } + + internal required TextWriter Output { + get; + init; + } + + internal required TextWriter Error { + get; + init; + } + + public void Deconstruct( out int exitCode, out TextWriter output, out TextWriter error ) { + exitCode = ExitCode; + output = Output; + error = Error; + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Utils/DriftTestCli.cs b/src/Cli.Tests/Utils/DriftTestCli.cs index 0ab879ce..d33141d8 100644 --- a/src/Cli.Tests/Utils/DriftTestCli.cs +++ b/src/Cli.Tests/Utils/DriftTestCli.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.CommandLine.Parsing; +using Drift.Cli.Commands.Agent.Subcommands; using Drift.Cli.Infrastructure; using Microsoft.Extensions.DependencyInjection; @@ -8,7 +9,7 @@ namespace Drift.Cli.Tests.Utils; internal static class DriftTestCli { private static readonly TimeSpan DefaultCommandTimeout = TimeSpan.FromSeconds( 7 ); - internal static async Task<(int ExitCode, TextWriter Output, TextWriter Error )> InvokeFromTestAsync( + internal static async Task InvokeAsync( string args, Action? configureServices = null, RootCommandFactory.CommandRegistration[]? customCommands = null, @@ -31,22 +32,66 @@ void ConfigureInvocation( InvocationConfiguration config ) { } try { - return ( - await DriftCli.InvokeAsync( - CommandLineParser.SplitCommandLine( args ).ToArray(), - false, - true, - configureServices, - customCommands, - ConfigureInvocation, - token - ), - output, - error + var exitCode = await DriftCli.InvokeAsync( + CommandLineParser.SplitCommandLine( args ).ToArray(), + false, + true, + configureServices, + customCommands, + ConfigureInvocation, + token ); + + return new CliCommandResult { ExitCode = exitCode, Output = output, Error = error }; } finally { cancellationTokenSource?.Dispose(); } } + + internal static RunningCliCommand StartAsync( + string args, + Action configureServices, + CancellationToken cancellationToken + ) { + var cts = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken ); + + var task = InvokeAsync( + args, + configureServices, + cancellationToken: cts.Token + ); + + return new RunningCliCommand( task, cts ); + } + + /// + /// Starts a new agent asynchronously and returns tasks that complete when it has started. + /// + internal static async Task StartAgentAsync( + string args, + CancellationToken cancellationToken, + Action? configureServices = null + ) { + var readyTcs = new AgentLifetime(); + + var command = StartAsync( + "agent start " + args, + services => { + services.AddSingleton( readyTcs ); + configureServices?.Invoke( services ); + }, + cancellationToken + ); + + // Wait for either readiness or command exit + var completed = await Task.WhenAny( readyTcs.Ready.Task, command.Completion ); + + if ( completed == command.Completion ) { + var com = await command.Completion; + throw new InvalidOperationException( "Command exited before agent was started. Details:\n" + com.Error ); + } + + return command; + } } \ No newline at end of file diff --git a/src/Cli.Tests/Utils/RunningCliCommand.cs b/src/Cli.Tests/Utils/RunningCliCommand.cs new file mode 100644 index 00000000..00fa3eba --- /dev/null +++ b/src/Cli.Tests/Utils/RunningCliCommand.cs @@ -0,0 +1,20 @@ +namespace Drift.Cli.Tests.Utils; + +internal sealed class RunningCliCommand : IAsyncDisposable { + private readonly CancellationTokenSource _cts; + + internal RunningCliCommand( Task task, CancellationTokenSource cts ) { + Completion = task; + _cts = cts; + } + + public Task Completion { + get; + } + + public async ValueTask DisposeAsync() { + await _cts.CancelAsync(); + await Completion; + _cts.Dispose(); + } +} \ No newline at end of file diff --git a/src/Cli/Cli.csproj b/src/Cli/Cli.csproj index 4c2454ff..7e18921b 100644 --- a/src/Cli/Cli.csproj +++ b/src/Cli/Cli.csproj @@ -1,4 +1,4 @@ - + linux-x64;linux-musl-x64;linux-arm64;linux-arm @@ -14,14 +14,19 @@ + + + + + @@ -32,13 +37,7 @@ - - all - - - - - + diff --git a/src/Cli/Commands/Agent/AgentCommand.cs b/src/Cli/Commands/Agent/AgentCommand.cs new file mode 100644 index 00000000..b7d42b85 --- /dev/null +++ b/src/Cli/Commands/Agent/AgentCommand.cs @@ -0,0 +1,24 @@ +using Drift.Cli.Commands.Agent.Subcommands.Start; +using Drift.Cli.Commands.Common.Commands; + +namespace Drift.Cli.Commands.Agent; + +internal class AgentCommand : ContainerCommandBase { + internal AgentCommand( IServiceProvider provider ) : base( "agent", "Manage the local Drift agent" ) { + Subcommands.Add( new AgentStartCommand( provider ) ); + // Subcommands.Add( new AgentServiceCommand( provider ) ); + + /*// Support other init systems in the future + var installCmd = new Command( "install", "Create agent systemd service file" ); + installCmd.Options.Add( new Option( "--join" ) { + Description = "Join the distributed agent network using a JWT" + } ); + Subcommands.Add( installCmd ); + + var uninstallCmd = new Command( "uninstall", "Remove agent systemd service file" ); + Subcommands.Add( uninstallCmd ); + + var statusCmd = new Command( "status", "Show agent status" ); + Subcommands.Add( statusCmd );*/ + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs b/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs new file mode 100644 index 00000000..416ee801 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs @@ -0,0 +1,7 @@ +namespace Drift.Cli.Commands.Agent.Subcommands; + +internal sealed class AgentLifetime { + public TaskCompletionSource Ready { + get; + } = new(); +} \ No newline at end of file diff --git a/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs new file mode 100644 index 00000000..8b25b754 --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs @@ -0,0 +1,83 @@ +using System.CommandLine; +using Drift.Agent.Hosting; +using Drift.Agent.PeerProtocol; +using Drift.Cli.Abstractions; +using Drift.Cli.Commands.Common.Commands; +using Drift.Cli.Infrastructure; +using Drift.Cli.Presentation.Console.Logging; +using Drift.Cli.Presentation.Console.Managers.Abstractions; +using Drift.Domain; +using Drift.Networking.Cluster; + +namespace Drift.Cli.Commands.Agent.Subcommands.Start; + +internal class AgentStartCommand : CommandBase { + internal AgentStartCommand( IServiceProvider provider ) : base( "start", "Start a local Drift agent", provider ) { + Options.Add( AgentStartParameters.Options.Port ); + Options.Add( AgentStartParameters.Options.Adoptable ); + Options.Add( AgentStartParameters.Options.Join ); + } + + 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" ); + + var logger = output.GetLogger(); + + logger.LogInformation( "Agent starting.." ); + + var identity = LoadAgentIdentity(); + + if ( identity == null ) { + 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 'milkyway'" ); + logger.LogInformation( "Attempting to re-join cluster 'milkyway'..." ); + } + + /*Inventory? inventory; + + try { + inventory = await specProvider.GetDeserializedAsync( parameters.SpecFile ); + } + catch ( FileNotFoundException ) { + return ExitCodes.GeneralError; + }*/ + + output.Log.LogDebug( "Starting agent..." ); + + await AgentHost.Run( parameters.Port, logger, ConfigureServices, cancellationToken, agentLifetime?.Ready ); + + output.Log.LogDebug( "Completed 'agent start' command" ); + + return ExitCodes.Success; + + void ConfigureServices( IServiceCollection services ) { + RootCommandFactory.ConfigureSubnetProvider( services ); + services.AddPeerProtocol(); + configureServicesOverride?.Invoke( services ); + } + } + + private static AgentId? LoadAgentIdentity() { + if ( false ) { + return AgentId.New(); // TODO load from file + } + + return null; + } +} \ 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..dffd01fb --- /dev/null +++ b/src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs @@ -0,0 +1,53 @@ +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 AgentStartParameters( ParseResult parseResult ) : base( parseResult ) { + Port = parseResult.GetValue( Options.Port ); + Adoptable = parseResult.GetValue( Options.Adoptable ); + Join = parseResult.GetValue( Options.Join ); + + 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; + } +} \ 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 72% rename from src/Cli/Commands/Common/CommandBase.cs rename to src/Cli/Commands/Common/Commands/CommandBase.cs index 111412df..5318539f 100644 --- a/src/Cli/Commands/Common/CommandBase.cs +++ b/src/Cli/Commands/Common/Commands/CommandBase.cs @@ -1,10 +1,10 @@ using System.CommandLine; -using Microsoft.Extensions.DependencyInjection; +using Drift.Cli.Commands.Common.Parameters; -namespace Drift.Cli.Commands.Common; +namespace Drift.Cli.Commands.Common.Commands; internal abstract class CommandBase : Command - where TParameters : DefaultParameters + where TParameters : BaseParameters where THandler : ICommandHandler { protected CommandBase( string name, string description, IServiceProvider provider ) : base( name, description ) { Add( CommonParameters.Options.Verbose ); @@ -13,8 +13,8 @@ protected CommandBase( string name, string description, IServiceProvider provide Add( CommonParameters.Options.OutputFormat ); Add( CommonParameters.Arguments.Spec ); - SetAction( ( parseResult, cancellationToken ) => { - using var scope = provider.CreateScope(); + SetAction( async ( parseResult, cancellationToken ) => { + await using var scope = provider.CreateAsyncScope(); var serviceProvider = scope.ServiceProvider; serviceProvider.GetRequiredService().ParseResult = parseResult; @@ -22,7 +22,7 @@ protected CommandBase( string name, string description, IServiceProvider provide var handler = serviceProvider.GetRequiredService(); var parameters = CreateParameters( parseResult ); - return handler.Invoke( parameters, cancellationToken ); + return await handler.Invoke( parameters, cancellationToken ); } ); } diff --git a/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs b/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs new file mode 100644 index 00000000..146b4c3c --- /dev/null +++ b/src/Cli/Commands/Common/Commands/ContainerCommandBase.cs @@ -0,0 +1,8 @@ +using System.CommandLine; + +namespace Drift.Cli.Commands.Common.Commands; + +internal class ContainerCommandBase : Command { + public ContainerCommandBase( string name, string description ) : base( name, description ) { + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/ICommandHandler.cs b/src/Cli/Commands/Common/Commands/ICommandHandler.cs similarity index 56% rename from src/Cli/Commands/Common/ICommandHandler.cs rename to src/Cli/Commands/Common/Commands/ICommandHandler.cs index e303b028..07ea9ef0 100644 --- a/src/Cli/Commands/Common/ICommandHandler.cs +++ b/src/Cli/Commands/Common/Commands/ICommandHandler.cs @@ -1,5 +1,7 @@ -namespace Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; -internal interface ICommandHandler where TParameters : DefaultParameters { +namespace Drift.Cli.Commands.Common.Commands; + +internal interface ICommandHandler where TParameters : BaseParameters { Task Invoke( TParameters parameters, CancellationToken cancellationToken ); } \ No newline at end of file diff --git a/src/Cli/Commands/Common/CommonParameters.cs b/src/Cli/Commands/Common/CommonParameters.cs index d574e9f6..488b3183 100644 --- a/src/Cli/Commands/Common/CommonParameters.cs +++ b/src/Cli/Commands/Common/CommonParameters.cs @@ -1,7 +1,6 @@ using System.CommandLine; using Drift.Cli.Presentation.Console; using Drift.Cli.Settings.V1_preview; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Common; diff --git a/src/Cli/Commands/Common/DefaultParameters.cs b/src/Cli/Commands/Common/DefaultParameters.cs deleted file mode 100644 index 8e8eb2db..00000000 --- a/src/Cli/Commands/Common/DefaultParameters.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.CommandLine; -using Drift.Cli.Presentation.Console; - -namespace Drift.Cli.Commands.Common; - -internal record DefaultParameters { - internal DefaultParameters( ParseResult parseResult ) { - OutputFormat = parseResult.GetValue( CommonParameters.Options.OutputFormat ); - SpecFile = parseResult.GetValue( CommonParameters.Arguments.Spec ); - } - - internal OutputFormat OutputFormat { - get; - } - - internal FileInfo? SpecFile { - get; - } -} \ No newline at end of file diff --git a/src/Cli/Commands/Common/Parameters/BaseParameters.cs b/src/Cli/Commands/Common/Parameters/BaseParameters.cs new file mode 100644 index 00000000..07e4338e --- /dev/null +++ b/src/Cli/Commands/Common/Parameters/BaseParameters.cs @@ -0,0 +1,14 @@ +using System.CommandLine; +using Drift.Cli.Presentation.Console; + +namespace Drift.Cli.Commands.Common.Parameters; + +internal abstract record BaseParameters { + protected BaseParameters( ParseResult parseResult ) { + OutputFormat = parseResult.GetValue( CommonParameters.Options.OutputFormat ); + } + + internal OutputFormat OutputFormat { + get; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/Parameters/SpecParameters.cs b/src/Cli/Commands/Common/Parameters/SpecParameters.cs new file mode 100644 index 00000000..3d55c247 --- /dev/null +++ b/src/Cli/Commands/Common/Parameters/SpecParameters.cs @@ -0,0 +1,13 @@ +using System.CommandLine; + +namespace Drift.Cli.Commands.Common.Parameters; + +internal abstract record SpecParameters : BaseParameters { + protected SpecParameters( ParseResult parseResult ) : base( parseResult ) { + SpecFile = parseResult.GetValue( CommonParameters.Arguments.Spec ); + } + + internal FileInfo? SpecFile { + get; + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Common/ParseResultHolder.cs b/src/Cli/Commands/Common/ParseResultHolder.cs index a05afe98..ee3b7751 100644 --- a/src/Cli/Commands/Common/ParseResultHolder.cs +++ b/src/Cli/Commands/Common/ParseResultHolder.cs @@ -8,7 +8,7 @@ internal class ParseResultHolder { public ParseResult ParseResult { get => _parseResult ?? throw new InvalidOperationException( - $"{nameof(ParseResult)} is null. This should have been set via dependency injection." + $"{nameof(ParseResult)} is null. This should have been set during dependency injection." ); set => _parseResult = value; } diff --git a/src/Cli/Commands/Init/InitCommand.cs b/src/Cli/Commands/Init/InitCommand.cs index a49ceb7a..24eec1a0 100644 --- a/src/Cli/Commands/Init/InitCommand.cs +++ b/src/Cli/Commands/Init/InitCommand.cs @@ -1,6 +1,6 @@ using System.CommandLine; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Commands.Init.Helpers; using Drift.Cli.Commands.Scan.NonInteractive; using Drift.Cli.Presentation.Console; @@ -11,7 +11,6 @@ using Drift.Common.Network; using Drift.Domain.Scan; using Drift.Scanning.Subnets.Interface; -using Microsoft.Extensions.Logging; using Spectre.Console; namespace Drift.Cli.Commands.Init; @@ -179,7 +178,9 @@ private async Task Initialize( InitOptions options ) { return false; } - var scanOptions = new NetworkScanOptions { Cidrs = interfaceSubnetProvider.Get().ToList() }; + var scanOptions = new NetworkScanOptions { + Cidrs = ( await interfaceSubnetProvider.GetAsync() ).Select( subnet => subnet.Cidr ).ToList() + }; LogSubnetDetails( scanOptions ); diff --git a/src/Cli/Commands/Init/InitParameters.cs b/src/Cli/Commands/Init/InitParameters.cs index 8115012a..0eac12a6 100644 --- a/src/Cli/Commands/Init/InitParameters.cs +++ b/src/Cli/Commands/Init/InitParameters.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Init; -internal record InitParameters : DefaultParameters { +internal record InitParameters : SpecParameters { internal bool? Discover { get; } diff --git a/src/Cli/Commands/Lint/LintCommand.cs b/src/Cli/Commands/Lint/LintCommand.cs index 28f99de0..ba9ab8e4 100644 --- a/src/Cli/Commands/Lint/LintCommand.cs +++ b/src/Cli/Commands/Lint/LintCommand.cs @@ -1,13 +1,13 @@ using System.CommandLine; using Drift.Cli.Abstractions; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Commands; using Drift.Cli.Commands.Lint.Presentation; using Drift.Cli.Presentation.Console; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Rendering; using Drift.Cli.SpecFile; +using Drift.Spec.Schema; using Drift.Spec.Validation; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Lint; @@ -48,7 +48,7 @@ public async Task Invoke( LintParameters parameters, CancellationToken canc var yamlContent = await File.ReadAllTextAsync( filePath!.FullName, cancellationToken ); - var result = SpecValidator.Validate( yamlContent, Spec.Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yamlContent, SpecVersion.V1_preview ); IRenderer renderer = parameters.OutputFormat switch { diff --git a/src/Cli/Commands/Lint/LintParameters.cs b/src/Cli/Commands/Lint/LintParameters.cs index b3e533bf..ef92c71d 100644 --- a/src/Cli/Commands/Lint/LintParameters.cs +++ b/src/Cli/Commands/Lint/LintParameters.cs @@ -1,9 +1,9 @@ using System.CommandLine; -using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Lint; -internal record LintParameters : DefaultParameters { +internal record LintParameters : SpecParameters { internal LintParameters( ParseResult parseResult ) : base( parseResult ) { } } \ No newline at end of file diff --git a/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs b/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs index e4cebbd5..776c3ebb 100644 --- a/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs +++ b/src/Cli/Commands/Lint/Presentation/LogLintRenderer.cs @@ -1,7 +1,6 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Rendering; using Drift.Spec.Validation; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Lint.Presentation; diff --git a/src/Cli/Commands/Preview/AgentCommand.cs b/src/Cli/Commands/Preview/AgentCommand.cs deleted file mode 100644 index 4c036ff7..00000000 --- a/src/Cli/Commands/Preview/AgentCommand.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.CommandLine; - -namespace Drift.Cli.Commands.Preview; - -internal class AgentCommand : Command { - internal AgentCommand() : base( "agent", "Manage the local Drift agent" ) { - var runCmd = new Command( "run", "Start the agent process" ); - runCmd.Options.Add( new Option( "--adoptable" ) { - Description = "Allow this agent to be adopted by another peer in the distributed agent network" - } ); - // terminology: agent network or agent group? - // support @ for supplying local file - runCmd.Options.Add( new Option( "--join" ) { - Description = "Join the distributed agent network using a JWT" - } ); - runCmd.Options.Add( new Option( "--daemon", "-d" ) { Description = "Run the agent as a background daemon" } ); - runCmd.Options.Add( new Option( "--adoptable" - ) { Description = "Allow this agent to be adopted by another peer in the distributed agent network" } ); - Subcommands.Add( runCmd ); - - // Support other init systems in the future - var installCmd = new Command( "install", "Create agent systemd service file" ); - installCmd.Options.Add( new Option( "--join" ) { - Description = "Join the distributed agent network using a JWT" - } ); - Subcommands.Add( installCmd ); - - var uninstallCmd = new Command( "uninstall", "Remove agent systemd service file" ); - Subcommands.Add( uninstallCmd ); - - var statusCmd = new Command( "status", "Show agent status" ); - Subcommands.Add( statusCmd ); - - // logs? - } -} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/AgentSubnetProvider.cs b/src/Cli/Commands/Scan/AgentSubnetProvider.cs new file mode 100644 index 00000000..5d6df123 --- /dev/null +++ b/src/Cli/Commands/Scan/AgentSubnetProvider.cs @@ -0,0 +1,42 @@ +using Drift.Domain; +using Drift.Networking.Cluster; +using Drift.Scanning.Subnets; + +namespace Drift.Cli.Commands.Scan; + +internal sealed class AgentSubnetProvider( + ILogger logger, + List agents, + ICluster cluster, + CancellationToken cancellationToken +) : ISubnetProvider { + public async Task> GetAsync() { + logger.LogDebug( "Getting subnets from agents" ); + var allSubnets = new List(); + + foreach ( var agent in agents ) { + logger.LogInformation( "Requesting subnets from agent {Id}", agent.Id ); + + try { + var response = await cluster.GetSubnetsAsync( agent, cancellationToken ); + + logger.LogInformation( + "Received subnet(s) from agent {Id}: {Subnets}", + agent.Id, + string.Join( ", ", response.Subnets ) + ); + + allSubnets.AddRange( response.Subnets.Select( cidr => + new ResolvedSubnet( cidr, SubnetSource.Agent( + new AgentId( "agentid_" + agent.Id ) // TODO Fix agent id + ) ) ) + ); + } + catch ( Exception ex ) { + logger.LogInformation( ex, "Failed requesting subnets from agent {Id} ({Address})", 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..0edb60a9 --- /dev/null +++ b/src/Cli/Commands/Scan/ClusterExtensions.cs @@ -0,0 +1,19 @@ +using Drift.Agent.PeerProtocol.Subnets; +using Drift.Networking.Cluster; + +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 + ); + } +} \ No newline at end of file diff --git a/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs b/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs index 169a91cb..5fc1bd79 100644 --- a/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs +++ b/src/Cli/Commands/Scan/NonInteractive/NonInteractiveUi.cs @@ -8,7 +8,6 @@ using Drift.Cli.Presentation.Rendering; using Drift.Domain; using Drift.Domain.Scan; -using Microsoft.Extensions.Logging; using Spectre.Console; namespace Drift.Cli.Commands.Scan.NonInteractive; diff --git a/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs b/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs index 54ad61a9..1e6c6b7f 100644 --- a/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs +++ b/src/Cli/Commands/Scan/Rendering/LogScanRenderer.cs @@ -2,7 +2,6 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Rendering; using Drift.Cli.Presentation.Rendering.DeviceState; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Scan.Rendering; diff --git a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs index 0359f08a..2f4ebabf 100644 --- a/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs +++ b/src/Cli/Commands/Scan/ResultProcessors/SubnetScanResultProcessor.cs @@ -18,7 +18,7 @@ namespace Drift.Cli.Commands.Scan.ResultProcessors; internal static class SubnetScanResultProcessor { private const string Na = "n/a"; - private static int _deviceIdCounter = 0; + private static int _deviceIdCounter; internal static List Process( SubnetScanResult scanResult, Network? network ) { // TODO test throw new Exception( "ads" ); diff --git a/src/Cli/Commands/Scan/ScanCommand.cs b/src/Cli/Commands/Scan/ScanCommand.cs index 1aac7dc0..c8b353ca 100644 --- a/src/Cli/Commands/Scan/ScanCommand.cs +++ b/src/Cli/Commands/Scan/ScanCommand.cs @@ -1,29 +1,24 @@ 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.Scanning.Subnets; using Drift.Scanning.Subnets.Interface; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Commands.Scan; /* - * Ideas: - * Interactive mode: - * ➤ New host found: 192.168.1.42 - * ➤ Port 22 no longer open on 192.168.1.10 - * → Would you like to update the declared state? [y/N] - * Monitor mode: - * drift monitor --reference declared.yaml --interval 10m --notify slack,email,log,webhook + * drift monitor declared.yaml --interval 10m --notify slack,email,log,webhook */ internal class ScanCommand : CommandBase { public ScanCommand( IServiceProvider provider ) : base( "scan", "Scan the network and detect drift", provider ) { @@ -60,23 +55,37 @@ internal class ScanCommandHandler( IOutputManager output, INetworkScanner scanner, IInterfaceSubnetProvider interfaceSubnetProvider, - ISpecFileProvider specProvider + ISpecFileProvider specProvider, + ICluster cluster ) : ICommandHandler { public async Task Invoke( ScanParameters parameters, CancellationToken cancellationToken ) { output.Log.LogDebug( "Running scan command" ); - Network? network; + Inventory? inventory; try { - network = ( await specProvider.GetDeserializedAsync( parameters.SpecFile ) )?.Network; + inventory = await specProvider.GetDeserializedAsync( parameters.SpecFile ); } catch ( FileNotFoundException ) { return ExitCodes.GeneralError; } var subnetProviders = new List { interfaceSubnetProvider }; - if ( network != null ) { - subnetProviders.Add( new PredefinedSubnetProvider( network.Subnets ) ); + if ( inventory?.Network != null ) { + subnetProviders.Add( new PredefinedSubnetProvider( inventory.Network.Subnets ) ); + } + + var hasAgents = inventory?.Agents.Any() ?? false; + + if ( hasAgents ) { + subnetProviders.Add( + new AgentSubnetProvider( + output.GetLogger(), + inventory.Agents, + cluster, + cancellationToken + ) + ); } var subnetProvider = new CompositeSubnetProvider( subnetProviders ); @@ -84,38 +93,57 @@ public async Task Invoke( ScanParameters parameters, CancellationToken canc output.Normal.WriteLineVerbose( $"Using {subnetProvider.GetType().Name}" ); output.Log.LogDebug( "Using {SubnetProviderType}", subnetProvider.GetType().Name ); - var subnets = subnetProvider.Get(); + var groupedSubnets = ( await subnetProvider.GetAsync() ) + .GroupBy( subnet => subnet.Cidr ) + .Select( group => new { Cidr = group.Key, Sources = group.Select( r => r.Source ).Distinct().ToList() } ) + .ToList(); - var scanRequest = new NetworkScanOptions { Cidrs = subnets }; + var scanRequest = new NetworkScanOptions { Cidrs = groupedSubnets.Select( group => group.Cidr ).ToList() }; // TODO many more varieties - output.Normal.WriteLine( 0, $"Scanning {subnets.Count} subnet{( subnets.Count > 1 ? "s" : string.Empty )}" ); - foreach ( var cidr in subnets ) { + 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 ); // TODO write name if from spec: Ui.WriteLine( 1, $"{subnet.Id}: {subnet.Network}" ); - output.Normal.Write( 1, $"{cidr}", ConsoleColor.Cyan ); + 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 ); + subnet.Cidr ) + // TODO .Humanize( 2, CultureInfo.InvariantCulture, minUnit: TimeUnit.Second ) + ")" + + ( hasAgents + ? $" via {sourceList}" + : string.Empty ), // TODO print without agentid_ prefix (internal technicality) + ConsoleColor.DarkGray + ); } output.Log.LogInformation( "Scanning {SubnetCount} subnet(s): {SubnetList}", - subnets.Count, - string.Join( ", ", subnets ) + groupedSubnets.Count, + string.Join( ", ", groupedSubnets.Select( s => s.Cidr ) ) ); Task uiTask; if ( parameters.Interactive ) { - var ui = new InteractiveUi( output, network, scanner, scanRequest, new DefaultKeyMap(), parameters.ShowLogPanel ); + var ui = new InteractiveUi( + output, + inventory?.Network, + scanner, + scanRequest, + new DefaultKeyMap(), + parameters.ShowLogPanel + ); uiTask = ui.RunAsync(); } else { var ui = new NonInteractiveUi( output, scanner ); - uiTask = ui.RunAsync( scanRequest, network, parameters.OutputFormat ); + uiTask = ui.RunAsync( scanRequest, inventory?.Network, parameters.OutputFormat ); } Task.WaitAll( uiTask ); diff --git a/src/Cli/Commands/Scan/ScanParameters.cs b/src/Cli/Commands/Scan/ScanParameters.cs index 37178c9e..63235d26 100644 --- a/src/Cli/Commands/Scan/ScanParameters.cs +++ b/src/Cli/Commands/Scan/ScanParameters.cs @@ -1,9 +1,10 @@ using System.CommandLine; using Drift.Cli.Commands.Common; +using Drift.Cli.Commands.Common.Parameters; namespace Drift.Cli.Commands.Scan; -internal record ScanParameters : DefaultParameters { +internal record ScanParameters : SpecParameters { internal static class Options { internal static readonly Option Interactive = new("--interactive", "-i") { Description = "Interactive mode", Arity = ArgumentArity.Zero diff --git a/src/Cli/DriftCli.cs b/src/Cli/DriftCli.cs index e50e8b62..2b988b6b 100644 --- a/src/Cli/DriftCli.cs +++ b/src/Cli/DriftCli.cs @@ -2,7 +2,6 @@ using Drift.Cli.Abstractions; using Drift.Cli.Infrastructure; using Drift.Cli.Presentation.Rendering; -using Microsoft.Extensions.DependencyInjection; namespace Drift.Cli; diff --git a/src/Cli/ExecutionEnvironment.cs b/src/Cli/ExecutionEnvironment.cs index 96674e39..f1933a23 100644 --- a/src/Cli/ExecutionEnvironment.cs +++ b/src/Cli/ExecutionEnvironment.cs @@ -5,7 +5,7 @@ namespace Drift.Cli; internal static class ExecutionEnvironment { internal static DriftExecutionEnvironment GetCurrent() { - var envVar = System.Environment.GetEnvironmentVariable( nameof(EnvVar.DRIFT_EXECUTION__ENVIRONMENT) ); + var envVar = Environment.GetEnvironmentVariable( nameof(EnvVar.DRIFT_EXECUTION__ENVIRONMENT) ); return Get( envVar ); } diff --git a/src/Cli/Infrastructure/RootCommandFactory.cs b/src/Cli/Infrastructure/RootCommandFactory.cs index 1161862f..5687deaa 100644 --- a/src/Cli/Infrastructure/RootCommandFactory.cs +++ b/src/Cli/Infrastructure/RootCommandFactory.cs @@ -2,6 +2,9 @@ using System.CommandLine.Help; using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using Drift.Agent.PeerProtocol; +using Drift.Cli.Commands.Agent; +using Drift.Cli.Commands.Agent.Subcommands.Start; using Drift.Cli.Commands.Common; using Drift.Cli.Commands.Help; using Drift.Cli.Commands.Init; @@ -14,11 +17,12 @@ using Drift.Cli.SpecFile; using Drift.Domain.ExecutionEnvironment; using Drift.Domain.Scan; +using Drift.Networking.Cluster; +using Drift.Networking.PeerStreaming.Client; +using Drift.Networking.PeerStreaming.Core; using Drift.Scanning; using Drift.Scanning.Scanners; using Drift.Scanning.Subnets.Interface; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Infrastructure; @@ -50,7 +54,12 @@ internal static RootCommand Create( ConfigureDefaults( services, toConsole, plainConsole ); ConfigureBuiltInCommandHandlers( services ); ConfigureDynamicCommands( services, customCommands ?? [] ); - configureServices?.Invoke( services ); + + if ( configureServices != null ) { + configureServices.Invoke( services ); + // Allow agent host to override it's services with the same configuration + services.AddScoped>( _ => configureServices ); + } var provider = services.BuildServiceProvider(); var rootCommand = CreateRootCommand( provider ); @@ -66,6 +75,15 @@ private static void ConfigureDefaults( IServiceCollection services, bool toConso ConfigureSpecProvider( services ); ConfigureSubnetProvider( services ); ConfigureNetworkScanner( services ); + ConfigureAgentCluster( services ); + } + + private static void ConfigureAgentCluster( IServiceCollection services ) { + services.AddPeerStreamingCore( new PeerStreamingOptions { + MessageAssembly = typeof(PeerProtocolAssemblyMarker).Assembly + } ); + services.AddPeerStreamingClient(); + services.AddClustering(); } private static void ConfigureExecutionEnvironment( IServiceCollection services ) { @@ -76,7 +94,10 @@ private static RootCommand CreateRootCommand( IServiceProvider provider ) { // TODO 'from' or 'against'? var rootCommand = new RootCommand( $"{Chars.SatelliteAntenna} Drift CLI — monitor network drift against your declared state" ) { - new InitCommand( provider ), new ScanCommand( provider ), new LintCommand( provider ) + new InitCommand( provider ), + new ScanCommand( provider ), + new LintCommand( provider ), + new AgentCommand( provider ) }; rootCommand.TreatUnmatchedTokensAsErrors = true; @@ -95,6 +116,7 @@ private static void ConfigureOutput( IServiceCollection services, bool toConsole var factory = sp.GetRequiredService(); return factory.Create( parseResult, plainConsole ); } ); + // Note: since ILogger is scoped, singletons cannot access logging via DI services.AddScoped( sp => sp.GetRequiredService().GetLogger() ); } @@ -102,7 +124,7 @@ private static void ConfigureSpecProvider( IServiceCollection services ) { services.AddScoped(); } - private static void ConfigureSubnetProvider( IServiceCollection services ) { + public static void ConfigureSubnetProvider( IServiceCollection services ) { services.AddScoped(); } @@ -110,6 +132,7 @@ private static void ConfigureBuiltInCommandHandlers( IServiceCollection services services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void ConfigureDynamicCommands( IServiceCollection services, CommandRegistration[] commands ) { diff --git a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs index dbb31e36..330d3204 100644 --- a/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs +++ b/src/Cli/Presentation/Console/Logging/NormalOutputLoggerAdapter.cs @@ -1,5 +1,4 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Presentation.Console.Logging; @@ -17,18 +16,38 @@ public void Log( case LogLevel.Critical: case LogLevel.Error: normalOutput.WriteLineError( message ); + if ( exception != null ) { + normalOutput.WriteLineError( exception.ToString() ); + } + break; case LogLevel.Warning: normalOutput.WriteLineWarning( message ); + if ( exception != null ) { + normalOutput.WriteLineWarning( exception.ToString() ); + } + break; case LogLevel.Information: normalOutput.WriteLine( message ); + if ( exception != null ) { + normalOutput.WriteLine( exception.ToString() ); + } + break; case LogLevel.Debug: normalOutput.WriteLineVerbose( message ); + if ( exception != null ) { + normalOutput.WriteLineVerbose( exception.ToString() ); + } + break; case LogLevel.Trace: normalOutput.WriteLineVeryVerbose( message ); + if ( exception != null ) { + normalOutput.WriteLineVeryVerbose( exception.ToString() ); + } + break; case LogLevel.None: break; diff --git a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs index f252e41e..d65e3166 100644 --- a/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs +++ b/src/Cli/Presentation/Console/Logging/OutputManagerExtensions.cs @@ -1,6 +1,5 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Common.Logging; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Presentation.Console.Logging; diff --git a/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs b/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs index 05bd518c..0dedb05b 100644 --- a/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Abstractions/ILogOutput.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.Logging; - namespace Drift.Cli.Presentation.Console.Managers.Abstractions; internal interface ILogOutput : ILogger; \ No newline at end of file diff --git a/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs index 5295d769..a16bd5a0 100644 --- a/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs +++ b/src/Cli/Presentation/Console/Managers/ConsoleOutputManager.cs @@ -1,6 +1,5 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Cli.Presentation.Console.Managers.Outputs; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Presentation.Console.Managers; diff --git a/src/Cli/Presentation/Console/Managers/NullOutputManager.cs b/src/Cli/Presentation/Console/Managers/NullOutputManager.cs index b6460270..a80b6003 100644 --- a/src/Cli/Presentation/Console/Managers/NullOutputManager.cs +++ b/src/Cli/Presentation/Console/Managers/NullOutputManager.cs @@ -1,5 +1,4 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Microsoft.Extensions.Logging; using Spectre.Console; namespace Drift.Cli.Presentation.Console.Managers; diff --git a/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs b/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs index 3d87c45a..d035010a 100644 --- a/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Outputs/LogOutput.cs @@ -1,5 +1,4 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Microsoft.Extensions.Logging; namespace Drift.Cli.Presentation.Console.Managers.Outputs; diff --git a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs index e8fb4d76..f197d94f 100644 --- a/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs +++ b/src/Cli/Presentation/Console/Managers/Outputs/NormalOutput.cs @@ -79,14 +79,13 @@ private static void WriteLineInternal( // ... on bgcolor] } } - else { - if ( foreground.HasValue ) { - System.Console.ForegroundColor = foreground.Value; - } - if ( background.HasValue ) { - System.Console.BackgroundColor = background.Value; - } + if ( foreground.HasValue ) { + System.Console.ForegroundColor = foreground.Value; + } + + if ( background.HasValue ) { + System.Console.BackgroundColor = background.Value; } textWriter.Write( line ); @@ -100,9 +99,8 @@ private static void WriteLineInternal( System.Console.BackgroundColor = background.Value; } } - else { - System.Console.ResetColor(); - } + + System.Console.ResetColor(); textWriter.WriteLine(); } diff --git a/src/Cli/Presentation/Console/OutputManagerFactory.cs b/src/Cli/Presentation/Console/OutputManagerFactory.cs index 976338ec..691a6ad8 100644 --- a/src/Cli/Presentation/Console/OutputManagerFactory.cs +++ b/src/Cli/Presentation/Console/OutputManagerFactory.cs @@ -5,7 +5,6 @@ using Drift.Cli.Presentation.Console.Managers; using Drift.Cli.Presentation.Console.Managers.Abstractions; using Drift.Common.IO; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Serilog; using Serilog.Events; diff --git a/src/Cli/Properties/launchSettings.json b/src/Cli/Properties/launchSettings.json index e8bbdf6a..d9f79120 100644 --- a/src/Cli/Properties/launchSettings.json +++ b/src/Cli/Properties/launchSettings.json @@ -20,7 +20,7 @@ }, "scan ~": { "commandName": "Project", - "commandLineArgs": "scan ~", + "commandLineArgs": "scan ~ -v", "dotnetRunMessages": true, "environmentVariables": {} }, @@ -47,6 +47,18 @@ "commandLineArgs": "lint ~", "dotnetRunMessages": true, "environmentVariables": {} + }, + "agent start (home)": { + "commandName": "Project", + "commandLineArgs": "agent start ~ -vv -o log --adoptable", + "dotnetRunMessages": true, + "environmentVariables": {} + }, + "agent start --port 51516 (home)": { + "commandName": "Project", + "commandLineArgs": "agent start ~ --port 51516", + "dotnetRunMessages": true, + "environmentVariables": {} } } } diff --git a/src/Cli/SpecFile/FileSystemSpecProvider.cs b/src/Cli/SpecFile/FileSystemSpecProvider.cs index 7c46a0df..c2b312e7 100644 --- a/src/Cli/SpecFile/FileSystemSpecProvider.cs +++ b/src/Cli/SpecFile/FileSystemSpecProvider.cs @@ -5,7 +5,6 @@ using Drift.Spec.Schema; using Drift.Spec.Serialization; using Drift.Spec.Validation; -using Microsoft.Extensions.Logging; namespace Drift.Cli.SpecFile; diff --git a/src/Cli/SpecFile/SpecFilePathResolver.cs b/src/Cli/SpecFile/SpecFilePathResolver.cs index 7b6c54db..4b7ee1c5 100644 --- a/src/Cli/SpecFile/SpecFilePathResolver.cs +++ b/src/Cli/SpecFile/SpecFilePathResolver.cs @@ -1,5 +1,4 @@ using Drift.Cli.Presentation.Console.Managers.Abstractions; -using Microsoft.Extensions.Logging; namespace Drift.Cli.SpecFile; diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index 2bdadc5e..a49980c3 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -1,11 +1,11 @@  - + - + \ No newline at end of file diff --git a/src/Diff.Tests/DiffTest.cs b/src/Diff.Tests/DiffTest.cs index fbb171db..363206a9 100644 --- a/src/Diff.Tests/DiffTest.cs +++ b/src/Diff.Tests/DiffTest.cs @@ -1,6 +1,4 @@ using System.Globalization; -using System.Text.Json; -using System.Text.Json.Serialization; using Drift.Diff.Domain; using Drift.Domain; using Drift.Domain.Device.Addresses; @@ -8,6 +6,7 @@ using Drift.Domain.Device.Discovered; using Drift.Domain.Extensions; using Drift.Domain.Scan; +using Drift.Serialization.Converters; using Drift.TestUtilities; using JsonConverter = Drift.Serialization.JsonConverter; @@ -247,21 +246,4 @@ private static void Print( List diffs ) { Console.WriteLine( $"{diff.PropertyPath}: {diff.DiffType} — '{diff.Original}' → '{diff.Updated}'" ); } } - - private sealed class IpAddressConverter : JsonConverter { - public override System.Net.IPAddress Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options - ) { - string? ip = reader.GetString(); - var ipAddress = ( ip == null ) ? null : System.Net.IPAddress.Parse( ip ); - return ipAddress ?? throw new Exception( "Cannot read" ); // System.Net.IPAddress.None; - } - - public override void Write( Utf8JsonWriter writer, System.Net.IPAddress value, JsonSerializerOptions options ) { - ArgumentNullException.ThrowIfNull( writer ); - writer.WriteStringValue( value?.ToString() ); - } - } } \ No newline at end of file diff --git a/src/Domain/AgentId.cs b/src/Domain/AgentId.cs new file mode 100644 index 00000000..29da6a02 --- /dev/null +++ b/src/Domain/AgentId.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace Drift.Domain; + +public class AgentId { + private const string Prefix = "agentid_"; + + public string Value { + get; + set; + } + + [JsonConstructor] + public AgentId() { + } + + public AgentId( string value ) { + if ( !value.StartsWith( Prefix ) ) + throw new FormatException( $"AgentId must start with '{Prefix}'." ); + + Value = value; + } + + public static AgentId New() => new AgentId( Prefix + Guid.NewGuid() ); + + public static implicit operator AgentId( string value ) => new AgentId( value ); + + public static implicit operator string( AgentId id ) => id.Value; + + public bool IsGuidBased => + Guid.TryParse( Value[Prefix.Length..], out _ ); + + public Guid? AsGuidOrNull => + Guid.TryParse( Value[Prefix.Length..], out var guid ) ? guid : null; + + 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..0247e632 --- /dev/null +++ b/src/Domain/RequestId.cs @@ -0,0 +1,19 @@ +namespace Drift.Domain; + +public record RequestId( Guid Value ) { + private const string Prefix = "requestid_"; + + public static implicit operator RequestId( string value ) { + 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." ); + + return new RequestId( guid ); + } + + public static implicit operator string( RequestId id ) => $"{Prefix}{id.Value}"; +} \ No newline at end of file diff --git a/src/FeatureFlagsDELETE.Tests/FeatureFlagMatrixAttribute.cs b/src/FeatureFlagsDELETE.Tests/FeatureFlagMatrixAttribute.cs new file mode 100644 index 00000000..ff9d0750 --- /dev/null +++ b/src/FeatureFlagsDELETE.Tests/FeatureFlagMatrixAttribute.cs @@ -0,0 +1,113 @@ +using System.Reflection; +using NUnit.Framework.Interfaces; +using NUnit.Framework.Internal; +using NUnit.Framework.Internal.Builders; +using NUnit.Framework.Internal.Commands; + +namespace Drift.FeatureFlagsDELETE.Tests; + +[AttributeUsage( AttributeTargets.Method, AllowMultiple = false )] +public class FeatureFlagMatrixAttribute : NUnitAttribute, ITestBuilder, IWrapTestMethod { + private readonly NUnitTestCaseBuilder _builder = new(); + + // ----------------------- + // ITestBuilder: generates all test cases + // ----------------------- + public IEnumerable BuildFrom( IMethodInfo method, Test? suite ) { + var sources = method.GetCustomAttributes( true ); + + if ( sources.Any() ) { + // Cross-product of each TestCaseSource with feature flags + foreach ( var sourceAttr in sources ) { + var sourceData = GetTestCaseSourceData( method, sourceAttr ); + + foreach ( var caseData in sourceData ) { + foreach ( var flags in FeatureFlagService.GetAllCombinations( includeEmpty: false ) ) { + var originalArgs = ExtractArgumentsFromCaseData( caseData ); + var parms = new TestCaseParameters( originalArgs ); + parms.Properties.Set( "FeatureFlags", flags ); + + // Add flag info to test name + parms.TestName = caseData is TestCaseData tcd && tcd.TestName != null + ? $"{tcd.TestName} [{string.Join( ",", flags )}]" + : $"{method.Name} [{string.Join( ",", flags )}]"; + + yield return _builder.BuildTestMethod( method, suite, parms ); + } + } + } + } + else { + // No TestCaseSource: one test per feature flag combination + foreach ( var flags in FeatureFlagService.GetAllCombinations( includeEmpty: false ) ) { + var parms = new TestCaseParameters( new object[] { flags } ); + parms.Properties.Set( "FeatureFlags", flags ); + parms.TestName = $"{method.Name} [{string.Join( ",", flags )}]"; + + yield return _builder.BuildTestMethod( method, suite, parms ); + } + } + } + + // ----------------------- + // IWrapTestMethod: ensures flags are accessed + // ----------------------- + public TestCommand Wrap( TestCommand command ) { + return new FeatureFlagCheckCommand( command ); + } + + private class FeatureFlagCheckCommand : DelegatingTestCommand { + public FeatureFlagCheckCommand( TestCommand inner ) : base( inner ) { + } + + public override TestResult Execute( TestExecutionContext context ) { + bool accessedFlags = false; + + // Provide a way for the test to access feature flags + context.CurrentTest.Properties.Set( "GetFeatureFlags", new Func>( () => { + accessedFlags = true; + return (HashSet) context.CurrentTest.Properties.Get( "FeatureFlags" )!; + } ) ); + + var result = innerCommand.Execute( context ); + + // Fail the test if the flags were never accessed + if ( !accessedFlags ) { + result.SetResult( ResultState.Failure, "FeatureFlags were never accessed." ); + } + + return result; + } + } + + // ----------------------- + // Helpers + // ----------------------- + private static object[] ExtractArgumentsFromCaseData( object caseData ) { + return caseData switch { + TestCaseData tcd => tcd.Arguments, + object[] arr => arr, + _ => new object[] { caseData } + }; + } + + private static IEnumerable GetTestCaseSourceData( IMethodInfo method, TestCaseSourceAttribute attr ) { + MemberInfo? sourceMember = attr.SourceType != null + ? attr.SourceType.GetMember( attr.SourceName, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic ) + .FirstOrDefault() + : method.TypeInfo.Type + .GetMember( attr.SourceName, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic ) + .FirstOrDefault(); + + if ( sourceMember == null ) + throw new InvalidOperationException( + $"Cannot find member {attr.SourceName} on type {( attr.SourceType ?? method.TypeInfo.Type ).FullName}" ); + + return sourceMember switch { + MethodInfo mi => mi.Invoke( null, null ) as IEnumerable ?? Enumerable.Empty(), + PropertyInfo pi => pi.GetValue( null ) as IEnumerable ?? Enumerable.Empty(), + FieldInfo fi => fi.GetValue( null ) as IEnumerable ?? Enumerable.Empty(), + _ => throw new InvalidOperationException( $"Unsupported member type for {attr.SourceName}" ) + }; + } +} \ No newline at end of file diff --git a/src/FeatureFlagsDELETE.Tests/FeatureFlagsDELETE.Tests.csproj b/src/FeatureFlagsDELETE.Tests/FeatureFlagsDELETE.Tests.csproj new file mode 100644 index 00000000..4a73a6ec --- /dev/null +++ b/src/FeatureFlagsDELETE.Tests/FeatureFlagsDELETE.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/FeatureFlagsDELETE.Tests/TestContextExtensions.cs b/src/FeatureFlagsDELETE.Tests/TestContextExtensions.cs new file mode 100644 index 00000000..70500f12 --- /dev/null +++ b/src/FeatureFlagsDELETE.Tests/TestContextExtensions.cs @@ -0,0 +1,8 @@ +namespace Drift.FeatureFlagsDELETE.Tests; + +public static class TestContextExtensions { + public static HashSet GetFeatureFlags( this TestContext testContext ) { + var getFlags = (Func>) TestContext.CurrentContext.Test.Properties.Get( "GetFeatureFlags" )!; + return getFlags(); + } +} \ No newline at end of file diff --git a/src/FeatureFlagsDELETE/FeatureFlagService.cs b/src/FeatureFlagsDELETE/FeatureFlagService.cs new file mode 100644 index 00000000..90055b71 --- /dev/null +++ b/src/FeatureFlagsDELETE/FeatureFlagService.cs @@ -0,0 +1,32 @@ +namespace Drift.FeatureFlagsDELETE; + +public class FeatureFlagService { + private readonly HashSet _enabledFlags; + + public FeatureFlagService( IEnumerable enabledFlags ) { + _enabledFlags = new HashSet( enabledFlags ); + } + + public bool IsEnabled( FeatureFlag flag ) => _enabledFlags.Contains( flag ); + + public static IEnumerable> GetAllCombinations( bool includeEmpty = true ) { + var flags = Enum.GetValues(); + int totalCombinations = 1 << flags.Length; // 2^n combinations + + for ( int i = 0; i < totalCombinations; i++ ) { + // Skip the empty set if includeEmpty is false + if ( !includeEmpty && i == 0 ) { + continue; + } + + var combination = new HashSet(); + for ( int j = 0; j < flags.Length; j++ ) { + if ( ( i & ( 1 << j ) ) != 0 ) { + combination.Add( flags[j] ); + } + } + + yield return combination; + } + } +} \ No newline at end of file diff --git a/src/FeatureFlagsDELETE/FeatureFlags.cs b/src/FeatureFlagsDELETE/FeatureFlags.cs new file mode 100644 index 00000000..b88bb979 --- /dev/null +++ b/src/FeatureFlagsDELETE/FeatureFlags.cs @@ -0,0 +1,6 @@ +namespace Drift.FeatureFlagsDELETE; + +public enum FeatureFlag { + Agent, + RandomFeature +} \ No newline at end of file diff --git a/src/FeatureFlagsDELETE/FeatureFlagsDELETE.csproj b/src/FeatureFlagsDELETE/FeatureFlagsDELETE.csproj new file mode 100644 index 00000000..a5b9e0e7 --- /dev/null +++ b/src/FeatureFlagsDELETE/FeatureFlagsDELETE.csproj @@ -0,0 +1,2 @@ + + \ 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..f9576699 --- /dev/null +++ b/src/Networking.Cluster/Cluster.cs @@ -0,0 +1,77 @@ +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 +) : ICluster { + /*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 { + var correlationId = Guid.NewGuid().ToString(); + var envelope = envelopeConverter.ToEnvelope( message ); + envelope.CorrelationId = correlationId; + + // Register correlator BEFORE sending + var responseTask = responseCorrelator.WaitForResponseAsync( + correlationId, + timeout ?? TimeSpan.FromSeconds( 30 ), + cancellationToken + ); + + // Request + var connection = peerStreamManager.GetOrCreate( new Uri( agent.Address ), "agentid_" + agent.Id ); + await connection.SendAsync( envelope ); + + // Response + var response = await responseTask; + return envelopeConverter.FromEnvelope( response ); + } +} \ 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..9f425851 --- /dev/null +++ b/src/Networking.Cluster/Enrollment.cs @@ -0,0 +1,10 @@ +namespace Drift.Networking.Cluster; + +public class EnrollmentRequest( bool parametersAdoptable, string? parametersJoin ) { + 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..727de42b --- /dev/null +++ b/src/Networking.Cluster/ICluster.cs @@ -0,0 +1,19 @@ +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 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..3f8db188 --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessage.cs @@ -0,0 +1,19 @@ +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; + } +} + +public interface IPeerRequest : IPeerMessage where TResponse : IPeerResponse; + +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..67a1d4da --- /dev/null +++ b/src/Networking.PeerStreaming.Core.Abstractions/IPeerMessageHandler.cs @@ -0,0 +1,39 @@ +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; + } + + Task HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + CancellationToken cancellationToken + ); +} + +public interface IPeerMessageHandler : IPeerMessageHandler + where TRequest : IPeerRequest + where TResponse : IPeerResponse { + Task HandleAsync( TRequest message, CancellationToken cancellationToken = default ); + + async Task IPeerMessageHandler.HandleAsync( + PeerMessage envelope, + IPeerMessageEnvelopeConverter converter, + CancellationToken cancellationToken ) { + var request = converter.FromEnvelope( envelope ); + + var response = await HandleAsync( request, cancellationToken ); + + if ( response is Empty ) { + return null; + } + + return converter.ToEnvelope( response ); + } +} \ 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/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..1833fb22 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerMessageDispatcher.cs @@ -0,0 +1,54 @@ +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; + } + + // Otherwise, dispatch to handler + if ( _handlers.TryGetValue( message.MessageType, out var handler ) ) { + var responseEnvelope = await handler.HandleAsync( message, _envelopeConverter, ct ); + + if ( responseEnvelope != null ) { + responseEnvelope.ReplyTo = message.CorrelationId; + await peerStream.SendAsync( responseEnvelope ); + } + + 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..760634d3 --- /dev/null +++ b/src/Networking.PeerStreaming.Core/Messages/PeerResponseCorrelator.cs @@ -0,0 +1,43 @@ +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 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(); + } + } ); + + return tcs.Task; + } + + public bool TryCompleteResponse( string correlationId, PeerMessage response ) { + if ( _pendingRequests.TryRemove( correlationId, out var tcs ) ) { + return tcs.TrySetResult( response ); + } + + _logger.LogWarning( "Received response for unknown correlation ID: {CorrelationId}", correlationId ); + return false; + } +} \ 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..a8846e9d --- /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 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 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 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() { + Console.WriteLine( "Disposing " + 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..21236126 --- /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" ); + 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..a018d098 --- /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 started" ); + 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..fccd08ca --- /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; + + public WriteOptions? WriteOptions { + get; + set; + } + + internal InMemoryStreamWriter( ChannelWriter writer ) { + _writer = writer; + } + + 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; + + public T Current { + get; + private set; + } = null!; + + internal InMemoryStreamReader( ChannelReader reader ) { + _reader = reader; + } + + 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; + + public WriteOptions? WriteOptions { + get { + return new WriteOptions(); + } + + set { + throw new NotSupportedException(); + } + } + + internal InMemoryServerStreamWriter( ChannelWriter writer, ServerCallContext context ) { + _writer = writer; + _context = context; + } + + 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..16db1473 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/Helpers/TestPeerMessage.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +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( TestPeerMessage message, CancellationToken cancellationToken = default ) { + LastMessage = message; + return Task.FromResult( null ); + } +} \ 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..6774b092 --- /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; + + public Metadata? ResponseHeaders { + get; + private set; + } + + private TestServerCallContext( Metadata requestHeaders, CancellationToken cancellationToken ) { + _requestHeaders = requestHeaders; + _cancellationToken = cancellationToken; + _responseTrailers = new Metadata(); + _authContext = new AuthContext( string.Empty, new Dictionary>() ); + _userState = new Dictionary(); + } + + 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 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; + } + + protected override IDictionary UserStateCore => _userState; + + public static TestServerCallContext Create( + Metadata? requestHeaders = null, + CancellationToken cancellationToken = default + ) { + return new TestServerCallContext( requestHeaders ?? new Metadata(), cancellationToken ); + } +} \ 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..0476da1f --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/InboundTests.cs @@ -0,0 +1,73 @@ +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 peerStreamManager = new PeerStreamManager( + logger, + null, + new PeerMessageDispatcher( [], null, null, 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 peerStreamManager = new PeerStreamManager( + logger, + null, + new PeerMessageDispatcher( [], null, null, 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..62bb8eb9 --- /dev/null +++ b/src/Networking.PeerStreaming.Tests/PeerStreamManagerTests.cs @@ -0,0 +1,44 @@ +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 dispatcher = new PeerMessageDispatcher( [testMessageHandler], envelopeConverter, null, 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/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/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/Serialization.csproj b/src/Serialization/Serialization.csproj index c6321610..493404aa 100644 --- a/src/Serialization/Serialization.csproj +++ b/src/Serialization/Serialization.csproj @@ -1,3 +1,7 @@  + + + + diff --git a/src/Spec.Tests/ValidationTests.cs b/src/Spec.Tests/ValidationTests.cs index be3892e3..6ea6498a 100644 --- a/src/Spec.Tests/ValidationTests.cs +++ b/src/Spec.Tests/ValidationTests.cs @@ -1,3 +1,4 @@ +using Drift.Spec.Schema; using Drift.Spec.Validation; using Drift.TestUtilities; using Json.Schema; @@ -89,7 +90,7 @@ internal sealed class ValidationTests { )] public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) { // Arrange / Act - var result = SpecValidator.Validate( yaml, Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); using ( Assert.EnterMultipleScope() ) { Assert.That( result.IsValid, Is.False, "Expected YAML to be invalid, but it was not" ); Assert.That( result.Errors, Is.Not.Empty ); @@ -132,7 +133,7 @@ public void YamlIsInvalidTest( int caseNo, string yaml, params string[] errors ) """ )] public void YamlIsValidTest( int caseNo, string yaml ) { // Arrange / Act - var result = SpecValidator.Validate( yaml, Schema.SpecVersion.V1_preview ); + var result = SpecValidator.Validate( yaml, SpecVersion.V1_preview ); using ( Assert.EnterMultipleScope() ) { Assert.That( result.IsValid, Is.True, result.ToUnitTestMessage() ); Assert.That( result.Errors, Is.Empty ); diff --git a/src/Spec/Dtos/V1_preview/DriftSpec.cs b/src/Spec/Dtos/V1_preview/DriftSpec.cs index b2bc980a..1dbc1a23 100644 --- a/src/Spec/Dtos/V1_preview/DriftSpec.cs +++ b/src/Spec/Dtos/V1_preview/DriftSpec.cs @@ -32,6 +32,11 @@ public Network Network { get; set; }*/ + + public List? Agents { + get; + set; + } } // [Title( "Network declaration" )] @@ -147,6 +152,21 @@ public bool? ScanOnlyDeclaredSubnets { } } +[AdditionalProperties( false )] +public record Agent { + [Required] + public string Id { + get; + set; + } + + [Required] + public string Address { + get; + set; + } +} + public enum UnknownDevicePolicy { Disallowed = 1, Allowed = 2 diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs index d1926213..3f53429c 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDomain.cs @@ -1,9 +1,32 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Declared; + namespace Drift.Spec.Dtos.V1_preview.Mappers; public static partial class Mapper { - public static Domain.Inventory ToDomain( DriftSpec dto ) { + public static Inventory ToDomain( DriftSpec dto ) { // ArgumentNullException.ThrowIfNull( dto.Address ); - return new Domain.Inventory { Network = Map( dto.Network ) }; + var spec = new Inventory { Network = Map( dto.Network ) }; + + if ( dto.Agents != null ) { + spec.Agents = Map( dto.Agents ); + } + + return spec; + } + + private static List Map( List dto ) { + return dto.Select( Map ).ToList(); + } + + private static Domain.Agent Map( Agent dto ) { + var agent = new Domain.Agent(); + + agent.Id = dto.Id; + agent.Address = dto.Address; + + return agent; } private static Domain.Network Map( Network dto ) { @@ -20,14 +43,14 @@ private static Domain.Network Map( Network dto ) { return network; } - private static List Map( List dto ) { + private static List Map( List dto ) { return dto.Select( Map ).ToList(); } - private static Domain.DeclaredSubnet Map( Subnet dto ) { + private static DeclaredSubnet Map( Subnet dto ) { // ArgumentNullException.ThrowIfNull( dto.Address ); - var subnet = new Domain.DeclaredSubnet { Address = dto.Address }; + var subnet = new DeclaredSubnet { Address = dto.Address }; if ( dto.Id != null ) { subnet.Id = dto.Id; @@ -40,14 +63,14 @@ private static Domain.DeclaredSubnet Map( Subnet dto ) { return subnet; } - private static List Map( List dto ) { + private static List Map( List dto ) { return dto.Select( Map ).ToList(); } - private static Domain.Device.Declared.DeclaredDevice Map( Device dto ) { + private static DeclaredDevice Map( Device dto ) { // ArgumentNullException.ThrowIfNull( dto.Addresses ); - var declaredDevice = new Domain.Device.Declared.DeclaredDevice { Addresses = dto.Addresses.Select( Map ).ToList() }; + var declaredDevice = new DeclaredDevice { Addresses = dto.Addresses.Select( Map ).ToList() }; if ( dto.Id != null ) { declaredDevice.Id = dto.Id; @@ -64,7 +87,7 @@ private static Domain.Device.Declared.DeclaredDevice Map( Device dto ) { return declaredDevice; } - private static Domain.Device.Addresses.IDeviceAddress Map( DeviceAddress dto ) { + private static IDeviceAddress Map( DeviceAddress dto ) { return dto.Type switch { "ip-v4" => new Domain.Device.Addresses.IpV4Address( dto.Value, dto.IsId ?? true ), "mac" => new Domain.Device.Addresses.MacAddress( dto.Value, dto.IsId ?? true ), @@ -73,12 +96,12 @@ private static Domain.Device.Addresses.IDeviceAddress Map( DeviceAddress dto ) { }; } - private static Domain.Device.Declared.DeclaredDeviceState? Map( DeviceState? dto ) { + private static DeclaredDeviceState? Map( DeviceState? dto ) { return dto switch { null => null, - DeviceState.Up => Domain.Device.Declared.DeclaredDeviceState.Up, - DeviceState.Dynamic => Domain.Device.Declared.DeclaredDeviceState.Dynamic, - DeviceState.Down => Domain.Device.Declared.DeclaredDeviceState.Down, + DeviceState.Up => DeclaredDeviceState.Up, + DeviceState.Dynamic => DeclaredDeviceState.Dynamic, + DeviceState.Down => DeclaredDeviceState.Down, _ => throw new ArgumentOutOfRangeException( nameof(dto), dto, null ) }; } diff --git a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs index 910568bf..65ab15d9 100644 --- a/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs +++ b/src/Spec/Dtos/V1_preview/Mappers/Mapper.ToDto.cs @@ -1,9 +1,13 @@ +using Drift.Domain; +using Drift.Domain.Device.Addresses; +using Drift.Domain.Device.Declared; + namespace Drift.Spec.Dtos.V1_preview.Mappers; public static partial class Mapper { internal const string VersionConstant = "v1-preview"; - public static DriftSpec ToDto( Domain.Inventory domain ) { + public static DriftSpec ToDto( Inventory domain ) { return new DriftSpec { Version = VersionConstant, Network = Map( domain.Network ) }; } @@ -13,11 +17,11 @@ private static Network Map( Domain.Network domain ) { }; } - private static Subnet Map( Domain.DeclaredSubnet domain ) { + private static Subnet Map( DeclaredSubnet domain ) { return new Subnet { Id = domain.Id, Address = domain.Address, Enabled = domain.Enabled }; } - private static Device Map( Domain.Device.Declared.DeclaredDevice domain ) { + private static Device Map( DeclaredDevice domain ) { return new Device { Id = domain.Id, Addresses = domain.Addresses.Select( Map ).ToList(), @@ -26,25 +30,25 @@ private static Device Map( Domain.Device.Declared.DeclaredDevice domain ) { }; } - private static DeviceAddress Map( Domain.Device.Addresses.IDeviceAddress domain ) { + private static DeviceAddress Map( IDeviceAddress domain ) { return new DeviceAddress { Value = domain.Value, Type = Map( domain.Type ), IsId = domain.IsId }; } - private static string Map( Domain.Device.Addresses.AddressType addressType ) { + private static string Map( AddressType addressType ) { return addressType switch { - Domain.Device.Addresses.AddressType.IpV4 => "ip-v4", - Domain.Device.Addresses.AddressType.Mac => "mac", - Domain.Device.Addresses.AddressType.Hostname => "hostname", + AddressType.IpV4 => "ip-v4", + AddressType.Mac => "mac", + AddressType.Hostname => "hostname", _ => throw new ArgumentOutOfRangeException( nameof(addressType), addressType, null ) }; } - private static DeviceState? Map( Domain.Device.Declared.DeclaredDeviceState? domain ) { + private static DeviceState? Map( DeclaredDeviceState? domain ) { return domain switch { null => null, - Domain.Device.Declared.DeclaredDeviceState.Up => DeviceState.Up, - Domain.Device.Declared.DeclaredDeviceState.Dynamic => DeviceState.Dynamic, - Domain.Device.Declared.DeclaredDeviceState.Down => DeviceState.Down, + DeclaredDeviceState.Up => DeviceState.Up, + DeclaredDeviceState.Dynamic => DeviceState.Dynamic, + DeclaredDeviceState.Down => DeviceState.Down, _ => throw new ArgumentOutOfRangeException( nameof(domain), domain, null ) }; } diff --git a/src/Spec/Schema/SchemaGenerator.cs b/src/Spec/Schema/SchemaGenerator.cs index b9656436..de37aa5f 100644 --- a/src/Spec/Schema/SchemaGenerator.cs +++ b/src/Spec/Schema/SchemaGenerator.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Drift.Common.Schemas; +using Drift.Spec.Dtos.V1_preview; using Json.Schema; using Json.Schema.Generation; @@ -19,7 +20,7 @@ public static class SchemaGenerator { public static string Generate( SpecVersion version ) { return version switch { - SpecVersion.V1_preview => Generate( version ), + SpecVersion.V1_preview => Generate( version ), _ => throw new ArgumentOutOfRangeException( nameof(version), version, "Unknown spec version" ) }; } diff --git a/src/Spec/Serialization/YamlStaticContext.cs b/src/Spec/Serialization/YamlStaticContext.cs index 78473bcd..b06693eb 100644 --- a/src/Spec/Serialization/YamlStaticContext.cs +++ b/src/Spec/Serialization/YamlStaticContext.cs @@ -1,19 +1,21 @@ +using Drift.Spec.Dtos.V1_preview; using YamlDotNet.Serialization; namespace Drift.Spec.Serialization; [YamlStaticContext] // TODO rely on attributes on the individual types instead? -[YamlSerializable( typeof(Dtos.V1_preview.DriftSpec) )] -[YamlSerializable( typeof(Dtos.V1_preview.Network) )] -[YamlSerializable( typeof(Dtos.V1_preview.Subnet) )] -[YamlSerializable( typeof(Dtos.V1_preview.Device) )] -[YamlSerializable( typeof(Dtos.V1_preview.DeviceState) )] -[YamlSerializable( typeof(Dtos.V1_preview.DeviceAddress) )] +[YamlSerializable( typeof(DriftSpec) )] +[YamlSerializable( typeof(Network) )] +[YamlSerializable( typeof(Subnet) )] +[YamlSerializable( typeof(Device) )] +[YamlSerializable( typeof(DeviceState) )] +[YamlSerializable( typeof(DeviceAddress) )] +[YamlSerializable( typeof(Agent) )] /*[YamlSerializable( typeof(IpV4Address) )] [YamlSerializable( typeof(HostnameAddress) )] [YamlSerializable( typeof(MacAddress) )] [YamlSerializable( typeof(Port) )] [YamlSerializable( typeof(AddressType) )]*/ -public partial class YamlStaticContext : YamlDotNet.Serialization.StaticContext { +public partial class YamlStaticContext : StaticContext { } \ No newline at end of file diff --git a/src/Spec/Validation/SpecValidator.cs b/src/Spec/Validation/SpecValidator.cs index b8ef946c..640f183f 100644 --- a/src/Spec/Validation/SpecValidator.cs +++ b/src/Spec/Validation/SpecValidator.cs @@ -2,13 +2,27 @@ using Drift.Spec.Schema; using Drift.Spec.Serialization; using Json.Schema; +using YamlDotNet.Core; namespace Drift.Spec.Validation; public static class SpecValidator { public static ValidationResult Validate( string yaml, SpecVersion version ) { - var schema = SpecSchemaProvider.Get( version ); - return Validate( yaml, schema ); + try { + var schema = SpecSchemaProvider.Get( version ); + return Validate( yaml, schema ); + } + catch ( YamlException ex ) { + var errors = new List(); + + Exception? exp = ex; + do { + errors.Add( new ValidationError { Message = exp.Message } ); + exp = exp.InnerException; + } while ( exp != null ); + + return new ValidationResult { IsValid = false, Errors = errors }; + } } private static ValidationResult Validate( string yaml, JsonSchema schema ) { diff --git a/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json b/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json index 3c955ed3..da2b2073 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": [ + "array", + "null" + ], + "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..7e09f796 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,