From 9cbee2b57ee9b76070af8c0cc4f728fddb2aceb8 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:43:35 +0200 Subject: [PATCH 01/59] feat: distributed scanning # Conflicts: # .github/workflows/ci.yaml --- .config/dotnet-tools.json | 7 + .editorconfig | 5 +- .gitattributes | 3 +- .github/actions/setup-runner/action.yml | 5 + .github/workflows/ci.yaml | 18 + .gitignore | 10 +- .nuke/build.schema.json | 17 + AGENTS.md | 100 +++ CLAUDE.md | 1 + Directory.Packages.props | 12 + Drift.Build.slnx | 9 +- Drift.sln | 152 ++++ README_dev.md | 4 + .../Versioning/VersioningTests.cs | 4 +- build/NukeBuild.Binaries.cs | 2 +- build/NukeBuild.Container.cs | 2 +- build/NukeBuild.Schemas.cs | 48 ++ build/NukeBuild.Test.cs | 26 +- build/NukeBuild.TestContainerlab.cs | 401 +++++++++++ build/NukeBuild.cs | 2 + build/_build.csproj | 9 +- containerlab/README.md | 70 ++ containerlab/cooperation-test-spec.yaml | 22 + containerlab/cooperation-test.clab.yaml | 70 ++ containerlab/simple-test-spec.yaml | 12 + containerlab/simple-test.clab.yaml | 27 + containerlab/subnet-isolation-test-spec.yaml | 18 + containerlab/subnet-isolation-test.clab.yaml | 118 ++++ src/Agent.Host.Tests/Agent.Host.Tests.csproj | 19 + src/Agent.Host.Tests/AssemblyInfo.cs | 1 + .../HandlerRegistrationTests.cs | 24 + .../Scan/ScanSubnetRequestHandlerTests.cs | 124 ++++ .../InterfaceSubnetsRequestHandlerTests.cs | 31 + .../Utils/RecordingMessageResponder.cs | 15 + .../RecordingStreamingMessageResponder.cs | 16 + src/Agent.Host/Agent.Host.csproj | 20 + src/Agent.Host/AgentHost.cs | 104 +++ .../Scan/ScanSubnetRequestHandler.cs | 100 +++ src/Agent.Host/ServiceCollectionExtensions.cs | 17 + .../Status/AgentStatusRequestHandler.cs | 16 + .../Subnets/InterfaceSubnetsRequestHandler.cs | 24 + src/ArchTests/ArchTests.csproj | 1 - src/ArchTests/SanityTests.cs | 4 +- .../BuiltInEnvironmentNames.cs | 5 + src/Cli.Abstractions/ExitCodes.cs | 9 +- src/Cli.Abstractions/Ports.cs | 5 + ...alOptionsTests.HelpOptionTest.verified.txt | 6 + ...ommand_ReturnsSuccessExitCode.verified.txt | 8 +- .../Installation/InstallShTests.Success.cs | 28 +- ...lizationTests.DefaultContents.verified.txt | 4 +- src/Cli.Settings.Tests/SerializationTests.cs | 80 ++- .../TemporarySettingsLocation.cs | 12 + .../TemporarySettingsLocationProvider.cs | 9 - .../Serialization/DefaultSettingsLocation.cs | 18 + .../DefaultSettingsLocationProvider.cs | 31 - .../Serialization/ISettingsLocation.cs | 15 + .../ISettingsLocationProvider.cs | 11 - .../V1_preview/CliSettings.Serialization.cs | 36 +- src/Cli.Settings/V1_preview/CliSettings.cs | 11 + .../Environments/CliSettingsExtensions.cs | 14 + .../Environments/EnvironmentSetting.cs | 3 + .../drift-settings-v1-preview.schema.json | 20 + ...sBackToDefault_platform=Linux.verified.txt | 5 +- src/Cli.Tests/BadSettingsFileTests.cs | 2 +- src/Cli.Tests/Cli.Tests.csproj | 1 + ...mandTests.SuccessfulLifecycle.verified.txt | 8 + src/Cli.Tests/Commands/AgentCommandTests.cs | 43 ++ src/Cli.Tests/Commands/EnvCommandTests.Add.cs | 82 +++ ..._DuplicateName_FailsWithError.verified.txt | 1 + ...dd_MissingName_FailsWithError.verified.txt | 1 + ...nments_OnlyFirstBecomesActive.verified.txt | 1 + ..._AddsEnvironmentAndSetsActive.verified.txt | 1 + ...Tests.EnvList_ListAlias_Works.verified.txt | 1 + ...thOneActive_DisplaysCorrectly.verified.txt | 4 + ...ronments_DisplaysBuiltInLocal.verified.txt | 1 + ...ysWithActive_commandName=list.verified.txt | 2 + ...laysWithActive_commandName=ls.verified.txt | 2 + ...NoEnvironments_FailsWithError.verified.txt | 1 + ...entEnvironment_FailsWithError.verified.txt | 1 + ...earsActive_AdvisesCreatingOne.verified.txt | 3 + ...ctiveEnvironment_ClearsActive.verified.txt | 3 + ...ve_Success_RemovesEnvironment.verified.txt | 3 + ...NoEnvironments_FailsWithError.verified.txt | 1 + ...entEnvironment_FailsWithError.verified.txt | 1 + ...Success_SetsActiveEnvironment.verified.txt | 1 + ...ow_AddMultipleAndManage_Works.verified.txt | 3 + .../Commands/EnvCommandTests.Integration.cs | 33 + .../Commands/EnvCommandTests.List.cs | 49 ++ .../Commands/EnvCommandTests.Remove.cs | 126 ++++ src/Cli.Tests/Commands/EnvCommandTests.Use.cs | 87 +++ src/Cli.Tests/Commands/EnvCommandTests.cs | 49 ++ ...utputFormat=-o log_verbose=-v.verified.txt | 2 + ...cWithoutDiscoverySuccess_spec.verified.txt | 27 +- ...ratedSpecWithDiscoveryIsValid.verified.txt | 9 +- ...edSpecWithoutDiscoveryIsValid.verified.txt | 27 +- src/Cli.Tests/Commands/InitCommandTests.cs | 16 +- ...gle_device_host_outputFormat=.verified.txt | 6 +- ..._device_host_outputFormat=log.verified.txt | 2 +- ...vice_host_outputFormat=normal.verified.txt | 6 +- ...k_single_subnet_outputFormat=.verified.txt | 3 +- ...ingle_subnet_outputFormat=log.verified.txt | 2 +- ...le_subnet_outputFormat=normal.verified.txt | 3 +- src/Cli.Tests/Commands/LintCommandTests.cs | 6 +- .../ScanCommandTests.Orchestration.cs | 183 +++++ ...ommandTests.Remote.RemoteScan.verified.txt | 18 + .../ScanCommandTests.RemoteScan.verified.txt | 25 + ..._AgentsOnly_NoLocalInterfaces.verified.txt | 20 + ...Tests.RemoteScan_EmptyResults.verified.txt | 16 + ...RemoteScan_OverlappingSubnets.verified.txt | 24 + ...eDevices_ProducesUniqueResult.verified.txt | 7 + ...hAgents_EmptyResults_Succeeds.verified.txt | 4 + ...Rules_ControlsAgentVisibility.verified.txt | 10 + ...Interfaces_UsesRemoteScanning.verified.txt | 9 + ...gSubnets_UsesSingleAssignment.verified.txt | 11 + ...on_UsesLocalAndRemoteScanning.verified.txt | 8 + ...sjointSubnets_CombinesResults.verified.txt | 12 + src/Cli.Tests/Commands/ScanCommandTests.cs | 89 ++- .../Commands/ScanInteractiveTests.cs | 4 +- ...mandTests.SuccessfulLifecycle.verified.txt | 9 + src/Cli.Tests/Commands/ServerCommandTests.cs | 43 ++ src/Cli.Tests/Commands/StatusCommandTests.cs | 203 ++++++ ...emCommandLineDefaultErrorTest.verified.txt | 6 + ...eptionReturnsUnknownErrorTest.verified.txt | 2 +- src/Cli.Tests/ExitCodeTests.cs | 34 +- src/Cli.Tests/FeatureFlagTest.cs | 80 +++ src/Cli.Tests/GlobalSuppressions.cs | 22 + src/Cli.Tests/SpecFilePathResolverTests.cs | 2 +- .../Utils/Agent/AgentConfiguration.cs | 47 ++ src/Cli.Tests/Utils/Agent/AgentTestHarness.cs | 328 +++++++++ .../Utils/Agent/CoordinatorConfiguration.cs | 25 + src/Cli.Tests/Utils/Agent/HarnessResult.cs | 53 ++ .../Utils/Agent/NetworkTopologyAdapter.cs | 132 ++++ src/Cli.Tests/Utils/CliCommandResult.cs | 24 + .../TemporaryCoordinatorDataLocation.cs | 21 + .../Coordinator/TemporaryDriftDataLocation.cs | 9 + src/Cli.Tests/Utils/DriftTestCli.cs | 145 +++- src/Cli.Tests/Utils/FirewallRulesTests.cs | 279 ++++++++ .../Network/Firewall/FirewallEvaluator.cs | 99 +++ .../Utils/Network/Firewall/FirewallRules.cs | 85 +++ .../Utils/Network/Firewall/FirewallTarget.cs | 96 +++ .../Utils/Network/Topology/NetworkTopology.cs | 123 ++++ .../Topology/NetworkTopologyBuilder.cs | 110 +++ .../Utils/Network/Topology/Topologies.cs | 315 +++++++++ src/Cli.Tests/Utils/RunningCliCommand.cs | 20 + .../Utils/Testing/MockSubnetScannerFactory.cs | 65 ++ .../Utils/Testing/ScanResultHelper.cs | 157 +++++ src/Cli/Cli.csproj | 13 +- src/Cli/Commands/Agent/AgentCommand.cs | 24 + .../Agent/Subcommands/AgentLifetime.cs | 7 + .../Subcommands/Start/AgentStartCommand.cs | 67 ++ .../Subcommands/Start/AgentStartParameters.cs | 38 + src/Cli/Commands/Common/CommandBase.cs | 30 - .../Commands/Common/Commands/CommandBase.cs | 67 ++ .../Common/Commands/ContainerCommandBase.cs | 8 + .../Common/{ => Commands}/ICommandHandler.cs | 6 +- src/Cli/Commands/Common/CommonParameters.cs | 18 +- src/Cli/Commands/Common/DefaultParameters.cs | 19 - .../Common/Parameters/BaseParameters.cs | 14 + .../Common/Parameters/SpecParameters.cs | 13 + src/Cli/Commands/Common/ParseResultHolder.cs | 2 +- .../Commands/Enrollment/EnrollmentCommand.cs | 11 + .../Subcommands/Add/EnrollmentAddCommand.cs | 80 +++ src/Cli/Commands/Env/EnvCommand.cs | 23 + .../Commands/Env/Subcommands/EnvAddCommand.cs | 83 +++ .../Env/Subcommands/EnvListCommand.cs | 70 ++ .../Env/Subcommands/EnvRemoveCommand.cs | 87 +++ .../Commands/Env/Subcommands/EnvUseCommand.cs | 72 ++ src/Cli/Commands/Init/InitCommand.cs | 29 +- src/Cli/Commands/Init/InitParameters.cs | 4 +- src/Cli/Commands/Lint/LintCommand.cs | 10 +- src/Cli/Commands/Lint/LintParameters.cs | 4 +- .../Lint/Presentation/NormalLintRenderer.cs | 11 +- src/Cli/Commands/Preview/AgentCommand.cs | 36 - src/Cli/Commands/Preview/EnvCommand.cs | 62 -- .../Scan/Interactive/InteractiveUi.cs | 16 +- .../Scan/Interactive/Ui/ScanLayout.cs | 6 +- .../Scan/NonInteractive/NonInteractiveUi.cs | 53 +- .../Commands/Scan/RemoteScanOrchestrator.cs | 53 ++ .../SubnetScanResultProcessor.cs | 2 +- src/Cli/Commands/Scan/ScanCommand.cs | 160 ++++- src/Cli/Commands/Scan/ScanParameters.cs | 5 +- src/Cli/Commands/Server/ServerCommand.cs | 10 + .../Subcommands/Start/ServerStartCommand.cs | 73 ++ .../Start/ServerStartParameters.cs | 49 ++ src/Cli/Commands/Spec/SpecCommand.cs | 10 + .../Subcommands/Apply/SpecApplyCommand.cs | 83 +++ src/Cli/Commands/Status/StatusCommand.cs | 18 + .../Commands/Status/StatusCommandHandler.cs | 221 ++++++ src/Cli/Commands/Status/StatusEnvironment.cs | 13 + src/Cli/Commands/Status/StatusParameters.cs | 9 + src/Cli/DriftCli.cs | 2 +- .../CurrentExecutionEnvironmentProvider.cs | 9 - src/Cli/Infrastructure/EnvironmentTarget.cs | 29 + .../EnvironmentTargetProvider.cs | 38 + src/Cli/Infrastructure/RootCommandFactory.cs | 76 +- .../Logging/NormalOutputLoggerAdapter.cs | 20 + .../Logging/OutputManagerExtensions.cs | 21 + .../Managers/Abstractions/INormalOutput.cs | 16 +- .../Console/Managers/ConsoleOutputManager.cs | 5 +- .../Console/Managers/NullOutputManager.cs | 16 +- .../Managers/Outputs/NormalOutput.Verbose.cs | 12 +- .../Outputs/NormalOutput.VeryVerbose.cs | 12 +- .../Console/Managers/Outputs/NormalOutput.cs | 23 +- .../Console/OutputManagerFactory.cs | 33 +- .../Rendering/NormalOutputExtensions.cs | 23 +- src/Cli/Properties/launchSettings.json | 14 +- src/Cli/SpecFile/FileSystemSpecProvider.cs | 14 +- src/Common/Common.csproj | 4 +- ...EnvironmentExecutionEnvironmentProvider.cs | 19 + src/Common/IO/DefaultAgentDataLocation.cs | 5 + src/Common/IO/DefaultDriftDataLocation.cs | 5 + src/Common/IO/DriftDirectories.cs | 41 ++ src/Common/IO/IAgentDataLocation.cs | 7 + src/Common/IO/IDriftDataLocation.cs | 7 + src/Common/Network/IpNetworkUtils.cs | 2 +- .../Coordinator.Api.OpenApi.csproj | 24 + src/Coordinator.Api.OpenApi/Program.cs | 12 + src/Coordinator.Api.OpenApi/openapi.json | 648 ++++++++++++++++++ .../Control/Dtos/AgentConnectionStatusDto.cs | 10 + .../Control/Dtos/AgentEnrollmentStatusDto.cs | 9 + .../Control/Dtos/AgentStateDto.cs | 8 + .../Control/Dtos/EnrollAgentRequest.cs | 3 + .../Control/Dtos/ScanEventDto.cs | 11 + .../Control/Dtos/ScanStatusDto.cs | 12 + .../Control/Dtos/ScanStatusResponseDto.cs | 3 + .../Control/Dtos/ServerStatusDto.cs | 12 + .../Control/Dtos/StartScanRequest.cs | 5 + .../Control/Dtos/StartScanResponseDto.cs | 3 + .../Control/EndpointRouteBuilderExtensions.cs | 311 +++++++++ .../JsonConverters/CidrBlockJsonConverter.cs | 15 + .../Control/JsonSerializerContext.cs | 31 + src/Coordinator.Api/Coordinator.Api.csproj | 21 + .../CoordinatorApiExtensions.cs | 54 ++ .../CoordinatorOpenApiTransformer.cs | 16 + src/Coordinator.Client/ControlApiClient.cs | 472 +++++++++++++ .../ControlJsonSerializerContext.cs | 29 + .../Coordinator.Client.csproj | 34 + .../Generated/Api/ApiRequestBuilder.cs | 41 ++ .../Api/V1/Agents/AgentsRequestBuilder.cs | 110 +++ .../EnrollmentRequestsRequestBuilder.cs | 94 +++ .../Enrollments/EnrollmentsRequestBuilder.cs | 96 +++ .../Agents/Item/AgentsItemRequestBuilder.cs | 81 +++ .../Scans/Item/Events/EventsRequestBuilder.cs | 83 +++ .../Item/Results/ResultsRequestBuilder.cs | 83 +++ .../V1/Scans/Item/ScansItemRequestBuilder.cs | 95 +++ .../Api/V1/Scans/ScansRequestBuilder.cs | 107 +++ .../Api/V1/Spec/SpecRequestBuilder.cs | 122 ++++ .../Api/V1/Status/StatusRequestBuilder.cs | 84 +++ .../Generated/Api/V1/V1RequestBuilder.cs | 59 ++ .../Generated/ControlClient.cs | 43 ++ .../Models/AgentConnectionStatusDto.cs | 24 + .../Models/AgentEnrollmentStatusDto.cs | 20 + .../Generated/Models/AgentStateDto.cs | 73 ++ .../Generated/Models/CidrBlock.cs | 60 ++ .../Generated/Models/EnrollAgentRequest.cs | 55 ++ .../Models/HttpValidationProblemDetails.cs | 102 +++ .../HttpValidationProblemDetails_errors.cs | 45 ++ .../Generated/Models/IPAddress.cs | 81 +++ .../Generated/Models/ProblemDetails.cs | 92 +++ .../Generated/Models/ScanStatusDto.cs | 32 + .../Generated/Models/ScanStatusResponseDto.cs | 57 ++ .../Generated/Models/ServerStatusDto.cs | 49 ++ .../Generated/Models/ServerStatusDtoValue.cs | 16 + .../Generated/Models/StartScanRequest.cs | 66 ++ .../Generated/Models/StartScanResponseDto.cs | 53 ++ .../Generated/kiota-lock.json | 35 + .../Models/AgentConnectionStatus.cs | 10 + .../Models/AgentEnrollmentStatus.cs | 9 + .../Models/CoordinatorAgentStatus.cs | 13 + .../Models/CoordinatorScanStatus.cs | 12 + .../Models/CoordinatorServiceStatus.cs | 8 + .../Models/CoordinatorStatus.cs | 3 + .../AgentGatewayTests.cs | 69 ++ .../AgentHost_CliOutput.verified.txt | 3 + ...Host_DistributedScanCliOutput.verified.txt | 8 + .../AgentManagementServiceTests.cs | 63 ++ src/Coordinator.Host.Tests/AssemblyInfo.cs | 1 + .../Coordinator.Host.Tests.csproj | 24 + .../CoordinatorHandlerRegistrationTests.cs | 19 + .../CoordinatorHostIntegrationTests.cs | 367 ++++++++++ .../CoordinatorHost_CliOutput.verified.txt | 6 + ...Host_DistributedScanCliOutput.verified.txt | 11 + ...torHost_DistributedScanEvents.verified.txt | 11 + .../InMemoryAgentDirectoryTests.cs | 39 ++ .../ScanServiceTests.cs | 180 +++++ .../Utils/InMemoryAgentEnrollmentStore.cs | 16 + .../Utils/TemporaryCoordinatorDataLocation.cs | 21 + .../Utils/TestCoordinatorSpec.cs | 15 + .../Utils/TestSubnetScannerFactory.cs | 28 + src/Coordinator.Host/Coordinator.Host.csproj | 34 + src/Coordinator.Host/CoordinatorHost.cs | 140 ++++ .../Logging/WebApplicationExtensions.cs | 46 ++ .../ServiceCollectionExtensions.cs | 38 + .../Ui/EndpointRouteBuilderExtensions.cs | 31 + .../Agents/AgentGateway.cs | 119 ++++ .../Agents/AgentManagementService.cs | 66 ++ .../Agents/EnrolledAgent.cs | 20 + .../Enrollment/AgentEnrollmentRecord.cs | 5 + .../Enrollment/FileAgentEnrollmentStore.cs | 40 ++ .../Enrollment/IAgentEnrollmentStore.cs | 9 + .../Agents/IAgentDirectory.cs | 17 + .../Agents/InMemoryAgentDirectory.cs | 64 ++ .../Coordinator.Services.csproj | 26 + .../Models/AgentConnectionStatus.cs | 7 + .../Models/AgentEnrollmentStatus.cs | 6 + src/Coordinator.Services/Models/AgentState.cs | 10 + .../Models/EnrollAgentCommand.cs | 5 + .../Models/ScanEventData.cs | 11 + .../Models/ScanStartResult.cs | 3 + src/Coordinator.Services/Models/ScanStatus.cs | 9 + .../Models/ScanStatusResult.cs | 3 + .../Models/ServerStatus.cs | 5 + .../Models/ServerStatusResult.cs | 3 + .../Models/StartScanCommand.cs | 5 + .../Scans/ScanJsonSerializerContext.cs | 19 + src/Coordinator.Services/Scans/ScanService.cs | 321 +++++++++ .../ServerStatusService.cs | 10 + .../Spec/CoordinatorSpec.cs | 40 ++ .../Spec/CoordinatorSpecView.cs | 13 + src/Coordinator.Services/Spec/SpecService.cs | 29 + .../State/DefaultCoordinatorDataLocation.cs | 25 + .../State/FileCoordinatorSpecStore.cs | 26 + .../State/ICoordinatorDataLocation.cs | 24 + .../State/ICoordinatorSpecStore.cs | 11 + src/Diff.Tests/DiffTest.cs | 22 +- src/Domain/AgentId.cs | 62 ++ src/Domain/CidrBlock.cs | 4 + src/Domain/Device/Addresses/AddressType.cs | 2 +- .../Device/Addresses/DeviceAddressSet.cs | 50 ++ src/Domain/Device/Addresses/IpV4Address.cs | 1 - .../Device/Discovered/DiscoveredDevice.cs | 1 + src/Domain/Environment.cs | 105 +++ src/Domain/Inventory.cs | 23 + src/Domain/RequestId.cs | 23 + ...NetworkScanner.cs => IScanOrchestrator.cs} | 4 +- src/Domain/Settings.cs | 55 ++ src/Messaging.Client/AgentClient.cs | 190 +++++ src/Messaging.Client/AgentClientOptions.cs | 43 ++ src/Messaging.Client/IAgentClient.cs | 26 + src/Messaging.Client/Messaging.Client.csproj | 7 + .../ServiceCollectionExtensions.cs | 9 + .../AgentProtocolMessagesAssemblyMarker.cs | 6 + .../Messaging.Protocol.Agent.csproj | 9 + .../Scan/ScanSubnetProgress.cs | 30 + .../Scan/ScanSubnetRequest.cs | 30 + .../Scan/ScanSubnetResponse.cs | 28 + .../Status/AgentStatusRequest.cs | 14 + .../Status/AgentStatusResponse.cs | 25 + .../Subnets/SubnetsRequest.cs | 14 + .../Subnets/SubnetsResponse.cs | 25 + src/Messaging.Tests/MessageHandlerTests.cs | 50 ++ src/Messaging.Tests/Messaging.Tests.csproj | 19 + .../DefaultMessagingClientFactory.cs | 13 + .../Networking.Client.csproj | 11 + .../ServiceCollectionExtensions.cs | 13 + .../ConnectionSide.cs | 6 + src/Networking.Core.Abstractions/Empty.cs | 17 + src/Networking.Core.Abstractions/IMessage.cs | 34 + .../IMessageEnvelopeConverter.cs | 42 ++ .../IMessageHandler.cs | 103 +++ .../IMessageResponder.cs | 40 ++ .../IMessageStream.cs | 20 + .../IMessageStreamConnection.cs | 39 ++ .../IMessageStreamManager.cs | 15 + .../IMessageTypesProvider.cs | 7 + .../IMessagingClientFactory.cs | 8 + .../MessageStreamExtensions.cs | 41 ++ .../Networking.Core.Abstractions.csproj | 12 + .../Common/GrpcMetadataExtensions.cs | 16 + src/Networking.Core/Connection.cs | 162 +++++ src/Networking.Core/MessageStream.cs | 160 +++++ src/Networking.Core/MessageStreamManager.cs | 148 ++++ .../Messages/MessageDispatcher.cs | 50 ++ .../Messages/MessageEnvelopeConverter.cs | 80 +++ .../Messages/MessageResponseCorrelator.cs | 118 ++++ src/Networking.Core/MessagingOptions.cs | 15 + src/Networking.Core/Networking.Core.csproj | 15 + .../ServiceCollectionExtensions.cs | 26 + src/Networking.Grpc/Networking.Grpc.csproj | 13 + src/Networking.Grpc/Protos/agent.proto | 19 + .../InboundMessageService.cs | 33 + .../MessagingServerMarker.cs | 8 + .../Networking.Server.csproj | 15 + .../ServiceCollectionExtensions.cs | 50 ++ src/Networking.Tests/AssemblyInfo.cs | 1 + .../Helpers/InMemoryDuplexStreamPair.cs | 121 ++++ src/Networking.Tests/Helpers/TestMessage.cs | 42 ++ .../Helpers/TestServerCallContext.cs | 74 ++ .../TestServerCallContextExtensions.cs | 23 + src/Networking.Tests/InboundTests.cs | 102 +++ .../MessageEnvelopeConverterTests.cs | 56 ++ .../MessageStreamManagerTests.cs | 102 +++ src/Networking.Tests/Networking.Tests.csproj | 21 + src/Scanning.Tests/NetworkScannerTests.cs | 12 +- ...5.255.254-32,192.168.32.0-20.verified.txt} | 2 +- ...sicTest_cidrs=192.168.0.0-24.verified.txt} | 2 +- ...sicTest_cidrs=192.168.0.1-24.verified.txt} | 2 +- ...cTest_cidrs=192.168.123.0-24.verified.txt} | 2 +- ...anner.cs => PredefinedScanOrchestrator.cs} | 4 +- .../Utils/PredefinedSubnetScannerFactory.cs | 6 +- .../{ => Factories}/ISubnetScannerFactory.cs | 2 +- .../LocalSubnetScannerFactory.cs} | 4 +- .../Scanners/LinuxFpingSubnetScanner.cs | 4 +- .../Scanners/PingSubnetScannerBase.cs | 86 ++- src/Scanning/Scanners/Preview/Ideas.cs | 12 +- ...tNetworkScanner.cs => ScanOrchestrator.cs} | 19 +- src/Scanning/Scanning.csproj | 3 +- src/Scanning/ServiceCollectionExtensions.cs | 31 + .../Subnets/CompositeSubnetProvider.cs | 7 +- src/Scanning/Subnets/IResolvedSubnet.cs | 26 + src/Scanning/Subnets/ISubnetProvider.cs | 4 +- .../Interface/InterfaceSubnetProviderBase.cs | 5 +- .../Subnets/PredefinedSubnetProvider.cs | 16 +- .../Converters/AgentIdConverter.cs | 18 + .../Converters/CidrBlockConverter.cs | 25 + .../Converters/DeviceAddressConverter.cs | 38 + .../Converters/IpAddressConverter.cs | 21 + .../Converters/IpV4AddressSetConverter.cs | 43 ++ .../Converters/PercentageConverter.cs | 15 + src/Serialization/Serialization.csproj | 4 + src/Spec.Tests/MapperTests.cs | 135 ++++ src/Spec.Tests/ValidationTests.cs | 131 +++- src/Spec.Tests/resources/network1.yaml | 12 +- .../resources/network_single_device_host.yaml | 3 +- src/Spec/Dtos/V1_preview/DriftSpec.cs | 148 +++- .../V1_preview/Mappers/Mapper.ToDomain.cs | 152 +++- .../Dtos/V1_preview/Mappers/Mapper.ToDto.cs | 99 ++- src/Spec/Schema/SchemaGenerator.cs | 3 +- .../YamlConverter.Deserialization.cs | 10 +- src/Spec/Serialization/YamlStaticContext.cs | 21 +- src/Spec/Validation/SpecValidator.cs | 34 +- .../schemas/drift-spec-v1-preview.schema.json | 197 +++++- src/TestUtilities/{ => IO}/StringLogger.cs | 8 +- src/TestUtilities/IO/TcpUtils.cs | 12 + src/TestUtilities/{ => IO}/TestLogger.cs | 2 +- .../NetworkProviders/TestNetworkProvider.cs | 6 - .../VerifySettingsTaskExtensions.cs | 20 +- 437 files changed, 16944 insertions(+), 863 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 build/NukeBuild.Schemas.cs create mode 100644 build/NukeBuild.TestContainerlab.cs create mode 100644 containerlab/README.md create mode 100644 containerlab/cooperation-test-spec.yaml create mode 100644 containerlab/cooperation-test.clab.yaml create mode 100644 containerlab/simple-test-spec.yaml create mode 100644 containerlab/simple-test.clab.yaml create mode 100644 containerlab/subnet-isolation-test-spec.yaml create mode 100644 containerlab/subnet-isolation-test.clab.yaml create mode 100644 src/Agent.Host.Tests/Agent.Host.Tests.csproj create mode 100644 src/Agent.Host.Tests/AssemblyInfo.cs create mode 100644 src/Agent.Host.Tests/HandlerRegistrationTests.cs create mode 100644 src/Agent.Host.Tests/Scan/ScanSubnetRequestHandlerTests.cs create mode 100644 src/Agent.Host.Tests/Subnets/InterfaceSubnetsRequestHandlerTests.cs create mode 100644 src/Agent.Host.Tests/Utils/RecordingMessageResponder.cs create mode 100644 src/Agent.Host.Tests/Utils/RecordingStreamingMessageResponder.cs create mode 100644 src/Agent.Host/Agent.Host.csproj create mode 100644 src/Agent.Host/AgentHost.cs create mode 100644 src/Agent.Host/Scan/ScanSubnetRequestHandler.cs create mode 100644 src/Agent.Host/ServiceCollectionExtensions.cs create mode 100644 src/Agent.Host/Status/AgentStatusRequestHandler.cs create mode 100644 src/Agent.Host/Subnets/InterfaceSubnetsRequestHandler.cs create mode 100644 src/Cli.Abstractions/BuiltInEnvironmentNames.cs create mode 100644 src/Cli.Abstractions/Ports.cs create mode 100644 src/Cli.Settings.Tests/TemporarySettingsLocation.cs delete mode 100644 src/Cli.Settings.Tests/TemporarySettingsLocationProvider.cs create mode 100644 src/Cli.Settings/Serialization/DefaultSettingsLocation.cs delete mode 100644 src/Cli.Settings/Serialization/DefaultSettingsLocationProvider.cs create mode 100644 src/Cli.Settings/Serialization/ISettingsLocation.cs delete mode 100644 src/Cli.Settings/Serialization/ISettingsLocationProvider.cs create mode 100644 src/Cli.Settings/V1_preview/Environments/CliSettingsExtensions.cs create mode 100644 src/Cli.Settings/V1_preview/Environments/EnvironmentSetting.cs create mode 100644 src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt create mode 100644 src/Cli.Tests/Commands/AgentCommandTests.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.Add.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_DuplicateName_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MissingName_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_Success_AddsEnvironmentAndSetsActive.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_ListAlias_Works.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysBuiltInLocal.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.Integration.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.List.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.Remove.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.Use.cs create mode 100644 src/Cli.Tests/Commands/EnvCommandTests.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Orchestration.cs create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.Remote.RemoteScan.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_AgentsOnly_NoLocalInterfaces.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_EmptyResults.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.RemoteScan_OverlappingSubnets.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_DuplicateDevices_ProducesUniqueResult.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_EmptyResults_Succeeds.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_FirewallRules_ControlsAgentVisibility.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_NoLocalInterfaces_UsesRemoteScanning.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_OverlappingSubnets_UsesSingleAssignment.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_ScannerSelection_UsesLocalAndRemoteScanning.verified.txt create mode 100644 src/Cli.Tests/Commands/ScanCommandTests.WithAgents_TwoDisjointSubnets_CombinesResults.verified.txt create mode 100644 src/Cli.Tests/Commands/ServerCommandTests.SuccessfulLifecycle.verified.txt create mode 100644 src/Cli.Tests/Commands/ServerCommandTests.cs create mode 100644 src/Cli.Tests/Commands/StatusCommandTests.cs create mode 100644 src/Cli.Tests/FeatureFlagTest.cs create mode 100644 src/Cli.Tests/GlobalSuppressions.cs create mode 100644 src/Cli.Tests/Utils/Agent/AgentConfiguration.cs create mode 100644 src/Cli.Tests/Utils/Agent/AgentTestHarness.cs create mode 100644 src/Cli.Tests/Utils/Agent/CoordinatorConfiguration.cs create mode 100644 src/Cli.Tests/Utils/Agent/HarnessResult.cs create mode 100644 src/Cli.Tests/Utils/Agent/NetworkTopologyAdapter.cs create mode 100644 src/Cli.Tests/Utils/CliCommandResult.cs create mode 100644 src/Cli.Tests/Utils/Coordinator/TemporaryCoordinatorDataLocation.cs create mode 100644 src/Cli.Tests/Utils/Coordinator/TemporaryDriftDataLocation.cs create mode 100644 src/Cli.Tests/Utils/FirewallRulesTests.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallEvaluator.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallRules.cs create mode 100644 src/Cli.Tests/Utils/Network/Firewall/FirewallTarget.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/NetworkTopology.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/NetworkTopologyBuilder.cs create mode 100644 src/Cli.Tests/Utils/Network/Topology/Topologies.cs create mode 100644 src/Cli.Tests/Utils/RunningCliCommand.cs create mode 100644 src/Cli.Tests/Utils/Testing/MockSubnetScannerFactory.cs create mode 100644 src/Cli.Tests/Utils/Testing/ScanResultHelper.cs create mode 100644 src/Cli/Commands/Agent/AgentCommand.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/AgentLifetime.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/Start/AgentStartCommand.cs create mode 100644 src/Cli/Commands/Agent/Subcommands/Start/AgentStartParameters.cs delete mode 100644 src/Cli/Commands/Common/CommandBase.cs create mode 100644 src/Cli/Commands/Common/Commands/CommandBase.cs create mode 100644 src/Cli/Commands/Common/Commands/ContainerCommandBase.cs rename src/Cli/Commands/Common/{ => Commands}/ICommandHandler.cs (56%) delete mode 100644 src/Cli/Commands/Common/DefaultParameters.cs create mode 100644 src/Cli/Commands/Common/Parameters/BaseParameters.cs create mode 100644 src/Cli/Commands/Common/Parameters/SpecParameters.cs create mode 100644 src/Cli/Commands/Enrollment/EnrollmentCommand.cs create mode 100644 src/Cli/Commands/Enrollment/Subcommands/Add/EnrollmentAddCommand.cs create mode 100644 src/Cli/Commands/Env/EnvCommand.cs create mode 100644 src/Cli/Commands/Env/Subcommands/EnvAddCommand.cs create mode 100644 src/Cli/Commands/Env/Subcommands/EnvListCommand.cs create mode 100644 src/Cli/Commands/Env/Subcommands/EnvRemoveCommand.cs create mode 100644 src/Cli/Commands/Env/Subcommands/EnvUseCommand.cs delete mode 100644 src/Cli/Commands/Preview/AgentCommand.cs delete mode 100644 src/Cli/Commands/Preview/EnvCommand.cs create mode 100644 src/Cli/Commands/Scan/RemoteScanOrchestrator.cs create mode 100644 src/Cli/Commands/Server/ServerCommand.cs create mode 100644 src/Cli/Commands/Server/Subcommands/Start/ServerStartCommand.cs create mode 100644 src/Cli/Commands/Server/Subcommands/Start/ServerStartParameters.cs create mode 100644 src/Cli/Commands/Spec/SpecCommand.cs create mode 100644 src/Cli/Commands/Spec/Subcommands/Apply/SpecApplyCommand.cs create mode 100644 src/Cli/Commands/Status/StatusCommand.cs create mode 100644 src/Cli/Commands/Status/StatusCommandHandler.cs create mode 100644 src/Cli/Commands/Status/StatusEnvironment.cs create mode 100644 src/Cli/Commands/Status/StatusParameters.cs delete mode 100644 src/Cli/Infrastructure/CurrentExecutionEnvironmentProvider.cs create mode 100644 src/Cli/Infrastructure/EnvironmentTarget.cs create mode 100644 src/Cli/Infrastructure/EnvironmentTargetProvider.cs create mode 100644 src/Common/EnvironmentExecutionEnvironmentProvider.cs create mode 100644 src/Common/IO/DefaultAgentDataLocation.cs create mode 100644 src/Common/IO/DefaultDriftDataLocation.cs create mode 100644 src/Common/IO/DriftDirectories.cs create mode 100644 src/Common/IO/IAgentDataLocation.cs create mode 100644 src/Common/IO/IDriftDataLocation.cs create mode 100644 src/Coordinator.Api.OpenApi/Coordinator.Api.OpenApi.csproj create mode 100644 src/Coordinator.Api.OpenApi/Program.cs create mode 100644 src/Coordinator.Api.OpenApi/openapi.json create mode 100644 src/Coordinator.Api/Control/Dtos/AgentConnectionStatusDto.cs create mode 100644 src/Coordinator.Api/Control/Dtos/AgentEnrollmentStatusDto.cs create mode 100644 src/Coordinator.Api/Control/Dtos/AgentStateDto.cs create mode 100644 src/Coordinator.Api/Control/Dtos/EnrollAgentRequest.cs create mode 100644 src/Coordinator.Api/Control/Dtos/ScanEventDto.cs create mode 100644 src/Coordinator.Api/Control/Dtos/ScanStatusDto.cs create mode 100644 src/Coordinator.Api/Control/Dtos/ScanStatusResponseDto.cs create mode 100644 src/Coordinator.Api/Control/Dtos/ServerStatusDto.cs create mode 100644 src/Coordinator.Api/Control/Dtos/StartScanRequest.cs create mode 100644 src/Coordinator.Api/Control/Dtos/StartScanResponseDto.cs create mode 100644 src/Coordinator.Api/Control/EndpointRouteBuilderExtensions.cs create mode 100644 src/Coordinator.Api/Control/JsonConverters/CidrBlockJsonConverter.cs create mode 100644 src/Coordinator.Api/Control/JsonSerializerContext.cs create mode 100644 src/Coordinator.Api/Coordinator.Api.csproj create mode 100644 src/Coordinator.Api/CoordinatorApiExtensions.cs create mode 100644 src/Coordinator.Api/CoordinatorOpenApiTransformer.cs create mode 100644 src/Coordinator.Client/ControlApiClient.cs create mode 100644 src/Coordinator.Client/ControlJsonSerializerContext.cs create mode 100644 src/Coordinator.Client/Coordinator.Client.csproj create mode 100644 src/Coordinator.Client/Generated/Api/ApiRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Agents/AgentsRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Agents/EnrollmentRequests/EnrollmentRequestsRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Agents/Enrollments/EnrollmentsRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Agents/Item/AgentsItemRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Scans/Item/Events/EventsRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Scans/Item/Results/ResultsRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Scans/Item/ScansItemRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Scans/ScansRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Spec/SpecRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/Status/StatusRequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/Api/V1/V1RequestBuilder.cs create mode 100644 src/Coordinator.Client/Generated/ControlClient.cs create mode 100644 src/Coordinator.Client/Generated/Models/AgentConnectionStatusDto.cs create mode 100644 src/Coordinator.Client/Generated/Models/AgentEnrollmentStatusDto.cs create mode 100644 src/Coordinator.Client/Generated/Models/AgentStateDto.cs create mode 100644 src/Coordinator.Client/Generated/Models/CidrBlock.cs create mode 100644 src/Coordinator.Client/Generated/Models/EnrollAgentRequest.cs create mode 100644 src/Coordinator.Client/Generated/Models/HttpValidationProblemDetails.cs create mode 100644 src/Coordinator.Client/Generated/Models/HttpValidationProblemDetails_errors.cs create mode 100644 src/Coordinator.Client/Generated/Models/IPAddress.cs create mode 100644 src/Coordinator.Client/Generated/Models/ProblemDetails.cs create mode 100644 src/Coordinator.Client/Generated/Models/ScanStatusDto.cs create mode 100644 src/Coordinator.Client/Generated/Models/ScanStatusResponseDto.cs create mode 100644 src/Coordinator.Client/Generated/Models/ServerStatusDto.cs create mode 100644 src/Coordinator.Client/Generated/Models/ServerStatusDtoValue.cs create mode 100644 src/Coordinator.Client/Generated/Models/StartScanRequest.cs create mode 100644 src/Coordinator.Client/Generated/Models/StartScanResponseDto.cs create mode 100644 src/Coordinator.Client/Generated/kiota-lock.json create mode 100644 src/Coordinator.Client/Models/AgentConnectionStatus.cs create mode 100644 src/Coordinator.Client/Models/AgentEnrollmentStatus.cs create mode 100644 src/Coordinator.Client/Models/CoordinatorAgentStatus.cs create mode 100644 src/Coordinator.Client/Models/CoordinatorScanStatus.cs create mode 100644 src/Coordinator.Client/Models/CoordinatorServiceStatus.cs create mode 100644 src/Coordinator.Client/Models/CoordinatorStatus.cs create mode 100644 src/Coordinator.Host.Tests/AgentGatewayTests.cs create mode 100644 src/Coordinator.Host.Tests/AgentHost_CliOutput.verified.txt create mode 100644 src/Coordinator.Host.Tests/AgentHost_DistributedScanCliOutput.verified.txt create mode 100644 src/Coordinator.Host.Tests/AgentManagementServiceTests.cs create mode 100644 src/Coordinator.Host.Tests/AssemblyInfo.cs create mode 100644 src/Coordinator.Host.Tests/Coordinator.Host.Tests.csproj create mode 100644 src/Coordinator.Host.Tests/CoordinatorHandlerRegistrationTests.cs create mode 100644 src/Coordinator.Host.Tests/CoordinatorHostIntegrationTests.cs create mode 100644 src/Coordinator.Host.Tests/CoordinatorHost_CliOutput.verified.txt create mode 100644 src/Coordinator.Host.Tests/CoordinatorHost_DistributedScanCliOutput.verified.txt create mode 100644 src/Coordinator.Host.Tests/CoordinatorHost_DistributedScanEvents.verified.txt create mode 100644 src/Coordinator.Host.Tests/InMemoryAgentDirectoryTests.cs create mode 100644 src/Coordinator.Host.Tests/ScanServiceTests.cs create mode 100644 src/Coordinator.Host.Tests/Utils/InMemoryAgentEnrollmentStore.cs create mode 100644 src/Coordinator.Host.Tests/Utils/TemporaryCoordinatorDataLocation.cs create mode 100644 src/Coordinator.Host.Tests/Utils/TestCoordinatorSpec.cs create mode 100644 src/Coordinator.Host.Tests/Utils/TestSubnetScannerFactory.cs create mode 100644 src/Coordinator.Host/Coordinator.Host.csproj create mode 100644 src/Coordinator.Host/CoordinatorHost.cs create mode 100644 src/Coordinator.Host/Logging/WebApplicationExtensions.cs create mode 100644 src/Coordinator.Host/ServiceCollectionExtensions.cs create mode 100644 src/Coordinator.Host/Ui/EndpointRouteBuilderExtensions.cs create mode 100644 src/Coordinator.Services/Agents/AgentGateway.cs create mode 100644 src/Coordinator.Services/Agents/AgentManagementService.cs create mode 100644 src/Coordinator.Services/Agents/EnrolledAgent.cs create mode 100644 src/Coordinator.Services/Agents/Enrollment/AgentEnrollmentRecord.cs create mode 100644 src/Coordinator.Services/Agents/Enrollment/FileAgentEnrollmentStore.cs create mode 100644 src/Coordinator.Services/Agents/Enrollment/IAgentEnrollmentStore.cs create mode 100644 src/Coordinator.Services/Agents/IAgentDirectory.cs create mode 100644 src/Coordinator.Services/Agents/InMemoryAgentDirectory.cs create mode 100644 src/Coordinator.Services/Coordinator.Services.csproj create mode 100644 src/Coordinator.Services/Models/AgentConnectionStatus.cs create mode 100644 src/Coordinator.Services/Models/AgentEnrollmentStatus.cs create mode 100644 src/Coordinator.Services/Models/AgentState.cs create mode 100644 src/Coordinator.Services/Models/EnrollAgentCommand.cs create mode 100644 src/Coordinator.Services/Models/ScanEventData.cs create mode 100644 src/Coordinator.Services/Models/ScanStartResult.cs create mode 100644 src/Coordinator.Services/Models/ScanStatus.cs create mode 100644 src/Coordinator.Services/Models/ScanStatusResult.cs create mode 100644 src/Coordinator.Services/Models/ServerStatus.cs create mode 100644 src/Coordinator.Services/Models/ServerStatusResult.cs create mode 100644 src/Coordinator.Services/Models/StartScanCommand.cs create mode 100644 src/Coordinator.Services/Scans/ScanJsonSerializerContext.cs create mode 100644 src/Coordinator.Services/Scans/ScanService.cs create mode 100644 src/Coordinator.Services/ServerStatusService.cs create mode 100644 src/Coordinator.Services/Spec/CoordinatorSpec.cs create mode 100644 src/Coordinator.Services/Spec/CoordinatorSpecView.cs create mode 100644 src/Coordinator.Services/Spec/SpecService.cs create mode 100644 src/Coordinator.Services/State/DefaultCoordinatorDataLocation.cs create mode 100644 src/Coordinator.Services/State/FileCoordinatorSpecStore.cs create mode 100644 src/Coordinator.Services/State/ICoordinatorDataLocation.cs create mode 100644 src/Coordinator.Services/State/ICoordinatorSpecStore.cs create mode 100644 src/Domain/AgentId.cs create mode 100644 src/Domain/Device/Addresses/DeviceAddressSet.cs create mode 100644 src/Domain/Environment.cs create mode 100644 src/Domain/RequestId.cs rename src/Domain/Scan/{INetworkScanner.cs => IScanOrchestrator.cs} (74%) create mode 100644 src/Domain/Settings.cs create mode 100644 src/Messaging.Client/AgentClient.cs create mode 100644 src/Messaging.Client/AgentClientOptions.cs create mode 100644 src/Messaging.Client/IAgentClient.cs create mode 100644 src/Messaging.Client/Messaging.Client.csproj create mode 100644 src/Messaging.Client/ServiceCollectionExtensions.cs create mode 100644 src/Messaging.Protocol.Agent/AgentProtocolMessagesAssemblyMarker.cs create mode 100644 src/Messaging.Protocol.Agent/Messaging.Protocol.Agent.csproj create mode 100644 src/Messaging.Protocol.Agent/Scan/ScanSubnetProgress.cs create mode 100644 src/Messaging.Protocol.Agent/Scan/ScanSubnetRequest.cs create mode 100644 src/Messaging.Protocol.Agent/Scan/ScanSubnetResponse.cs create mode 100644 src/Messaging.Protocol.Agent/Status/AgentStatusRequest.cs create mode 100644 src/Messaging.Protocol.Agent/Status/AgentStatusResponse.cs create mode 100644 src/Messaging.Protocol.Agent/Subnets/SubnetsRequest.cs create mode 100644 src/Messaging.Protocol.Agent/Subnets/SubnetsResponse.cs create mode 100644 src/Messaging.Tests/MessageHandlerTests.cs create mode 100644 src/Messaging.Tests/Messaging.Tests.csproj create mode 100644 src/Networking.Client/DefaultMessagingClientFactory.cs create mode 100644 src/Networking.Client/Networking.Client.csproj create mode 100644 src/Networking.Client/ServiceCollectionExtensions.cs create mode 100644 src/Networking.Core.Abstractions/ConnectionSide.cs create mode 100644 src/Networking.Core.Abstractions/Empty.cs create mode 100644 src/Networking.Core.Abstractions/IMessage.cs create mode 100644 src/Networking.Core.Abstractions/IMessageEnvelopeConverter.cs create mode 100644 src/Networking.Core.Abstractions/IMessageHandler.cs create mode 100644 src/Networking.Core.Abstractions/IMessageResponder.cs create mode 100644 src/Networking.Core.Abstractions/IMessageStream.cs create mode 100644 src/Networking.Core.Abstractions/IMessageStreamConnection.cs create mode 100644 src/Networking.Core.Abstractions/IMessageStreamManager.cs create mode 100644 src/Networking.Core.Abstractions/IMessageTypesProvider.cs create mode 100644 src/Networking.Core.Abstractions/IMessagingClientFactory.cs create mode 100644 src/Networking.Core.Abstractions/MessageStreamExtensions.cs create mode 100644 src/Networking.Core.Abstractions/Networking.Core.Abstractions.csproj create mode 100644 src/Networking.Core/Common/GrpcMetadataExtensions.cs create mode 100644 src/Networking.Core/Connection.cs create mode 100644 src/Networking.Core/MessageStream.cs create mode 100644 src/Networking.Core/MessageStreamManager.cs create mode 100644 src/Networking.Core/Messages/MessageDispatcher.cs create mode 100644 src/Networking.Core/Messages/MessageEnvelopeConverter.cs create mode 100644 src/Networking.Core/Messages/MessageResponseCorrelator.cs create mode 100644 src/Networking.Core/MessagingOptions.cs create mode 100644 src/Networking.Core/Networking.Core.csproj create mode 100644 src/Networking.Core/ServiceCollectionExtensions.cs create mode 100644 src/Networking.Grpc/Networking.Grpc.csproj create mode 100644 src/Networking.Grpc/Protos/agent.proto create mode 100644 src/Networking.Server/InboundMessageService.cs create mode 100644 src/Networking.Server/MessagingServerMarker.cs create mode 100644 src/Networking.Server/Networking.Server.csproj create mode 100644 src/Networking.Server/ServiceCollectionExtensions.cs create mode 100644 src/Networking.Tests/AssemblyInfo.cs create mode 100644 src/Networking.Tests/Helpers/InMemoryDuplexStreamPair.cs create mode 100644 src/Networking.Tests/Helpers/TestMessage.cs create mode 100644 src/Networking.Tests/Helpers/TestServerCallContext.cs create mode 100644 src/Networking.Tests/Helpers/TestServerCallContextExtensions.cs create mode 100644 src/Networking.Tests/InboundTests.cs create mode 100644 src/Networking.Tests/MessageEnvelopeConverterTests.cs create mode 100644 src/Networking.Tests/MessageStreamManagerTests.cs create mode 100644 src/Networking.Tests/Networking.Tests.csproj rename src/Scanning.Tests/{NetworkScannerTests.BasicTest_cidrs=10.255.255.254-32,192.168.32.0-20.verified.txt => ScanOrchestratorTests.BasicTest_cidrs=10.255.255.254-32,192.168.32.0-20.verified.txt} (90%) rename src/Scanning.Tests/{NetworkScannerTests.BasicTest_cidrs=192.168.0.0-24.verified.txt => ScanOrchestratorTests.BasicTest_cidrs=192.168.0.0-24.verified.txt} (87%) rename src/Scanning.Tests/{NetworkScannerTests.BasicTest_cidrs=192.168.0.1-24.verified.txt => ScanOrchestratorTests.BasicTest_cidrs=192.168.0.1-24.verified.txt} (87%) rename src/Scanning.Tests/{NetworkScannerTests.BasicTest_cidrs=192.168.123.0-24.verified.txt => ScanOrchestratorTests.BasicTest_cidrs=192.168.123.0-24.verified.txt} (88%) rename src/Scanning.Tests/Utils/{PredefinedResultNetworkScanner.cs => PredefinedScanOrchestrator.cs} (85%) rename src/Scanning/Scanners/{ => Factories}/ISubnetScannerFactory.cs (74%) rename src/Scanning/Scanners/{DefaultSubnetScannerFactory.cs => Factories/LocalSubnetScannerFactory.cs} (95%) rename src/Scanning/Scanners/{DefaultNetworkScanner.cs => ScanOrchestrator.cs} (86%) create mode 100644 src/Scanning/ServiceCollectionExtensions.cs create mode 100644 src/Scanning/Subnets/IResolvedSubnet.cs create mode 100644 src/Serialization/Converters/AgentIdConverter.cs create mode 100644 src/Serialization/Converters/CidrBlockConverter.cs create mode 100644 src/Serialization/Converters/DeviceAddressConverter.cs create mode 100644 src/Serialization/Converters/IpAddressConverter.cs create mode 100644 src/Serialization/Converters/IpV4AddressSetConverter.cs create mode 100644 src/Serialization/Converters/PercentageConverter.cs create mode 100644 src/Spec.Tests/MapperTests.cs rename src/TestUtilities/{ => IO}/StringLogger.cs (83%) create mode 100644 src/TestUtilities/IO/TcpUtils.cs rename src/TestUtilities/{ => IO}/TestLogger.cs (97%) delete mode 100644 src/TestUtilities/NetworkProviders/TestNetworkProvider.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 769f8ab4..99c1191a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -22,6 +22,13 @@ "trx" ], "rollForward": false + }, + "Microsoft.OpenApi.Kiota": { + "version": "1.35.0", + "commands": [ + "kiota" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 0c9e904f..63315da4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ root = true indent_style = space # Code files -[*.{cs,csproj,slnx,props,json}] +[*.{cs,csproj,slnx,props,targets,json}] indent_size = 2 insert_final_newline = false charset = utf-8 @@ -159,6 +159,7 @@ dotnet_diagnostic.CA1848.severity = suggestion dotnet_diagnostic.CA2007.severity = suggestion dotnet_diagnostic.CA1303.severity = none dotnet_diagnostic.CA1848.severity = none +dotnet_diagnostic.CA1068.severity = warning # Elements should be documented dotnet_diagnostic.SA1600.severity = none # Braces for multi-line statements should not share line @@ -225,6 +226,8 @@ dotnet_diagnostic.SA1502.severity = none dotnet_diagnostic.SA1508.severity = none # SA1516 Elements should be separated by blank line (but reports false positives) dotnet_diagnostic.SA1516.severity = none +# SA1201 An element within a C# code file is out of order in relation to the other elements in the code. +dotnet_diagnostic.SA1201.severity = none # TODO TBD diff --git a/.gitattributes b/.gitattributes index 8353a8cb..272647f6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,4 +4,5 @@ *.cmd text eol=crlf *.ps1 text eol=crlf *.sh text eol=lf -*.png binary \ No newline at end of file +*.png binary +/src/Coordinator.Client/Generated/** linguist-generated=true \ No newline at end of file diff --git a/.github/actions/setup-runner/action.yml b/.github/actions/setup-runner/action.yml index 7bc05321..0fd503d1 100644 --- a/.github/actions/setup-runner/action.yml +++ b/.github/actions/setup-runner/action.yml @@ -18,3 +18,8 @@ runs: - name: Restore .NET tools shell: bash run: dotnet tool restore + + - name: Install Containerlab + if: runner.os == 'Linux' + shell: bash + run: bash -c "$(curl -sL https://get.containerlab.dev)" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 02519b27..528606f5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,6 +59,15 @@ jobs: --project ./build/_build.csproj --target CheckBuildWarnings + - name: Check generated files + shell: bash + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "The build changed tracked or untracked files:" + git status --short + exit 1 + fi + test-windows: name: πŸͺŸ Test runs-on: windows-latest @@ -113,6 +122,15 @@ jobs: --project ./build/_build.csproj --target CheckBuildWarnings + - name: Check generated files + shell: bash + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "The build changed tracked or untracked files:" + git status --short + exit 1 + fi + markdown-lint: name: Markdown lint runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d3ed220a..a09aad24 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,14 @@ obj/ #*.idea .idea/.idea.Drift/.idea/watcherTasks.xml .idea/.idea.Drift/.idea/encodings.xml +.idea/.idea.Drift.Build/.idea/encodings.xml +.air/ *.DotSettings *.received.* artifacts/ publish/ TestResults/ -build.binlog -build.binlog-warnings-only.log -publish.binlog -publish.binlog-warnings-only.log \ No newline at end of file +*.log +*.binlog +*.binlog-warnings-only.log +containerlab/*/ \ No newline at end of file diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 092c5c15..653b3b96 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -49,6 +49,9 @@ "CreateRelease", "CreateWindowsArtifacts", "DeleteUntaggedImages", + "GenerateSchemas", + "GenerateSettingsSchema", + "GenerateSpecSchema", "OutputVersion", "PackBinaries", "PublishBinaries", @@ -58,11 +61,13 @@ "Test", "TestE2E", "TestE2E_Binary", + "TestE2E_Clab", "TestE2E_Container", "TestE2E_General", "TestLocal", "TestSelf", "TestUnit", + "TestUnitLocal", "UpdateOui" ] }, @@ -137,6 +142,10 @@ "allOf": [ { "properties": { + "ClabTopology": { + "type": "string", + "description": "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified" + }, "Commit": { "type": "string", "description": "Commit - e.g. '4c16978aa41a3b435c0b2e34590f1759c1dc0763'" @@ -167,6 +176,10 @@ "description": "GitHubToken - GitHub token used to create releases", "default": "Secrets must be entered via 'nuke :secrets [profile]'" }, + "KeepClabRunning": { + "type": "boolean", + "description": "Keep Containerlab topology running after tests" + }, "MsBuildVerbosity": { "type": "string", "description": "MsBuildVerbosity - Console output verbosity - Default is 'normal'" @@ -218,6 +231,10 @@ "description": "ReleaseType - None (default/safe), PreRelease, or Release", "$ref": "#/definitions/ReleaseType" }, + "SkipClabDeploy": { + "type": "boolean", + "description": "Skip Containerlab deployment (useful for debugging when topology is already running)" + }, "Solution": { "type": "string", "description": "Path to a solution file that is automatically loaded" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e68d20c1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,100 @@ +# AGENTS.md + +This file provides guidance to AI agents when working with code in this repository. + +## Project Overview + +Drift is a .NET 10 CLI tool for network drift detection β€” it compares a declarative YAML spec (desired network state) against live network scanning results and reports differences. It supports distributed scanning via agents communicating over gRPC. + +## Build System + +The build uses [NUKE](https://nuke.build/). Entry point is `dotnet nuke`. + +Common targets: + +```sh +dotnet nuke Build # Restore + compile +dotnet nuke TestUnit # Unit tests only (fast) +dotnet nuke Test # All tests (unit + E2E) +dotnet nuke TestE2E # E2E tests (General, Binary, Container image, Container network topologies using Containerlab) +dotnet nuke PublishBinaries # Self-contained binary for the current platform +dotnet nuke BuildContainerImage +``` + +Run a single test class or filter by name using standard `dotnet test` filters: + +```sh +dotnet test src/Domain.Tests --filter "FullyQualifiedName~MyTest" +``` + +## Architecture + +### Source layout (`src/`) + +The solution is split into focused projects. The main ones: + +| Project | Role | +|---|---| +| `Cli` | Entry point; commands: `init`, `scan`, `agent start`; AOT-compiled | +| `Cli.Abstractions` | Shared CLI constants: exit codes, env var names, port numbers, file names | +| `Cli.Settings` | User settings file (`~/.config/drift/settings.json`) | +| `Domain` | Core value types: `Network`, `Device`, `Inventory`, `CidrBlock`, `Port`, `AgentId` | +| `Spec` | YAML spec parsing and validation into declared-state domain types | +| `Scanning` | Network discovery: ARP, ping, port scanning | +| `Diff` | Compares declared spec state vs. discovered scan state to produce a drift report | +| `Networking.Grpc` | Generated gRPC/protobuf contracts for the messaging transport | +| `Networking.Core.Abstractions` | Interfaces for message streams, handlers, and client factories | +| `Networking.Core` | Message stream/manager implementation built on gRPC | +| `Networking.Client` | Default client factory for opening outbound messaging connections | +| `Networking.Server` | Hosts the inbound gRPC service for messaging endpoints | +| `Messaging.Protocol.Agent` | Agent request/response message contracts (e.g. scan, subnets) | +| `Messaging.Client` | Typed agent client built on top of `Networking.Client`/`Networking.Core` | +| `Agent.Host` | Hosts an agent's messaging/gRPC endpoint (Kestrel/ASP.NET Core) | +| `Coordinator.Host` | Coordinator-side host counterpart to `Agent.Host` (work in progress) | +| `Common` | Shared cross-cutting helpers: IO, logging, network utilities, embedded resources | +| `Common.Schemas` | Shared JSON Schema generation helpers (e.g. lowercase enum naming) | +| `Serialization` | Cross-module serialization helpers | +| `TestUtilities` | Shared test helpers (loggers, Verify/snapshot settings) used by `*.Tests` projects | +| `ArchTests` | ArchUnitNET tests enforcing dependency rules and naming conventions | + +Schema generators live in `Spec.SchemaGenerator.Cli` and `Cli.Settings.SchemaGenerator.Cli` β€” they produce JSON Schema from C# types. + +`Networking.*` and `Messaging.*` implement the role-agnostic transport layer (see naming rule below); `Agent.Host` and `Coordinator.Host` build role-specific hosting on top of it. + +### Data flow + +``` +YAML spec β†’ Spec (parse/validate) β†’ Domain types (declared state) + ↓ +Network β†’ Scanning β†’ Domain types (discovered state) + ↓ + Diff β†’ Drift report β†’ Cli (render) +``` + +Agents (remote Drift instances) report discovered state back to the coordinator over gRPC, extending scan coverage across subnets. + +### Key conventions + +- **Central package management**: all NuGet versions in `Directory.Packages.props`; do not add `Version=` attributes to `` in individual project files. +- **Shared project defaults**: `Directory.Build.props` applies nullable refs, implicit usings, and logging config to all projects. +- **InternalsVisibleTo**: test projects access internal members for white-box testing; this is intentional. +- **Snapshot testing**: `Verify.NUnit` is used for golden file comparisons. Run tests to regenerate snapshots when output changes; committed `.verified.*` files are the source of truth. +- **AOT**: `Cli` is published with `PublishAot=true`. Avoid reflection-heavy patterns in the CLI project; use source generators instead. +- **Embedded resources**: schemas, default specs, and scripts are embedded in project assemblies under `embedded_resources/`. +- **`Networking.*` are role-agnostic**: No "Agent", "Peer", "Coordinator", or "Server" in type names, property names, parameter names, method names, or log strings inside `Networking.*`. These assemblies implement the transport layer only (streams, messages, connections). Role-specific concerns belong in `Agent.*`, `Coordinator.*`, or `Cli.*`. + +## Testing + +- **Unit tests**: `*.Tests` projects using NUnit 4 and NSubstitute for mocking. +- **E2E tests**: `Cli.E2ETests.*` projects (`General` install scripts and schemas, `Binary` against the published binary, `Container` against the container image). +- **Containerlab tests**: driven directly by the NUKE build (`build/NukeBuild.TestContainerlab.cs`, target `TestE2E_Clab`) against multi-node topologies β€” not a `Cli.E2ETests.*` project. Requires Containerlab installed and uses topology files in `containerlab/`. +- **Architecture tests**: `ArchTests` project validates project dependency graph and naming rules. + +## Terminology (from domain model) + +- **Spec**: declarative YAML definition of desired network state +- **Declared resource**: a device/subnet defined in the spec +- **Discovered resource**: a device/subnet found by scanning +- **Drift**: difference between declared and discovered state +- **Device ID**: one or more addresses (MAC, IPv4, IPv6, hostname) that uniquely identify a device; spec addresses with `is_id: false` are metadata only +- **Agent**: a Drift instance in agent mode that reports scan results to peers diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 741ea223..2ad16332 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,12 @@ + + + + + + @@ -18,9 +24,13 @@ + + + + @@ -39,8 +49,10 @@ + + diff --git a/Drift.Build.slnx b/Drift.Build.slnx index 7b2e0f28..087e9f37 100644 --- a/Drift.Build.slnx +++ b/Drift.Build.slnx @@ -1,15 +1,18 @@ + + + - - - + + + \ No newline at end of file diff --git a/Drift.sln b/Drift.sln index 0086ed09..b297834a 100644 --- a/Drift.sln +++ b/Drift.sln @@ -26,6 +26,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution install.sh = install.sh Directory.Packages.props = Directory.Packages.props Containerfile = Containerfile + AGENTS.md = AGENTS.md EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diff.Tests", "src\Diff.Tests\Diff.Tests.csproj", "{904351B1-CC53-477A-834E-7C3A120EACE9}" @@ -81,6 +82,61 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Commo EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "E2E", "E2E", "{D1DBBF0F-1A0D-486C-A893-ACEB667F2A63}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Grpc", "src\Networking.Grpc\Networking.Grpc.csproj", "{8ED3FF22-90D2-4F08-A079-55FE7127D1C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Core", "src\Networking.Core\Networking.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Server", "src\Networking.Server\Networking.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Client", "src\Networking.Client\Networking.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.Core.Abstractions", "src\Networking.Core.Abstractions\Networking.Core.Abstractions.csproj", "{ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Tests", "src\Networking.Tests\Networking.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Protocol.Agent", "src\Messaging.Protocol.Agent\Messaging.Protocol.Agent.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Client", "src\Messaging.Client\Messaging.Client.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host", "src\Agent.Host\Agent.Host.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host", "src\Coordinator.Host\Coordinator.Host.csproj", "{B9C0D1E2-F3A4-5678-BCDE-F01234567891}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Api", "src\Coordinator.Api\Coordinator.Api.csproj", "{C0D1E2F3-A4B5-6789-CDEF-0123456789A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Api.OpenApi", "src\Coordinator.Api.OpenApi\Coordinator.Api.OpenApi.csproj", "{D1E2F3A4-B5C6-789A-DEF0-123456789AB2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Services", "src\Coordinator.Services\Coordinator.Services.csproj", "{51D4802F-8FF7-492B-978A-B701F1980C7F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host.Tests", "src\Agent.Host.Tests\Agent.Host.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{F1A2B3C4-D5E6-7890-ABCD-EF1234567890}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Messaging", "Messaging", "{E2F3A4B5-C6D7-8901-BCDE-F12345678901}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Coordinator", "Coordinator", "{0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Cli", "Cli", "{DD31E4C8-7058-4407-AEC4-43EDA0AB06D1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host.Tests", "src\Coordinator.Host.Tests\Coordinator.Host.Tests.csproj", "{CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Client", "src\Coordinator.Client\Coordinator.Client.csproj", "{D43AA81A-B177-44D9-AE87-8E3EAB0508A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Tests", "src\Messaging.Tests\Messaging.Tests.csproj", "{87C0FA35-BC55-4B86-AAAD-C425C9A0E050}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Clab", "Clab", "{BA370A6D-DD96-4E5B-82B4-5CE7B6860F26}" + ProjectSection(SolutionItems) = preProject + containerlab\README.md = containerlab\README.md + containerlab\cooperation-test-spec.yaml = containerlab\cooperation-test-spec.yaml + containerlab\cooperation-test.clab.yaml = containerlab\cooperation-test.clab.yaml + containerlab\simple-test-spec.yaml = containerlab\simple-test-spec.yaml + containerlab\simple-test.clab.yaml = containerlab\simple-test.clab.yaml + containerlab\subnet-isolation-test-spec.yaml = containerlab\subnet-isolation-test-spec.yaml + containerlab\subnet-isolation-test.clab.yaml = containerlab\subnet-isolation-test.clab.yaml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -193,11 +249,107 @@ Global {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Debug|Any CPU.Build.0 = Debug|Any CPU {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Release|Any CPU.ActiveCfg = Release|Any CPU {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Release|Any CPU.Build.0 = Release|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Release|Any CPU.Build.0 = Release|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Debug|Any CPU.Build.0 = Debug|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Release|Any CPU.ActiveCfg = Release|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Release|Any CPU.Build.0 = Release|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Release|Any CPU.Build.0 = Release|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Release|Any CPU.Build.0 = Release|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Release|Any CPU.Build.0 = Release|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Release|Any CPU.Build.0 = Release|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Release|Any CPU.Build.0 = Release|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Release|Any CPU.Build.0 = Release|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Release|Any CPU.Build.0 = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.Build.0 = Release|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.Build.0 = Release|Any CPU + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1}.Release|Any CPU.Build.0 = Release|Any CPU + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2}.Release|Any CPU.Build.0 = Release|Any CPU + {51D4802F-8FF7-492B-978A-B701F1980C7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51D4802F-8FF7-492B-978A-B701F1980C7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51D4802F-8FF7-492B-978A-B701F1980C7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51D4802F-8FF7-492B-978A-B701F1980C7F}.Release|Any CPU.Build.0 = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.Build.0 = Release|Any CPU + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5}.Release|Any CPU.Build.0 = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {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} + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {80445644-7342-4C6D-88E5-BF27126FE9A2} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {A26B4527-6EBF-4A20-8E75-945CCD59016B} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {E69772D3-8A07-414F-8F9A-30370D81A972} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {9DFDD692-22F8-4F9A-8808-94E318863D23} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {655124DB-312F-4135-B104-20518CAFDA82} = {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} + {C4576156-BD24-463F-88F2-8A4378855BCC} = {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {091D3DCE-F062-4D40-A8F6-5B6F123ED713} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {A2CE629F-8D56-4539-9642-C31B550F7C30} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {C2B3D4E5-F6A7-8901-BCDE-F12345678901} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {D3C4E5F6-A7B8-9012-CDEF-123456789012} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {B9C0D1E2-F3A4-5678-BCDE-F01234567891} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {51D4802F-8FF7-492B-978A-B701F1980C7F} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {51BC193B-A7CB-4A3D-8852-AC8A0CE74DF5} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {42ECC801-ADCB-43E4-86C0-E0E4B017D7B6} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {38621D2E-6AF0-4969-897D-898C39D41A20} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {B75C5C0C-A5A2-419E-B0DD-342A44B1B48D} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {130363FC-478E-472A-978B-731D39E3E264} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {76093473-3375-4519-9524-68448A19DE4A} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {BA370A6D-DD96-4E5B-82B4-5CE7B6860F26} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} EndGlobalSection EndGlobal diff --git a/README_dev.md b/README_dev.md index 8fafffae..cf90c3e0 100644 --- a/README_dev.md +++ b/README_dev.md @@ -35,6 +35,10 @@ One or more addresses (MAC, IPv4, IPv6, and/or hostname) that together serve as a unique identifier for a network device. +- **Agent** + A running instance of Drift in agent mode that reports network state to other Drift peers. + Agents help ensure full network visibility by uncovering state that's only observable when scanning from specific subnets. + ## Concepts ### Device ID diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index f2d79555..0d25ec35 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -31,7 +31,7 @@ public async Task DefaultVersioningVersionTest() { [Test] public async Task DefaultVersioningWhenNoReleaseTargets() { // Arrange - var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => b.Arbitrary ); + var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( _ => NukeBuildWithArbitraryTarget.Arbitrary ); // Act var factory = new VersioningStrategyFactory( build ); @@ -349,7 +349,7 @@ public async Task ExactVersioningPreReleaseImageReferencesDoesNotIncludeLatest() internal sealed class NukeBuildWithArbitraryTarget : TestNukeBuild { // Justification: NUKE Target properties are instance properties by convention; static is not valid here #pragma warning disable S2325 - public Target Arbitrary => _ => _ + public static Target Arbitrary => _ => _ #pragma warning restore S2325 .Executes( () => { } diff --git a/build/NukeBuild.Binaries.cs b/build/NukeBuild.Binaries.cs index 7a51e37b..a6cfdf36 100644 --- a/build/NukeBuild.Binaries.cs +++ b/build/NukeBuild.Binaries.cs @@ -30,7 +30,7 @@ sealed partial class NukeBuild { Log.Information( "Publishing {Runtime} build to {PublishDir}", Platform, publishDir ); Log.Debug( "Supported runtimes are {SupportedRuntimes}", string.Join( ", ", SupportedRuntimes ) ); DotNetPublish( s => s - .SetProject( Solution.Cli ) + .SetProject( Solution.Cli.Cli ) .SetConfiguration( Configuration ) .SetOutput( publishDir ) .SetSelfContained( true ) diff --git a/build/NukeBuild.Container.cs b/build/NukeBuild.Container.cs index e442ba33..c90d5411 100644 --- a/build/NukeBuild.Container.cs +++ b/build/NukeBuild.Container.cs @@ -45,7 +45,7 @@ partial class NukeBuild { var version = await Versioning.Value.GetVersionAsync(); - _driftImageRef = LocalDriftImage.Qualify( new Tag( $"staging.{Guid.NewGuid().ToString( "N" )}" ) ); + _driftImageRef = LocalDriftImage.Qualify( new Tag( $"staging.{Guid.NewGuid():N}" ) ); Log.Information( "Building container image..." ); // var created = DateTime.UtcNow.ToString( "o", CultureInfo.InvariantCulture ); // o = round-trip format / ISO 8601 diff --git a/build/NukeBuild.Schemas.cs b/build/NukeBuild.Schemas.cs new file mode 100644 index 00000000..58c1bfc4 --- /dev/null +++ b/build/NukeBuild.Schemas.cs @@ -0,0 +1,48 @@ +using Drift.Build.Utilities; +using Nuke.Common.IO; +using Nuke.Common.Tools.DotNet; +using Serilog; +using static Nuke.Common.Tools.DotNet.DotNetTasks; +using Target = Nuke.Common.Target; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + Target GenerateSchemas => _ => _ + .DependsOn( GenerateSpecSchema, GenerateSettingsSchema ); + + Target GenerateSpecSchema => _ => _ + .Executes( () => { + using var _ = new OperationTimer( nameof(GenerateSpecSchema) ); + + RunSchemaGenerator( + Solution.Spec_SchemaGenerator_Cli.Path, + Solution.Spec.Directory / "embedded_resources" / "schemas" + ); + } + ); + + Target GenerateSettingsSchema => _ => _ + .Executes( () => { + using var _ = new OperationTimer( nameof(GenerateSettingsSchema) ); + + RunSchemaGenerator( + Solution.Cli.Cli_Settings_SchemaGenerator_Cli.Path, + Solution.Cli.Cli_Settings.Directory / "embedded_resources" / "schemas" + ); + } + ); + + private void RunSchemaGenerator( AbsolutePath projectFile, AbsolutePath outputDirectory ) { + Log.Information( "Generating schema from {Project} into {OutputDirectory}", projectFile, outputDirectory ); + + DotNetRun( s => s + .SetProjectFile( projectFile ) + .SetConfiguration( Configuration ) + .SetApplicationArguments( outputDirectory ) + ); + } + +} diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index 75d54042..e16f7b3e 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -41,10 +41,24 @@ sealed partial class NukeBuild { Target TestLocal => _ => _ .DependsOn( Test ) + .AssuredAfterFailure() .Executes( () => { var result = ProcessTasks.StartProcess( "dotnet", - "trx --verbosity verbose", + "trx", //"trx --verbosity verbose", + workingDirectory: RootDirectory + ); + result.AssertZeroExitCode(); + } + ); + + Target TestUnitLocal => _ => _ + .DependsOn( TestUnit ) + .AssuredAfterFailure() + .Executes( () => { + var result = ProcessTasks.StartProcess( + "dotnet", + "trx", //"trx --verbosity verbose", workingDirectory: RootDirectory ); result.AssertZeroExitCode(); @@ -70,7 +84,7 @@ sealed partial class NukeBuild { ); Target TestE2E => _ => _ - .DependsOn( TestE2E_General, TestE2E_Binary, TestE2E_Container ); + .DependsOn( TestE2E_General, TestE2E_Binary, TestE2E_Container, TestE2E_Clab ); Target TestE2E_General => _ => _ .DependsOn( Build ) @@ -81,7 +95,7 @@ sealed partial class NukeBuild { Log.Information( "Running general E2E tests" ); DotNetTest( settings => settings - .SetProjectFile( Solution.Cli_E2ETests_General ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_General ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) @@ -105,7 +119,7 @@ sealed partial class NukeBuild { var envVars = new Dictionary { { "DRIFT_BINARY_PATH", driftBinary }, }; DotNetTest( settings => settings - .SetProjectFile( Solution.Cli_E2ETests_Binary ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_Binary ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) @@ -118,7 +132,7 @@ sealed partial class NukeBuild { ); Target TestE2E_Container => _ => _ - .DependsOn( PublishBinaries, BuildContainerImage ) + .DependsOn( BuildContainerImage ) .After( TestUnit ) .OnlyWhenDynamic( () => Platform != DotNetRuntimeIdentifier.win_x64 ) .Executes( async () => { @@ -145,7 +159,7 @@ sealed partial class NukeBuild { } return settings - .SetProjectFile( Solution.Cli_E2ETests_Container ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_Container ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs new file mode 100644 index 00000000..a3236d73 --- /dev/null +++ b/build/NukeBuild.TestContainerlab.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Drift.Build.Utilities; +using Nuke.Common; +using Nuke.Common.IO; +using Nuke.Common.Tooling; +using Nuke.Common.Tools.DotNet; +using Serilog; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + [Parameter( "Skip Containerlab deployment (useful for debugging when topology is already running)" )] + readonly bool SkipClabDeploy = false; + + [Parameter( "Keep Containerlab topology running after tests" )] + readonly bool KeepClabRunning = false; + + [Parameter( "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified." )] + readonly string ClabTopology = null; + + /// + /// Defines all Containerlab integration test cases. + /// Each test case specifies a topology, its spec file, the CLI container name, + /// and assertions to validate the scan output. + /// + private static readonly ContainerlabTestCase[] TestCases = [ + new( + Name: "simple-test", + TopologyFile: "simple-test.clab.yaml", + SpecFile: "simple-test-spec.yaml", + CliContainer: "clab-drift-simple-test-cli", + CoordinatorAddress: "http://clab-drift-simple-test-server:51510", + AgentIds: ["agent_test1"], + Assertions: [ + new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), + new ScanAssertion( "The active coordinator environment was used", + output => output.Contains( "Using active coordinator environment" ) ), + ] + ), + new( + Name: "cooperation-test", + TopologyFile: "cooperation-test.clab.yaml", + SpecFile: "cooperation-test-spec.yaml", + CliContainer: "clab-drift-cooperation-test-cli", + CoordinatorAddress: "http://clab-drift-cooperation-test-server:51510", + AgentIds: ["agent_coop_agent1", "agent_coop_agent2", "agent_coop_agent3"], + Assertions: [ + new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), + new ScanAssertion( "The active coordinator environment was used", + output => output.Contains( "Using active coordinator environment" ) ), + ] + ), + new( + Name: "subnet-isolation-test", + TopologyFile: "subnet-isolation-test.clab.yaml", + SpecFile: "subnet-isolation-test-spec.yaml", + CliContainer: "clab-drift-subnet-isolation-test-cli", + CoordinatorAddress: "http://clab-drift-subnet-isolation-test-server:51510", + AgentIds: ["agent_subnet_agent1", "agent_subnet_agent2"], + Assertions: [ + new ScanAssertion( "Subnet-A scanned", output => output.Contains( "192.168.10.0/24" ) ), + new ScanAssertion( "Subnet-B scanned", output => output.Contains( "192.168.20.0/24" ) ), + new ScanAssertion( "The active coordinator environment was used", + output => output.Contains( "Using active coordinator environment" ) ), + ] + ), + ]; + + Target TestE2E_Clab => _ => _ + .DependsOn( BuildContainerImage ) + .After( TestUnit, TestE2E_Container ) + .OnlyWhenDynamic( () => Platform != DotNetRuntimeIdentifier.win_x64 ) + .Executes( async () => { + using var _ = new OperationTimer( nameof(TestE2E_Clab) ); + + var imageRef = _driftImageRef ?? throw new ArgumentNullException( nameof(_driftImageRef) ); + Log.Information( "Using image {ImageRef} for Containerlab tests", imageRef ); + + if ( !RuntimeInformation.IsOSPlatform( OSPlatform.Linux ) ) { + Log.Warning( "Containerlab tests require Linux. Skipping." ); + return; + } + + if ( !await IsContainerlabAvailableAsync() ) { + throw new Exception( "Containerlab does not appear to be installed or in PATH." ); + } + + var casesToRun = SelectTestCases(); + + Log.Information( + "Running {Count} Containerlab test case(s): {Names}", + casesToRun.Length, + string.Join( ", ", casesToRun.Select( tc => tc.Name ) ) + ); + + var total = casesToRun.Length; + var passed = 0; + var failed = 0; + + foreach ( var testCase in casesToRun ) { + var run = passed + failed + 1; + Log.Information( "---------------------------------------------" ); + Log.Information( "{Name} ({Run}/{Total})", testCase.Name, run, total ); + Log.Information( "---------------------------------------------" ); + + if ( await RunTestCaseAsync( testCase ) ) { + passed++; + Log.Information( "🟒 PASS: {Name}", testCase.Name ); + } + else { + failed++; + Log.Error( "πŸ”΄ FAIL: {Name}", testCase.Name ); + } + } + + Log.Information( "Containerlab integration tests: {Passed} passed, {Failed} failed", passed, failed ); + + if ( failed > 0 ) { + throw new Exception( $"{failed} Containerlab test case(s) failed" ); + } + } + ); + + private ContainerlabTestCase[] SelectTestCases() { + if ( ClabTopology == null ) { + return TestCases; + } + + Log.Warning( "Only selecting test case(s) matching topology '{Topology}'", ClabTopology ); + + var selected = TestCases.Where( tc => tc.Name == ClabTopology ).ToArray(); + if ( !selected.Any() ) { + throw new Exception( + $"No test case found matching topology '{ClabTopology}'. " + + $"Valid names: {string.Join( ", ", TestCases.Select( tc => tc.Name ) )}" + ); + } + + return selected; + } + + private async Task RunTestCaseAsync( ContainerlabTestCase testCase ) { + var topoFile = Paths.ContainerlabsDirectory / testCase.TopologyFile; + var specFile = Paths.ContainerlabsDirectory / testCase.SpecFile; + + if ( !File.Exists( topoFile ) ) { + Log.Error( "Topology file not found: {File}", topoFile ); + return false; + } + + if ( !File.Exists( specFile ) ) { + Log.Error( "Spec file not found: {File}", specFile ); + return false; + } + + try { + if ( SkipClabDeploy ) { + Log.Information( "Skipping deployment (--skip-clab-deploy)" ); + } + else { + await DeployTopologyAsync( testCase.TopologyFile ); + } + + await RunScanAndAssertAsync( specFile, testCase ); + return true; + } + catch ( Exception ex ) { + Log.Error( "Test case '{Name}' failed: {Error}", testCase.Name, ex.Message ); + return false; + } + finally { + if ( KeepClabRunning ) { + Log.Information( "Keeping topology running (--keep-clab-running)" ); + } + else { + await DestroyTopologyAsync( testCase.TopologyFile ); + } + } + } + + private static async Task IsContainerlabAvailableAsync() { + try { + var versionOutput = await CommandRunner.RunAsync( "containerlab", "version" ); + Log.Debug( "\n{Version}", versionOutput ); + return true; + } + catch { + return false; + } + } + + private static async Task DeployTopologyAsync( string topologyFile ) { + Log.Information( "Deploying topology: {File}", topologyFile ); + + DestroyTopologyIfExists( topologyFile ); + EnsureClabManagementNetwork(); + + Clab( + $"deploy --topo {topologyFile}", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 5 ) + ).AssertZeroExitCode(); + + // TODO try to disable fixed waiting + // Log.Information( "Waiting for containers to be ready..." ); + // await Task.Delay( TimeSpan.FromSeconds( 10 ) ); + } + + private static void DestroyTopologyIfExists( string topologyFile ) { + try { + Clab( + $"destroy --topo {topologyFile} --cleanup", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 2 ), + logOutput: false + ).AssertZeroExitCode(); + } + catch { + Log.Debug( "No existing topology to destroy (or destroy failed β€” continuing)" ); + } + } + + /// + /// Pre-creates the 'clab' management network before deploying. + /// + /// Rootless Podman with pasta networking does NOT create kernel bridge interfaces. + /// Containerlab always tries `ip link show br-<network-id>` immediately after + /// creating a new network, which fatally fails ("Link not found") because no + /// kernel bridge was created. However, when the network already exists, + /// Containerlab skips the creation step and reuses it β€” avoiding the fatal lookup. + /// + /// Strategy: try to remove any stale 'clab' network (ignore failure β€” may be in + /// use by another running topology), then create it. Ignore "already exists" errors + /// from create β€” the important thing is the network is present before deploy. + /// + private static void EnsureClabManagementNetwork() { + Log.Debug( "Pre-creating Containerlab management network..." ); + + // Ignore failure β€” network may not exist yet, or may still be in use by another topology + var rm = ProcessTasks.StartProcess( "docker", "network rm clab", logOutput: false ); + rm.WaitForExit(); + + // Ignore failure β€” "network already exists" is acceptable; we just need it to be present + var create = ProcessTasks.StartProcess( + "docker", "network create --subnet 172.20.20.0/24 --ipv6 --subnet 3fff:172:20:20::/64 clab", + logOutput: false + ); + create.WaitForExit(); + + Log.Debug( "Management network 'clab' ready" ); + } + + private static async Task DestroyTopologyAsync( string topologyFile ) { + Log.Information( "Destroying topology: {File}", topologyFile ); + try { + Clab( + $"destroy --topo {topologyFile} --cleanup", + Paths.ContainerlabsDirectory, + timeout: TimeSpan.FromMinutes( 2 ) + ).AssertZeroExitCode(); + } + catch ( Exception ex ) { + Log.Warning( "Failed to destroy topology: {Error}", ex.Message ); + } + } + + private static async Task RunScanAndAssertAsync( AbsolutePath specFile, ContainerlabTestCase testCase ) { + Log.Information( "Running scan for test case: {Name}", testCase.Name ); + + // Give agent(s) a moment to finish starting up + // TODO try without fixed delay + // await Task.Delay( TimeSpan.FromSeconds( 5 ) ); + + Log.Debug( "Copying spec to CLI container {Container}...", testCase.CliContainer ); + Docker( $"cp {specFile} {testCase.CliContainer}:/tmp/spec.yaml" ).AssertZeroExitCode(); + + Log.Information( "Configuring the CLI to use coordinator {Address}...", testCase.CoordinatorAddress ); + RunCliCommand( testCase, $"env add container-server {testCase.CoordinatorAddress}" ); + await RunCliCommandWithRetryAsync( testCase, "spec apply /tmp/spec.yaml" ); + + foreach ( var agentId in testCase.AgentIds ) { + await RunCliCommandWithRetryAsync( testCase, $"enrollment add {agentId}" ); + } + + Log.Information( "Running scan in {Container}...", testCase.CliContainer ); + var scanResult = Docker( + $"exec {testCase.CliContainer} /app/drift scan /tmp/spec.yaml -v", + timeout: TimeSpan.FromMinutes( 5 ) + ); + + foreach ( var line in scanResult.Output ) { + Log.Debug( "[scan:{Name}] {Line}", testCase.Name, line.Text ); + } + + scanResult.AssertZeroExitCode(); + + AssertScanOutput( testCase, scanResult.Output.Select( o => o.Text ) ); + } + + private static void RunCliCommand( ContainerlabTestCase testCase, string command ) { + Docker( $"exec {testCase.CliContainer} /app/drift {command}" ).AssertZeroExitCode(); + } + + private static async Task RunCliCommandWithRetryAsync( ContainerlabTestCase testCase, string command ) { + Exception lastException = null; + + for ( var attempt = 1; attempt <= 10; attempt++ ) { + try { + RunCliCommand( testCase, command ); + return; + } + catch ( Exception exception ) { + lastException = exception; + Log.Debug( "CLI command '{Command}' failed on attempt {Attempt}; retrying", command, attempt ); + await Task.Delay( TimeSpan.FromSeconds( 1 ) ); + } + } + + throw new InvalidOperationException( $"CLI command '{command}' did not succeed", lastException ); + } + + private static void AssertScanOutput( ContainerlabTestCase testCase, IEnumerable outputLines ) { + var output = string.Join( "\n", outputLines ); + var failures = new List(); + + foreach ( var assertion in testCase.Assertions ) { + if ( assertion.Check( output ) ) { + Log.Debug( "Assertion passed: {Description}", assertion.Description ); + } + else { + failures.Add( assertion.Description ); + Log.Error( "Assertion failed: {Description}", assertion.Description ); + } + } + + if ( failures.Count > 0 ) { + Log.Error( "Scan output was:\n{Output}", output ); + var failList = string.Join( "\n", failures.Select( f => $" FAIL: {f}" ) ); + throw new Exception( $"Scan assertions failed for '{testCase.Name}':\n{failList}" ); + } + + Log.Information( "All {Count} assertions passed for '{Name}'", testCase.Assertions.Length, testCase.Name ); + } + + private static void ClabLogger( OutputType type, string text ) => Log.Debug( text ); + + private static IProcess Clab( + string args, + AbsolutePath workDir = null, + TimeSpan? timeout = null, + bool logOutput = true + ) => + ProcessTasks.StartProcess( + "containerlab", args, + workingDirectory: workDir, + timeout: (int?) timeout?.TotalMilliseconds, + logOutput: logOutput, + logger: logOutput ? ClabLogger : null + ); + + private static IProcess Docker( + string args, + AbsolutePath workDir = null, + TimeSpan? timeout = null + ) => + ProcessTasks.StartProcess( + "docker", args, + workingDirectory: workDir, + timeout: (int?) timeout?.TotalMilliseconds + ); +} + +/// +/// A Containerlab-based E2E test case. +/// +/// Test case name +/// A Containerlab topology file +/// A Drift spec file +/// Name of the container hosting the Drift CLI +/// Address of the coordinator used by the CLI +/// IDs of the agents declared in the spec and enrolled before scanning +/// A collection of assertions to be run against the scan output +sealed record ContainerlabTestCase( + string Name, + string TopologyFile, + string SpecFile, + string CliContainer, + string CoordinatorAddress, + string[] AgentIds, + ScanAssertion[] Assertions +); + +/// A named assertion over scan output text. +sealed record ScanAssertion( string Description, Func Check ); \ No newline at end of file diff --git a/build/NukeBuild.cs b/build/NukeBuild.cs index 94be3123..4148d23d 100644 --- a/build/NukeBuild.cs +++ b/build/NukeBuild.cs @@ -129,6 +129,8 @@ private static class Paths { internal static AbsolutePath PublishDirectoryForRuntime( DotNetRuntimeIdentifier id ) => PublishDirectory / id.ToString(); + + internal static AbsolutePath ContainerlabsDirectory => RootDirectory / "containerlab"; } Target OutputVersion => _ => _ diff --git a/build/_build.csproj b/build/_build.csproj index 7737c9f5..1db84060 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -12,16 +12,17 @@ false + + + + + - - - - diff --git a/containerlab/README.md b/containerlab/README.md new file mode 100644 index 00000000..6ebfaafb --- /dev/null +++ b/containerlab/README.md @@ -0,0 +1,70 @@ +# Containerlab integration testing + +This directory contains [Containerlab](https://containerlab.dev/) topologies for testing Drift's distributed network scanning capabilities. + +## Prerequisites + +- [Containerlab](https://containerlab.dev/) installed +- Docker, or Podman with Docker CLI shim +- Drift Docker image: `localhost:5000/drift:dev` + +## Quick start + +```bash +# Run ALL tests including Containerlab integration tests +dotnet nuke Test + +# Run only containerlab tests +dotnet nuke Test_E2EClab + +# Run a single topology for debugging +dotnet nuke Test_E2EClab --clab-topology simple-test + +# Keep containers running after tests (for debugging) +dotnet nuke Test_E2EClab --keep-clab-running +``` + +## Agent identity + +Agents in these topologies use the `--id` flag to set a fixed, predictable agent ID: + +```yaml +agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_test1 +``` + +The `--id` flag is hidden from the help output and logs a warning when used β€” it is only for testing. + +In production, agents generate and persist their own ID at `/root/.config/drift/agent/agent-identity.json`. + +## NUKE target parameters + +| Parameter | Description | +|---|---| +| `--clab-topology ` | Run only the named topology (e.g. `simple-test`). Runs all if omitted. | +| `--skip-clab-deploy` | Skip deployment β€” useful when topology is already running | +| `--keep-clab-running` | Keep containers running after tests for debugging | + +## Troubleshooting + +**Deploy fails with "Link not found"** β€” This is a known issue with rootless Podman + pasta networking. The NUKE target works around it by pre-creating the `clab` management network before deploying. If you are deploying manually, run: +```bash +docker network rm clab 2>/dev/null; docker network create --subnet 172.20.20.0/24 --ipv6 --subnet 3fff:172:20:20::/64 clab +containerlab deploy --topo simple-test.clab.yaml +``` + +**Agents not starting** β€” Check container logs: +```bash +docker logs clab-drift-simple-test-agent1 +docker logs clab-drift-cooperation-test-agent1 +docker logs clab-drift-subnet-isolation-test-agent1 +``` + +**Cannot reach agents** β€” Verify containers are on same network: +```bash +docker network inspect clab +``` + +**Connection refused on first scan attempt** β€” Normal. The agent starts slowly and the client retries automatically. diff --git a/containerlab/cooperation-test-spec.yaml b/containerlab/cooperation-test-spec.yaml new file mode 100644 index 00000000..418978e1 --- /dev/null +++ b/containerlab/cooperation-test-spec.yaml @@ -0,0 +1,22 @@ +version: "v1-preview" + +network: + subnets: + - address: 172.20.20.0/24 + devices: [] + +agents: + - id: agent_coop_agent1 + address: http://clab-drift-cooperation-test-agent1:5000 + authentication: + type: none + + - id: agent_coop_agent2 + address: http://clab-drift-cooperation-test-agent2:5000 + authentication: + type: none + + - id: agent_coop_agent3 + address: http://clab-drift-cooperation-test-agent3:5000 + authentication: + type: none diff --git a/containerlab/cooperation-test.clab.yaml b/containerlab/cooperation-test.clab.yaml new file mode 100644 index 00000000..68a0d767 --- /dev/null +++ b/containerlab/cooperation-test.clab.yaml @@ -0,0 +1,70 @@ +name: drift-cooperation-test + +# Multi-agent cooperation topology +# +# All nodes share a single flat network (172.20.20.0/24). +# Three agents cooperate to scan the same subnet, testing: +# - Multi-agent coordination (3 agents) +# - Result merging from multiple agents +# - Correct handling of overlapping scan results +# +# Targets: +# - 5 Alpine containers as scan targets + +topology: + nodes: + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + server: + kind: linux + image: localhost:5000/drift:dev + cmd: server start --port 51510 --no-agent + + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agent_coop_agent1 + + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agent_coop_agent2 + + agent3: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agent_coop_agent3 + + target1: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target2: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target3: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target4: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + target5: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" diff --git a/containerlab/simple-test-spec.yaml b/containerlab/simple-test-spec.yaml new file mode 100644 index 00000000..235cea22 --- /dev/null +++ b/containerlab/simple-test-spec.yaml @@ -0,0 +1,12 @@ +version: "v1-preview" + +network: + subnets: + - address: 172.20.20.0/24 + devices: [] + +agents: + - id: agent_test1 + address: http://clab-drift-simple-test-agent1:5000 + authentication: + type: none diff --git a/containerlab/simple-test.clab.yaml b/containerlab/simple-test.clab.yaml new file mode 100644 index 00000000..9d7d1837 --- /dev/null +++ b/containerlab/simple-test.clab.yaml @@ -0,0 +1,27 @@ +name: drift-simple-test + +topology: + nodes: + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + server: + kind: linux + image: localhost:5000/drift:dev + cmd: server start --port 51510 --no-agent + + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agent_test1 + ports: + - "5001:5000" + + target1: + kind: linux + image: alpine:latest + entrypoint: /bin/sh + cmd: -c "sleep infinity" diff --git a/containerlab/subnet-isolation-test-spec.yaml b/containerlab/subnet-isolation-test-spec.yaml new file mode 100644 index 00000000..9dfa6c86 --- /dev/null +++ b/containerlab/subnet-isolation-test-spec.yaml @@ -0,0 +1,18 @@ +version: "v1-preview" + +network: + subnets: + - address: 192.168.10.0/24 + - address: 192.168.20.0/24 + devices: [] + +agents: + - id: agent_subnet_agent1 + address: http://clab-drift-subnet-isolation-test-agent1:5000 + authentication: + type: none + + - id: agent_subnet_agent2 + address: http://clab-drift-subnet-isolation-test-agent2:5000 + authentication: + type: none diff --git a/containerlab/subnet-isolation-test.clab.yaml b/containerlab/subnet-isolation-test.clab.yaml new file mode 100644 index 00000000..27b7502d --- /dev/null +++ b/containerlab/subnet-isolation-test.clab.yaml @@ -0,0 +1,118 @@ +name: drift-subnet-isolation-test + +# Subnet isolation topology +# +# Tests that each agent only scans targets reachable on its own isolated subnet. +# +# Networks: +# - mgmt (172.20.20.0/24): CLI + Agent1 + Agent2 [management/control plane] +# - subnet-a (veth + bridge): Agent1 + Target-A1 + Target-A2 [192.168.10.0/24] +# - subnet-b (veth + bridge): Agent2 + Target-B1 + Target-B2 [192.168.20.0/24] +# +# Targets use network-mode: none β€” they have no management interface and are +# only reachable via the veth link to their respective agent. +# +# Inside each agent, a Linux bridge (br0) is created to bridge the two veth +# links together on a single /24 subnet β€” allowing the agent to reach both +# targets with a single IP address. +# +# Scan spec explicitly declares the two isolated subnets. +# Agent1 can reach 192.168.10.0/24; Agent2 can reach 192.168.20.0/24. +# Neither agent can reach the other's subnet. + +topology: + nodes: + # Controller/CLI node (mgmt only) + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Coordinator/CLI control plane node. + server: + kind: linux + image: localhost:5000/drift:dev + cmd: server start --port 51510 --no-agent + + # Agent 1 β€” scans subnet-a (192.168.10.0/24) + # Creates br0 bridging eth1+eth2, assigns 192.168.10.1/24 to the bridge + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agent_subnet_agent1 + exec: + - ip link add br0 type bridge + - ip link set eth1 master br0 + - ip link set eth2 master br0 + - ip link set eth1 up + - ip link set eth2 up + - ip link set br0 up + - ip addr add 192.168.10.1/24 dev br0 + + # Agent 2 β€” scans subnet-b (192.168.20.0/24) + # Creates br0 bridging eth1+eth2, assigns 192.168.20.1/24 to the bridge + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agent_subnet_agent2 + exec: + - ip link add br0 type bridge + - ip link set eth1 master br0 + - ip link set eth2 master br0 + - ip link set eth1 up + - ip link set eth2 up + - ip link set br0 up + - ip addr add 192.168.20.1/24 dev br0 + + # Subnet-A targets (only reachable by agent1) + # network-mode: none means no management interface; eth0 is the veth link + target-a1: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.10.101/24 dev eth0 + - ip link set eth0 up + + target-a2: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.10.102/24 dev eth0 + - ip link set eth0 up + + # Subnet-B targets (only reachable by agent2) + target-b1: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.20.101/24 dev eth0 + - ip link set eth0 up + + target-b2: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.20.102/24 dev eth0 + - ip link set eth0 up + + links: + # Subnet-A: agent1 <-> target-a1 and agent1 <-> target-a2 + - endpoints: ["agent1:eth1", "target-a1:eth0"] + - endpoints: ["agent1:eth2", "target-a2:eth0"] + + # Subnet-B: agent2 <-> target-b1 and agent2 <-> target-b2 + - endpoints: ["agent2:eth1", "target-b1:eth0"] + - endpoints: ["agent2:eth2", "target-b2:eth0"] diff --git a/src/Agent.Host.Tests/Agent.Host.Tests.csproj b/src/Agent.Host.Tests/Agent.Host.Tests.csproj new file mode 100644 index 00000000..435d7306 --- /dev/null +++ b/src/Agent.Host.Tests/Agent.Host.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Agent.Host.Tests/AssemblyInfo.cs b/src/Agent.Host.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..5493e66a --- /dev/null +++ b/src/Agent.Host.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Category( "Unit" )] \ No newline at end of file diff --git a/src/Agent.Host.Tests/HandlerRegistrationTests.cs b/src/Agent.Host.Tests/HandlerRegistrationTests.cs new file mode 100644 index 00000000..53ad21cc --- /dev/null +++ b/src/Agent.Host.Tests/HandlerRegistrationTests.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Agent.Host.Tests; + +internal sealed class HandlerRegistrationTests { + private static readonly Assembly HandlersAssembly = typeof(AgentHost).Assembly; + private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); + + [Test] + public void FindMessagesAndHandlers() { + using ( Assert.EnterMultipleScope() ) { + Assert.That( HandlerTypes.ToList(), Has.Count.GreaterThan( 1 ), "No handlers found via reflection" ); + } + } + + private static List GetAllConcreteHandlerTypes() { + return HandlersAssembly + .GetTypes() + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => typeof(IMessageHandler).IsAssignableFrom( t ) ) + .ToList(); + } +} diff --git a/src/Agent.Host.Tests/Scan/ScanSubnetRequestHandlerTests.cs b/src/Agent.Host.Tests/Scan/ScanSubnetRequestHandlerTests.cs new file mode 100644 index 00000000..d18ffe31 --- /dev/null +++ b/src/Agent.Host.Tests/Scan/ScanSubnetRequestHandlerTests.cs @@ -0,0 +1,124 @@ +using Drift.Agent.Host.Scan; +using Drift.Agent.Host.Tests.Utils; +using Drift.Domain; +using Drift.Domain.Scan; +using Drift.Messaging.Protocol.Agent.Scan; +using Drift.Scanning.Scanners.Factories; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Agent.Host.Tests.Scan; + +internal sealed class ScanSubnetRequestHandlerTests { + [Test] + public async Task SendsProgressAndCompletion() { + var cidr = new CidrBlock( "192.168.1.0/24" ); + var scanner = new StubSubnetScanner(); + var scannerFactory = new StubSubnetScannerFactory( scanner ); + var responder = new RecordingStreamingMessageResponder(); + + await new ScanSubnetRequestHandler( scannerFactory, NullLogger.Instance ).HandleAsync( + new ScanSubnetRequest { Cidr = cidr }, + responder, + CancellationToken.None + ); + + using ( Assert.EnterMultipleScope() ) { + Assert.That( responder.Progress.Single().ProgressPercentage, Is.EqualTo( 50 ) ); + Assert.That( responder.Progress.Single().DevicesFound, Is.Zero ); + Assert.That( responder.Responses.Single().Result.Status, Is.EqualTo( ScanResultStatus.Success ) ); + Assert.That( scanner.ResultUpdatedSubscriberCount, Is.Zero ); + } + } + + [Test] + public async Task PropagatesCancellationAndUnsubscribes() { + var cidr = new CidrBlock( "192.168.1.0/24" ); + var scanner = new StubSubnetScanner { WaitForCompletion = true }; + var scannerFactory = new StubSubnetScannerFactory( scanner ); + var responder = new RecordingStreamingMessageResponder(); + using var cancellation = new CancellationTokenSource(); + + var task = new ScanSubnetRequestHandler( scannerFactory, NullLogger.Instance ).HandleAsync( + new ScanSubnetRequest { Cidr = cidr }, + responder, + cancellation.Token + ); + + await scanner.ScanStarted; + await cancellation.CancelAsync(); + await task; + + using ( Assert.EnterMultipleScope() ) { + Assert.That( scanner.ReceivedCancellationToken.IsCancellationRequested, Is.True ); + Assert.That( scanner.ResultUpdatedSubscriberCount, Is.Zero ); + } + } + + private sealed class StubSubnetScannerFactory( ISubnetScanner scanner ) : ISubnetScannerFactory { + public ISubnetScanner Get( CidrBlock cidr ) => scanner; + } + + private sealed class StubSubnetScanner : ISubnetScanner { + private readonly TaskCompletionSource _scanStarted = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + private EventHandler? _resultUpdated; + + public bool WaitForCompletion { + get; + init; + } + + public Task ScanStarted => _scanStarted.Task; + + public CancellationToken ReceivedCancellationToken { + get; + private set; + } + + public int ResultUpdatedSubscriberCount { + get; + private set; + } + + public event EventHandler? ResultUpdated { + add { + _resultUpdated += value; + ResultUpdatedSubscriberCount++; + } + remove { + _resultUpdated -= value; + ResultUpdatedSubscriberCount--; + } + } + + public async Task ScanAsync( + SubnetScanOptions options, + Microsoft.Extensions.Logging.ILogger logger, + CancellationToken cancellationToken = default + ) { + ReceivedCancellationToken = cancellationToken; + _scanStarted.SetResult(); + _resultUpdated?.Invoke( this, CreateResult( options.Cidr, 50, ScanResultStatus.InProgress ) ); + + if ( WaitForCompletion ) { + try { + await Task.Delay( Timeout.InfiniteTimeSpan, cancellationToken ); + } + catch ( OperationCanceledException ) when ( cancellationToken.IsCancellationRequested ) { + return CreateResult( options.Cidr, 0, ScanResultStatus.Canceled ); + } + } + + return CreateResult( options.Cidr, 100, ScanResultStatus.Success ); + } + + private static SubnetScanResult CreateResult( CidrBlock cidr, byte progress, ScanResultStatus status ) => new() { + CidrBlock = cidr, + Metadata = new Metadata { StartedAt = DateTime.UtcNow }, + Progress = new Percentage( progress ), + Status = status + }; + } +} \ No newline at end of file diff --git a/src/Agent.Host.Tests/Subnets/InterfaceSubnetsRequestHandlerTests.cs b/src/Agent.Host.Tests/Subnets/InterfaceSubnetsRequestHandlerTests.cs new file mode 100644 index 00000000..4df47fc1 --- /dev/null +++ b/src/Agent.Host.Tests/Subnets/InterfaceSubnetsRequestHandlerTests.cs @@ -0,0 +1,31 @@ +using Drift.Agent.Host.Subnets; +using Drift.Agent.Host.Tests.Utils; +using Drift.Domain; +using Drift.Messaging.Protocol.Agent.Subnets; +using Drift.Scanning.Subnets; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Agent.Host.Tests.Subnets; + +internal sealed class InterfaceSubnetsRequestHandlerTests { + [Test] + public async Task ReturnsInterfaceSubnets() { + var cidr = new CidrBlock( "192.168.1.0/24" ); + var provider = new StubInterfaceSubnetProvider( [new ResolvedSubnet( cidr, SubnetSource.Local )] ); + var responder = new RecordingMessageResponder(); + + await new InterfaceSubnetsRequestHandler( provider, NullLogger.Instance ).HandleAsync( + new SubnetsRequest(), + responder, + CancellationToken.None + ); + + Assert.That( responder.Responses.Single().Subnets, Is.EqualTo( [cidr] ) ); + } + + private sealed class StubInterfaceSubnetProvider( List subnets ) : IInterfaceSubnetProvider { + public Task> GetAsync() => Task.FromResult( subnets ); + public List GetInterfaces() => []; + } +} \ No newline at end of file diff --git a/src/Agent.Host.Tests/Utils/RecordingMessageResponder.cs b/src/Agent.Host.Tests/Utils/RecordingMessageResponder.cs new file mode 100644 index 00000000..57fbff97 --- /dev/null +++ b/src/Agent.Host.Tests/Utils/RecordingMessageResponder.cs @@ -0,0 +1,15 @@ +using Drift.Networking.Core.Abstractions; + +namespace Drift.Agent.Host.Tests.Utils; + +internal class RecordingMessageResponder : IMessageResponder + where TResponse : IResponse { + public List Responses { + get; + } = []; + + public Task SendAsync( TResponse response ) { + Responses.Add( response ); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Agent.Host.Tests/Utils/RecordingStreamingMessageResponder.cs b/src/Agent.Host.Tests/Utils/RecordingStreamingMessageResponder.cs new file mode 100644 index 00000000..af47c5ba --- /dev/null +++ b/src/Agent.Host.Tests/Utils/RecordingStreamingMessageResponder.cs @@ -0,0 +1,16 @@ +using Drift.Networking.Core.Abstractions; + +namespace Drift.Agent.Host.Tests.Utils; + +internal sealed class RecordingStreamingMessageResponder + : RecordingMessageResponder, IStreamingMessageResponder + where TProgress : IResponse + where TResponse : IResponse { + public List Progress { + get; + } = []; + + public void SendProgress( TProgress progress ) { + Progress.Add( progress ); + } +} \ No newline at end of file diff --git a/src/Agent.Host/Agent.Host.csproj b/src/Agent.Host/Agent.Host.csproj new file mode 100644 index 00000000..913d2519 --- /dev/null +++ b/src/Agent.Host/Agent.Host.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Agent.Host/AgentHost.cs b/src/Agent.Host/AgentHost.cs new file mode 100644 index 00000000..75ac6368 --- /dev/null +++ b/src/Agent.Host/AgentHost.cs @@ -0,0 +1,104 @@ +using Drift.Common; +using Drift.Common.IO; +using Drift.Domain.ExecutionEnvironment; +using Drift.Messaging.Protocol.Agent; +using Drift.Networking.Client; +using Drift.Networking.Core; +using Drift.Networking.Server; +using Drift.Scanning; +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.Host; + +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.AddSingleton(); + builder.Services.AddSingleton(); + // TODO consolidate all the addmessaging* into single configurable extension that can be used for all roles + // (CLI, Agent, Coordinator) with different config flags. Should be high-level (domain preferred) + builder.Services.AddMessagingServer( options => { + options.EnableDetailedErrors = true; + } ); + builder.Services.AddMessagingClient(); + var messagingOptions = + new MessagingOptions { MessageAssembly = typeof(AgentProtocolMessagesAssemblyMarker).Assembly }; + builder.Services.AddMessagingCore( messagingOptions ); + builder.Services.AddScanning(); + builder.Services.AddSingleton(); + configureServices?.Invoke( builder.Services ); + + builder.WebHost.ConfigureKestrel( options => { + options.ListenAnyIP( port, o => { + o.Protocols = HttpProtocols.Http2; // gRPC requires HTTP/2 + } ); + } ); + + var app = builder.Build(); + + // Note: a service reading StoppingToken during initialization (really, any code run before this point) + // will get CancellationToken.None. + messagingOptions.StoppingToken = app.Lifetime.ApplicationStopping; + + // Unreachable while Kestrel ListenOptions.Protocols is HTTP/2-only (browsers can't speak HTTP/2 without TLS), + // but kept here for when this is moved to its own HTTP/1.1 port. + // Setting it to Http1AndHttp2 is not an option since that degrades ALL connections, including gRPC + // calls, to HTTP/1.1, which then fail against gRPC's HTTP/2-only endpoints with HTTP_1_1_REQUIRED. + // See https://github.com/grpc/grpc-dotnet/issues/979. So this must stay HTTP/2-only until either + // TLS is added or the friendly "/" page below is moved to its own HTTP/1.1-only port. + // app.MapGet( "/", () => + // // TODO Render figlet using same flf as in the help command + // """ + // ___ _ __ _ + // | \ _ _ (_) / _| | |_ + // | |) | | '_| | | | _| | _| + // |___/ |_| |_| |_| \__| + // """ + // + + // "\n\nAgent" + // ); + app.MapMessagingServerEndpoints(); + + app.Lifetime.ApplicationStarted.Register( () => { + logger.LogDebug( + "Agent data directory: {DataDirectory}", + app.Services.GetRequiredService().Directory + ); + logger.LogInformation( "Listening for inbound server connections on port {Port} (gRPC)", 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.Host/Scan/ScanSubnetRequestHandler.cs b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs new file mode 100644 index 00000000..5cead8a9 --- /dev/null +++ b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs @@ -0,0 +1,100 @@ +using Drift.Domain; +using Drift.Domain.Scan; +using Drift.Messaging.Protocol.Agent.Scan; +using Drift.Networking.Core.Abstractions; +using Drift.Scanning.Scanners.Factories; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Scan; + +internal sealed class ScanSubnetRequestHandler( + ISubnetScannerFactory subnetScannerFactory, + ILogger logger +) : StreamingRequestHandler( logger ) { + public override async Task HandleAsync( + ScanSubnetRequest request, + IStreamingMessageResponder responder, + CancellationToken cancellationToken + ) { + var options = new SubnetScanOptions { Cidr = request.Cidr, PingsPerSecond = request.PingsPerSecond }; + + Logger.LogInformation( "Starting scan of {Cidr}", request.Cidr ); + + var subnetScanner = subnetScannerFactory.Get( request.Cidr ); + var policy = new ProgressUpdatePolicy( responder, request.Cidr, Logger ); + + subnetScanner.ResultUpdated += policy.Handle; + + try { + var result = await subnetScanner.ScanAsync( options, Logger, cancellationToken ); + + Logger.LogInformation( + "Scan complete for {Cidr}: {DeviceCount} devices found", + request.Cidr, + result.DiscoveredDevices.Count + ); + + var completeResponse = new ScanSubnetResponse { Result = result }; + await responder.SendAsync( completeResponse ); + } + finally { + subnetScanner.ResultUpdated -= policy.Handle; + } + } + + private sealed class ProgressUpdatePolicy { + private readonly IStreamingMessageResponder _responder; + private readonly CidrBlock _cidr; + private readonly ILogger _logger; + + private byte _lastProgressPercentage; + private uint _lastDeviceCount; + private DateTime _lastSentAt = DateTime.UtcNow; + + public EventHandler Handle { + get; + } + + public ProgressUpdatePolicy( + IStreamingMessageResponder responder, + CidrBlock cidr, + ILogger logger + ) { + _responder = responder; + _cidr = cidr; + _logger = logger; + Handle = OnResultUpdated; + } + + private void OnResultUpdated( object? sender, SubnetScanResult result ) { + var progressPercentage = result.Progress.Value; + var deviceCount = result.DiscoveredDevices.Count; + var now = DateTime.UtcNow; + + bool progressThresholdReached = progressPercentage >= _lastProgressPercentage + 5; + bool isFirstCompletion = progressPercentage == 100 && _lastProgressPercentage < 100; + bool heartbeatDue = now - _lastSentAt > TimeSpan.FromSeconds( 10 ); + bool firstDeviceDiscovered = deviceCount > 0 && _lastDeviceCount == 0; + + if ( !( progressThresholdReached || isFirstCompletion || heartbeatDue || firstDeviceDiscovered ) ) { + return; + } + + _lastProgressPercentage = progressPercentage; + _lastDeviceCount = (uint) deviceCount; + _lastSentAt = now; + + var progressUpdate = new ScanSubnetProgress { + ProgressPercentage = progressPercentage, DevicesFound = deviceCount, Status = result.Status.ToString() + }; + + _responder.SendProgress( progressUpdate ); + + _logger.LogDebug( + "Sent progress update: {Progress}% for {Cidr}", + progressPercentage, + _cidr + ); + } + } +} \ No newline at end of file diff --git a/src/Agent.Host/ServiceCollectionExtensions.cs b/src/Agent.Host/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..ed0ecfea --- /dev/null +++ b/src/Agent.Host/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +using Drift.Agent.Host.Scan; +using Drift.Agent.Host.Status; +using Drift.Agent.Host.Subnets; +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Agent.Host; + +public static class ServiceCollectionExtensions { + extension( IServiceCollection services ) { + public void AddAgentHandlers() { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + } +} diff --git a/src/Agent.Host/Status/AgentStatusRequestHandler.cs b/src/Agent.Host/Status/AgentStatusRequestHandler.cs new file mode 100644 index 00000000..5bc40c98 --- /dev/null +++ b/src/Agent.Host/Status/AgentStatusRequestHandler.cs @@ -0,0 +1,16 @@ +using Drift.Messaging.Protocol.Agent.Status; +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Status; + +internal sealed class AgentStatusRequestHandler( ILogger logger ) + : RequestHandler( logger ) { + public override async Task HandleAsync( + AgentStatusRequest request, + IMessageResponder responder, + CancellationToken cancellationToken + ) { + await responder.SendAsync( new AgentStatusResponse { Status = AgentStatus.Ready } ); + } +} \ No newline at end of file diff --git a/src/Agent.Host/Subnets/InterfaceSubnetsRequestHandler.cs b/src/Agent.Host/Subnets/InterfaceSubnetsRequestHandler.cs new file mode 100644 index 00000000..306eee6a --- /dev/null +++ b/src/Agent.Host/Subnets/InterfaceSubnetsRequestHandler.cs @@ -0,0 +1,24 @@ +using Drift.Messaging.Protocol.Agent.Subnets; +using Drift.Networking.Core.Abstractions; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Subnets; + +internal sealed class InterfaceSubnetsRequestHandler( + IInterfaceSubnetProvider interfaceSubnetProvider, + ILogger logger +) : RequestHandler( logger ) { + public override async Task HandleAsync( + SubnetsRequest request, + IMessageResponder responder, + CancellationToken cancellationToken + ) { + var subnets = ( await interfaceSubnetProvider.GetAsync() ).Select( s => s.Cidr ).ToList(); + + Logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) ); + + var response = new SubnetsResponse { Subnets = subnets }; + await responder.SendAsync( response ); + } +} \ No newline at end of file diff --git a/src/ArchTests/ArchTests.csproj b/src/ArchTests/ArchTests.csproj index 73115aac..7d34c1ed 100644 --- a/src/ArchTests/ArchTests.csproj +++ b/src/ArchTests/ArchTests.csproj @@ -2,7 +2,6 @@ - false none diff --git a/src/ArchTests/SanityTests.cs b/src/ArchTests/SanityTests.cs index 8639eace..b3268160 100644 --- a/src/ArchTests/SanityTests.cs +++ b/src/ArchTests/SanityTests.cs @@ -3,8 +3,8 @@ namespace Drift.ArchTests; internal sealed class SanityTests : DriftArchitectureFixture { - private const uint ExpectedAssemblyCount = 25; - private const uint ExpectedAssemblyCountTolerance = 5; + private const uint ExpectedAssemblyCount = 30; + private const uint ExpectedAssemblyCountTolerance = 10; [Test] public void FindManyAssemblies() { diff --git a/src/Cli.Abstractions/BuiltInEnvironmentNames.cs b/src/Cli.Abstractions/BuiltInEnvironmentNames.cs new file mode 100644 index 00000000..5eccfb8e --- /dev/null +++ b/src/Cli.Abstractions/BuiltInEnvironmentNames.cs @@ -0,0 +1,5 @@ +namespace Drift.Cli.Abstractions; + +public static class BuiltInEnvironmentNames { + public const string Local = "local"; +} \ No newline at end of file diff --git a/src/Cli.Abstractions/ExitCodes.cs b/src/Cli.Abstractions/ExitCodes.cs index 091250d2..7100c545 100644 --- a/src/Cli.Abstractions/ExitCodes.cs +++ b/src/Cli.Abstractions/ExitCodes.cs @@ -24,13 +24,18 @@ public static class ExitCodes { /// public const int GeneralError = 2; + /// + /// Indicates that the operation was canceled. + /// + public const int Canceled = 3; + /// /// Indicates that a command timed out. /// - public const int TimeOutError = 3; + public const int TimeOutError = 4; /// /// Indicates that a spec validation error occurred. /// - public const int SpecValidationError = 4; + public const int SpecValidationError = 5; } \ No newline at end of file diff --git a/src/Cli.Abstractions/Ports.cs b/src/Cli.Abstractions/Ports.cs new file mode 100644 index 00000000..50ae2171 --- /dev/null +++ b/src/Cli.Abstractions/Ports.cs @@ -0,0 +1,5 @@ +namespace Drift.Cli.Abstractions; + +public static class Ports { + public const ushort AgentDefault = 51515; +} \ No newline at end of file diff --git a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt index 046ac81e..4d3da6a1 100644 --- a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt +++ b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt @@ -16,5 +16,11 @@ Options: Commands: init Create a network spec scan Scan the network and detect drift + spec Manage the coordinator specification + enrollment Manage coordinator agent enrollment lint Validate a network spec + agent Manage the local Drift agent + server Manage the local Drift server + env Manage Drift environments + status Show Drift status diff --git a/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt b/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt index 8523aa12..26a0a734 100644 --- a/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt +++ b/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt @@ -18,8 +18,14 @@ + diff --git a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt new file mode 100644 index 00000000..5e468116 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt @@ -0,0 +1,8 @@ +ο»Ώ-------------------------------------- PREVIEW -------------------------------------- +Distributed scanning via agents is a preview feature and should be used with caution. +Communication is unencrypted and unauthenticated. Do not use on untrusted networks. +------------------------------------------------------------------------------------- +Listening for inbound server connections on port 51515 (gRPC) +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..14913d51 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -0,0 +1,43 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; + +namespace Drift.Cli.Tests.Commands; + +// TODO picking a random port to remove NonParallelizable +[NonParallelizable] +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", + cancellationToken: tcs.Token + ); + + Console.WriteLine( output ); + + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task SuccessfulLifecycle() { + using var tcs = new CancellationTokenSource(); + + var runningCommand = await DriftTestCli.StartAgentAsync( + string.Empty, + 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 ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Add.cs b/src/Cli.Tests/Commands/EnvCommandTests.Add.cs new file mode 100644 index 00000000..e874f538 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Add.cs @@ -0,0 +1,82 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Settings.V1_preview; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvAdd_Success_AddsEnvironmentAndSetsActive() { + // Arrange / Act + WriteSettings( new CliSettings() ); + + var (exitCode, output, error) = await InvokeAsync( + "env add myenv localhost:5000" + ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + Assert.That( settings.Environments[0].Name, Is.EqualTo( "myenv" ) ); + Assert.That( settings.Environments[0].Address, Is.EqualTo( "localhost:5000" ) ); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "myenv" ) ); + } + + [Test] + public async Task EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env add env2 host2:5000" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 2 ) ); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env1" ) ); + } + + [Test] + public async Task EnvAdd_DuplicateName_FailsWithError() { + // Arrange + CreateInitialEnvironment( "myenv", "localhost:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( + "env add myenv localhost:5001" + ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + Assert.That( settings.Environments[0].Address, Is.EqualTo( "localhost:5000" ) ); + } + + [Test] + public async Task EnvAdd_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env add localhost:5000" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'add'." ) ); + } + + [Test] + public async Task EnvAdd_MissingUri_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env add myenv" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'add'." ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_DuplicateName_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_DuplicateName_FailsWithError.verified.txt new file mode 100644 index 00000000..7da57788 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_DuplicateName_FailsWithError.verified.txt @@ -0,0 +1 @@ +ο»Ώβœ• 'myenv' already exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MissingName_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MissingName_FailsWithError.verified.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MissingName_FailsWithError.verified.txt @@ -0,0 +1 @@ +ο»Ώ \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive.verified.txt new file mode 100644 index 00000000..3c5d5346 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_MultipleEnvironments_OnlyFirstBecomesActive.verified.txt @@ -0,0 +1 @@ +ο»Ώβœ“ Added 'env2@host2:5000' diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_Success_AddsEnvironmentAndSetsActive.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_Success_AddsEnvironmentAndSetsActive.verified.txt new file mode 100644 index 00000000..d622f929 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_Success_AddsEnvironmentAndSetsActive.verified.txt @@ -0,0 +1 @@ +ο»Ώβœ“ Added 'myenv@localhost:5000' diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_ListAlias_Works.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_ListAlias_Works.verified.txt new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_ListAlias_Works.verified.txt @@ -0,0 +1 @@ +ο»Ώ \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt new file mode 100644 index 00000000..8312bdae --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt @@ -0,0 +1,4 @@ +ο»Ώ local + env1 @ host1:5000 +* env2 @ host2:5000 + env3 @ host3:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysBuiltInLocal.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysBuiltInLocal.verified.txt new file mode 100644 index 00000000..12f3a63c --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysBuiltInLocal.verified.txt @@ -0,0 +1 @@ +ο»Ώ* local diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt new file mode 100644 index 00000000..4acafe2c --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt @@ -0,0 +1,2 @@ +ο»Ώ local +* myenv @ localhost:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt new file mode 100644 index 00000000..4acafe2c --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt @@ -0,0 +1,2 @@ +ο»Ώ local +* myenv @ localhost:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt new file mode 100644 index 00000000..0142b7ab --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt @@ -0,0 +1 @@ +ο»Ώβœ• 'myenv' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt new file mode 100644 index 00000000..02b39abe --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt @@ -0,0 +1 @@ +ο»Ώβœ• 'nonexistent' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt new file mode 100644 index 00000000..049bd2ed --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt @@ -0,0 +1,3 @@ +βœ“ Removed 'env1' +No environments are configured. +πŸ’‘οΈ Add one with drift env add
diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt new file mode 100644 index 00000000..ee54af10 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt @@ -0,0 +1,3 @@ +βœ“ Removed 'env1' +No environment is active. +πŸ’‘οΈ Set one with drift env use diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt new file mode 100644 index 00000000..ee54af10 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt @@ -0,0 +1,3 @@ +βœ“ Removed 'env1' +No environment is active. +πŸ’‘οΈ Set one with drift env use diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt new file mode 100644 index 00000000..0142b7ab --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt @@ -0,0 +1 @@ +ο»Ώβœ• 'myenv' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt new file mode 100644 index 00000000..02b39abe --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt @@ -0,0 +1 @@ +ο»Ώβœ• 'nonexistent' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt new file mode 100644 index 00000000..25887a0f --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt @@ -0,0 +1 @@ +ο»Ώβœ“ 'env2' is active diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt new file mode 100644 index 00000000..8d178e46 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt @@ -0,0 +1,3 @@ +ο»Ώ local + env1 @ host1:5000 +* env2 @ host2:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs b/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs new file mode 100644 index 00000000..28e9a4f3 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs @@ -0,0 +1,33 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvWorkflow_AddMultipleAndManage_Works() { + // Arrange / Act + + var (exitCode1, _, _) = await InvokeAsync( "env add env1 host1:5000" ); + var (exitCode2, _, _) = await InvokeAsync( "env add env2 host2:5000" ); + var (exitCode3, _, _) = await InvokeAsync( "env use env2" ); + var (exitCode4, output, error) = await InvokeAsync( "env list" ); + + // Assert - exit codes + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode1, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( exitCode2, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( exitCode3, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( exitCode4, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert - output + await Verify( output.ToString() + error ); + + // Assert - settings + var settings = ReadSettings(); + using ( Assert.EnterMultipleScope() ) { + Assert.That( settings.Environments, Has.Count.EqualTo( 2 ) ); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.List.cs b/src/Cli.Tests/Commands/EnvCommandTests.List.cs new file mode 100644 index 00000000..44701c8f --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.List.cs @@ -0,0 +1,49 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvList_NoEnvironments_DisplaysBuiltInLocal() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_SingleEnvironment_DisplaysWithActive( [Values( "list", "ls" )] string commandName ) { + // Arrange + CreateInitialEnvironment( "myenv", "localhost:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( $"env {commandName}" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env2"; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs b/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs new file mode 100644 index 00000000..e0aaf0af --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs @@ -0,0 +1,126 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvRemove_Success_RemovesEnvironment() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + Assert.That( settings.Environments[0].Name, Is.EqualTo( "env2" ) ); + } + + [Test] + public async Task EnvRemove_RemovesActiveEnvironment_ClearsActive() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env1"; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.Null ); + Assert.That( updatedSettings.Environments, Has.Count.EqualTo( 1 ) ); + } + + [Test] + public async Task EnvRemove_RemovesNonActiveEnvironment_KeepsActiveUnchanged() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env2"; + WriteSettings( settings ); + + // Act + var (exitCode, _, _) = await InvokeAsync( "env remove env1" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + Assert.That( updatedSettings.Environments, Has.Count.EqualTo( 2 ) ); + } + + [Test] + public async Task EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Is.Empty ); + Assert.That( settings.ActiveEnvironment, Is.Null ); + } + + [Test] + public async Task EnvRemove_NonExistentEnvironment_FailsWithError() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + } + + [Test] + public async Task EnvRemove_NoEnvironments_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env remove myenv" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + } + + [Test] + public async Task EnvRemove_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env remove" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'remove'." ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Use.cs b/src/Cli.Tests/Commands/EnvCommandTests.Use.cs new file mode 100644 index 00000000..423a5ecf --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Use.cs @@ -0,0 +1,87 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvUse_Success_SetsActiveEnvironment() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use env2" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + } + + [Test] + public async Task EnvUse_NonExistentEnvironment_FailsWithError() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env1" ) ); + } + + [Test] + public async Task EnvUse_NoEnvironments_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env use myenv" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + } + + [Test] + public async Task EnvUse_SwitchBetweenEnvironments_Works() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env1"; + WriteSettings( settings ); + + // Act - switch to env2 + var (exitCode1, _, _) = await InvokeAsync( "env use env2" ); + Assert.That( exitCode1, Is.EqualTo( ExitCodes.Success ) ); + + // Act - switch to env3 + var (exitCode2, _, _) = await InvokeAsync( "env use env3" ); + + // Assert + Assert.That( exitCode2, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.EqualTo( "env3" ) ); + } + + [Test] + public async Task EnvUse_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env use" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'use'." ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.cs b/src/Cli.Tests/Commands/EnvCommandTests.cs new file mode 100644 index 00000000..f5efa338 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.cs @@ -0,0 +1,49 @@ +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.Tests; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.Environments; +using Drift.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + private ISettingsLocation SettingsLocation { + get; + } = new TemporarySettingsLocation(); + + private Task InvokeAsync( string args ) { + return DriftTestCli.InvokeAsync( args, settingsLocation: SettingsLocation ); + } + + private CliSettings ReadSettings() { + return CliSettings.Read( SettingsLocation ); + } + + private void WriteSettings( CliSettings settings ) { + settings.Write( NullLogger.Instance, location: SettingsLocation ); + } + + [TearDown] + public void TearDown() { + var settingsDir = SettingsLocation.Directory; + if ( Directory.Exists( settingsDir ) ) { + Directory.Delete( settingsDir, true ); + } + } + + private void CreateInitialEnvironment( string name, string address ) { + CreateInitialEnvironments( ( name, address ) ); + } + + private void CreateInitialEnvironments( params (string name, string address)[] environments ) { + var settings = new CliSettings { + Environments = environments + .Select( e => new EnvironmentSetting( e.name, e.address ) ) + .ToList(), + ActiveEnvironment = environments.Length > 0 ? environments[0].name : null + }; + + WriteSettings( settings ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithDiscoverySuccess_outputFormat=-o log_verbose=-v.verified.txt b/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithDiscoverySuccess_outputFormat=-o log_verbose=-v.verified.txt index ade236e3..47bd94dc 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithDiscoverySuccess_outputFormat=-o log_verbose=-v.verified.txt +++ b/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithDiscoverySuccess_outputFormat=-o log_verbose=-v.verified.txt @@ -1,4 +1,6 @@ ο»Ώ[