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/.github/actions/setup-runner/action.yml b/.github/actions/setup-runner/action.yml index 7bc05321..0fd503d1 100644 --- a/.github/actions/setup-runner/action.yml +++ b/.github/actions/setup-runner/action.yml @@ -18,3 +18,8 @@ runs: - name: Restore .NET tools shell: bash run: dotnet tool restore + + - name: Install Containerlab + if: runner.os == 'Linux' + shell: bash + run: bash -c "$(curl -sL https://get.containerlab.dev)" diff --git a/.gitignore b/.gitignore index d3ed220a..8319e2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ 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/ @@ -14,4 +16,5 @@ TestResults/ build.binlog build.binlog-warnings-only.log publish.binlog -publish.binlog-warnings-only.log \ No newline at end of file +publish.binlog-warnings-only.log +containerlab/*/ \ No newline at end of file diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 092c5c15..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..3100b41e --- /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` | Concrete request/response message contracts (e.g. scan, subnets) | +| `Messaging.Client` | Typed agent client built on top of `Networking.Client`/`Networking.Core` | +| `Agent.Host` | Hosts an agent's messaging/gRPC endpoint (Kestrel/ASP.NET Core) | +| `Coordinator.Host` | Coordinator-side host counterpart to `Agent.Host` (work in progress) | +| `Common` | Shared cross-cutting helpers: IO, logging, network utilities, embedded resources | +| `Common.Schemas` | Shared JSON Schema generation helpers (e.g. lowercase enum naming) | +| `Serialization` | Cross-module serialization helpers | +| `TestUtilities` | Shared test helpers (loggers, Verify/snapshot settings) used by `*.Tests` projects | +| `ArchTests` | ArchUnitNET tests enforcing dependency rules and naming conventions | + +Schema generators live in `Spec.SchemaGenerator.Cli` and `Cli.Settings.SchemaGenerator.Cli` — they produce JSON Schema from C# types. + +`Networking.*` and `Messaging.*` implement the role-agnostic transport layer (see naming rule below); `Agent.Host` and `Coordinator.Host` build role-specific hosting on top of it. + +### Data flow + +``` +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 cc8f0603..35a3869d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,12 @@ + + + + + + @@ -18,6 +24,7 @@ + @@ -41,6 +48,7 @@ + diff --git a/Drift.Build.slnx b/Drift.Build.slnx index 7b2e0f28..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..788a1bde 100644 --- a/Drift.sln +++ b/Drift.sln @@ -26,6 +26,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution install.sh = install.sh Directory.Packages.props = Directory.Packages.props Containerfile = Containerfile + AGENTS.md = AGENTS.md EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diff.Tests", "src\Diff.Tests\Diff.Tests.csproj", "{904351B1-CC53-477A-834E-7C3A120EACE9}" @@ -81,6 +82,53 @@ 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", "src\Messaging.Protocol\Messaging.Protocol.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Client", "src\Messaging.Client\Messaging.Client.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host", "src\Agent.Host\Agent.Host.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host", "src\Coordinator.Host\Coordinator.Host.csproj", "{B9C0D1E2-F3A4-5678-BCDE-F01234567891}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host.Tests", "src\Agent.Host.Tests\Agent.Host.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" +EndProject +Project("{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}") = "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 +241,87 @@ 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 + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.Build.0 = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {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} + {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} + {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..a01fbb4a --- /dev/null +++ b/build/NukeBuild.Schemas.cs @@ -0,0 +1,47 @@ +using Drift.Build.Utilities; +using Nuke.Common.IO; +using Nuke.Common.Tools.DotNet; +using Serilog; +using static Nuke.Common.Tools.DotNet.DotNetTasks; +using Target = Nuke.Common.Target; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + Target GenerateSchemas => _ => _ + .DependsOn( GenerateSpecSchema, GenerateSettingsSchema ); + + Target GenerateSpecSchema => _ => _ + .Executes( () => { + using var _ = new OperationTimer( nameof(GenerateSpecSchema) ); + + RunSchemaGenerator( + Solution.Spec_SchemaGenerator_Cli.Path, + Solution.Spec.Directory / "embedded_resources" / "schemas" + ); + } + ); + + Target GenerateSettingsSchema => _ => _ + .Executes( () => { + using var _ = new OperationTimer( nameof(GenerateSettingsSchema) ); + + RunSchemaGenerator( + Solution.Cli.Cli_Settings_SchemaGenerator_Cli.Path, + Solution.Cli.Cli_Settings.Directory / "embedded_resources" / "schemas" + ); + } + ); + + private void RunSchemaGenerator( AbsolutePath projectFile, AbsolutePath outputDirectory ) { + Log.Information( "Generating schema from {Project} into {OutputDirectory}", projectFile, outputDirectory ); + + DotNetRun( s => s + .SetProjectFile( projectFile ) + .SetConfiguration( Configuration ) + .SetApplicationArguments( outputDirectory ) + ); + } +} \ No newline at end of file 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..206e8df7 --- /dev/null +++ b/build/NukeBuild.TestContainerlab.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Drift.Build.Utilities; +using Nuke.Common; +using Nuke.Common.IO; +using Nuke.Common.Tooling; +using Nuke.Common.Tools.DotNet; +using Serilog; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + [Parameter( "Skip Containerlab deployment (useful for debugging when topology is already running)" )] + readonly bool SkipClabDeploy = false; + + [Parameter( "Keep Containerlab topology running after tests" )] + readonly bool KeepClabRunning = false; + + [Parameter( "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified." )] + readonly string ClabTopology = null; + + /// + /// Defines all Containerlab integration test cases. + /// Each test case specifies a topology, its spec file, the CLI container name, + /// and assertions to validate the scan output. + /// + private static readonly ContainerlabTestCase[] TestCases = [ + new( + Name: "simple-test", + TopologyFile: "simple-test.clab.yaml", + SpecFile: "simple-test-spec.yaml", + CliContainer: "clab-drift-simple-test-cli", + Assertions: [ + new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 1 local, 1 via agents, 1 unique subnets" ) ), + ] + ), + new( + Name: "cooperation-test", + TopologyFile: "cooperation-test.clab.yaml", + SpecFile: "cooperation-test-spec.yaml", + CliContainer: "clab-drift-cooperation-test-cli", + Assertions: [ + new ScanAssertion( "Management subnet scanned", output => output.Contains( "172.20.20.0/24" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 1 local, 3 via agents, 1 unique subnets" ) ), + ] + ), + new( + Name: "subnet-isolation-test", + TopologyFile: "subnet-isolation-test.clab.yaml", + SpecFile: "subnet-isolation-test-spec.yaml", + CliContainer: "clab-drift-subnet-isolation-test-cli", + Assertions: [ + new ScanAssertion( "Subnet-A scanned", output => output.Contains( "192.168.10.0/24" ) ), + new ScanAssertion( "Subnet-B scanned", output => output.Contains( "192.168.20.0/24" ) ), + new ScanAssertion( "Scan completed successfully", + output => output.Contains( "Scan completed: 3 local, 4 via agents, 3 unique subnets" ) ), + ] + ), + ]; + + 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( "Running scan in {Container}...", testCase.CliContainer ); + var scanResult = Docker( + $"exec {testCase.CliContainer} /app/drift scan /tmp/spec.yaml", + timeout: TimeSpan.FromMinutes( 5 ) + ); + + foreach ( var line in scanResult.Output ) { + Log.Debug( "[scan:{Name}] {Line}", testCase.Name, line.Text ); + } + + scanResult.AssertZeroExitCode(); + + AssertScanOutput( testCase, scanResult.Output.Select( o => o.Text ) ); + } + + private static void AssertScanOutput( ContainerlabTestCase testCase, IEnumerable outputLines ) { + var output = string.Join( "\n", outputLines ); + var failures = new List(); + + foreach ( var assertion in testCase.Assertions ) { + if ( assertion.Check( output ) ) { + Log.Debug( "Assertion passed: {Description}", assertion.Description ); + } + else { + failures.Add( assertion.Description ); + Log.Error( "Assertion failed: {Description}", assertion.Description ); + } + } + + if ( failures.Count > 0 ) { + Log.Error( "Scan output was:\n{Output}", output ); + var failList = string.Join( "\n", failures.Select( f => $" FAIL: {f}" ) ); + throw new Exception( $"Scan assertions failed for '{testCase.Name}':\n{failList}" ); + } + + Log.Information( "All {Count} assertions passed for '{Name}'", testCase.Assertions.Length, testCase.Name ); + } + + 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 +/// A collection of assertions to be run against the scan output +sealed record ContainerlabTestCase( + string Name, + string TopologyFile, + string SpecFile, + string CliContainer, + ScanAssertion[] Assertions +); + +/// A named assertion over scan output text. +sealed record ScanAssertion( string Description, Func Check ); \ No newline at end of file diff --git a/build/NukeBuild.cs b/build/NukeBuild.cs index 94be3123..4148d23d 100644 --- a/build/NukeBuild.cs +++ b/build/NukeBuild.cs @@ -129,6 +129,8 @@ private static class Paths { internal static AbsolutePath PublishDirectoryForRuntime( DotNetRuntimeIdentifier id ) => PublishDirectory / id.ToString(); + + internal static AbsolutePath ContainerlabsDirectory => RootDirectory / "containerlab"; } Target OutputVersion => _ => _ diff --git a/build/_build.csproj b/build/_build.csproj index 7737c9f5..1db84060 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -12,16 +12,17 @@ false + + + + + - - - - diff --git a/containerlab/README.md b/containerlab/README.md new file mode 100644 index 00000000..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..00994efd --- /dev/null +++ b/containerlab/cooperation-test-spec.yaml @@ -0,0 +1,21 @@ +version: "v1-preview" + +network: + subnets: [] + devices: [] + +agents: + - id: agentid_coop_agent1 + address: http://clab-drift-cooperation-test-agent1:5000 + authentication: + type: none + + - id: agentid_coop_agent2 + address: http://clab-drift-cooperation-test-agent2:5000 + authentication: + type: none + + - id: agentid_coop_agent3 + address: http://clab-drift-cooperation-test-agent3:5000 + authentication: + type: none diff --git a/containerlab/cooperation-test.clab.yaml b/containerlab/cooperation-test.clab.yaml new file mode 100644 index 00000000..092c860d --- /dev/null +++ b/containerlab/cooperation-test.clab.yaml @@ -0,0 +1,65 @@ +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" + + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_coop_agent1 + + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_coop_agent2 + + agent3: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_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..8075f51a --- /dev/null +++ b/containerlab/simple-test-spec.yaml @@ -0,0 +1,11 @@ +version: "v1-preview" + +network: + subnets: [] + devices: [] + +agents: + - id: agentid_test1 + address: http://clab-drift-simple-test-agent1:5000 + authentication: + type: none diff --git a/containerlab/simple-test.clab.yaml b/containerlab/simple-test.clab.yaml new file mode 100644 index 00000000..e57eee20 --- /dev/null +++ b/containerlab/simple-test.clab.yaml @@ -0,0 +1,22 @@ +name: drift-simple-test + +topology: + nodes: + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_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..c3fc35c6 --- /dev/null +++ b/containerlab/subnet-isolation-test-spec.yaml @@ -0,0 +1,18 @@ +version: "v1-preview" + +network: + subnets: + - address: 192.168.10.0/24 + - address: 192.168.20.0/24 + devices: [] + +agents: + - id: agentid_subnet_agent1 + address: http://clab-drift-subnet-isolation-test-agent1:5000 + authentication: + type: none + + - id: agentid_subnet_agent2 + address: http://clab-drift-subnet-isolation-test-agent2:5000 + authentication: + type: none diff --git a/containerlab/subnet-isolation-test.clab.yaml b/containerlab/subnet-isolation-test.clab.yaml new file mode 100644 index 00000000..bad5dfe0 --- /dev/null +++ b/containerlab/subnet-isolation-test.clab.yaml @@ -0,0 +1,112 @@ +name: drift-subnet-isolation-test + +# Subnet isolation topology +# +# Tests that each agent only scans targets reachable on its own isolated subnet. +# +# Networks: +# - mgmt (172.20.20.0/24): CLI + Agent1 + Agent2 [management/control plane] +# - subnet-a (veth + bridge): Agent1 + Target-A1 + Target-A2 [192.168.10.0/24] +# - subnet-b (veth + bridge): Agent2 + Target-B1 + Target-B2 [192.168.20.0/24] +# +# Targets use network-mode: none — they have no management interface and are +# only reachable via the veth link to their respective agent. +# +# Inside each agent, a Linux bridge (br0) is created to bridge the two veth +# links together on a single /24 subnet — allowing the agent to reach both +# targets with a single IP address. +# +# Scan spec explicitly declares the two isolated subnets. +# Agent1 can reach 192.168.10.0/24; Agent2 can reach 192.168.20.0/24. +# Neither agent can reach the other's subnet. + +topology: + nodes: + # Controller/CLI node (mgmt only) + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Agent 1 — scans subnet-a (192.168.10.0/24) + # Creates br0 bridging eth1+eth2, assigns 192.168.10.1/24 to the bridge + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_subnet_agent1 + exec: + - ip link add br0 type bridge + - ip link set eth1 master br0 + - ip link set eth2 master br0 + - ip link set eth1 up + - ip link set eth2 up + - ip link set br0 up + - ip addr add 192.168.10.1/24 dev br0 + + # Agent 2 — scans subnet-b (192.168.20.0/24) + # Creates br0 bridging eth1+eth2, assigns 192.168.20.1/24 to the bridge + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --adoptable --port 5000 --id agentid_subnet_agent2 + exec: + - ip link add br0 type bridge + - ip link set eth1 master br0 + - ip link set eth2 master br0 + - ip link set eth1 up + - ip link set eth2 up + - ip link set br0 up + - ip addr add 192.168.20.1/24 dev br0 + + # Subnet-A targets (only reachable by agent1) + # network-mode: none means no management interface; eth0 is the veth link + target-a1: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.10.101/24 dev eth0 + - ip link set eth0 up + + target-a2: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.10.102/24 dev eth0 + - ip link set eth0 up + + # Subnet-B targets (only reachable by agent2) + target-b1: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.20.101/24 dev eth0 + - ip link set eth0 up + + target-b2: + kind: linux + image: alpine:latest + network-mode: none + entrypoint: /bin/sh + cmd: -c "sleep infinity" + exec: + - ip addr add 192.168.20.102/24 dev eth0 + - ip link set eth0 up + + links: + # Subnet-A: agent1 <-> target-a1 and agent1 <-> target-a2 + - endpoints: ["agent1:eth1", "target-a1:eth0"] + - endpoints: ["agent1:eth2", "target-a2:eth0"] + + # Subnet-B: agent2 <-> target-b1 and agent2 <-> target-b2 + - endpoints: ["agent2:eth1", "target-b1:eth0"] + - endpoints: ["agent2:eth2", "target-b2:eth0"] diff --git a/src/Agent.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/MessageHandlerTests.cs b/src/Agent.Host.Tests/MessageHandlerTests.cs new file mode 100644 index 00000000..938f569f --- /dev/null +++ b/src/Agent.Host.Tests/MessageHandlerTests.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Agent.Host.Tests; + +// TODO currently not doing anything useful +// TODO almost duplicate of MessageHandlerTests in Coordinator.Host.Tests +internal sealed class MessageHandlerTests { + private static readonly Assembly HandlersAssembly = typeof(AgentHost).Assembly; + 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(); + } +} \ 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..a9486bea --- /dev/null +++ b/src/Agent.Host/Agent.Host.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Agent.Host/AgentHost.cs b/src/Agent.Host/AgentHost.cs new file mode 100644 index 00000000..a0d8fbe7 --- /dev/null +++ b/src/Agent.Host/AgentHost.cs @@ -0,0 +1,91 @@ +using Drift.Messaging.Protocol; +using Drift.Networking.Client; +using Drift.Networking.Core; +using Drift.Networking.Server; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +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 ); + // TODO consolidate all the addmessaging* into single configurable extension that can be used for all roles + // (CLI, Agent, Coordinator) with different config flags. Should be high-level (domain preferred) + builder.Services.AddMessagingServer( options => { + options.EnableDetailedErrors = true; + } ); + builder.Services.AddMessagingClient(); + var messagingOptions = new MessagingOptions { MessageAssembly = typeof(ProtocolMessagesAssemblyMarker).Assembly }; + builder.Services.AddMessagingCore( messagingOptions ); + configureServices?.Invoke( builder.Services ); + + builder.WebHost.ConfigureKestrel( options => { + options.ListenAnyIP( port, 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.LogInformation( "Listening for incoming connections on port {Port}", port ); + logger.LogInformation( "Agent started" ); + ready?.TrySetResult(); + } ); + app.Lifetime.ApplicationStopping.Register( () => { + logger.LogInformation( "Agent stopping..." ); + } ); + app.Lifetime.ApplicationStopped.Register( () => { + logger.LogInformation( "Agent stopped" ); + } ); + + return app; + } +} \ No newline at end of file diff --git a/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs new file mode 100644 index 00000000..b699140e --- /dev/null +++ b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs @@ -0,0 +1,113 @@ +using Drift.Domain; +using Drift.Domain.Scan; +using Drift.Messaging.Protocol.Scan; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Grpc.Generated; +using Drift.Scanning.Scanners; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Scan; + +internal sealed class ScanSubnetRequestHandler( + ISubnetScannerFactory subnetScannerFactory, + ILogger logger +) : IMessageHandler { + public string MessageType => ScanSubnetRequest.MessageType; + + public async Task HandleAsync( + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, + CancellationToken cancellationToken + ) { + logger.LogInformation( "Handling scan subnet request" ); + + var request = converter.FromEnvelope( envelope ); + var options = new SubnetScanOptions { Cidr = request.Cidr, PingsPerSecond = request.PingsPerSecond }; + + logger.LogInformation( "Starting scan of {Cidr}", request.Cidr ); + + var scanner = subnetScannerFactory.Get( request.Cidr ); + var policy = new ProgressUpdatePolicy( stream, converter, envelope, request.Cidr, logger ); + + scanner.ResultUpdated += policy.Handle; + + try { + var result = await scanner.ScanAsync( options, logger, cancellationToken ); + + logger.LogInformation( + "Scan complete for {Cidr}: {DeviceCount} devices found", + request.Cidr, + result.DiscoveredDevices.Count + ); + + var completeResponse = new ScanSubnetCompleteResponse { Result = result }; + await stream.SendAsync( converter, completeResponse, envelope.CorrelationId ); + } + finally { + scanner.ResultUpdated -= policy.Handle; + } + } + + private sealed class ProgressUpdatePolicy { + private readonly IMessageStream _stream; + private readonly IMessageEnvelopeConverter _converter; + private readonly Message _envelope; + private readonly CidrBlock _cidr; + private readonly ILogger _logger; + + private byte _lastProgressPercentage; + private uint _lastDeviceCount; + private DateTime _lastSentAt = DateTime.UtcNow; + + public EventHandler Handle { + get; + } + + public ProgressUpdatePolicy( + IMessageStream stream, + IMessageEnvelopeConverter converter, + Message envelope, + CidrBlock cidr, + ILogger logger + ) { + _stream = stream; + _converter = converter; + _envelope = envelope; + _cidr = cidr; + _logger = logger; + Handle = OnResultUpdated; + } + + private void OnResultUpdated( object? sender, SubnetScanResult result ) { + var progressPercentage = result.Progress.Value; + var deviceCount = result.DiscoveredDevices.Count; + var now = DateTime.UtcNow; + + bool progressThresholdReached = progressPercentage >= _lastProgressPercentage + 5; + bool isFirstCompletion = progressPercentage == 100 && _lastProgressPercentage < 100; + bool heartbeatDue = now - _lastSentAt > TimeSpan.FromSeconds( 10 ); + bool firstDeviceDiscovered = deviceCount > 0 && _lastDeviceCount == 0; + + if ( !( progressThresholdReached || isFirstCompletion || heartbeatDue || firstDeviceDiscovered ) ) { + return; + } + + _lastProgressPercentage = progressPercentage; + _lastDeviceCount = (uint) deviceCount; + _lastSentAt = now; + + var progressUpdate = new ScanSubnetProgressUpdate { + ProgressPercentage = progressPercentage, DevicesFound = deviceCount, Status = result.Status.ToString() + }; + + _stream.SendFireAndForget( _converter, progressUpdate, _envelope.CorrelationId ); + + _logger.LogDebug( + "Sent progress update: {Progress}% for {Cidr}", + progressPercentage, + _cidr + ); + } + } +} \ No newline at end of file diff --git a/src/Agent.Host/ServiceCollectionExtensions.cs b/src/Agent.Host/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..d628abef --- /dev/null +++ b/src/Agent.Host/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Drift.Agent.Host.Scan; +using Drift.Agent.Host.Subnets; +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Agent.Host; + +public static class ServiceCollectionExtensions { + extension( IServiceCollection services ) { + public void AddAgentHandlers() { + services.AddScoped(); + services.AddScoped(); + } + } +} \ No newline at end of file diff --git a/src/Agent.Host/Subnets/SubnetsRequestHandler.cs b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs new file mode 100644 index 00000000..94d7e01d --- /dev/null +++ b/src/Agent.Host/Subnets/SubnetsRequestHandler.cs @@ -0,0 +1,30 @@ +using Drift.Messaging.Protocol.Subnets; +using Drift.Networking.Core.Abstractions; +using Drift.Networking.Grpc.Generated; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Subnets; + +internal sealed class SubnetsRequestHandler( + IInterfaceSubnetProvider interfaceSubnetProvider, + ILogger logger +) : IMessageHandler { + public string MessageType => SubnetsRequest.MessageType; + + public async Task HandleAsync( + Message envelope, + IMessageEnvelopeConverter converter, + IMessageStream stream, + CancellationToken cancellationToken + ) { + logger.LogInformation( "Handling subnet request" ); + + var subnets = ( await interfaceSubnetProvider.GetAsync() ).Select( s => s.Cidr ).ToList(); + + logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) ); + + var response = new SubnetsResponse { Subnets = subnets }; + await stream.SendAsync( converter, response, envelope.CorrelationId ); + } +} \ 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/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..c69b8662 100644 --- a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt +++ b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt @@ -17,4 +17,5 @@ Commands: init Create a network spec scan Scan the network and detect drift lint Validate a network spec + agent Manage the local Drift agent diff --git a/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt b/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt index 8523aa12..cd3c69c5 100644 --- a/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt +++ b/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt @@ -19,6 +19,7 @@ + diff --git a/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt new file mode 100644 index 00000000..3e13a42b --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt @@ -0,0 +1,10 @@ +---------------------------------------- PREVIEW --------------------------------------- +Distributed scanning via agents is a preview feature and should be used with caution. +Agent communication is unencrypted. Do not use on untrusted networks. +Agents run without authentication. Any client that can reach the agent port can connect. +---------------------------------------------------------------------------------------- +Agent starting... +Listening for incoming connections on port 51515 +Agent started +Agent stopping... +Agent stopped diff --git a/src/Cli.Tests/Commands/AgentCommandTests.cs b/src/Cli.Tests/Commands/AgentCommandTests.cs new file mode 100644 index 00000000..11f913cf --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -0,0 +1,41 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; + +namespace Drift.Cli.Tests.Commands; + +internal sealed class AgentCommandTests { + [CancelAfter( 3000 )] + [Test] + public async Task RespectsCancellationToken() { + using var tcs = new CancellationTokenSource( TimeSpan.FromMilliseconds( 2000 ) ); + + var (exitCode, output, _) = await DriftTestCli.InvokeAsync( + "agent start", + 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..dde91fc3 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt @@ -0,0 +1,3 @@ + env1 @ host1:5000 +* env2 @ host2:5000 + env3 @ host3:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoActiveEnvironment_DisplaysWarning.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoActiveEnvironment_DisplaysWarning.verified.txt new file mode 100644 index 00000000..6a711962 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoActiveEnvironment_DisplaysWarning.verified.txt @@ -0,0 +1,5 @@ + env1 @ host1:5000 + env2 @ host2:5000 + +No environment is active. +💡️ Set one with drift env use diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysEmptyMessage.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysEmptyMessage.verified.txt new file mode 100644 index 00000000..5500d3c2 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysEmptyMessage.verified.txt @@ -0,0 +1,2 @@ +No environments configured. +💡️ Add one with drift env add
diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt new file mode 100644 index 00000000..c0d67a68 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt @@ -0,0 +1 @@ +* myenv @ localhost:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt new file mode 100644 index 00000000..c0d67a68 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt @@ -0,0 +1 @@ +* myenv @ localhost:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt new file mode 100644 index 00000000..0142b7ab --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NoEnvironments_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'myenv' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt new file mode 100644 index 00000000..02b39abe --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironment_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'nonexistent' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt new file mode 100644 index 00000000..049bd2ed --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne.verified.txt @@ -0,0 +1,3 @@ +✓ Removed 'env1' +No environments are configured. +💡️ Add one with drift env add
diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt new file mode 100644 index 00000000..ee54af10 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_ClearsActive.verified.txt @@ -0,0 +1,3 @@ +✓ Removed 'env1' +No environment is active. +💡️ Set one with drift env use diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt new file mode 100644 index 00000000..ee54af10 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt @@ -0,0 +1,3 @@ +✓ Removed 'env1' +No environment is active. +💡️ Set one with drift env use diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt new file mode 100644 index 00000000..0142b7ab --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NoEnvironments_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'myenv' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt new file mode 100644 index 00000000..02b39abe --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironment_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'nonexistent' does not exist diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt new file mode 100644 index 00000000..25887a0f --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Success_SetsActiveEnvironment.verified.txt @@ -0,0 +1 @@ +✓ 'env2' is active diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt new file mode 100644 index 00000000..b9118f12 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow_AddMultipleAndManage_Works.verified.txt @@ -0,0 +1,2 @@ + env1 @ host1:5000 +* env2 @ host2:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs b/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs new file mode 100644 index 00000000..a193645b --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs @@ -0,0 +1,31 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvWorkflow_AddMultipleAndManage_Works() { + // Arrange / Act - add env1 + var (exitCode1, _, _) = await InvokeAsync( "env add env1 host1:5000" ); + Assert.That( exitCode1, Is.EqualTo( ExitCodes.Success ) ); + + // Act - add env2 + var (exitCode2, _, _) = await InvokeAsync( "env add env2 host2:5000" ); + Assert.That( exitCode2, Is.EqualTo( ExitCodes.Success ) ); + + // Act - switch to env2 + var (exitCode3, _, _) = await InvokeAsync( "env use env2" ); + Assert.That( exitCode3, Is.EqualTo( ExitCodes.Success ) ); + + // Act - list + var (exitCode4, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode4, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 2 ) ); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.List.cs b/src/Cli.Tests/Commands/EnvCommandTests.List.cs new file mode 100644 index 00000000..ea96bb60 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.List.cs @@ -0,0 +1,70 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Settings.V1_preview; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvList_NoEnvironments_DisplaysEmptyMessage() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_SingleEnvironment_DisplaysWithActive( [Values( "list", "ls" )] string commandName ) { + // Arrange + CreateInitialEnvironment( "myenv", "localhost:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( $"env {commandName}" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env2"; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_NoActiveEnvironment_DisplaysWarning() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = null; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs b/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs new file mode 100644 index 00000000..b5129884 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs @@ -0,0 +1,127 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Settings.V1_preview; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvRemove_Success_RemovesEnvironment() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + Assert.That( settings.Environments[0].Name, Is.EqualTo( "env2" ) ); + } + + [Test] + public async Task EnvRemove_RemovesActiveEnvironment_ClearsActive() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env1"; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.Null ); + Assert.That( updatedSettings.Environments, Has.Count.EqualTo( 1 ) ); + } + + [Test] + public async Task EnvRemove_RemovesNonActiveEnvironment_KeepsActiveUnchanged() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env2"; + WriteSettings( settings ); + + // Act + var (exitCode, _, _) = await InvokeAsync( "env remove env1" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + Assert.That( updatedSettings.Environments, Has.Count.EqualTo( 2 ) ); + } + + [Test] + public async Task EnvRemove_RemoveLastEnvironment_ClearsActive_AdvisesCreatingOne() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env1" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Is.Empty ); + Assert.That( settings.ActiveEnvironment, Is.Null ); + } + + [Test] + public async Task EnvRemove_NonExistentEnvironment_FailsWithError() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.Environments, Has.Count.EqualTo( 1 ) ); + } + + [Test] + public async Task EnvRemove_NoEnvironments_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env remove myenv" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + } + + [Test] + public async Task EnvRemove_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env remove" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'remove'." ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Use.cs b/src/Cli.Tests/Commands/EnvCommandTests.Use.cs new file mode 100644 index 00000000..c4547ccf --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Use.cs @@ -0,0 +1,88 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Settings.V1_preview; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvUse_Success_SetsActiveEnvironment() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use env2" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + } + + [Test] + public async Task EnvUse_NonExistentEnvironment_FailsWithError() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env1" ) ); + } + + [Test] + public async Task EnvUse_NoEnvironments_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env use myenv" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + } + + [Test] + public async Task EnvUse_SwitchBetweenEnvironments_Works() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env1"; + WriteSettings( settings ); + + // Act - switch to env2 + var (exitCode1, _, _) = await InvokeAsync( "env use env2" ); + Assert.That( exitCode1, Is.EqualTo( ExitCodes.Success ) ); + + // Act - switch to env3 + var (exitCode2, _, _) = await InvokeAsync( "env use env3" ); + + // Assert + Assert.That( exitCode2, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + Assert.That( updatedSettings.ActiveEnvironment, Is.EqualTo( "env3" ) ); + } + + [Test] + public async Task EnvUse_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env use" ); + + // Assert + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'use'." ) ); + } +} diff --git a/src/Cli.Tests/Commands/EnvCommandTests.cs b/src/Cli.Tests/Commands/EnvCommandTests.cs new file mode 100644 index 00000000..51fc42f2 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.cs @@ -0,0 +1,49 @@ +using Drift.Cli.Settings.Serialization; +using Drift.Cli.Settings.Tests; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.Environments; +using Drift.Cli.Tests.Utils; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + private ISettingsLocationProvider SettingsLocation { + get; + } = new TemporarySettingsLocationProvider(); + + private Task InvokeAsync( string args ) { + return DriftTestCli.InvokeAsync( args, settingsLocation: SettingsLocation ); + } + + private CliSettings ReadSettings() { + return CliSettings.Read( SettingsLocation ); + } + + private void WriteSettings( CliSettings settings ) { + settings.Write( NullLogger.Instance, location: SettingsLocation ); + } + + [TearDown] + public void TearDown() { + var settingsDir = SettingsLocation.GetDirectory(); + if ( Directory.Exists( settingsDir ) ) { + Directory.Delete( settingsDir, true ); + } + } + + private void CreateInitialEnvironment( string name, string address ) { + CreateInitialEnvironments( ( name, address ) ); + } + + private void CreateInitialEnvironments( params (string name, string address)[] environments ) { + var settings = new CliSettings { + Environments = environments + .Select( e => new EnvironmentSetting( e.name, e.address ) ) + .ToList(), + ActiveEnvironment = environments.Length > 0 ? environments[0].name : null + }; + + WriteSettings( settings ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithoutDiscoverySuccess_spec.verified.txt b/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithoutDiscoverySuccess_spec.verified.txt index db8b24dd..0a53f6f8 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithoutDiscoverySuccess_spec.verified.txt +++ b/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithoutDiscoverySuccess_spec.verified.txt @@ -10,37 +10,28 @@ network: devices: - id: router addresses: - - type: ip-v4 - value: 192.168.1.10 + ipv4: 192.168.1.10 - id: nas addresses: - - type: ip-v4 - value: 192.168.1.20 + ipv4: 192.168.1.20 - id: server addresses: - - type: ip-v4 - value: 192.168.1.30 + ipv4: 192.168.1.30 - id: desktop addresses: - - type: ip-v4 - value: 192.168.1.40 + ipv4: 192.168.1.40 - id: laptop addresses: - - type: ip-v4 - value: 192.168.1.50 + ipv4: 192.168.1.50 - id: smart-tv addresses: - - type: ip-v4 - value: 192.168.100.10 + ipv4: 192.168.100.10 - id: security-camera addresses: - - type: ip-v4 - value: 192.168.100.20 + ipv4: 192.168.100.20 - id: smart-switch addresses: - - type: ip-v4 - value: 192.168.100.30 + ipv4: 192.168.100.30 - id: guest-device addresses: - - type: ip-v4 - value: 192.168.200.100 + ipv4: 192.168.200.100 diff --git a/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithDiscoveryIsValid.verified.txt b/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithDiscoveryIsValid.verified.txt index ea6c0865..4cbad69b 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithDiscoveryIsValid.verified.txt +++ b/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithDiscoveryIsValid.verified.txt @@ -5,13 +5,10 @@ network: devices: - id: device-1 addresses: - - type: ip-v4 - value: 192.168.0.10 + ipv4: 192.168.0.10 - id: device-2 addresses: - - type: ip-v4 - value: 192.168.0.11 + ipv4: 192.168.0.11 - id: device-3 addresses: - - type: ip-v4 - value: 192.168.0.12 + ipv4: 192.168.0.12 diff --git a/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithoutDiscoveryIsValid.verified.txt b/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithoutDiscoveryIsValid.verified.txt index db8b24dd..0a53f6f8 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithoutDiscoveryIsValid.verified.txt +++ b/src/Cli.Tests/Commands/InitCommandTests.GeneratedSpecWithoutDiscoveryIsValid.verified.txt @@ -10,37 +10,28 @@ network: devices: - id: router addresses: - - type: ip-v4 - value: 192.168.1.10 + ipv4: 192.168.1.10 - id: nas addresses: - - type: ip-v4 - value: 192.168.1.20 + ipv4: 192.168.1.20 - id: server addresses: - - type: ip-v4 - value: 192.168.1.30 + ipv4: 192.168.1.30 - id: desktop addresses: - - type: ip-v4 - value: 192.168.1.40 + ipv4: 192.168.1.40 - id: laptop addresses: - - type: ip-v4 - value: 192.168.1.50 + ipv4: 192.168.1.50 - id: smart-tv addresses: - - type: ip-v4 - value: 192.168.100.10 + ipv4: 192.168.100.10 - id: security-camera addresses: - - type: ip-v4 - value: 192.168.100.20 + ipv4: 192.168.100.20 - id: smart-switch addresses: - - type: ip-v4 - value: 192.168.100.30 + ipv4: 192.168.100.30 - id: guest-device addresses: - - type: ip-v4 - value: 192.168.200.100 + ipv4: 192.168.200.100 diff --git a/src/Cli.Tests/Commands/InitCommandTests.cs b/src/Cli.Tests/Commands/InitCommandTests.cs index 3a9a27bb..b502d1a1 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.cs +++ b/src/Cli.Tests/Commands/InitCommandTests.cs @@ -78,7 +78,7 @@ public void TearDown() { [Test] public async Task MissingNameOption() { // Arrange / Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( "init --overwrite" ); + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( "init --overwrite" ); // Assert using ( Assert.EnterMultipleScope() ) { @@ -95,7 +95,7 @@ public async Task CancellationIsRespected() { try { // Act - var (exitCode, _, _) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, _, _) = await DriftTestCli.InvokeAsync( "init", cancellationToken: cancellationTokenSource.Token ); @@ -126,7 +126,7 @@ public async Task GenerateSpecWithDiscoverySuccess( }; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"init {SpecNameWithDiscovery} --discover {outputFormat} {verbose}", serviceConfig ); @@ -155,7 +155,7 @@ public async Task GenerateSpecWithoutDiscoverySuccess() { }; // Act - var (exitCode, output, error) = await DriftTestCli.InvokeFromTestAsync( + var (exitCode, output, error) = await DriftTestCli.InvokeAsync( $"init {SpecNameWithoutDiscovery}", serviceConfig ); diff --git a/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=.verified.txt b/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=.verified.txt index 810c1d7e..51e01e1d 100644 --- a/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=.verified.txt +++ b/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=.verified.txt @@ -1,4 +1,2 @@ -Validating {SolutionDirectory}src/Spec.Tests/resources/network_single_device_host.yaml -✕ Validation failed -• /: Required properties ["version"] are not present -• /network/subnets/0: Required properties ["address"] are not present +/: Required properties ["version"] are not present +/network/subnets/0: Required properties ["address"] are not present diff --git a/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=log.verified.txt b/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=log.verified.txt index fee297e1..1e9eaaab 100644 --- a/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=log.verified.txt +++ b/src/Cli.Tests/Commands/LintCommandTests.LintInvalidSpec_platform=Linux_specName=network_single_device_host_outputFormat=log.verified.txt @@ -1,4 +1,4 @@ -[