diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 769f8ab4..99c1191a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -22,6 +22,13 @@ "trx" ], "rollForward": false + }, + "Microsoft.OpenApi.Kiota": { + "version": "1.35.0", + "commands": [ + "kiota" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 0c9e904f..63315da4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,7 +9,7 @@ root = true indent_style = space # Code files -[*.{cs,csproj,slnx,props,json}] +[*.{cs,csproj,slnx,props,targets,json}] indent_size = 2 insert_final_newline = false charset = utf-8 @@ -159,6 +159,7 @@ dotnet_diagnostic.CA1848.severity = suggestion dotnet_diagnostic.CA2007.severity = suggestion dotnet_diagnostic.CA1303.severity = none dotnet_diagnostic.CA1848.severity = none +dotnet_diagnostic.CA1068.severity = warning # Elements should be documented dotnet_diagnostic.SA1600.severity = none # Braces for multi-line statements should not share line @@ -225,6 +226,8 @@ dotnet_diagnostic.SA1502.severity = none dotnet_diagnostic.SA1508.severity = none # SA1516 Elements should be separated by blank line (but reports false positives) dotnet_diagnostic.SA1516.severity = none +# SA1201 An element within a C# code file is out of order in relation to the other elements in the code. +dotnet_diagnostic.SA1201.severity = none # TODO TBD diff --git a/.gitattributes b/.gitattributes index 8353a8cb..272647f6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,4 +4,5 @@ *.cmd text eol=crlf *.ps1 text eol=crlf *.sh text eol=lf -*.png binary \ No newline at end of file +*.png binary +/src/Coordinator.Client/Generated/** linguist-generated=true \ No newline at end of file diff --git a/.github/actions/setup-runner/action.yml b/.github/actions/setup-runner/action.yml index 7bc05321..0fd503d1 100644 --- a/.github/actions/setup-runner/action.yml +++ b/.github/actions/setup-runner/action.yml @@ -18,3 +18,8 @@ runs: - name: Restore .NET tools shell: bash run: dotnet tool restore + + - name: Install Containerlab + if: runner.os == 'Linux' + shell: bash + run: bash -c "$(curl -sL https://get.containerlab.dev)" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 02519b27..a01cb136 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,6 +59,15 @@ jobs: --project ./build/_build.csproj --target CheckBuildWarnings + - name: Check for uncommitted files + shell: bash + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "The build changed tracked or untracked files:" + git status --short + exit 1 + fi + test-windows: name: 🪟 Test runs-on: windows-latest @@ -113,6 +122,15 @@ jobs: --project ./build/_build.csproj --target CheckBuildWarnings + - name: Check for uncommitted files + shell: bash + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "The build changed tracked or untracked files:" + git status --short + exit 1 + fi + markdown-lint: name: Markdown lint runs-on: ubuntu-latest @@ -125,5 +143,4 @@ jobs: - name: Lint markdown run: | - npm i -g markdownlint-cli@0.49.1 - markdownlint --config .markdownlint.jsonc '**/*.md' \ No newline at end of file + ./lint-markdown.sh diff --git a/.gitignore b/.gitignore index d3ed220a..a09aad24 100644 --- a/.gitignore +++ b/.gitignore @@ -6,12 +6,14 @@ obj/ #*.idea .idea/.idea.Drift/.idea/watcherTasks.xml .idea/.idea.Drift/.idea/encodings.xml +.idea/.idea.Drift.Build/.idea/encodings.xml +.air/ *.DotSettings *.received.* artifacts/ publish/ TestResults/ -build.binlog -build.binlog-warnings-only.log -publish.binlog -publish.binlog-warnings-only.log \ No newline at end of file +*.log +*.binlog +*.binlog-warnings-only.log +containerlab/*/ \ No newline at end of file diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 092c5c15..653b3b96 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -49,6 +49,9 @@ "CreateRelease", "CreateWindowsArtifacts", "DeleteUntaggedImages", + "GenerateSchemas", + "GenerateSettingsSchema", + "GenerateSpecSchema", "OutputVersion", "PackBinaries", "PublishBinaries", @@ -58,11 +61,13 @@ "Test", "TestE2E", "TestE2E_Binary", + "TestE2E_Clab", "TestE2E_Container", "TestE2E_General", "TestLocal", "TestSelf", "TestUnit", + "TestUnitLocal", "UpdateOui" ] }, @@ -137,6 +142,10 @@ "allOf": [ { "properties": { + "ClabTopology": { + "type": "string", + "description": "Run only this topology (e.g. 'simple-test'). Runs all topologies if not specified" + }, "Commit": { "type": "string", "description": "Commit - e.g. '4c16978aa41a3b435c0b2e34590f1759c1dc0763'" @@ -167,6 +176,10 @@ "description": "GitHubToken - GitHub token used to create releases", "default": "Secrets must be entered via 'nuke :secrets [profile]'" }, + "KeepClabRunning": { + "type": "boolean", + "description": "Keep Containerlab topology running after tests" + }, "MsBuildVerbosity": { "type": "string", "description": "MsBuildVerbosity - Console output verbosity - Default is 'normal'" @@ -218,6 +231,10 @@ "description": "ReleaseType - None (default/safe), PreRelease, or Release", "$ref": "#/definitions/ReleaseType" }, + "SkipClabDeploy": { + "type": "boolean", + "description": "Skip Containerlab deployment (useful for debugging when topology is already running)" + }, "Solution": { "type": "string", "description": "Path to a solution file that is automatically loaded" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..48abe2f7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,100 @@ +# AGENTS.md + +This file provides guidance to AI agents when working with code in this repository. + +## Project Overview + +Drift is a .NET 10 CLI tool for network drift detection — it compares a declarative YAML spec (desired network state) against live network scanning results and reports differences. It supports distributed scanning via agents communicating over gRPC. + +## Build System + +The build uses [NUKE](https://nuke.build/). Entry point is `dotnet nuke`. + +Common targets: + +```sh +dotnet nuke Build # Restore + compile +dotnet nuke TestUnit # Unit tests only (fast) +dotnet nuke Test # All tests (unit + E2E) +dotnet nuke TestE2E # E2E tests (General, Binary, Container image, Container network topologies using Containerlab) +dotnet nuke PublishBinaries # Self-contained binary for the current platform +dotnet nuke BuildContainerImage +``` + +Run a single test class or filter by name using standard `dotnet test` filters: + +```sh +dotnet test src/Domain.Tests --filter "FullyQualifiedName~MyTest" +``` + +## Architecture + +### Source layout (`src/`) + +The solution is split into focused projects. The main ones: + +| Project | Role | +| --- | --- | +| `Cli` | Entry point; commands: `init`, `scan`, `agent start`; AOT-compiled | +| `Cli.Abstractions` | Shared CLI constants: exit codes, env var names, port numbers, file names | +| `Cli.Settings` | User settings file (`~/.config/drift/settings.json`) | +| `Domain` | Core value types: `Network`, `Device`, `Inventory`, `CidrBlock`, `Port`, `AgentId` | +| `Spec` | YAML spec parsing and validation into declared-state domain types | +| `Scanning` | Network discovery: ARP, ping, port scanning | +| `Diff` | Compares declared spec state vs. discovered scan state to produce a drift report | +| `Networking.Grpc` | Generated gRPC/protobuf contracts for the messaging transport | +| `Networking.Core.Abstractions` | Interfaces for message streams, handlers, and client factories | +| `Networking.Core` | Message stream/manager implementation built on gRPC | +| `Networking.Client` | Default client factory for opening outbound messaging connections | +| `Networking.Server` | Hosts the inbound gRPC service for messaging endpoints | +| `Messaging.Protocol.Agent` | Agent request/response message contracts (e.g. scan, subnets) | +| `Messaging.Client` | Typed agent client built on top of `Networking.Client`/`Networking.Core` | +| `Agent.Host` | Hosts an agent's messaging/gRPC endpoint (Kestrel/ASP.NET Core) | +| `Coordinator.Host` | Coordinator-side host counterpart to `Agent.Host` (work in progress) | +| `Common` | Shared cross-cutting helpers: IO, logging, network utilities, embedded resources | +| `Common.Schemas` | Shared JSON Schema generation helpers (e.g. lowercase enum naming) | +| `Serialization` | Cross-module serialization helpers | +| `TestUtilities` | Shared test helpers (loggers, Verify/snapshot settings) used by `*.Tests` projects | +| `ArchTests` | ArchUnitNET tests enforcing dependency rules and naming conventions | + +Schema generators live in `Spec.SchemaGenerator.Cli` and `Cli.Settings.SchemaGenerator.Cli` — they produce JSON Schema from C# types. + +`Networking.*` and `Messaging.*` implement the role-agnostic transport layer (see naming rule below); `Agent.Host` and `Coordinator.Host` build role-specific hosting on top of it. + +### Data flow + +```text +YAML spec → Spec (parse/validate) → Domain types (declared state) + ↓ +Network → Scanning → Domain types (discovered state) + ↓ + Diff → Drift report → Cli (render) +``` + +Agents (remote Drift instances) report discovered state back to the coordinator over gRPC, extending scan coverage across subnets. + +### Key conventions + +- **Central package management**: all NuGet versions in `Directory.Packages.props`; do not add `Version=` attributes to `` in individual project files. +- **Shared project defaults**: `Directory.Build.props` applies nullable refs, implicit usings, and logging config to all projects. +- **InternalsVisibleTo**: test projects access internal members for white-box testing; this is intentional. +- **Snapshot testing**: `Verify.NUnit` is used for golden file comparisons. Run tests to regenerate snapshots when output changes; committed `.verified.*` files are the source of truth. +- **AOT**: `Cli` is published with `PublishAot=true`. Avoid reflection-heavy patterns in the CLI project; use source generators instead. +- **Embedded resources**: schemas, default specs, and scripts are embedded in project assemblies under `embedded_resources/`. +- **`Networking.*` are role-agnostic**: No "Agent", "Peer", "Coordinator", or "Server" in type names, property names, parameter names, method names, or log strings inside `Networking.*`. These assemblies implement the transport layer only (streams, messages, connections). Role-specific concerns belong in `Agent.*`, `Coordinator.*`, or `Cli.*`. + +## Testing + +- **Unit tests**: `*.Tests` projects using NUnit 4 and NSubstitute for mocking. +- **E2E tests**: `Cli.E2ETests.*` projects (`General` install scripts and schemas, `Binary` against the published binary, `Container` against the container image). +- **Containerlab tests**: driven directly by the NUKE build (`build/NukeBuild.TestContainerlab.cs`, target `TestE2E_Clab`) against multi-node topologies — not a `Cli.E2ETests.*` project. Requires Containerlab installed and uses topology files in `containerlab/`. +- **Architecture tests**: `ArchTests` project validates project dependency graph and naming rules. + +## Terminology (from domain model) + +- **Spec**: declarative YAML definition of desired network state +- **Declared resource**: a device/subnet defined in the spec +- **Discovered resource**: a device/subnet found by scanning +- **Drift**: difference between declared and discovered state +- **Device ID**: one or more addresses (MAC, IPv4, IPv6, hostname) that uniquely identify a device; spec addresses with `is_id: false` are metadata only +- **Agent**: a Drift instance in agent mode that reports scan results to peers diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 741ea223..1dee3c04 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,6 +10,11 @@ + + + + + @@ -18,9 +23,13 @@ + + + + @@ -39,8 +48,10 @@ + + diff --git a/Drift.Build.slnx b/Drift.Build.slnx index 7b2e0f28..087e9f37 100644 --- a/Drift.Build.slnx +++ b/Drift.Build.slnx @@ -1,15 +1,18 @@ + + + - - - + + + \ No newline at end of file diff --git a/Drift.sln b/Drift.sln index 0086ed09..5f28a417 100644 --- a/Drift.sln +++ b/Drift.sln @@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diff", "src\Diff\Diff.cspro EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestUtilities", "src\TestUtilities\TestUtilities.csproj", "{5602BD77-1A57-4C5E-BFA9-15DDD14D0F54}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestUtilities.Hosts", "src\TestUtilities.Hosts\TestUtilities.Hosts.csproj", "{A4A0F3C1-2C55-4A0E-9B8F-1E651C8C43D2}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serialization", "src\Serialization\Serialization.csproj", "{66AF3505-A886-4C73-8EE9-0F14A9FF2593}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{75466C1B-ED91-43E9-9B81-809B95D868EC}" @@ -26,6 +28,8 @@ 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 + install.ps1 = install.ps1 EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Diff.Tests", "src\Diff.Tests\Diff.Tests.csproj", "{904351B1-CC53-477A-834E-7C3A120EACE9}" @@ -81,6 +85,64 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Schemas", "src\Commo EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "E2E", "E2E", "{D1DBBF0F-1A0D-486C-A893-ACEB667F2A63}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Grpc", "src\Networking.Grpc\Networking.Grpc.csproj", "{8ED3FF22-90D2-4F08-A079-55FE7127D1C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Core", "src\Networking.Core\Networking.Core.csproj", "{80445644-7342-4C6D-88E5-BF27126FE9A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Server", "src\Networking.Server\Networking.Server.csproj", "{A26B4527-6EBF-4A20-8E75-945CCD59016B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Client", "src\Networking.Client\Networking.Client.csproj", "{E69772D3-8A07-414F-8F9A-30370D81A972}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Networking", "Networking", "{75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Core.Abstractions", "src\Networking.Core.Abstractions\Networking.Core.Abstractions.csproj", "{ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Networking.Tests", "src\Networking.Tests\Networking.Tests.csproj", "{9DFDD692-22F8-4F9A-8808-94E318863D23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Protocol.Agent", "src\Messaging.Protocol.Agent\Messaging.Protocol.Agent.csproj", "{7C72C2AE-2888-47A0-AAA4-61CC66B9F941}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Client", "src\Messaging.Client\Messaging.Client.csproj", "{091D3DCE-F062-4D40-A8F6-5B6F123ED713}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host", "src\Agent.Host\Agent.Host.csproj", "{655124DB-312F-4135-B104-20518CAFDA82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host", "src\Coordinator.Host\Coordinator.Host.csproj", "{B9C0D1E2-F3A4-5678-BCDE-F01234567891}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Api", "src\Coordinator.Api\Coordinator.Api.csproj", "{C0D1E2F3-A4B5-6789-CDEF-0123456789A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Api.OpenApi", "src\Coordinator.Api.OpenApi\Coordinator.Api.OpenApi.csproj", "{D1E2F3A4-B5C6-789A-DEF0-123456789AB2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Services", "src\Coordinator.Services\Coordinator.Services.csproj", "{51D4802F-8FF7-492B-978A-B701F1980C7F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agent.Host.Tests", "src\Agent.Host.Tests\Agent.Host.Tests.csproj", "{C4576156-BD24-463F-88F2-8A4378855BCC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{F1A2B3C4-D5E6-7890-ABCD-EF1234567890}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Messaging", "Messaging", "{E2F3A4B5-C6D7-8901-BCDE-F12345678901}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Coordinator", "Coordinator", "{0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Cli", "Cli", "{DD31E4C8-7058-4407-AEC4-43EDA0AB06D1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Host.Tests", "src\Coordinator.Host.Tests\Coordinator.Host.Tests.csproj", "{CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Coordinator.Client", "src\Coordinator.Client\Coordinator.Client.csproj", "{D43AA81A-B177-44D9-AE87-8E3EAB0508A5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Messaging.Tests", "src\Messaging.Tests\Messaging.Tests.csproj", "{87C0FA35-BC55-4B86-AAAD-C425C9A0E050}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Clab", "Clab", "{BA370A6D-DD96-4E5B-82B4-5CE7B6860F26}" + ProjectSection(SolutionItems) = preProject + containerlab\README.md = containerlab\README.md + containerlab\cooperation-test.spec.yaml = containerlab\cooperation-test.spec.yaml + containerlab\cooperation-test.clab.yaml = containerlab\cooperation-test.clab.yaml + containerlab\simple-test.spec.yaml = containerlab\simple-test.spec.yaml + containerlab\simple-test.clab.yaml = containerlab\simple-test.clab.yaml + containerlab\subnet-isolation-test.spec.yaml = containerlab\subnet-isolation-test.spec.yaml + containerlab\subnet-isolation-test.clab.yaml = containerlab\subnet-isolation-test.clab.yaml + containerlab\cooperation-test-scan-output.json = containerlab\cooperation-test-scan-output.json + containerlab\simple-test-scan-output.json = containerlab\simple-test-scan-output.json + containerlab\subnet-isolation-test-scan-output.json = containerlab\subnet-isolation-test-scan-output.json + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -115,6 +177,10 @@ Global {5602BD77-1A57-4C5E-BFA9-15DDD14D0F54}.Debug|Any CPU.Build.0 = Debug|Any CPU {5602BD77-1A57-4C5E-BFA9-15DDD14D0F54}.Release|Any CPU.ActiveCfg = Release|Any CPU {5602BD77-1A57-4C5E-BFA9-15DDD14D0F54}.Release|Any CPU.Build.0 = Release|Any CPU + {A4A0F3C1-2C55-4A0E-9B8F-1E651C8C43D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4A0F3C1-2C55-4A0E-9B8F-1E651C8C43D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4A0F3C1-2C55-4A0E-9B8F-1E651C8C43D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4A0F3C1-2C55-4A0E-9B8F-1E651C8C43D2}.Release|Any CPU.Build.0 = Release|Any CPU {66AF3505-A886-4C73-8EE9-0F14A9FF2593}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {66AF3505-A886-4C73-8EE9-0F14A9FF2593}.Debug|Any CPU.Build.0 = Debug|Any CPU {66AF3505-A886-4C73-8EE9-0F14A9FF2593}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -193,11 +259,107 @@ Global {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Debug|Any CPU.Build.0 = Debug|Any CPU {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Release|Any CPU.ActiveCfg = Release|Any CPU {BAC0F9AF-CAE2-43FB-AF47-B9AC7B62544B}.Release|Any CPU.Build.0 = Release|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7}.Release|Any CPU.Build.0 = Release|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Debug|Any CPU.Build.0 = Debug|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Release|Any CPU.ActiveCfg = Release|Any CPU + {091D3DCE-F062-4D40-A8F6-5B6F123ED713}.Release|Any CPU.Build.0 = Release|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80445644-7342-4C6D-88E5-BF27126FE9A2}.Release|Any CPU.Build.0 = Release|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {655124DB-312F-4135-B104-20518CAFDA82}.Release|Any CPU.Build.0 = Release|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A26B4527-6EBF-4A20-8E75-945CCD59016B}.Release|Any CPU.Build.0 = Release|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E69772D3-8A07-414F-8F9A-30370D81A972}.Release|Any CPU.Build.0 = Release|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403}.Release|Any CPU.Build.0 = Release|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9DFDD692-22F8-4F9A-8808-94E318863D23}.Release|Any CPU.Build.0 = Release|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941}.Release|Any CPU.Build.0 = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4576156-BD24-463F-88F2-8A4378855BCC}.Release|Any CPU.Build.0 = Release|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9C0D1E2-F3A4-5678-BCDE-F01234567891}.Release|Any CPU.Build.0 = Release|Any CPU + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1}.Release|Any CPU.Build.0 = Release|Any CPU + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2}.Release|Any CPU.Build.0 = Release|Any CPU + {51D4802F-8FF7-492B-978A-B701F1980C7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51D4802F-8FF7-492B-978A-B701F1980C7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51D4802F-8FF7-492B-978A-B701F1980C7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51D4802F-8FF7-492B-978A-B701F1980C7F}.Release|Any CPU.Build.0 = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C}.Release|Any CPU.Build.0 = Release|Any CPU + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5}.Release|Any CPU.Build.0 = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {8523E9E0-F412-41B7-B361-ADE639FFAF24} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {FEA2FBBE-785F-4187-8242-FD348F9E78AF} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {272166CF-E425-45F8-984F-FAFD3CE953C9} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} {DD70FBC7-8367-45B3-8D3D-757F1CDF6531} = {C0698EF0-61C8-403E-8E93-1F1D34C5B910} + {8ED3FF22-90D2-4F08-A079-55FE7127D1C7} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {80445644-7342-4C6D-88E5-BF27126FE9A2} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {A26B4527-6EBF-4A20-8E75-945CCD59016B} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {E69772D3-8A07-414F-8F9A-30370D81A972} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {ED4522C5-C32B-4FDB-B1BA-82D40D1EC403} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {9DFDD692-22F8-4F9A-8808-94E318863D23} = {75F8AA01-64B6-4EF6-A1B2-CC6E8745A2CC} + {655124DB-312F-4135-B104-20518CAFDA82} = {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} + {C4576156-BD24-463F-88F2-8A4378855BCC} = {F1A2B3C4-D5E6-7890-ABCD-EF1234567890} + {7C72C2AE-2888-47A0-AAA4-61CC66B9F941} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {091D3DCE-F062-4D40-A8F6-5B6F123ED713} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {A2CE629F-8D56-4539-9642-C31B550F7C30} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {B1A2C3D4-E5F6-7890-ABCD-EF1234567890} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {C2B3D4E5-F6A7-8901-BCDE-F12345678901} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {D3C4E5F6-A7B8-9012-CDEF-123456789012} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} + {B9C0D1E2-F3A4-5678-BCDE-F01234567891} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {C0D1E2F3-A4B5-6789-CDEF-0123456789A1} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {D1E2F3A4-B5C6-789A-DEF0-123456789AB2} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {51D4802F-8FF7-492B-978A-B701F1980C7F} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {51BC193B-A7CB-4A3D-8852-AC8A0CE74DF5} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {42ECC801-ADCB-43E4-86C0-E0E4B017D7B6} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {38621D2E-6AF0-4969-897D-898C39D41A20} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {B75C5C0C-A5A2-419E-B0DD-342A44B1B48D} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {130363FC-478E-472A-978B-731D39E3E264} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {76093473-3375-4519-9524-68448A19DE4A} = {DD31E4C8-7058-4407-AEC4-43EDA0AB06D1} + {CAD33BF2-FEC5-4AA9-A9E1-47697D00545C} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {D43AA81A-B177-44D9-AE87-8E3EAB0508A5} = {0EB052A6-5AFD-4DB8-9C64-1E83D0DFD1E3} + {87C0FA35-BC55-4B86-AAAD-C425C9A0E050} = {E2F3A4B5-C6D7-8901-BCDE-F12345678901} + {BA370A6D-DD96-4E5B-82B4-5CE7B6860F26} = {D1DBBF0F-1A0D-486C-A893-ACEB667F2A63} EndGlobalSection EndGlobal diff --git a/README_dev.md b/README_dev.md index 8fafffae..cf90c3e0 100644 --- a/README_dev.md +++ b/README_dev.md @@ -35,6 +35,10 @@ One or more addresses (MAC, IPv4, IPv6, and/or hostname) that together serve as a unique identifier for a network device. +- **Agent** + A running instance of Drift in agent mode that reports network state to other Drift peers. + Agents help ensure full network visibility by uncovering state that's only observable when scanning from specific subnets. + ## Concepts ### Device ID diff --git a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs index f2d79555..0d25ec35 100644 --- a/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs +++ b/build-utils/Build.Utilities.Tests/Versioning/VersioningTests.cs @@ -31,7 +31,7 @@ public async Task DefaultVersioningVersionTest() { [Test] public async Task DefaultVersioningWhenNoReleaseTargets() { // Arrange - var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( b => b.Arbitrary ); + var build = new NukeBuildWithArbitraryTarget().WithExecutionPlan( _ => NukeBuildWithArbitraryTarget.Arbitrary ); // Act var factory = new VersioningStrategyFactory( build ); @@ -349,7 +349,7 @@ public async Task ExactVersioningPreReleaseImageReferencesDoesNotIncludeLatest() internal sealed class NukeBuildWithArbitraryTarget : TestNukeBuild { // Justification: NUKE Target properties are instance properties by convention; static is not valid here #pragma warning disable S2325 - public Target Arbitrary => _ => _ + public static Target Arbitrary => _ => _ #pragma warning restore S2325 .Executes( () => { } diff --git a/build/NukeBuild.Binaries.cs b/build/NukeBuild.Binaries.cs index 7a51e37b..a6cfdf36 100644 --- a/build/NukeBuild.Binaries.cs +++ b/build/NukeBuild.Binaries.cs @@ -30,7 +30,7 @@ sealed partial class NukeBuild { Log.Information( "Publishing {Runtime} build to {PublishDir}", Platform, publishDir ); Log.Debug( "Supported runtimes are {SupportedRuntimes}", string.Join( ", ", SupportedRuntimes ) ); DotNetPublish( s => s - .SetProject( Solution.Cli ) + .SetProject( Solution.Cli.Cli ) .SetConfiguration( Configuration ) .SetOutput( publishDir ) .SetSelfContained( true ) diff --git a/build/NukeBuild.Container.cs b/build/NukeBuild.Container.cs index e442ba33..c90d5411 100644 --- a/build/NukeBuild.Container.cs +++ b/build/NukeBuild.Container.cs @@ -45,7 +45,7 @@ partial class NukeBuild { var version = await Versioning.Value.GetVersionAsync(); - _driftImageRef = LocalDriftImage.Qualify( new Tag( $"staging.{Guid.NewGuid().ToString( "N" )}" ) ); + _driftImageRef = LocalDriftImage.Qualify( new Tag( $"staging.{Guid.NewGuid():N}" ) ); Log.Information( "Building container image..." ); // var created = DateTime.UtcNow.ToString( "o", CultureInfo.InvariantCulture ); // o = round-trip format / ISO 8601 diff --git a/build/NukeBuild.Schemas.cs b/build/NukeBuild.Schemas.cs new file mode 100644 index 00000000..58c1bfc4 --- /dev/null +++ b/build/NukeBuild.Schemas.cs @@ -0,0 +1,48 @@ +using Drift.Build.Utilities; +using Nuke.Common.IO; +using Nuke.Common.Tools.DotNet; +using Serilog; +using static Nuke.Common.Tools.DotNet.DotNetTasks; +using Target = Nuke.Common.Target; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + Target GenerateSchemas => _ => _ + .DependsOn( GenerateSpecSchema, GenerateSettingsSchema ); + + Target GenerateSpecSchema => _ => _ + .Executes( () => { + using var _ = new OperationTimer( nameof(GenerateSpecSchema) ); + + RunSchemaGenerator( + Solution.Spec_SchemaGenerator_Cli.Path, + Solution.Spec.Directory / "embedded_resources" / "schemas" + ); + } + ); + + Target GenerateSettingsSchema => _ => _ + .Executes( () => { + using var _ = new OperationTimer( nameof(GenerateSettingsSchema) ); + + RunSchemaGenerator( + Solution.Cli.Cli_Settings_SchemaGenerator_Cli.Path, + Solution.Cli.Cli_Settings.Directory / "embedded_resources" / "schemas" + ); + } + ); + + private void RunSchemaGenerator( AbsolutePath projectFile, AbsolutePath outputDirectory ) { + Log.Information( "Generating schema from {Project} into {OutputDirectory}", projectFile, outputDirectory ); + + DotNetRun( s => s + .SetProjectFile( projectFile ) + .SetConfiguration( Configuration ) + .SetApplicationArguments( outputDirectory ) + ); + } + +} diff --git a/build/NukeBuild.Test.cs b/build/NukeBuild.Test.cs index 75d54042..e16f7b3e 100644 --- a/build/NukeBuild.Test.cs +++ b/build/NukeBuild.Test.cs @@ -41,10 +41,24 @@ sealed partial class NukeBuild { Target TestLocal => _ => _ .DependsOn( Test ) + .AssuredAfterFailure() .Executes( () => { var result = ProcessTasks.StartProcess( "dotnet", - "trx --verbosity verbose", + "trx", //"trx --verbosity verbose", + workingDirectory: RootDirectory + ); + result.AssertZeroExitCode(); + } + ); + + Target TestUnitLocal => _ => _ + .DependsOn( TestUnit ) + .AssuredAfterFailure() + .Executes( () => { + var result = ProcessTasks.StartProcess( + "dotnet", + "trx", //"trx --verbosity verbose", workingDirectory: RootDirectory ); result.AssertZeroExitCode(); @@ -70,7 +84,7 @@ sealed partial class NukeBuild { ); Target TestE2E => _ => _ - .DependsOn( TestE2E_General, TestE2E_Binary, TestE2E_Container ); + .DependsOn( TestE2E_General, TestE2E_Binary, TestE2E_Container, TestE2E_Clab ); Target TestE2E_General => _ => _ .DependsOn( Build ) @@ -81,7 +95,7 @@ sealed partial class NukeBuild { Log.Information( "Running general E2E tests" ); DotNetTest( settings => settings - .SetProjectFile( Solution.Cli_E2ETests_General ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_General ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) @@ -105,7 +119,7 @@ sealed partial class NukeBuild { var envVars = new Dictionary { { "DRIFT_BINARY_PATH", driftBinary }, }; DotNetTest( settings => settings - .SetProjectFile( Solution.Cli_E2ETests_Binary ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_Binary ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) @@ -118,7 +132,7 @@ sealed partial class NukeBuild { ); Target TestE2E_Container => _ => _ - .DependsOn( PublishBinaries, BuildContainerImage ) + .DependsOn( BuildContainerImage ) .After( TestUnit ) .OnlyWhenDynamic( () => Platform != DotNetRuntimeIdentifier.win_x64 ) .Executes( async () => { @@ -145,7 +159,7 @@ sealed partial class NukeBuild { } return settings - .SetProjectFile( Solution.Cli_E2ETests_Container ) + .SetProjectFile( Solution.E2E.Cli_E2ETests_Container ) .SetConfiguration( Configuration ) .ConfigureLoggers( MsBuildVerbosityParsed ) .SetBlameHangTimeout( "60s" ) diff --git a/build/NukeBuild.TestContainerlab.cs b/build/NukeBuild.TestContainerlab.cs new file mode 100644 index 00000000..d5c2d759 --- /dev/null +++ b/build/NukeBuild.TestContainerlab.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +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. + /// + private static readonly ContainerlabTestCase[] TestCases = [ + new( + Name: "simple-test", + TopologyFile: "simple-test.clab.yaml", + SpecFile: "simple-test.spec.yaml", + CliContainer: "clab-drift-simple-test-cli", + CoordinatorAddress: "http://clab-drift-simple-test-server:51510", + AgentIds: ["agent_test1"], + SnapshotFile: "simple-test-scan-output.json" + ), + new( + Name: "cooperation-test", + TopologyFile: "cooperation-test.clab.yaml", + SpecFile: "cooperation-test.spec.yaml", + CliContainer: "clab-drift-cooperation-test-cli", + CoordinatorAddress: "http://clab-drift-cooperation-test-server:51510", + AgentIds: ["agent_coop_agent1", "agent_coop_agent2", "agent_coop_agent3"], + SnapshotFile: "cooperation-test-scan-output.json" + ), + new( + Name: "subnet-isolation-test", + TopologyFile: "subnet-isolation-test.clab.yaml", + SpecFile: "subnet-isolation-test.spec.yaml", + CliContainer: "clab-drift-subnet-isolation-test-cli", + CoordinatorAddress: "http://clab-drift-subnet-isolation-test-server:51510", + AgentIds: ["agent_subnet_agent1", "agent_subnet_agent2"], + SnapshotFile: "subnet-isolation-test-scan-output.json" + ), + ]; + + Target TestE2E_Clab => _ => _ + .DependsOn( BuildContainerImage ) + .After( TestUnit, TestE2E_Container, TestE2E_General, TestE2E_Binary ) + .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 ); + } + + var scanResult = await RunScan( specFile, testCase ); + + return AssertMatches( testCase, scanResult.Output.Select( o => o.Text ) ); + } + 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(); + } + + 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 RunScan( AbsolutePath specFile, ContainerlabTestCase testCase ) { + Log.Information( "Running scan for test case: {Name}", testCase.Name ); + + //Log.Debug( "Copying spec to CLI container {Container}...", testCase.CliContainer ); + Docker( $"cp {specFile} {testCase.CliContainer}:/tmp/spec.yaml" ).AssertZeroExitCode(); + + //Log.Information( "Configuring the CLI to use coordinator {Address}...", testCase.CoordinatorAddress ); + RunCliCommand( testCase, $"env add container-server {testCase.CoordinatorAddress}" ); + RunCliCommand( testCase, "spec apply /tmp/spec.yaml" ); + + foreach ( var agentId in testCase.AgentIds ) { + RunCliCommand( testCase, $"enrollment add {agentId}" ); + } + + return RunCliCommand( testCase, "scan --output Json", timeout: TimeSpan.FromMinutes( 5 ) ); + } + + private static IProcess RunCliCommand( ContainerlabTestCase testCase, string command, TimeSpan? timeout = null ) { + return Docker( $"exec {testCase.CliContainer} /app/drift {command}", timeout: timeout ).AssertZeroExitCode(); + } + + // Unused? + private static async Task RunCliCommandWithRetryAsync( + ContainerlabTestCase testCase, + string command, + TimeSpan? timeout = null + ) { + Exception lastException = null; + + for ( var attempt = 1; attempt <= 10; attempt++ ) { + try { + return RunCliCommand( testCase, command, timeout ); + } + catch ( Exception exception ) { + lastException = exception; + Log.Debug( "CLI command '{Command}' failed on attempt {Attempt}; retrying", command, attempt ); + await Task.Delay( TimeSpan.FromSeconds( 1 ) ); + } + } + + throw new InvalidOperationException( $"CLI command '{command}' did not succeed", lastException ); + } + + private static bool AssertMatches( ContainerlabTestCase testCase, IEnumerable outputLines ) { + var actual = string.Join( System.Environment.NewLine, outputLines ).Trim(); + actual = Regex.Replace( actual, "(?i)\\b[0-9a-f]{2}([-:][0-9a-f]{2}){5}\\b", "" ); + + var snapshotFile = Paths.ContainerlabsDirectory / testCase.SnapshotFile; + if ( !File.Exists( snapshotFile ) ) { + throw new FileNotFoundException( "Scan snapshot not found", snapshotFile ); + } + + var expected = File.ReadAllText( snapshotFile ).Trim(); + if ( !string.Equals( actual, expected, StringComparison.Ordinal ) ) { + Log.Error( + "Scan output did not match snapshot for '{Name}'.\nActual:\n{Actual}\nExpected:\n{Expected}", + testCase.Name, actual, expected + ); + return false; + } + + Log.Information( "Scan output matched snapshot for '{Name}'", testCase.Name ); + + return true; + } + + private static void ClabLogger( OutputType type, string text ) => Log.Debug( text ); + + private static IProcess Clab( + string args, + AbsolutePath workDir = null, + TimeSpan? timeout = null, + bool logOutput = true + ) => + ProcessTasks.StartProcess( + "containerlab", args, + workingDirectory: workDir, + timeout: (int?) timeout?.TotalMilliseconds, + logOutput: logOutput, + logger: logOutput ? ClabLogger : null + ); + + private static IProcess Docker( + string args, + AbsolutePath workDir = null, + TimeSpan? timeout = null + ) => + ProcessTasks.StartProcess( + "docker", args, + workingDirectory: workDir, + timeout: (int?) timeout?.TotalMilliseconds + ); +} + +/// +/// A Containerlab-based E2E test case. +/// +/// Test case name +/// A Containerlab topology file +/// A Drift spec file +/// Name of the container hosting the Drift CLI +/// Address of the coordinator used by the CLI +/// IDs of the agents declared in the spec and enrolled before scanning +/// File containing the expected JSON scan output +sealed record ContainerlabTestCase( + string Name, + string TopologyFile, + string SpecFile, + string CliContainer, + string CoordinatorAddress, + string[] AgentIds, + string SnapshotFile +); \ 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..bf95159b 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -12,6 +12,10 @@ false + + + + @@ -20,8 +24,4 @@ - - - - diff --git a/containerlab/README.md b/containerlab/README.md new file mode 100644 index 00000000..6a026e1f --- /dev/null +++ b/containerlab/README.md @@ -0,0 +1,61 @@ +# Containerlab integration testing + +This directory contains [Containerlab](https://containerlab.dev/) topologies for testing Drift's distributed network scanning capabilities. + +## Prerequisites + +- Linux +- [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 TestE2E_Clab + +# Run a single topology for debugging +dotnet nuke TestE2E_Clab --clab-topology simple-test + +# Keep containers running after tests (for debugging) +dotnet nuke TestE2E_Clab --keep-clab-running +``` + +The target deploys and destroys each topology automatically. Use `--skip-clab-deploy` to run against an already deployed topology. + +## 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 the 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-scan-output.json b/containerlab/cooperation-test-scan-output.json new file mode 100644 index 00000000..7e91c27c --- /dev/null +++ b/containerlab/cooperation-test-scan-output.json @@ -0,0 +1,65 @@ +{ + "subnets": [ + { + "cidr": "172.20.20.0/24", + "deviceCount": 11, + "devices": [ + { + "ip": "172.20.20.1", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.2", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.3", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.4", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.5", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.6", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.7", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.8", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.9", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.10", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.11", + "mac": "", + "state": "Online (unknown device)" + } + ] + } + ] +} diff --git a/containerlab/cooperation-test.clab.yaml b/containerlab/cooperation-test.clab.yaml new file mode 100644 index 00000000..f985ebf7 --- /dev/null +++ b/containerlab/cooperation-test.clab.yaml @@ -0,0 +1,70 @@ +name: drift-cooperation-test + +# Multi-agent cooperation topology +# +# All nodes share a single flat network (172.20.20.0/24). +# Three agents cooperate to scan the same subnet, testing: +# - Multi-agent coordination (3 agents) +# - Result merging from multiple agents +# - Correct handling of overlapping scan results +# +# Targets: +# - 5 Alpine containers as scan targets + +topology: + nodes: + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + server: + kind: linux + image: localhost:5000/drift:dev + cmd: server start --port 51510 --no-agent-port + + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --port 5000 + + agent2: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --port 5000 + + agent3: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --port 5000 + + 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/cooperation-test.spec.yaml b/containerlab/cooperation-test.spec.yaml new file mode 100644 index 00000000..3bcf6b01 --- /dev/null +++ b/containerlab/cooperation-test.spec.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/hojmark/drift/refs/heads/main/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json + +version: "v1-preview" + +network: + subnets: + - address: 172.20.20.0/24 + devices: [ ] + +agents: + - id: agent_coop_agent1 + address: http://clab-drift-cooperation-test-agent1:5000 + + - id: agent_coop_agent2 + address: http://clab-drift-cooperation-test-agent2:5000 + + - id: agent_coop_agent3 + address: http://clab-drift-cooperation-test-agent3:5000 \ No newline at end of file diff --git a/containerlab/simple-test-scan-output.json b/containerlab/simple-test-scan-output.json new file mode 100644 index 00000000..b6249a96 --- /dev/null +++ b/containerlab/simple-test-scan-output.json @@ -0,0 +1,35 @@ +{ + "subnets": [ + { + "cidr": "172.20.20.0/24", + "deviceCount": 5, + "devices": [ + { + "ip": "172.20.20.1", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.2", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.3", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.4", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "172.20.20.5", + "mac": "", + "state": "Online (unknown device)" + } + ] + } + ] +} diff --git a/containerlab/simple-test.clab.yaml b/containerlab/simple-test.clab.yaml new file mode 100644 index 00000000..b3158ec4 --- /dev/null +++ b/containerlab/simple-test.clab.yaml @@ -0,0 +1,27 @@ +name: drift-simple-test + +topology: + nodes: + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + server: + kind: linux + image: localhost:5000/drift:dev + cmd: server start --port 51510 --no-agent-port + + agent1: + kind: linux + image: localhost:5000/drift:dev + cmd: agent start --port 5000 + ports: + - "5001:5000" + + target1: + 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..f83a1af6 --- /dev/null +++ b/containerlab/simple-test.spec.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/hojmark/drift/refs/heads/main/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json + +version: "v1-preview" + +network: + subnets: + - address: 172.20.20.0/24 + devices: [ ] + +agents: + - id: agent_test1 + address: http://clab-drift-simple-test-agent1:5000 diff --git a/containerlab/subnet-isolation-test-scan-output.json b/containerlab/subnet-isolation-test-scan-output.json new file mode 100644 index 00000000..9ead7449 --- /dev/null +++ b/containerlab/subnet-isolation-test-scan-output.json @@ -0,0 +1,46 @@ +{ + "subnets": [ + { + "cidr": "192.168.10.0/24", + "deviceCount": 3, + "devices": [ + { + "ip": "192.168.10.1", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "192.168.10.101", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "192.168.10.102", + "mac": "", + "state": "Online (unknown device)" + } + ] + }, + { + "cidr": "192.168.20.0/24", + "deviceCount": 3, + "devices": [ + { + "ip": "192.168.20.1", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "192.168.20.101", + "mac": "", + "state": "Online (unknown device)" + }, + { + "ip": "192.168.20.102", + "mac": "", + "state": "Online (unknown device)" + } + ] + } + ] +} diff --git a/containerlab/subnet-isolation-test.clab.yaml b/containerlab/subnet-isolation-test.clab.yaml new file mode 100644 index 00000000..9ecaa441 --- /dev/null +++ b/containerlab/subnet-isolation-test.clab.yaml @@ -0,0 +1,118 @@ +name: drift-subnet-isolation-test + +# Subnet isolation topology +# +# Tests that each agent only scans targets reachable on its own isolated subnet. +# +# Networks: +# - mgmt (172.20.20.0/24): CLI + Agent1 + Agent2 [management/control plane] +# - subnet-a (veth + bridge): Agent1 + Target-A1 + Target-A2 [192.168.10.0/24] +# - subnet-b (veth + bridge): Agent2 + Target-B1 + Target-B2 [192.168.20.0/24] +# +# Targets use network-mode: none — they have no management interface and are +# only reachable via the veth link to their respective agent. +# +# Inside each agent, a Linux bridge (br0) is created to bridge the two veth +# links together on a single /24 subnet — allowing the agent to reach both +# targets with a single IP address. +# +# Scan spec explicitly declares the two isolated subnets. +# Agent1 can reach 192.168.10.0/24; Agent2 can reach 192.168.20.0/24. +# Neither agent can reach the other's subnet. + +topology: + nodes: + # Controller/CLI node (mgmt only) + cli: + kind: linux + image: localhost:5000/drift:dev + entrypoint: /bin/sh + cmd: -c "sleep infinity" + + # Coordinator/CLI control plane node. + server: + kind: linux + image: localhost:5000/drift:dev + cmd: server start --port 51510 --no-agent-port + + # 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 --port 5000 + 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 --port 5000 + 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/containerlab/subnet-isolation-test.spec.yaml b/containerlab/subnet-isolation-test.spec.yaml new file mode 100644 index 00000000..b63d9749 --- /dev/null +++ b/containerlab/subnet-isolation-test.spec.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/hojmark/drift/refs/heads/main/src/Spec/embedded_resources/schemas/drift-spec-v1-preview.schema.json + +version: "v1-preview" + +network: + subnets: + - address: 192.168.10.0/24 + - address: 192.168.20.0/24 + devices: [ ] + +agents: + - id: agent_subnet_agent1 + address: http://clab-drift-subnet-isolation-test-agent1:5000 + + - id: agent_subnet_agent2 + address: http://clab-drift-subnet-isolation-test-agent2:5000 diff --git a/lint-markdown.sh b/lint-markdown.sh new file mode 100755 index 00000000..6204de1c --- /dev/null +++ b/lint-markdown.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if ! command -v markdownlint >/dev/null 2>&1; then + if [[ -z "${CI:-}" ]]; then + read -rp "Do you want to install markdownlint-cli? [y/N]: " INSTALL_DEPS + if [[ ! "$INSTALL_DEPS" =~ ^[Yy]$ ]]; then + echo "Markdown lint cancelled; markdownlint-cli is required." + exit 1 + fi + fi + + npm i -g markdownlint-cli@0.49.1 +fi + +markdownlint --config .markdownlint.jsonc '**/*.md' "$@" 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/AgentHostListenerTests.cs b/src/Agent.Host.Tests/AgentHostListenerTests.cs new file mode 100644 index 00000000..0431be2f --- /dev/null +++ b/src/Agent.Host.Tests/AgentHostListenerTests.cs @@ -0,0 +1,72 @@ +using Drift.Common.IO; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Agent.Host.Tests; + +internal sealed class AgentHostListenerTests { + [Test] + [Ignore( "Failing - A default port is actually bound!" )] + public async Task Run_WithNoAgentPort_DoesNotBindAnyAddresses() { + var addresses = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); + using var cancellation = new CancellationTokenSource(); + var ready = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously ); + var runTask = AgentHost.Run( + new AgentConfiguration { Port = null }, + NullLogger.Instance, + services => { + services.AddSingleton( new TestAgentDataLocation() ); + services.AddSingleton( serviceProvider => new ServerAddressesObserver( + serviceProvider, + serviceProvider.GetRequiredService(), + addresses + ) ); + }, + cancellation.Token, + ready + ); + + try { + await ready.Task.WaitAsync( TimeSpan.FromSeconds( 10 ), cancellation.Token ); + var boundAddresses = await addresses.Task.WaitAsync( TimeSpan.FromSeconds( 10 ), cancellation.Token ); + + Assert.That( boundAddresses, Is.Empty ); + } + finally { + await cancellation.CancelAsync(); + try { + await runTask; + } + catch ( OperationCanceledException ) when ( cancellation.IsCancellationRequested ) { + // Expected when stopping the host. + } + } + } + + private sealed class ServerAddressesObserver( + IServiceProvider services, + IHostApplicationLifetime lifetime, + TaskCompletionSource addresses + ) : IHostedService { + public Task StartAsync( CancellationToken cancellationToken ) { + lifetime.ApplicationStarted.Register( () => { + var server = services.GetService(); + var serverAddresses = server?.Features.Get()?.Addresses.ToArray() ?? []; + addresses.TrySetResult( serverAddresses ); + } ); + return Task.CompletedTask; + } + + public Task StopAsync( CancellationToken cancellationToken ) => Task.CompletedTask; + } + + private sealed class TestAgentDataLocation : IAgentDataLocation { + public string Directory => string.Empty; + + public void EnsureCreated() { + } + } +} \ No newline at end of file diff --git a/src/Agent.Host.Tests/AssemblyInfo.cs b/src/Agent.Host.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..5493e66a --- /dev/null +++ b/src/Agent.Host.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Category( "Unit" )] \ No newline at end of file diff --git a/src/Agent.Host.Tests/HandlerRegistrationTests.cs b/src/Agent.Host.Tests/HandlerRegistrationTests.cs new file mode 100644 index 00000000..53ad21cc --- /dev/null +++ b/src/Agent.Host.Tests/HandlerRegistrationTests.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using Drift.Networking.Core.Abstractions; + +namespace Drift.Agent.Host.Tests; + +internal sealed class HandlerRegistrationTests { + private static readonly Assembly HandlersAssembly = typeof(AgentHost).Assembly; + private static readonly IEnumerable HandlerTypes = GetAllConcreteHandlerTypes(); + + [Test] + public void FindMessagesAndHandlers() { + using ( Assert.EnterMultipleScope() ) { + Assert.That( HandlerTypes.ToList(), Has.Count.GreaterThan( 1 ), "No handlers found via reflection" ); + } + } + + private static List GetAllConcreteHandlerTypes() { + return HandlersAssembly + .GetTypes() + .Where( t => t is { IsAbstract: false, IsInterface: false } ) + .Where( t => typeof(IMessageHandler).IsAssignableFrom( t ) ) + .ToList(); + } +} diff --git a/src/Agent.Host.Tests/Scan/ScanSubnetRequestHandlerTests.cs b/src/Agent.Host.Tests/Scan/ScanSubnetRequestHandlerTests.cs new file mode 100644 index 00000000..d18ffe31 --- /dev/null +++ b/src/Agent.Host.Tests/Scan/ScanSubnetRequestHandlerTests.cs @@ -0,0 +1,124 @@ +using Drift.Agent.Host.Scan; +using Drift.Agent.Host.Tests.Utils; +using Drift.Domain; +using Drift.Domain.Scan; +using Drift.Messaging.Protocol.Agent.Scan; +using Drift.Scanning.Scanners.Factories; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Agent.Host.Tests.Scan; + +internal sealed class ScanSubnetRequestHandlerTests { + [Test] + public async Task SendsProgressAndCompletion() { + var cidr = new CidrBlock( "192.168.1.0/24" ); + var scanner = new StubSubnetScanner(); + var scannerFactory = new StubSubnetScannerFactory( scanner ); + var responder = new RecordingStreamingMessageResponder(); + + await new ScanSubnetRequestHandler( scannerFactory, NullLogger.Instance ).HandleAsync( + new ScanSubnetRequest { Cidr = cidr }, + responder, + CancellationToken.None + ); + + using ( Assert.EnterMultipleScope() ) { + Assert.That( responder.Progress.Single().ProgressPercentage, Is.EqualTo( 50 ) ); + Assert.That( responder.Progress.Single().DevicesFound, Is.Zero ); + Assert.That( responder.Responses.Single().Result.Status, Is.EqualTo( ScanResultStatus.Success ) ); + Assert.That( scanner.ResultUpdatedSubscriberCount, Is.Zero ); + } + } + + [Test] + public async Task PropagatesCancellationAndUnsubscribes() { + var cidr = new CidrBlock( "192.168.1.0/24" ); + var scanner = new StubSubnetScanner { WaitForCompletion = true }; + var scannerFactory = new StubSubnetScannerFactory( scanner ); + var responder = new RecordingStreamingMessageResponder(); + using var cancellation = new CancellationTokenSource(); + + var task = new ScanSubnetRequestHandler( scannerFactory, NullLogger.Instance ).HandleAsync( + new ScanSubnetRequest { Cidr = cidr }, + responder, + cancellation.Token + ); + + await scanner.ScanStarted; + await cancellation.CancelAsync(); + await task; + + using ( Assert.EnterMultipleScope() ) { + Assert.That( scanner.ReceivedCancellationToken.IsCancellationRequested, Is.True ); + Assert.That( scanner.ResultUpdatedSubscriberCount, Is.Zero ); + } + } + + private sealed class StubSubnetScannerFactory( ISubnetScanner scanner ) : ISubnetScannerFactory { + public ISubnetScanner Get( CidrBlock cidr ) => scanner; + } + + private sealed class StubSubnetScanner : ISubnetScanner { + private readonly TaskCompletionSource _scanStarted = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + + private EventHandler? _resultUpdated; + + public bool WaitForCompletion { + get; + init; + } + + public Task ScanStarted => _scanStarted.Task; + + public CancellationToken ReceivedCancellationToken { + get; + private set; + } + + public int ResultUpdatedSubscriberCount { + get; + private set; + } + + public event EventHandler? ResultUpdated { + add { + _resultUpdated += value; + ResultUpdatedSubscriberCount++; + } + remove { + _resultUpdated -= value; + ResultUpdatedSubscriberCount--; + } + } + + public async Task ScanAsync( + SubnetScanOptions options, + Microsoft.Extensions.Logging.ILogger logger, + CancellationToken cancellationToken = default + ) { + ReceivedCancellationToken = cancellationToken; + _scanStarted.SetResult(); + _resultUpdated?.Invoke( this, CreateResult( options.Cidr, 50, ScanResultStatus.InProgress ) ); + + if ( WaitForCompletion ) { + try { + await Task.Delay( Timeout.InfiniteTimeSpan, cancellationToken ); + } + catch ( OperationCanceledException ) when ( cancellationToken.IsCancellationRequested ) { + return CreateResult( options.Cidr, 0, ScanResultStatus.Canceled ); + } + } + + return CreateResult( options.Cidr, 100, ScanResultStatus.Success ); + } + + private static SubnetScanResult CreateResult( CidrBlock cidr, byte progress, ScanResultStatus status ) => new() { + CidrBlock = cidr, + Metadata = new Metadata { StartedAt = DateTime.UtcNow }, + Progress = new Percentage( progress ), + Status = status + }; + } +} \ No newline at end of file diff --git a/src/Agent.Host.Tests/Subnets/InterfaceSubnetsRequestHandlerTests.cs b/src/Agent.Host.Tests/Subnets/InterfaceSubnetsRequestHandlerTests.cs new file mode 100644 index 00000000..4df47fc1 --- /dev/null +++ b/src/Agent.Host.Tests/Subnets/InterfaceSubnetsRequestHandlerTests.cs @@ -0,0 +1,31 @@ +using Drift.Agent.Host.Subnets; +using Drift.Agent.Host.Tests.Utils; +using Drift.Domain; +using Drift.Messaging.Protocol.Agent.Subnets; +using Drift.Scanning.Subnets; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Agent.Host.Tests.Subnets; + +internal sealed class InterfaceSubnetsRequestHandlerTests { + [Test] + public async Task ReturnsInterfaceSubnets() { + var cidr = new CidrBlock( "192.168.1.0/24" ); + var provider = new StubInterfaceSubnetProvider( [new ResolvedSubnet( cidr, SubnetSource.Local )] ); + var responder = new RecordingMessageResponder(); + + await new InterfaceSubnetsRequestHandler( provider, NullLogger.Instance ).HandleAsync( + new SubnetsRequest(), + responder, + CancellationToken.None + ); + + Assert.That( responder.Responses.Single().Subnets, Is.EqualTo( [cidr] ) ); + } + + private sealed class StubInterfaceSubnetProvider( List subnets ) : IInterfaceSubnetProvider { + public Task> GetAsync() => Task.FromResult( subnets ); + public List GetInterfaces() => []; + } +} \ No newline at end of file diff --git a/src/Agent.Host.Tests/Utils/RecordingMessageResponder.cs b/src/Agent.Host.Tests/Utils/RecordingMessageResponder.cs new file mode 100644 index 00000000..57fbff97 --- /dev/null +++ b/src/Agent.Host.Tests/Utils/RecordingMessageResponder.cs @@ -0,0 +1,15 @@ +using Drift.Networking.Core.Abstractions; + +namespace Drift.Agent.Host.Tests.Utils; + +internal class RecordingMessageResponder : IMessageResponder + where TResponse : IResponse { + public List Responses { + get; + } = []; + + public Task SendAsync( TResponse response ) { + Responses.Add( response ); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Agent.Host.Tests/Utils/RecordingStreamingMessageResponder.cs b/src/Agent.Host.Tests/Utils/RecordingStreamingMessageResponder.cs new file mode 100644 index 00000000..af47c5ba --- /dev/null +++ b/src/Agent.Host.Tests/Utils/RecordingStreamingMessageResponder.cs @@ -0,0 +1,16 @@ +using Drift.Networking.Core.Abstractions; + +namespace Drift.Agent.Host.Tests.Utils; + +internal sealed class RecordingStreamingMessageResponder + : RecordingMessageResponder, IStreamingMessageResponder + where TProgress : IResponse + where TResponse : IResponse { + public List Progress { + get; + } = []; + + public void SendProgress( TProgress progress ) { + Progress.Add( progress ); + } +} \ No newline at end of file diff --git a/src/Agent.Host/Agent.Host.csproj b/src/Agent.Host/Agent.Host.csproj new file mode 100644 index 00000000..913d2519 --- /dev/null +++ b/src/Agent.Host/Agent.Host.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Agent.Host/AgentConfiguration.cs b/src/Agent.Host/AgentConfiguration.cs new file mode 100644 index 00000000..a4b4fc4b --- /dev/null +++ b/src/Agent.Host/AgentConfiguration.cs @@ -0,0 +1,8 @@ +namespace Drift.Agent.Host; + +public class AgentConfiguration { + public ushort? Port { + get; + init; + } +} \ 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..cc972282 --- /dev/null +++ b/src/Agent.Host/AgentHost.cs @@ -0,0 +1,115 @@ +using Drift.Common; +using Drift.Common.IO; +using Drift.Domain.ExecutionEnvironment; +using Drift.Messaging.Protocol.Agent; +using Drift.Networking.Client; +using Drift.Networking.Core; +using Drift.Networking.Server; +using 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( + AgentConfiguration configuration, + ILogger logger, + Action? configureServices, + CancellationToken cancellationToken, + TaskCompletionSource? ready = null + ) { + var app = Build( configuration, logger, configureServices, ready ); + return app.RunAsync( cancellationToken ); + } + + private static WebApplication Build( + AgentConfiguration configuration, + ILogger logger, + Action? configureServices = null, + TaskCompletionSource? ready = null + ) { + var builder = WebApplication.CreateSlimBuilder(); + + builder.Logging.ClearProviders(); + builder.Services.AddSingleton( logger ); + builder.Services.AddSingleton(); + + var messagingOptions = new MessagingOptions { + MessageAssembly = typeof(AgentProtocolMessagesAssemblyMarker).Assembly + }; + builder.Services.AddMessagingCore( messagingOptions ); + builder.Services.AddMessagingClient(); + if ( configuration.Port is not null ) { + builder.Services.AddMessagingServer( options => { + options.EnableDetailedErrors = true; + } ); + } + + builder.Services.AddAgentServices(); + + configureServices?.Invoke( builder.Services ); + + builder.WebHost.ConfigureKestrel( options => { + if ( configuration.Port is not null ) { + options.ListenAnyIP( + configuration.Port.Value, + o => o.Protocols = HttpProtocols.Http2 // gRPC requires HTTP/2 + ); + } + } ); + + var app = builder.Build(); + + // Note: code reading StoppingToken before this point will get CancellationToken.None + messagingOptions.StoppingToken = app.Lifetime.ApplicationStopping; + + app.Services.GetRequiredService().EnsureCreated(); + + if ( configuration.Port is not null ) { + app.MapMessagingServerEndpoints(); + } + + app.Lifetime.ApplicationStarted.Register( () => { + logger.LogDebug( + "Agent data directory: {DataDirectory}", + app.Services.GetRequiredService().Directory + ); + if ( configuration.Port is not null ) { + logger.LogInformation( "Port: {Port} (gRPC)", configuration.Port.Value ); + } + else { + logger.LogWarning( + "Not listening for inbound server connections. Outbound connections are still possible." + ); + } + + logger.LogInformation( "Agent started" ); + ready?.TrySetResult(); + } ); + app.Lifetime.ApplicationStopping.Register( () => { + logger.LogInformation( "Agent stopping..." ); + } ); + app.Lifetime.ApplicationStopped.Register( () => { + logger.LogInformation( "Agent stopped" ); + } ); + + logger.LogInformation( + """ + + ___ _ __ _ + | \ _ _ (_) / _| | |_ + | |) | | '_| | | | _| | _| + |___/ |_| |_| |_| \__| AGENT + + + """ + ); + logger.LogInformation( "Version: {Version}", DriftMetadata.Version ); + + return app; + } +} \ No newline at end of file diff --git a/src/Agent.Host/Scan/ProgressUpdatePolicy.cs b/src/Agent.Host/Scan/ProgressUpdatePolicy.cs new file mode 100644 index 00000000..8cbd6ebb --- /dev/null +++ b/src/Agent.Host/Scan/ProgressUpdatePolicy.cs @@ -0,0 +1,49 @@ +using Drift.Domain; +using Drift.Domain.Scan; +using Drift.Messaging.Protocol.Agent.Scan; +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Scan; + +internal sealed class ProgressUpdatePolicy { + private readonly IStreamingMessageResponder _responder; + + private byte _lastProgressPercentage; + private uint _lastDeviceCount; + private DateTime _lastSentAt = DateTime.UtcNow; + + public EventHandler Handle { + get; + } + + public ProgressUpdatePolicy( IStreamingMessageResponder responder ) { + _responder = responder; + Handle = OnResultUpdated; + } + + private void OnResultUpdated( object? sender, SubnetScanResult result ) { + var progressPercentage = result.Progress.Value; + var deviceCount = result.DiscoveredDevices.Count; + var now = DateTime.UtcNow; + + bool progressThresholdReached = progressPercentage >= _lastProgressPercentage + 5; + bool isFirstCompletion = progressPercentage == 100 && _lastProgressPercentage < 100; + bool heartbeatDue = now - _lastSentAt > TimeSpan.FromSeconds( 10 ); + bool firstDeviceDiscovered = deviceCount > 0 && _lastDeviceCount == 0; + + if ( !( progressThresholdReached || isFirstCompletion || heartbeatDue || firstDeviceDiscovered ) ) { + return; + } + + _lastProgressPercentage = progressPercentage; + _lastDeviceCount = (uint) deviceCount; + _lastSentAt = now; + + var progressUpdate = new ScanSubnetProgress { + ProgressPercentage = progressPercentage, DevicesFound = deviceCount, Status = result.Status.ToString() + }; + + _responder.SendProgress( progressUpdate ); + } +} \ 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..9b95b9fc --- /dev/null +++ b/src/Agent.Host/Scan/ScanSubnetRequestHandler.cs @@ -0,0 +1,42 @@ +using Drift.Domain.Scan; +using Drift.Messaging.Protocol.Agent.Scan; +using Drift.Networking.Core.Abstractions; +using Drift.Scanning.Scanners.Factories; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Scan; + +internal sealed class ScanSubnetRequestHandler( + ISubnetScannerFactory subnetScannerFactory, + ILogger logger +) : StreamingRequestHandler( logger ) { + public override async Task HandleAsync( + ScanSubnetRequest request, + IStreamingMessageResponder responder, + CancellationToken cancellationToken + ) { + var options = new SubnetScanOptions { Cidr = request.Cidr, PingsPerSecond = request.PingsPerSecond }; + + Logger.LogInformation( "Starting scan of {Cidr}", request.Cidr ); + + var scanner = subnetScannerFactory.Get( request.Cidr ); + var policy = new ProgressUpdatePolicy( responder ); + 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 finalResponse = new ScanSubnetResponse { Result = result }; + await responder.SendAsync( finalResponse ); + } + finally { + scanner.ResultUpdated -= policy.Handle; + } + } +} \ 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..83ac89b4 --- /dev/null +++ b/src/Agent.Host/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Drift.Agent.Host.Scan; +using Drift.Agent.Host.State; +using Drift.Agent.Host.Status; +using Drift.Agent.Host.Subnets; +using Drift.Common.IO; +using Drift.Networking.Core.Abstractions; +using Drift.Scanning; +using Microsoft.Extensions.DependencyInjection; + +namespace Drift.Agent.Host; + +internal static class ServiceCollectionExtensions { + extension( IServiceCollection services ) { + internal void AddAgentServices() { + services.AddSingleton(); + services.AddSingleton(); + + services.AddScanning(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + } +} \ No newline at end of file diff --git a/src/Agent.Host/State/DefaultAgentDataLocation.cs b/src/Agent.Host/State/DefaultAgentDataLocation.cs new file mode 100644 index 00000000..a58bf098 --- /dev/null +++ b/src/Agent.Host/State/DefaultAgentDataLocation.cs @@ -0,0 +1,14 @@ +using Drift.Common.IO; + +namespace Drift.Agent.Host.State; + +/// +/// Resolves the agent data location within Drift's data directory. +/// +public sealed class DefaultAgentDataLocation( IDriftDataLocation driftDataLocation ) : IAgentDataLocation { + public string Directory => Path.Combine( driftDataLocation.Directory, "agent" ); + + public void EnsureCreated() { + System.IO.Directory.CreateDirectory( Directory ); + } +} \ No newline at end of file diff --git a/src/Agent.Host/Status/AgentStatusRequestHandler.cs b/src/Agent.Host/Status/AgentStatusRequestHandler.cs new file mode 100644 index 00000000..5bc40c98 --- /dev/null +++ b/src/Agent.Host/Status/AgentStatusRequestHandler.cs @@ -0,0 +1,16 @@ +using Drift.Messaging.Protocol.Agent.Status; +using Drift.Networking.Core.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Status; + +internal sealed class AgentStatusRequestHandler( ILogger logger ) + : RequestHandler( logger ) { + public override async Task HandleAsync( + AgentStatusRequest request, + IMessageResponder responder, + CancellationToken cancellationToken + ) { + await responder.SendAsync( new AgentStatusResponse { Status = AgentStatus.Ready } ); + } +} \ No newline at end of file diff --git a/src/Agent.Host/Subnets/InterfaceSubnetsRequestHandler.cs b/src/Agent.Host/Subnets/InterfaceSubnetsRequestHandler.cs new file mode 100644 index 00000000..306eee6a --- /dev/null +++ b/src/Agent.Host/Subnets/InterfaceSubnetsRequestHandler.cs @@ -0,0 +1,24 @@ +using Drift.Messaging.Protocol.Agent.Subnets; +using Drift.Networking.Core.Abstractions; +using Drift.Scanning.Subnets.Interface; +using Microsoft.Extensions.Logging; + +namespace Drift.Agent.Host.Subnets; + +internal sealed class InterfaceSubnetsRequestHandler( + IInterfaceSubnetProvider interfaceSubnetProvider, + ILogger logger +) : RequestHandler( logger ) { + public override async Task HandleAsync( + SubnetsRequest request, + IMessageResponder responder, + CancellationToken cancellationToken + ) { + var subnets = ( await interfaceSubnetProvider.GetAsync() ).Select( s => s.Cidr ).ToList(); + + Logger.LogInformation( "Sending subnets: {Subnets}", string.Join( ", ", subnets ) ); + + var response = new SubnetsResponse { Subnets = subnets }; + await responder.SendAsync( response ); + } +} \ No newline at end of file diff --git a/src/ArchTests/ArchTests.csproj b/src/ArchTests/ArchTests.csproj index 73115aac..7d34c1ed 100644 --- a/src/ArchTests/ArchTests.csproj +++ b/src/ArchTests/ArchTests.csproj @@ -2,7 +2,6 @@ - false none diff --git a/src/ArchTests/NamingTests.cs b/src/ArchTests/NamingTests.cs index 55b0cc3d..1123a53d 100644 --- a/src/ArchTests/NamingTests.cs +++ b/src/ArchTests/NamingTests.cs @@ -1,6 +1,10 @@ +using System.Text.RegularExpressions; using ArchUnitNET.Domain; using ArchUnitNET.NUnit; using Drift.ArchTests.Fixtures; +using Drift.Common.IO; +using Drift.Domain.Device; +using Drift.Networking.Core.Abstractions; using static ArchUnitNET.Fluent.ArchRuleDefinition; namespace Drift.ArchTests; @@ -16,18 +20,63 @@ public void InterfacesShouldStartWithI() { rule.Check( DriftArchitecture ); } - [Explicit( "Fix architecture" )] // TODO + [TestCase( new[] { typeof(RequestHandler<,>), typeof(StreamingRequestHandler<,,>) }, 2 )] + [TestCase( new[] { typeof(IAddressableDevice) }, 1 )] + [TestCase( new[] { typeof(IDriftSettingsLocation) }, 3 )] + [TestCase( new[] { typeof(IDriftDataLocation) }, 3 )] + public void DescendantsShouldEndWithImplementedTypeSuffix( Type[] handlerTypes, int wordCount ) { + foreach ( var handlerType in handlerTypes ) { + // Remove generic arity ("RequestHandler`2" -> "RequestHandler") + string cleanBaseName = Regex.Replace( handlerType.Name, @"`\d+", string.Empty ); + + // Split name based on PascalCase ("StreamingRequestHandler" -> ["Streaming", "Request", "Handler"]) + var words = Regex.Matches( cleanBaseName, "[A-Z][a-z0-9]*" ) + .Select( m => m.Value ) + .ToArray(); + + if ( words.Length == 0 ) { + Assert.Fail( $"Could not determine words from type name: '{handlerType.Name}'" ); + } + + // Take n last words + int takeCount = Math.Min( wordCount, words.Length ); + string expectedSuffix = string.Join( string.Empty, words.TakeLast( takeCount ) ); + + var implementations = Classes().That() + .AreAssignableTo( handlerType ) + .GetObjects( DriftArchitecture ) + .Where( type => type.FullName != handlerType.FullName ) + .DistinctBy( type => type.FullName ) + .ToArray(); + + var incorrectlyNamed = implementations + .Where( type => !type.Name.EndsWith( expectedSuffix, StringComparison.Ordinal ) ) + .Select( type => type.FullName ) + .ToArray(); + + Assert.That( + incorrectlyNamed, + Is.Empty, + $"{handlerType.Name} implementations should end with '{expectedSuffix}', but some did not." + ); + } + } + [Test] - public void TestClassesShouldEndWithTests() { - /* - * Assembly names are "FullName" e.g. "Drift.Cli.E2ETests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - */ - /*var testClasses = Classes() - .That() - .ResideInAssemblyMatching( @".*\.Tests," ) - .Or() - .ResideInAssemblyMatching( @".*\.E2ETests," );*/ +#pragma warning disable S2699 + public void ListAllInterfacesInSolution() { +#pragma warning restore S2699 + var interfaces = Interfaces() + .GetObjects( DriftArchitecture ) + .Select( type => type.FullName ) + .OrderBy( name => name ) + .ToArray(); + Console.WriteLine( $"Found {interfaces.Length} interfaces:\n\n" + string.Join( "\n", interfaces ) ); + } + + [Test] + public void TestClassesShouldEndWithTests() { var rule = Members().That() .HaveAnyAttributes( typeof(TestAttribute) ) .Should() diff --git a/src/ArchTests/SanityTests.cs b/src/ArchTests/SanityTests.cs index 8639eace..3dccf02a 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 = 40; + private const uint ExpectedAssemblyCountTolerance = 10; [Test] public void FindManyAssemblies() { diff --git a/src/Cli.Abstractions/BuiltInEnvironmentNames.cs b/src/Cli.Abstractions/BuiltInEnvironmentNames.cs new file mode 100644 index 00000000..5eccfb8e --- /dev/null +++ b/src/Cli.Abstractions/BuiltInEnvironmentNames.cs @@ -0,0 +1,5 @@ +namespace Drift.Cli.Abstractions; + +public static class BuiltInEnvironmentNames { + public const string Local = "local"; +} \ No newline at end of file diff --git a/src/Cli.Abstractions/ExitCodes.cs b/src/Cli.Abstractions/ExitCodes.cs index 091250d2..7100c545 100644 --- a/src/Cli.Abstractions/ExitCodes.cs +++ b/src/Cli.Abstractions/ExitCodes.cs @@ -24,13 +24,18 @@ public static class ExitCodes { /// public const int GeneralError = 2; + /// + /// Indicates that the operation was canceled. + /// + public const int Canceled = 3; + /// /// Indicates that a command timed out. /// - public const int TimeOutError = 3; + public const int TimeOutError = 4; /// /// Indicates that a spec validation error occurred. /// - public const int SpecValidationError = 4; + public const int SpecValidationError = 5; } \ No newline at end of file diff --git a/src/Cli.Abstractions/Ports.cs b/src/Cli.Abstractions/Ports.cs new file mode 100644 index 00000000..50ae2171 --- /dev/null +++ b/src/Cli.Abstractions/Ports.cs @@ -0,0 +1,5 @@ +namespace Drift.Cli.Abstractions; + +public static class Ports { + public const ushort AgentDefault = 51515; +} \ No newline at end of file diff --git a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt index 046ac81e..8d41ae28 100644 --- a/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt +++ b/src/Cli.E2ETests.Binary/Commands/GlobalOptionsTests.HelpOptionTest.verified.txt @@ -17,4 +17,10 @@ Commands: init Create a network spec scan Scan the network and detect drift lint Validate a network spec + env Manage Drift environments + spec Manage the coordinator network spec + enrollment Manage coordinator agent enrollment + agent Manage the local Drift agent + server Manage the local Drift server + status Show Drift status diff --git a/src/Cli.E2ETests.Binary/Commands/LintTests.InitThenLintTest.verified.txt b/src/Cli.E2ETests.Binary/Commands/LintTests.InitThenLintTest.verified.txt deleted file mode 100644 index 01fd0170..00000000 --- a/src/Cli.E2ETests.Binary/Commands/LintTests.InitThenLintTest.verified.txt +++ /dev/null @@ -1,2 +0,0 @@ -Validating {CurrentDirectory}unittest.spec.yaml -✓ Valid diff --git a/src/Cli.E2ETests.Binary/Commands/LintTests.cs b/src/Cli.E2ETests.Binary/Commands/LintTests.cs index b8efbcfd..da6bb8ad 100644 --- a/src/Cli.E2ETests.Binary/Commands/LintTests.cs +++ b/src/Cli.E2ETests.Binary/Commands/LintTests.cs @@ -33,7 +33,7 @@ public async Task InitThenLintTest() { // Assert using ( Assert.EnterMultipleScope() ) { Assert.That( lintResult.ExitCode, Is.EqualTo( ExitCodes.Success ) ); - await Verify( lintResult.StdOut ); + Assert.That( lintResult.StdOut, Contains.Substring( "Valid" ) ); Assert.That( lintResult.ErrOut, Is.Empty ); } } diff --git a/src/Cli.E2ETests.Binary/Commands/UnsupportedOptionsTests.cs b/src/Cli.E2ETests.Binary/Commands/UnsupportedOptionsTests.cs new file mode 100644 index 00000000..c9a8485e --- /dev/null +++ b/src/Cli.E2ETests.Binary/Commands/UnsupportedOptionsTests.cs @@ -0,0 +1,20 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.E2ETests.Binary.Commands; + +internal sealed class UnsupportedOptionsTests : DriftBinaryFixture { + [TestCase( "" )] + [TestCase( "--version" )] + [TestCase( "status" )] + [TestCase( "env list" )] + // [TestCase( "agent start --bogus" )] + // [TestCase( "agent --bogus" )] + public async Task UnsupportedOption_ReturnsErrorExitCode( string command ) { + var result = await DriftBinary.ExecuteAsync( command + " --bogus" ); + + using ( Assert.EnterMultipleScope() ) { + Assert.That( result.ExitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( result.ErrOut, Does.Contain( "Unrecognized command or argument '--bogus'" ) ); + } + } +} \ No newline at end of file diff --git a/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt b/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt index 8523aa12..0589c7a9 100644 --- a/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt +++ b/src/Cli.E2ETests.Container/CommandTests.ValidCommand_ReturnsSuccessExitCode.verified.txt @@ -19,6 +19,12 @@
+ + 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..fccd80c6 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.SuccessfulLifecycle.verified.txt @@ -0,0 +1,15 @@ +-------------------------------------- PREVIEW --------------------------------------------- +Distributed scanning via agents is a preview feature and should be used with caution. +All communication between CLI/server/agent is unencrypted. Do not use on untrusted networks. +-------------------------------------------------------------------------------------------- + + ___ _ __ _ +| \ _ _ (_) / _| | |_ +| |) | | '_| | | | _| | _| +|___/ |_| |_| |_| \__| AGENT + +Version: +Port: +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..8afb75a4 --- /dev/null +++ b/src/Cli.Tests/Commands/AgentCommandTests.cs @@ -0,0 +1,43 @@ +using Drift.Cli.Abstractions; +using Drift.Cli.Tests.Utils; +using Drift.TestUtilities; +using Drift.TestUtilities.IO; + +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 --port " + TcpUtils.GetFreePort(), + 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( + "--port " + TcpUtils.GetFreePort(), + 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() ).ScrubVersion().ScrubPort(); + 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..fd38a477 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Add.cs @@ -0,0 +1,104 @@ +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 + WriteSettings( new CliSettings() ); + + // Act + 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 ) ); + using ( Assert.EnterMultipleScope() ) { + 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(); + using ( Assert.EnterMultipleScope() ) { + 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_BuiltInLocal_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env add local localhost:5000" ); + + // Assert + await Verify( output.ToString() + error ); + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + Assert.That( ReadSettings().Environments, Is.Empty ); + } + } + + [Test] + public async Task EnvAdd_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env add localhost:5000" ); + + // Assert + using ( Assert.EnterMultipleScope() ) { + 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 + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'add'." ) ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_BuiltInLocal_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_BuiltInLocal_FailsWithError.verified.txt new file mode 100644 index 00000000..08c7715b --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvAdd_BuiltInLocal_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'local' is the built-in local environment. 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_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_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt new file mode 100644 index 00000000..8312bdae --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly.verified.txt @@ -0,0 +1,4 @@ + local + env1 @ host1:5000 +* env2 @ host2:5000 + env3 @ host3:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysBuiltInLocal.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysBuiltInLocal.verified.txt new file mode 100644 index 00000000..12f3a63c --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_NoEnvironments_DisplaysBuiltInLocal.verified.txt @@ -0,0 +1 @@ +* local diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt new file mode 100644 index 00000000..4acafe2c --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=list.verified.txt @@ -0,0 +1,2 @@ + local +* myenv @ localhost:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt new file mode 100644 index 00000000..4acafe2c --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvList_SingleEnvironment_DisplaysWithActive_commandName=ls.verified.txt @@ -0,0 +1,2 @@ + local +* myenv @ localhost:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_BuiltInLocal_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_BuiltInLocal_FailsWithError.verified.txt new file mode 100644 index 00000000..83c55b01 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_BuiltInLocal_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ The built-in local environment cannot be removed. diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironmentWithNoConfiguredEnvironments_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironmentWithNoConfiguredEnvironments_FailsWithError.verified.txt new file mode 100644 index 00000000..02b39abe --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_NonExistentEnvironmentWithNoConfiguredEnvironments_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'nonexistent' 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_RemovesActiveEnvironment_FallsBackToLocal.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_FallsBackToLocal.verified.txt new file mode 100644 index 00000000..6e56d99f --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesActiveEnvironment_FallsBackToLocal.verified.txt @@ -0,0 +1,2 @@ +✓ Removed 'env1' +'local' is active diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesLastConfiguredEnvironment_FallsBackToLocal.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesLastConfiguredEnvironment_FallsBackToLocal.verified.txt new file mode 100644 index 00000000..6e56d99f --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_RemovesLastConfiguredEnvironment_FallsBackToLocal.verified.txt @@ -0,0 +1,2 @@ +✓ Removed 'env1' +'local' is active 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..caf1af57 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvRemove_Success_RemovesEnvironment.verified.txt @@ -0,0 +1 @@ +✓ Removed 'env2' diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Local_SetsBuiltInEnvironmentActive.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Local_SetsBuiltInEnvironmentActive.verified.txt new file mode 100644 index 00000000..7a23bed5 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_Local_SetsBuiltInEnvironmentActive.verified.txt @@ -0,0 +1 @@ +✓ 'local' is active diff --git a/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironmentWithNoConfiguredEnvironments_FailsWithError.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironmentWithNoConfiguredEnvironments_FailsWithError.verified.txt new file mode 100644 index 00000000..02b39abe --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvUse_NonExistentEnvironmentWithNoConfiguredEnvironments_FailsWithError.verified.txt @@ -0,0 +1 @@ +✕ 'nonexistent' 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.verified.txt b/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow.verified.txt new file mode 100644 index 00000000..8d178e46 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.EnvWorkflow.verified.txt @@ -0,0 +1,3 @@ + local + env1 @ host1:5000 +* env2 @ host2:5000 diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs b/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs new file mode 100644 index 00000000..6484d0e5 --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Integration.cs @@ -0,0 +1,32 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvWorkflow() { + // Arrange / Act + var (exitCode1, _, _) = await InvokeAsync( "env add env1 host1:5000" ); + var (exitCode2, _, _) = await InvokeAsync( "env add env2 host2:5000" ); + var (exitCode3, _, _) = await InvokeAsync( "env use env2" ); + var (exitCode4, output4, error4) = await InvokeAsync( "env list" ); + + // Assert - exit codes + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode1, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( exitCode2, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( exitCode3, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( exitCode4, Is.EqualTo( ExitCodes.Success ) ); + } + + // Assert - output + await Verify( output4.ToString() + error4 ); + + // Assert - settings + var settings = ReadSettings(); + using ( Assert.EnterMultipleScope() ) { + Assert.That( settings.Environments, Has.Count.EqualTo( 2 ) ); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.List.cs b/src/Cli.Tests/Commands/EnvCommandTests.List.cs new file mode 100644 index 00000000..44701c8f --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.List.cs @@ -0,0 +1,49 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvList_NoEnvironments_DisplaysBuiltInLocal() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_SingleEnvironment_DisplaysWithActive( [Values( "list", "ls" )] string commandName ) { + // Arrange + CreateInitialEnvironment( "myenv", "localhost:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( $"env {commandName}" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } + + [Test] + public async Task EnvList_MultipleEnvironmentsWithOneActive_DisplaysCorrectly() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ), + ( "env3", "host3:5000" ) + ); + + var settings = ReadSettings(); + settings.ActiveEnvironment = "env2"; + WriteSettings( settings ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env list" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs b/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs new file mode 100644 index 00000000..5efde61c --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Remove.cs @@ -0,0 +1,150 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvRemove_Success_RemovesEnvironment() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env remove env2" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var updatedSettings = ReadSettings(); + using ( Assert.EnterMultipleScope() ) { + Assert.That( updatedSettings.Environments, Has.Count.EqualTo( 1 ) ); + Assert.That( updatedSettings.Environments[0].Name, Is.EqualTo( "env1" ) ); + Assert.That( updatedSettings.ActiveEnvironment, Is.EqualTo( "env1" ) ); + } + } + + [Test] + public async Task EnvRemove_RemovesActiveEnvironment_FallsBackToLocal() { + // 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(); + using ( Assert.EnterMultipleScope() ) { + 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(); + using ( Assert.EnterMultipleScope() ) { + Assert.That( updatedSettings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + Assert.That( updatedSettings.Environments, Has.Count.EqualTo( 2 ) ); + } + } + + [Test] + public async Task EnvRemove_RemovesLastConfiguredEnvironment_FallsBackToLocal() { + // 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(); + using ( Assert.EnterMultipleScope() ) { + 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_NonExistentEnvironmentWithNoConfiguredEnvironments_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env remove nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + } + + [Test] + public async Task EnvRemove_BuiltInLocal_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env remove local" ); + + // Assert + await Verify( output.ToString() + error ); + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + Assert.That( ReadSettings().Environments, Is.Empty ); + } + } + + [Test] + public async Task EnvRemove_MissingName_FailsWithError() { + // Arrange / Act + var (exitCode, _, error) = await InvokeAsync( "env remove" ); + + // Assert + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'remove'." ) ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.Use.cs b/src/Cli.Tests/Commands/EnvCommandTests.Use.cs new file mode 100644 index 00000000..61a529dd --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.Use.cs @@ -0,0 +1,105 @@ +using Drift.Cli.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + [Test] + public async Task EnvUse_Success_SetsActiveEnvironment() { + // Arrange + CreateInitialEnvironments( + ( "env1", "host1:5000" ), + ( "env2", "host2:5000" ) + ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use env2" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + + var settings = ReadSettings(); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env2" ) ); + } + + [Test] + public async Task EnvUse_NonExistentEnvironment_FailsWithError() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + + var settings = ReadSettings(); + Assert.That( settings.ActiveEnvironment, Is.EqualTo( "env1" ) ); + } + + [Test] + public async Task EnvUse_NonExistentEnvironmentWithNoConfiguredEnvironments_FailsWithError() { + // Arrange / Act + var (exitCode, output, error) = await InvokeAsync( "env use nonexistent" ); + + // Assert + await Verify( output.ToString() + error ); + Assert.That( exitCode, Is.EqualTo( ExitCodes.GeneralError ) ); + } + + [Test] + public async Task EnvUse_Local_SetsBuiltInEnvironmentActive() { + // Arrange + CreateInitialEnvironment( "env1", "host1:5000" ); + + // Act + var (exitCode, output, error) = await InvokeAsync( "env use local" ); + + // Assert + await Verify( output.ToString() + error ); + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.Success ) ); + Assert.That( ReadSettings().ActiveEnvironment, Is.Null ); + } + } + + [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 + using ( Assert.EnterMultipleScope() ) { + Assert.That( exitCode, Is.EqualTo( ExitCodes.SystemCommandLineDefaultError ) ); + Assert.That( error.ToString(), Does.Contain( "Required argument missing for command: 'use'." ) ); + } + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/EnvCommandTests.cs b/src/Cli.Tests/Commands/EnvCommandTests.cs new file mode 100644 index 00000000..d68cb1ef --- /dev/null +++ b/src/Cli.Tests/Commands/EnvCommandTests.cs @@ -0,0 +1,49 @@ +using Drift.Cli.Settings.Tests; +using Drift.Cli.Settings.V1_preview; +using Drift.Cli.Settings.V1_preview.Environments; +using Drift.Cli.Tests.Utils; +using Drift.Common.IO; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Drift.Cli.Tests.Commands; + +internal sealed partial class EnvCommandTests { + private IDriftSettingsLocation SettingsLocation { + get; + } = new TemporaryDriftSettingsLocation(); + + private Task InvokeAsync( string args ) { + return DriftTestCli.InvokeAsync( args, settingsLocation: SettingsLocation ); + } + + private CliSettings ReadSettings() { + return CliSettings.Read( SettingsLocation ); + } + + private void WriteSettings( CliSettings settings ) { + settings.Write( NullLogger.Instance, location: SettingsLocation ); + } + + [TearDown] + public void TearDown() { + var settingsDir = SettingsLocation.Directory; + if ( Directory.Exists( settingsDir ) ) { + Directory.Delete( settingsDir, true ); + } + } + + private void CreateInitialEnvironment( string name, string address ) { + CreateInitialEnvironments( ( name, address ) ); + } + + private void CreateInitialEnvironments( params (string name, string address)[] environments ) { + var settings = new CliSettings { + Environments = environments + .Select( e => new EnvironmentSetting( e.name, e.address ) ) + .ToList(), + ActiveEnvironment = environments.Length > 0 ? environments[0].name : null + }; + + WriteSettings( settings ); + } +} \ No newline at end of file diff --git a/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithDiscoverySuccess_outputFormat=-o log_verbose=-v.verified.txt b/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithDiscoverySuccess_outputFormat=-o log_verbose=-v.verified.txt index ade236e3..245edb46 100644 --- a/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithDiscoverySuccess_outputFormat=-o log_verbose=-v.verified.txt +++ b/src/Cli.Tests/Commands/InitCommandTests.GenerateSpecWithDiscoverySuccess_outputFormat=-o log_verbose=-v.verified.txt @@ -1,4 +1,6 @@ [