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