From 298f359cc0b0762b2196a592d040fee741cd4fd0 Mon Sep 17 00:00:00 2001 From: Mattias Karlsson Date: Fri, 16 Oct 2026 16:53:08 +0200 Subject: [PATCH] feat: report NuGet inventory through Azure Monitor Logs Ingestion API Replace HTTP Data Collector as the default path with Logs Ingestion (DefaultAzureCredential, no client secret). Keep workspace/shared-key as a deprecated fallback, reject mixed mode, and require an absolute HTTPS endpoint. Skip upload when a scan has no records so empty inventories still succeed. Add a Direct DCR that maps DPI JSON onto a migrated NuGetReport_CL table (suffixed columns, quoted source). Use a distinct input stream (Custom-NuGetReport) and outputStream Custom-NuGetReport_CL. Document table migrate, Entra app, Monitoring Metrics Publisher on the DCR, and GitHub OIDC for main and develop only (not pull_request), parameterized with $owner/$repo. Gate azure/login and ingestion env to push/workflow_dispatch on main or develop. Stop passing shared-key and stream secrets in CI; an empty stream env no longer overrides the default. Cover settings, reporters, and serialization with xUnit v3, MTP, Verify, and Cake testing fakes. Move pack metadata onto DPI.csproj, keep Cake restore paths absolute, and snapshot Verify files with LF endings. --- .gitattributes | 2 + .github/workflows/build.yml | 17 +- .gitignore | 9 +- README.md | 369 +++++++++++++++++- build/records.cs | 3 + cake.cs | 41 +- deploy/azure-monitor/main.bicep | 101 +++++ deploy/azure-monitor/main.bicepparam | 8 + global.json | 3 + src/DPI.Tests/Constants.cs | 3 + src/DPI.Tests/DPI.Tests.csproj | 45 +++ .../Fixture/ReportSettingsFactory.cs | 28 ++ .../Fixture/ServiceProviderFixture.cs | 22 ++ src/DPI.Tests/Resources/Empty.json | 1 + src/DPI.Tests/Resources/Routes.json | 21 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 5 + .../_._.verified.txt | 6 + .../_._.verified.txt | 6 + .../_._.verified.txt | 15 + .../_._.verified.txt | 3 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 5 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 5 + .../_._.verified.txt | 6 + .../_._.verified.txt | 6 + .../_._.verified.txt | 15 + .../_._.verified.txt | 3 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 5 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 5 + .../_._.verified.txt | 6 + .../_._.verified.txt | 6 + .../_._.verified.txt | 15 + .../_._.verified.txt | 3 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 5 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 5 + .../_._.verified.txt | 6 + .../_._.verified.txt | 6 + .../_._.verified.txt | 15 + .../_._.verified.txt | 3 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 4 + .../_._.verified.txt | 5 + .../_._.verified.txt | 4 + .../Unit/NuGetReportSettingsTests.cs | 189 +++++++++ .../PackageReferenceSerializationTests.cs | 35 ++ src/DPI.Tests/Unit/ReportingTests.cs | 242 ++++++++++++ src/DPI.Tests/VerifyChecksTests.cs | 6 + src/DPI.Tests/VerifyConfig.cs | 35 ++ src/DPI.sln | 34 -- src/DPI.slnx | 4 + src/DPI/Commands/NuGet/NuGetReportCommand.cs | 86 +--- .../NuGet/Reporting/DataCollectorReporter.cs | 89 +++++ .../DefaultLogsIngestionClientFactory.cs | 11 + .../NuGet/Reporting/ILogAnalyticsReporter.cs | 11 + .../Reporting/ILogsIngestionClientFactory.cs | 9 + .../NuGet/Reporting/ILogsIngestionUploader.cs | 13 + .../NuGet/Reporting/LogAnalyticsReportMode.cs | 7 + .../Reporting/LogAnalyticsReporterFactory.cs | 16 + .../NuGet/Reporting/LogsIngestionReporter.cs | 51 +++ .../NuGet/Reporting/LogsIngestionUploader.cs | 19 + .../Settings/NuGet/NuGetReportSettings.cs | 93 ++++- src/DPI/DPI.csproj | 5 + src/DPI/Program.cs | 8 +- 99 files changed, 1803 insertions(+), 153 deletions(-) create mode 100644 .gitattributes create mode 100644 deploy/azure-monitor/main.bicep create mode 100644 deploy/azure-monitor/main.bicepparam create mode 100644 src/DPI.Tests/Constants.cs create mode 100644 src/DPI.Tests/DPI.Tests.csproj create mode 100644 src/DPI.Tests/Fixture/ReportSettingsFactory.cs create mode 100644 src/DPI.Tests/Fixture/ServiceProviderFixture.cs create mode 100644 src/DPI.Tests/Resources/Empty.json create mode 100644 src/DPI.Tests/Resources/Routes.json create mode 100644 src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt create mode 100644 src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt create mode 100644 src/DPI.Tests/Unit/NuGetReportSettingsTests.cs create mode 100644 src/DPI.Tests/Unit/PackageReferenceSerializationTests.cs create mode 100644 src/DPI.Tests/Unit/ReportingTests.cs create mode 100644 src/DPI.Tests/VerifyChecksTests.cs create mode 100644 src/DPI.Tests/VerifyConfig.cs delete mode 100644 src/DPI.sln create mode 100644 src/DPI.slnx create mode 100644 src/DPI/Commands/NuGet/Reporting/DataCollectorReporter.cs create mode 100644 src/DPI/Commands/NuGet/Reporting/DefaultLogsIngestionClientFactory.cs create mode 100644 src/DPI/Commands/NuGet/Reporting/ILogAnalyticsReporter.cs create mode 100644 src/DPI/Commands/NuGet/Reporting/ILogsIngestionClientFactory.cs create mode 100644 src/DPI/Commands/NuGet/Reporting/ILogsIngestionUploader.cs create mode 100644 src/DPI/Commands/NuGet/Reporting/LogAnalyticsReportMode.cs create mode 100644 src/DPI/Commands/NuGet/Reporting/LogAnalyticsReporterFactory.cs create mode 100644 src/DPI/Commands/NuGet/Reporting/LogsIngestionReporter.cs create mode 100644 src/DPI/Commands/NuGet/Reporting/LogsIngestionUploader.cs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a21d02e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Verify +*.verified.txt text eol=lf working-tree-encoding=UTF-8 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 983e936..231593b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,11 @@ jobs: fail-fast: false matrix: os: [windows-latest, ubuntu-latest, macos-latest] + env: + TRUSTED_INGEST: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} steps: - name: Get the sources uses: actions/checkout@v7 @@ -39,11 +44,19 @@ jobs: with: global-json-file: global.json + - name: Azure login + if: ${{ env.TRUSTED_INGEST == 'true' && env.AZURE_CLIENT_ID != '' && env.AZURE_TENANT_ID != '' && env.AZURE_SUBSCRIPTION_ID != '' }} + uses: azure/login@v3 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + - name: Run Cake script uses: cake-build/cake-action@v3 env: - NuGetReportSettings_SharedKey: ${{ secrets.NUGETREPORTSETTINGS_SHAREDKEY }} - NuGetReportSettings_WorkspaceId: ${{ secrets.NUGETREPORTSETTINGS_WORKSPACEID }} + NuGetReportSettings_Endpoint: ${{ env.TRUSTED_INGEST == 'true' && secrets.NUGETREPORTSETTINGS_ENDPOINT || '' }} + NuGetReportSettings_RuleId: ${{ env.TRUSTED_INGEST == 'true' && secrets.NUGETREPORTSETTINGS_RULEID || '' }} GH_PACKAGES_NUGET_SOURCE: ${{ secrets.GH_PACKAGES_NUGET_SOURCE }} GH_PACKAGES_NUGET_APIKEY: ${{ secrets.GH_PACKAGES_NUGET_APIKEY }} NUGET_SOURCE: ${{ secrets.NUGET_SOURCE }} diff --git a/.gitignore b/.gitignore index 7fa6711..2a260f1 100644 --- a/.gitignore +++ b/.gitignore @@ -348,4 +348,11 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ -[Aa]rtifacts/ \ No newline at end of file +[Aa]rtifacts/ + +# Compiled Bicep output +deploy/azure-monitor/*.json + +# Verify +*.received.* +*.received/ \ No newline at end of file diff --git a/README.md b/README.md index 774e2ab..5ebcb21 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![NuGet](https://img.shields.io/nuget/v/dpi.svg)](https://www.nuget.org/packages/dpi) -Dependency Inventory .NET Tool - Inventories dependencies and reports to Azure Log Analytics +Dependency Inventory .NET Tool - Inventories dependencies and reports to Azure Monitor Logs You can get an introduction to the tool, in the blog post: [Introducing DPI - A DevOps tool to inspect dependencies and report to Azure Log Analytics](https://www.devlead.se/posts/2021/2021-03-20-introducing-dpi) @@ -48,22 +48,367 @@ dpi nuget --output json --file dependencies.json analyze #### report -**nuget report** analyzes and reports the result to Azure Log Analytics. +**nuget report** analyzes and reports the result to Azure Monitor Logs through the [Logs Ingestion API](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/logs-ingestion-api-overview). Authenticate with `DefaultAzureCredential` (Azure CLI, managed identity, GitHub OIDC, or Azure Pipelines workload identity). Do not pass a client secret to DPI. ```bash -export NuGetReportSettings_WorkspaceId= +export NuGetReportSettings_Endpoint=https:// +export NuGetReportSettings_RuleId=dcr- +# Optional; defaults to Custom-NuGetReport (DCR input stream, not the table name) +export NuGetReportSettings_Stream=Custom-NuGetReport -export NuGetReportSettings_SharedKey= - -# Output as table (default) dpi nuget --output table report - -# Output as JSON dpi nuget --output json report - -# Output as Markdown dpi nuget --output markdown report - -# Save output to file dpi nuget --output json --file report.json report ``` + +CLI equivalents: `--endpoint`, `--rule-id`, and `--stream`. + +This release still accepts the deprecated HTTP Data Collector API pair `--workspace` / `--sharedkey` (or `NuGetReportSettings_WorkspaceId` / `NuGetReportSettings_SharedKey`) and writes a warning. Do not mix those settings with Logs Ingestion settings. Shared-key ingestion will be removed in a later release. + +##### Migrate an existing `NuGetReport_CL` table + +The HTTP Data Collector API is [unsupported](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/custom-logs-migrate). Existing DPI customers should keep the current `NuGetReport_CL` table and history. + +1. Confirm the table is still classic (`schema.tableSubType` is `Classic`). `table show` uses `--name`; snapshot queries should use Data Collector suffixes (`sessionId_g`, `packageId_s`, `timestamp_t`). +2. Export or snapshot current queries, workbooks, and a sample of recent rows. +3. Migrate the table in place. This Azure operation cannot be rolled back. After migrate, `tableSubType` is `DataCollectionRuleBased` and the suffixed columns stay. + +Set `$owner` and `$repo` to your GitHub origin (`devlead` / `DPI` here). Azure names in this walkthrough use a lowercase repo slug (`devlead-dpi`). + +Bash: + +```bash +owner=devlead +repo=DPI +resourceGroup=dpi +workspaceName="${owner}-${repo,,}" +dcrName=dpi-nugetreport +``` + +PowerShell: + +```powershell +$owner = 'devlead' +$repo = 'DPI' +$resourceGroup = 'dpi' +$workspaceName = "$owner-$($repo.ToLowerInvariant())" +$dcrName = 'dpi-nugetreport' +``` + +Bash: + +```bash +az monitor log-analytics workspace table migrate \ + --resource-group "$resourceGroup" \ + --workspace-name "$workspaceName" \ + --table-name NuGetReport_CL +``` + +PowerShell: + +```powershell +az monitor log-analytics workspace table migrate ` + --resource-group $resourceGroup ` + --workspace-name $workspaceName ` + --table-name NuGetReport_CL +``` + +4. Deploy the Direct data collection rule in [`deploy/azure-monitor/main.bicep`](deploy/azure-monitor/main.bicep). Fill in [`deploy/azure-monitor/main.bicepparam`](deploy/azure-monitor/main.bicepparam) first. Use the same Azure region as the workspace. + +Bash: + +```bash +az deployment group create \ + --resource-group "$resourceGroup" \ + --template-file deploy/azure-monitor/main.bicep \ + --parameters deploy/azure-monitor/main.bicepparam +``` + +PowerShell: + +```powershell +az deployment group create ` + --resource-group $resourceGroup ` + --template-file deploy/azure-monitor/main.bicep ` + --parameters deploy/azure-monitor/main.bicepparam +``` + +The deployment outputs `endpoint`, `ruleId`, and `stream` (the DCR input stream, default `Custom-NuGetReport`). The table stream remains `Custom-NuGetReport_CL`. Grant the runtime identity **Monitoring Metrics Publisher** on that data collection rule. A role on the Log Analytics workspace does not authorize ingestion. Role assignments can take up to 30 minutes; earlier calls return HTTP 403. + +DPI posts to the input stream `Custom-NuGetReport`. The DCR transform maps those JSON names (`sessionId`, `packageId`, `timestamp`) onto the migrated table columns and `outputStream` `Custom-NuGetReport_CL`. The project-path field is `extend source_s = ['source']` so it is not the KQL `source` operator. After a canary `dpi nuget report`, query `NuGetReport_CL` by `sessionId_g` and compare row counts, `timestamp_t`, `Computer`, and `packageId_s`. Rows can take a few minutes to appear. For a local canary with `az login`, assign **Monitoring Metrics Publisher** to the signed-in user as well as any service principal. Remove workspace-key secrets only after that check succeeds. Application fallback is pinning the previous DPI version with the shared-key pair; the table migration itself cannot be reversed. When you add DPI fields later, update the table schema and the DCR stream together. + +##### Create the Entra sender app + +The signed-in administrator needs User Access Administrator or Owner on the data collection rule. Confirm `az account show` is the intended subscription. If `az ad app list --display-name dpi-logs-ingestion` already returns an app, reuse that `appId` instead of creating a second one. Create the app with no client secret. + +Bash: + +```bash +appId=$(az ad app create --display-name "dpi-logs-ingestion" --query appId --output tsv) +spId=$(az ad sp create --id "$appId" --query id --output tsv) +tenantId=$(az account show --query tenantId --output tsv) +subscriptionId=$(az account show --query id --output tsv) +az ad app credential list --id "$appId" +``` + +PowerShell: + +```powershell +$appId = (az ad app create --display-name "dpi-logs-ingestion" --query appId --output tsv).Trim() +$spId = (az ad sp create --id $appId --query id --output tsv).Trim() +$tenantId = (az account show --query tenantId --output tsv).Trim() +$subscriptionId = (az account show --query id --output tsv).Trim() +az ad app credential list --id $appId +``` + +`az ad app credential list` should be empty. Grant **Monitoring Metrics Publisher** on the data collection rule. Reuse `$dcrName` and `$resourceGroup` from the Bicep deploy. Pass `--subscription` so the assignment is not treated as a tenant-level request. + +Bash: + +```bash +dcrId=$(az monitor data-collection rule show \ + --name "$dcrName" \ + --resource-group "$resourceGroup" \ + --subscription "$subscriptionId" \ + --query id --output tsv) + +az role assignment create \ + --assignee-object-id "$spId" \ + --assignee-principal-type ServicePrincipal \ + --role "Monitoring Metrics Publisher" \ + --scope "$dcrId" \ + --subscription "$subscriptionId" + +userId=$(az ad signed-in-user show --query id --output tsv) +az role assignment create \ + --assignee-object-id "$userId" \ + --assignee-principal-type User \ + --role "Monitoring Metrics Publisher" \ + --scope "$dcrId" \ + --subscription "$subscriptionId" +``` + +PowerShell: + +```powershell +$dcrId = (az monitor data-collection rule show ` + --name $dcrName ` + --resource-group $resourceGroup ` + --subscription $subscriptionId ` + --query id --output tsv).Trim() + +az role assignment create ` + --assignee-object-id $spId ` + --assignee-principal-type ServicePrincipal ` + --role "Monitoring Metrics Publisher" ` + --scope $dcrId ` + --subscription $subscriptionId + +$userId = (az ad signed-in-user show --query id --output tsv).Trim() +az role assignment create ` + --assignee-object-id $userId ` + --assignee-principal-type User ` + --role "Monitoring Metrics Publisher" ` + --scope $dcrId ` + --subscription $subscriptionId +``` + +Keep workspace rights with the deployment administrator: Log Analytics Contributor migrates `NuGetReport_CL`, and Monitoring Contributor creates the DCR. + +##### GitHub Actions OIDC + +Add one federated credential per trusted branch (`main` and `develop`). Do not federate `pull_request` (untrusted forks). Subjects use `repo:$owner/$repo`; credential names use the lowercase repo slug. Store `AZURE_CLIENT_ID=$appId`, `AZURE_TENANT_ID=$tenantId`, and `AZURE_SUBSCRIPTION_ID=$subscriptionId`. + +Bash: + +```bash +repoSlug=${repo,,} +for branch in main develop; do + az ad app federated-credential create --id "$appId" --parameters "{ + \"name\": \"github-${owner}-${repoSlug}-${branch}\", + \"issuer\": \"https://token.actions.githubusercontent.com\", + \"subject\": \"repo:${owner}/${repo}:ref:refs/heads/${branch}\", + \"audiences\": [\"api://AzureADTokenExchange\"] + }" +done +``` + +PowerShell: + +```powershell +$repoSlug = $repo.ToLowerInvariant() +@( + @{ name = "github-$owner-$repoSlug-main"; subject = "repo:${owner}/${repo}:ref:refs/heads/main" }, + @{ name = "github-$owner-$repoSlug-develop"; subject = "repo:${owner}/${repo}:ref:refs/heads/develop" } +) | ForEach-Object { + @{ + name = $_.name + issuer = "https://token.actions.githubusercontent.com" + subject = $_.subject + audiences = @("api://AzureADTokenExchange") + } | ConvertTo-Json | Set-Content "$($_.name).json" + az ad app federated-credential create --id $appId --parameters "$($_.name).json" +} +Remove-Item "github-$owner-$repoSlug-main.json", "github-$owner-$repoSlug-develop.json" +``` + +```yaml +permissions: + id-token: write + contents: read + +- name: Azure login + if: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') }} + uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} +``` + +Store the OIDC app and Logs Ingestion settings as Actions secrets. Delete the Data Collector pair if it is still present; DPI rejects mixed modes. + +```powershell +"$appId" | gh secret set AZURE_CLIENT_ID +"$tenantId" | gh secret set AZURE_TENANT_ID +"$subscriptionId" | gh secret set AZURE_SUBSCRIPTION_ID +"$endpoint" | gh secret set NUGETREPORTSETTINGS_ENDPOINT +"$ruleId" | gh secret set NUGETREPORTSETTINGS_RULEID +gh secret delete NUGETREPORTSETTINGS_STREAM +gh secret delete NUGETREPORTSETTINGS_SHAREDKEY +gh secret delete NUGETREPORTSETTINGS_WORKSPACEID +``` + +`$endpoint` and `$ruleId` are the Bicep deployment outputs. CI uses the tool default stream (`Custom-NuGetReport`); set `NUGETREPORTSETTINGS_STREAM` only to override it. `azure/login` signs in Azure CLI; DPI then uses that session through `DefaultAzureCredential`. Pass Logs Ingestion secrets into the workflow only on push or `workflow_dispatch` of `main` or `develop`, not on pull requests. Do not pass the Data Collector workspace/shared-key pair alongside them. + +##### Azure Pipelines workload identity + +On Azure public cloud, create a manual Azure Resource Manager service connection for the single-tenant app, then require the returned issuer to be the Microsoft Entra issuer `https://login.microsoftonline.com//v2.0` with issuer type `EntraID`. The Azure DevOps CLI needs a one-time `AZURE_DEVOPS_EXT_PAT` with permission to manage service connections; that token is not a DPI runtime secret. See [Automate Azure Resource Manager workload identity service connections](https://learn.microsoft.com/en-us/azure/devops/pipelines/release/automate-service-connections?view=azure-devops). + +Bash: + +```bash +az extension add --name azure-devops +az devops configure --defaults organization="https://dev.azure.com/$org" project="$project" +projectId=$(az devops project show --project "$project" --query id --output tsv) +``` + +PowerShell: + +```powershell +az extension add --name azure-devops +az devops configure --defaults organization="https://dev.azure.com/$org" project="$project" +$projectId = (az devops project show --project $project --query id --output tsv).Trim() +``` + +Create `dpi-service-connection.json` with `"creationMode": "Manual"` and `"scheme": "WorkloadIdentityFederation"`, then: + +Bash: + +```bash +endpointParameters=$(az devops service-endpoint create \ + --service-endpoint-configuration ./dpi-service-connection.json \ + --query authorization.parameters \ + --output json) +issuer=$(jq -r '.workloadIdentityFederationIssuer' <<<"$endpointParameters") +issuerType=$(jq -r '.workloadIdentityFederationIssuerType' <<<"$endpointParameters") +subject=$(jq -r '.workloadIdentityFederationSubject' <<<"$endpointParameters") +expectedIssuer="https://login.microsoftonline.com/${tenantId}/v2.0" + +if [[ "$issuerType" != "EntraID" || "$issuer" != "$expectedIssuer" ]]; then + echo "Expected Microsoft Entra issuer ${expectedIssuer} with issuer type EntraID." >&2 + exit 1 +fi + +az ad app federated-credential create --id "$appId" --parameters "$(jq -n \ + --arg issuer "$issuer" \ + --arg subject "$subject" \ + '{name:"ado-dpi-logs-ingestion", issuer:$issuer, subject:$subject, audiences:["api://AzureADTokenExchange"]}')" +``` + +PowerShell: + +```powershell +$endpointParameters = az devops service-endpoint create ` + --service-endpoint-configuration ./dpi-service-connection.json ` + --query authorization.parameters ` + --output json | ConvertFrom-Json +$expectedIssuer = "https://login.microsoftonline.com/$tenantId/v2.0" + +if ($endpointParameters.workloadIdentityFederationIssuerType -ne "EntraID" -or + $endpointParameters.workloadIdentityFederationIssuer -ne $expectedIssuer) { + throw "Expected Microsoft Entra issuer $expectedIssuer with issuer type EntraID." +} + +@{ + name = "ado-dpi-logs-ingestion" + issuer = $endpointParameters.workloadIdentityFederationIssuer + subject = $endpointParameters.workloadIdentityFederationSubject + audiences = @("api://AzureADTokenExchange") +} | ConvertTo-Json | Set-Content ado-dpi-logs-ingestion.json + +az ad app federated-credential create --id $appId --parameters ado-dpi-logs-ingestion.json +``` + +Run DPI inside `AzureCLI@2` with `azureSubscription: dpi-logs-ingestion` and the same endpoint, rule ID, and stream variables used by GitHub Actions. + +Example `dpi-service-connection.json`: + +```json +{ + "name": "dpi-logs-ingestion", + "type": "AzureRM", + "url": "https://management.azure.com/", + "isShared": false, + "isReady": true, + "authorization": { + "scheme": "WorkloadIdentityFederation", + "parameters": { + "tenantid": "", + "serviceprincipalid": "" + } + }, + "data": { + "subscriptionId": "", + "subscriptionName": "", + "environment": "AzureCloud", + "scopeLevel": "Subscription", + "creationMode": "Manual" + }, + "serviceEndpointProjectReferences": [ + { + "projectReference": { "id": "", "name": "" }, + "name": "dpi-logs-ingestion" + } + ] +} +``` + +Example pipeline. `AzureCLI@2` signs in with the service connection so `dnx dpi` can use `DefaultAzureCredential`: + +```yaml +trigger: + - main + +pool: + vmImage: ubuntu-latest + +steps: + - task: UseDotNet@2 + inputs: + packageType: sdk + version: 10.x + + - task: AzureCLI@2 + displayName: Report NuGet inventory to Azure Monitor Logs + env: + NuGetReportSettings_Endpoint: $(DPI_LOGS_ENDPOINT) + NuGetReportSettings_RuleId: $(DPI_LOGS_RULE_ID) + inputs: + azureSubscription: dpi-logs-ingestion + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + dnx dpi --yes -- nuget "$(Build.SourcesDirectory)" --output table --buildversion "$(Build.BuildNumber)" report +``` diff --git a/build/records.cs b/build/records.cs index 3252895..e9331da 100644 --- a/build/records.cs +++ b/build/records.cs @@ -17,6 +17,9 @@ DirectoryPath OutputPath { private const string IntegrationTest = "integrationtest"; private const string Markdown = nameof(Markdown); + public FilePath SolutionPath { get; } = ProjectRoot.CombineWithFilePath("DPI.slnx"); + public FilePath ProjectPath { get; } = ProjectRoot.CombineWithFilePath("DPI/DPI.csproj"); + public FilePath TestProjectPath { get; } = ProjectRoot.CombineWithFilePath("DPI.Tests/DPI.Tests.csproj"); public DirectoryPath NuGetOutputPath { get; } = OutputPath.Combine("nuget"); public DirectoryPath BinaryOutputPath { get; } = OutputPath.Combine("bin"); public DirectoryPath IntegrationTestPath { get; } = OutputPath.Combine(IntegrationTest); diff --git a/cake.cs b/cake.cs index 81a3fa5..5465e8e 100755 --- a/cake.cs +++ b/cake.cs @@ -1,6 +1,7 @@ #!/usr/bin/env dotnet #:sdk Cake.Sdk@6.3.0 #:package xunit.v3.assert@4.0.2 +#:package System.Security.Cryptography.Pkcs@10.0.13 #:property IncludeAdditionalFiles=./build/*.cs using Xunit; @@ -33,6 +34,7 @@ var artifactsPath = context .MakeAbsolute(context.Directory("./artifacts")); + var projectRoot = context.MakeAbsolute(context.Directory("./src")); return new BuildData( version, @@ -40,19 +42,16 @@ !context.IsRunningOnWindows(), GitHubActions.IsRunningOnGitHubActions, GitHubActions.IsRunningOnGitHubActions ? GitHubActions.Environment.Workflow.Ref : null, - "src", + projectRoot, new DotNetMSBuildSettings() .SetConfiguration("Release") .SetVersion(version) - .WithProperty("PackAsTool", "true") - .WithProperty("PackageId", "DPI") .WithProperty("Copyright", $"Mattias Karlsson © {DateTime.UtcNow.Year}") - .WithProperty("ToolCommandName", "dpi") .WithProperty("Authors", "devlead") .WithProperty("Company", "devlead") .WithProperty("PackageLicenseExpression", "MIT") .WithProperty("PackageTags", "tool") - .WithProperty("PackageDescription", "Dependency Inventory .NET Tool - Inventories dependencies to Azure Log Analytics") + .WithProperty("PackageDescription", "Dependency Inventory .NET Tool - Inventories dependencies to Azure Monitor Logs") .WithProperty("RepositoryUrl", "https://github.com/devlead/DPI.git") .WithProperty("ContinuousIntegrationBuild", GitHubActions.IsRunningOnGitHubActions ? "true" : "false") .WithProperty("EmbedUntrackedSources", "true"), @@ -82,7 +81,7 @@ .Then("Restore") .Does( static (context, data) => context.DotNetRestore( - data.ProjectRoot.FullPath, + data.SolutionPath.FullPath, new DotNetRestoreSettings { MSBuildSettings = data.MSBuildSettings } @@ -91,17 +90,29 @@ .Then("Build") .Does( static (context, data) => context.DotNetBuild( - data.ProjectRoot.FullPath, + data.SolutionPath.FullPath, new DotNetBuildSettings { NoRestore = true, MSBuildSettings = data.MSBuildSettings } ) ) +.Then("Test") + .Does( + static (context, data) => context.DotNetTest( + data.TestProjectPath.FullPath, + new DotNetTestSettings { + NoRestore = true, + NoBuild = true, + Configuration = "Release", + MSBuildSettings = data.MSBuildSettings + } + ) + ) .Then("Pack") .Does( static (context, data) => context.DotNetPack( - data.ProjectRoot.FullPath, + data.ProjectPath.FullPath, new DotNetPackSettings { NoBuild = true, NoRestore = true, @@ -164,9 +175,17 @@ .AppendSwitchQuoted("--output", "table") .Append( ( - !string.IsNullOrWhiteSpace(context.EnvironmentVariable("NuGetReportSettings_SharedKey")) - && - !string.IsNullOrWhiteSpace(context.EnvironmentVariable("NuGetReportSettings_WorkspaceId")) + ( + !string.IsNullOrWhiteSpace(context.EnvironmentVariable("NuGetReportSettings_Endpoint")) + && + !string.IsNullOrWhiteSpace(context.EnvironmentVariable("NuGetReportSettings_RuleId")) + ) + || + ( + !string.IsNullOrWhiteSpace(context.EnvironmentVariable("NuGetReportSettings_SharedKey")) + && + !string.IsNullOrWhiteSpace(context.EnvironmentVariable("NuGetReportSettings_WorkspaceId")) + ) ) ? "report" : "analyze" diff --git a/deploy/azure-monitor/main.bicep b/deploy/azure-monitor/main.bicep new file mode 100644 index 0000000..d3a1417 --- /dev/null +++ b/deploy/azure-monitor/main.bicep @@ -0,0 +1,101 @@ +@description('Name of the Direct data collection rule.') +param dcrName string = 'dpi-nugetreport' + +@description('Azure region for the data collection rule. Use the same region as the Log Analytics workspace.') +param location string + +@description('Resource ID of the destination Log Analytics workspace.') +param workspaceResourceId string + +@description('Optional object ID of the sender service principal. Leave empty to skip the role assignment.') +param senderPrincipalId string = '' + +@description('Input stream name used by dpi nuget report. Distinct from the table output stream Custom-.') +param streamName string = 'Custom-NuGetReport' + +@description('Destination custom table. Migrate NuGetReport_CL from classic before deploying this DCR.') +param tableName string = 'NuGetReport_CL' + +var outputStream = 'Custom-${tableName}' +var monitoringMetricsPublisherRoleId = '3913510d-42f4-4e42-8a64-420c390055eb' +// DPI JSON uses unsuffixed names; a migrated Data Collector table keeps *_s/*_t/*_g columns. +var transformKql = ''' +source +| extend TimeGenerated = iif(isempty(tostring(timestamp)), now(), todatetime(timestamp)) +| extend sessionId_g = toguid(sessionId) +| extend timestamp_t = todatetime(timestamp) +| extend targetFramework_s = targetFramework +| extend buildVersion_s = buildVersion +| extend buildNo_s = buildNo +| extend platformFamily_s = platformFamily +| extend buildSCM_s = buildSCM +| extend buildProvider_s = buildProvider +| extend source_s = ['source'] +| extend sourceType_s = sourceType +| extend packageId_s = packageId +| extend version_s = version +| project TimeGenerated, Computer, timestamp_t, sessionId_g, targetFramework_s, buildVersion_s, buildNo_s, platformFamily_s, buildSCM_s, buildProvider_s, source_s, sourceType_s, packageId_s, version_s +''' + +resource dcr 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { + name: dcrName + location: location + kind: 'Direct' + properties: { + description: 'DPI NuGetReport Logs Ingestion API rule targeting the existing NuGetReport_CL table.' + destinations: { + logAnalytics: [ + { + name: 'dpiWorkspace' + workspaceResourceId: workspaceResourceId + } + ] + } + dataFlows: [ + { + streams: [ + streamName + ] + destinations: [ + 'dpiWorkspace' + ] + transformKql: transformKql + outputStream: outputStream + } + ] + streamDeclarations: { + '${streamName}': { + columns: [ + { name: 'timestamp', type: 'datetime' } + { name: 'sessionId', type: 'string' } + { name: 'buildProvider', type: 'string' } + { name: 'platformFamily', type: 'string' } + { name: 'buildNo', type: 'string' } + { name: 'buildSCM', type: 'string' } + { name: 'buildVersion', type: 'string' } + { name: 'sourceType', type: 'string' } + { name: 'source', type: 'string' } + { name: 'targetFramework', type: 'string' } + { name: 'packageId', type: 'string' } + { name: 'version', type: 'string' } + { name: 'Computer', type: 'string' } + ] + } + } + } +} + +resource senderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(senderPrincipalId)) { + name: guid(dcr.id, senderPrincipalId, monitoringMetricsPublisherRoleId) + scope: dcr + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', monitoringMetricsPublisherRoleId) + principalId: senderPrincipalId + principalType: 'ServicePrincipal' + } +} + +output endpoint string = dcr.properties.endpoints.logsIngestion +output ruleId string = dcr.properties.immutableId +output stream string = streamName +output dataCollectionRuleId string = dcr.id diff --git a/deploy/azure-monitor/main.bicepparam b/deploy/azure-monitor/main.bicepparam new file mode 100644 index 0000000..e1e40e4 --- /dev/null +++ b/deploy/azure-monitor/main.bicepparam @@ -0,0 +1,8 @@ +using 'main.bicep' + +param dcrName = 'dpi-nugetreport' +param location = '' +param workspaceResourceId = '/subscriptions//resourceGroups//providers/Microsoft.OperationalInsights/workspaces/' +param senderPrincipalId = '' +param streamName = 'Custom-NuGetReport' +param tableName = 'NuGetReport_CL' diff --git a/global.json b/global.json index 60ff44e..56e0a42 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "rollForward": "latestFeature", "version": "11.0.100-rc.2.26504.105" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } \ No newline at end of file diff --git a/src/DPI.Tests/Constants.cs b/src/DPI.Tests/Constants.cs new file mode 100644 index 0000000..f292587 --- /dev/null +++ b/src/DPI.Tests/Constants.cs @@ -0,0 +1,3 @@ +namespace DPI.Tests; + +public sealed class Constants; diff --git a/src/DPI.Tests/DPI.Tests.csproj b/src/DPI.Tests/DPI.Tests.csproj new file mode 100644 index 0000000..d7d7fdd --- /dev/null +++ b/src/DPI.Tests/DPI.Tests.csproj @@ -0,0 +1,45 @@ + + + + enable + enable + Exe + DPI.Tests + net8.0;net9.0;net10.0;net11.0 + latest + false + true + true + OpenSource + $([System.DateTime]::UtcNow.AddMonths(6).ToString('yyyy-MM')) + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DPI.Tests/Fixture/ReportSettingsFactory.cs b/src/DPI.Tests/Fixture/ReportSettingsFactory.cs new file mode 100644 index 0000000..7de94a9 --- /dev/null +++ b/src/DPI.Tests/Fixture/ReportSettingsFactory.cs @@ -0,0 +1,28 @@ +using Cake.Core; +using DPI.Commands.Settings.NuGet; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DPI.Tests; + +internal static class ReportSettingsFactory +{ + public static NuGetReportSettings Create( + ICakeContext context, + IHttpClientFactory httpClientFactory, + string? endpoint = null, + string? ruleId = null, + string? stream = null, + string? workspaceId = null, + string? sharedKey = null) + => new( + context, + NullLogger.Instance, + httpClientFactory) + { + Endpoint = endpoint, + RuleId = ruleId, + Stream = stream ?? NuGetReportSettings.DefaultStream, + WorkspaceId = workspaceId, + SharedKey = sharedKey + }; +} diff --git a/src/DPI.Tests/Fixture/ServiceProviderFixture.cs b/src/DPI.Tests/Fixture/ServiceProviderFixture.cs new file mode 100644 index 0000000..9c0f4da --- /dev/null +++ b/src/DPI.Tests/Fixture/ServiceProviderFixture.cs @@ -0,0 +1,22 @@ +using Cake.Bridge.DependencyInjection.Testing; +using Devlead.Testing.MockHttp; +using DPI.Commands.NuGet.Reporting; +using DPI.Commands.Settings.NuGet; +using NSubstitute; + +public static partial class ServiceProviderFixture +{ + static partial void InitServiceProvider(IServiceCollection services) + { + services + .AddCakeCoreFakes() + .AddMockHttpClient() + .AddLogging() + .AddTransient() + .AddTransient() + .AddSingleton(_ => Substitute.For()) + .AddSingleton() + .AddSingleton() + .AddSingleton(); + } +} diff --git a/src/DPI.Tests/Resources/Empty.json b/src/DPI.Tests/Resources/Empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/DPI.Tests/Resources/Empty.json @@ -0,0 +1 @@ +{} diff --git a/src/DPI.Tests/Resources/Routes.json b/src/DPI.Tests/Resources/Routes.json new file mode 100644 index 0000000..ff26b6f --- /dev/null +++ b/src/DPI.Tests/Resources/Routes.json @@ -0,0 +1,21 @@ +[ + { + "Request": { + "Methods": [ + { + "Method": "POST" + } + ], + "AbsoluteUri": "https://workspace-id.ods.opinsights.azure.com/api/logs?api-version=2016-04-01" + }, + "Responses": [ + { + "RequestHeaders": {}, + "ContentResource": "Empty.json", + "ContentType": "application/json", + "ContentHeaders": {}, + "StatusCode": 200 + } + ] + } +] diff --git a/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt new file mode 100644 index 0000000..6255a7e --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: SharedKey not specified as argument --sharedkey , nor environment variable NuGetReportSettings_SharedKey. Prefer Logs Ingestion API settings --endpoint and --rule-id . +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt new file mode 100644 index 0000000..59e0321 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Endpoint must be an absolute HTTPS URI. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt new file mode 100644 index 0000000..59e0321 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Endpoint must be an absolute HTTPS URI. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt new file mode 100644 index 0000000..1772eb0 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: RuleId not specified as argument --rule-id , nor environment variable NuGetReportSettings_RuleId +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt new file mode 100644 index 0000000..31434a9 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Specify either Logs Ingestion API settings (--endpoint and --rule-id) or deprecated Data Collector API settings (--workspace and --sharedkey), not both. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt new file mode 100644 index 0000000..f6d9b3c --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt @@ -0,0 +1,5 @@ +{ + Successful: true, + Message: null, + ReportMode: DataCollector +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt new file mode 100644 index 0000000..685b7d8 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt @@ -0,0 +1,6 @@ +{ + Successful: true, + Message: null, + ReportMode: LogsIngestion, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt new file mode 100644 index 0000000..685b7d8 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt @@ -0,0 +1,6 @@ +{ + Successful: true, + Message: null, + ReportMode: LogsIngestion, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt new file mode 100644 index 0000000..28f3cb7 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt @@ -0,0 +1,15 @@ +{ + sessionId: Guid_1, + buildProvider: GitHubActions, + platformFamily: Windows, + buildNo: 42, + buildSCM: abcdef, + buildVersion: 2026.10.16.1, + sourceType: CSProj, + source: src/DPI/DPI.csproj, + targetFramework: net8.0, + packageId: Azure.Identity, + version: 1.21.0, + timestamp: {Scrubbed}, + Computer: {Scrubbed} +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt new file mode 100644 index 0000000..592cf86 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt @@ -0,0 +1,3 @@ +{ + Prefix: SharedKey workspace-id: +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt new file mode 100644 index 0000000..2c61b84 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt @@ -0,0 +1,4 @@ +{ + WorkspaceId: workspace-id, + LogType: NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt new file mode 100644 index 0000000..f8f0f59 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Reporter: DataCollectorReporter, + SameAsDataCollector: true +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt new file mode 100644 index 0000000..22a6f3e --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Reporter: LogsIngestionReporter, + SameAsLogsIngestion: true +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt new file mode 100644 index 0000000..b0ff430 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt @@ -0,0 +1,4 @@ +{ + uploaded: false, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt new file mode 100644 index 0000000..8ef0746 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt @@ -0,0 +1,5 @@ +{ + RuleId: dcr-123, + Stream: Custom-NuGetReport, + UploadedCount: 2 +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt new file mode 100644 index 0000000..5520970 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net10.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Message: Failed to upload 2 records to Azure Monitor Logs Ingestion API (status 403)., + Inner: RequestFailedException +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt new file mode 100644 index 0000000..6255a7e --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: SharedKey not specified as argument --sharedkey , nor environment variable NuGetReportSettings_SharedKey. Prefer Logs Ingestion API settings --endpoint and --rule-id . +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt new file mode 100644 index 0000000..59e0321 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Endpoint must be an absolute HTTPS URI. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt new file mode 100644 index 0000000..59e0321 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Endpoint must be an absolute HTTPS URI. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt new file mode 100644 index 0000000..1772eb0 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: RuleId not specified as argument --rule-id , nor environment variable NuGetReportSettings_RuleId +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt new file mode 100644 index 0000000..31434a9 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Specify either Logs Ingestion API settings (--endpoint and --rule-id) or deprecated Data Collector API settings (--workspace and --sharedkey), not both. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt new file mode 100644 index 0000000..f6d9b3c --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt @@ -0,0 +1,5 @@ +{ + Successful: true, + Message: null, + ReportMode: DataCollector +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt new file mode 100644 index 0000000..685b7d8 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt @@ -0,0 +1,6 @@ +{ + Successful: true, + Message: null, + ReportMode: LogsIngestion, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt new file mode 100644 index 0000000..685b7d8 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt @@ -0,0 +1,6 @@ +{ + Successful: true, + Message: null, + ReportMode: LogsIngestion, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt new file mode 100644 index 0000000..28f3cb7 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt @@ -0,0 +1,15 @@ +{ + sessionId: Guid_1, + buildProvider: GitHubActions, + platformFamily: Windows, + buildNo: 42, + buildSCM: abcdef, + buildVersion: 2026.10.16.1, + sourceType: CSProj, + source: src/DPI/DPI.csproj, + targetFramework: net8.0, + packageId: Azure.Identity, + version: 1.21.0, + timestamp: {Scrubbed}, + Computer: {Scrubbed} +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt new file mode 100644 index 0000000..592cf86 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt @@ -0,0 +1,3 @@ +{ + Prefix: SharedKey workspace-id: +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt new file mode 100644 index 0000000..2c61b84 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt @@ -0,0 +1,4 @@ +{ + WorkspaceId: workspace-id, + LogType: NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt new file mode 100644 index 0000000..f8f0f59 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Reporter: DataCollectorReporter, + SameAsDataCollector: true +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt new file mode 100644 index 0000000..22a6f3e --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Reporter: LogsIngestionReporter, + SameAsLogsIngestion: true +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt new file mode 100644 index 0000000..b0ff430 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt @@ -0,0 +1,4 @@ +{ + uploaded: false, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt new file mode 100644 index 0000000..8ef0746 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt @@ -0,0 +1,5 @@ +{ + RuleId: dcr-123, + Stream: Custom-NuGetReport, + UploadedCount: 2 +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt new file mode 100644 index 0000000..5520970 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net11.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Message: Failed to upload 2 records to Azure Monitor Logs Ingestion API (status 403)., + Inner: RequestFailedException +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt new file mode 100644 index 0000000..6255a7e --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: SharedKey not specified as argument --sharedkey , nor environment variable NuGetReportSettings_SharedKey. Prefer Logs Ingestion API settings --endpoint and --rule-id . +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt new file mode 100644 index 0000000..59e0321 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Endpoint must be an absolute HTTPS URI. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt new file mode 100644 index 0000000..59e0321 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Endpoint must be an absolute HTTPS URI. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt new file mode 100644 index 0000000..1772eb0 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: RuleId not specified as argument --rule-id , nor environment variable NuGetReportSettings_RuleId +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt new file mode 100644 index 0000000..31434a9 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Specify either Logs Ingestion API settings (--endpoint and --rule-id) or deprecated Data Collector API settings (--workspace and --sharedkey), not both. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt new file mode 100644 index 0000000..f6d9b3c --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt @@ -0,0 +1,5 @@ +{ + Successful: true, + Message: null, + ReportMode: DataCollector +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt new file mode 100644 index 0000000..685b7d8 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt @@ -0,0 +1,6 @@ +{ + Successful: true, + Message: null, + ReportMode: LogsIngestion, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt new file mode 100644 index 0000000..685b7d8 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt @@ -0,0 +1,6 @@ +{ + Successful: true, + Message: null, + ReportMode: LogsIngestion, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt new file mode 100644 index 0000000..28f3cb7 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt @@ -0,0 +1,15 @@ +{ + sessionId: Guid_1, + buildProvider: GitHubActions, + platformFamily: Windows, + buildNo: 42, + buildSCM: abcdef, + buildVersion: 2026.10.16.1, + sourceType: CSProj, + source: src/DPI/DPI.csproj, + targetFramework: net8.0, + packageId: Azure.Identity, + version: 1.21.0, + timestamp: {Scrubbed}, + Computer: {Scrubbed} +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt new file mode 100644 index 0000000..592cf86 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt @@ -0,0 +1,3 @@ +{ + Prefix: SharedKey workspace-id: +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt new file mode 100644 index 0000000..2c61b84 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt @@ -0,0 +1,4 @@ +{ + WorkspaceId: workspace-id, + LogType: NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt new file mode 100644 index 0000000..f8f0f59 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Reporter: DataCollectorReporter, + SameAsDataCollector: true +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt new file mode 100644 index 0000000..22a6f3e --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Reporter: LogsIngestionReporter, + SameAsLogsIngestion: true +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt new file mode 100644 index 0000000..b0ff430 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt @@ -0,0 +1,4 @@ +{ + uploaded: false, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt new file mode 100644 index 0000000..8ef0746 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt @@ -0,0 +1,5 @@ +{ + RuleId: dcr-123, + Stream: Custom-NuGetReport, + UploadedCount: 2 +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt new file mode 100644 index 0000000..5520970 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net8.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Message: Failed to upload 2 records to Azure Monitor Logs Ingestion API (status 403)., + Inner: RequestFailedException +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt new file mode 100644 index 0000000..6255a7e --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenDataCollectorSettingsArePartial/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: SharedKey not specified as argument --sharedkey , nor environment variable NuGetReportSettings_SharedKey. Prefer Logs Ingestion API settings --endpoint and --rule-id . +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt new file mode 100644 index 0000000..59e0321 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsHttp/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Endpoint must be an absolute HTTPS URI. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt new file mode 100644 index 0000000..59e0321 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenEndpointIsNotAbsolute/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Endpoint must be an absolute HTTPS URI. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt new file mode 100644 index 0000000..1772eb0 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenLogsIngestionSettingsArePartial/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: RuleId not specified as argument --rule-id , nor environment variable NuGetReportSettings_RuleId +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt new file mode 100644 index 0000000..31434a9 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldFail_WhenModesAreMixed/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Successful: false, + Message: Specify either Logs Ingestion API settings (--endpoint and --rule-id) or deprecated Data Collector API settings (--workspace and --sharedkey), not both. +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt new file mode 100644 index 0000000..f6d9b3c --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet/_._.verified.txt @@ -0,0 +1,5 @@ +{ + Successful: true, + Message: null, + ReportMode: DataCollector +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt new file mode 100644 index 0000000..685b7d8 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet/_._.verified.txt @@ -0,0 +1,6 @@ +{ + Successful: true, + Message: null, + ReportMode: LogsIngestion, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt new file mode 100644 index 0000000..685b7d8 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/NuGetReportSettingsTests/Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace/_._.verified.txt @@ -0,0 +1,6 @@ +{ + Successful: true, + Message: null, + ReportMode: LogsIngestion, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt new file mode 100644 index 0000000..28f3cb7 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/PackageReferenceSerializationTests/SerializesExpectedLogAnalyticsFieldNames/_._.verified.txt @@ -0,0 +1,15 @@ +{ + sessionId: Guid_1, + buildProvider: GitHubActions, + platformFamily: Windows, + buildNo: 42, + buildSCM: abcdef, + buildVersion: 2026.10.16.1, + sourceType: CSProj, + source: src/DPI/DPI.csproj, + targetFramework: net8.0, + packageId: Azure.Identity, + version: 1.21.0, + timestamp: {Scrubbed}, + Computer: {Scrubbed} +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt new file mode 100644 index 0000000..592cf86 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader/_._.verified.txt @@ -0,0 +1,3 @@ +{ + Prefix: SharedKey workspace-id: +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt new file mode 100644 index 0000000..2c61b84 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/DataCollectorReporter_ShouldPostEachRecordAndWarn/_._.verified.txt @@ -0,0 +1,4 @@ +{ + WorkspaceId: workspace-id, + LogType: NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt new file mode 100644 index 0000000..f8f0f59 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Reporter: DataCollectorReporter, + SameAsDataCollector: true +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt new file mode 100644 index 0000000..22a6f3e --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Reporter: LogsIngestionReporter, + SameAsLogsIngestion: true +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt new file mode 100644 index 0000000..b0ff430 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords/_._.verified.txt @@ -0,0 +1,4 @@ +{ + uploaded: false, + Stream: Custom-NuGetReport +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt new file mode 100644 index 0000000..8ef0746 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule/_._.verified.txt @@ -0,0 +1,5 @@ +{ + RuleId: dcr-123, + Stream: Custom-NuGetReport, + UploadedCount: 2 +} \ No newline at end of file diff --git a/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt new file mode 100644 index 0000000..5520970 --- /dev/null +++ b/src/DPI.Tests/Snapshots/net9.0/ReportingTests/LogsIngestionReporter_ShouldWrapFailedUploads/_._.verified.txt @@ -0,0 +1,4 @@ +{ + Message: Failed to upload 2 records to Azure Monitor Logs Ingestion API (status 403)., + Inner: RequestFailedException +} \ No newline at end of file diff --git a/src/DPI.Tests/Unit/NuGetReportSettingsTests.cs b/src/DPI.Tests/Unit/NuGetReportSettingsTests.cs new file mode 100644 index 0000000..fffb653 --- /dev/null +++ b/src/DPI.Tests/Unit/NuGetReportSettingsTests.cs @@ -0,0 +1,189 @@ +using Cake.Core; +using DPI.Commands.Settings.NuGet; + +namespace DPI.Tests.Unit; + +public sealed class NuGetReportSettingsTests +{ + [Fact] + public async Task Validate_ShouldSelectLogsIngestion_WhenEndpointAndRuleIdAreSet() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "https://example.ingest.monitor.azure.com", + ruleId: "dcr-123"); + + // When + var result = settings.Validate(); + + // Then + await Verify(new + { + result.Successful, + result.Message, + settings.ReportMode, + settings.Stream + }); + } + + [Fact] + public async Task Validate_ShouldSelectDataCollector_WhenWorkspaceAndSharedKeyAreSet() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + workspaceId: "workspace-id", + sharedKey: Convert.ToBase64String("shared-key"u8.ToArray())); + + // When + var result = settings.Validate(); + + // Then + await Verify(new + { + result.Successful, + result.Message, + settings.ReportMode + }); + } + + [Fact] + public async Task Validate_ShouldFail_WhenModesAreMixed() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "https://example.ingest.monitor.azure.com", + ruleId: "dcr-123", + workspaceId: "workspace-id", + sharedKey: Convert.ToBase64String("shared-key"u8.ToArray())); + + // When + var result = settings.Validate(); + + // Then + await Verify(new + { + result.Successful, + result.Message + }); + } + + [Fact] + public async Task Validate_ShouldUseDefaultStream_WhenStreamIsWhitespace() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "https://example.ingest.monitor.azure.com", + ruleId: "dcr-123", + stream: " "); + + // When + var result = settings.Validate(); + + // Then + await Verify(new + { + result.Successful, + result.Message, + settings.ReportMode, + settings.Stream + }); + } + + [Fact] + public async Task Validate_ShouldFail_WhenLogsIngestionSettingsArePartial() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "https://example.ingest.monitor.azure.com"); + + // When + var result = settings.Validate(); + + // Then + await Verify(new + { + result.Successful, + result.Message + }); + } + + [Fact] + public async Task Validate_ShouldFail_WhenEndpointIsHttp() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "http://example.ingest.monitor.azure.com", + ruleId: "dcr-123"); + + // When + var result = settings.Validate(); + + // Then + await Verify(new + { + result.Successful, + result.Message + }); + } + + [Fact] + public async Task Validate_ShouldFail_WhenEndpointIsNotAbsolute() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "example.ingest.monitor.azure.com", + ruleId: "dcr-123"); + + // When + var result = settings.Validate(); + + // Then + await Verify(new + { + result.Successful, + result.Message + }); + } + + [Fact] + public async Task Validate_ShouldFail_WhenDataCollectorSettingsArePartial() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + workspaceId: "workspace-id"); + + // When + var result = settings.Validate(); + + // Then + await Verify(new + { + result.Successful, + result.Message + }); + } +} diff --git a/src/DPI.Tests/Unit/PackageReferenceSerializationTests.cs b/src/DPI.Tests/Unit/PackageReferenceSerializationTests.cs new file mode 100644 index 0000000..5fccdce --- /dev/null +++ b/src/DPI.Tests/Unit/PackageReferenceSerializationTests.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using Cake.Common.Build; +using Cake.Core; +using Cake.Core.IO; +using DPI.Models.NuGet; + +namespace DPI.Tests.Unit; + +public sealed class PackageReferenceSerializationTests +{ + [Fact] + public async Task SerializesExpectedLogAnalyticsFieldNames() + { + // Given + var package = new PackageReference( + SessionId: Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + BuildProvider: BuildProvider.GitHubActions, + PlatformFamily: PlatformFamily.Windows, + BuildNo: "42", + BuildSCM: "abcdef", + BuildVersion: "2026.10.16.1", + SourceType: NuGetSourceType.CSProj, + Source: FilePath.FromString("src/DPI/DPI.csproj"), + TargetFramework: "net8.0", + PackageId: "Azure.Identity", + Version: "1.21.0" + ); + + // When + var json = JsonSerializer.Serialize(package); + + // Then + await VerifyJson(json); + } +} diff --git a/src/DPI.Tests/Unit/ReportingTests.cs b/src/DPI.Tests/Unit/ReportingTests.cs new file mode 100644 index 0000000..8664063 --- /dev/null +++ b/src/DPI.Tests/Unit/ReportingTests.cs @@ -0,0 +1,242 @@ +using Azure; +using Azure.Monitor.Ingestion; +using Cake.Common.Build; +using Cake.Core; +using Cake.Core.IO; +using DPI.Commands.NuGet.Reporting; +using DPI.Commands.Settings.NuGet; +using DPI.Models.NuGet; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace DPI.Tests.Unit; + +public sealed class ReportingTests +{ + [Fact] + public async Task Factory_ShouldReturnLogsIngestionReporter_WhenModeIsLogsIngestion() + { + // Given + var (factory, logsIngestionReporter, context, httpClientFactory) = ServiceProviderFixture + .GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "https://example.ingest.monitor.azure.com", + ruleId: "dcr-123"); + settings.Validate(); + + // When + var reporter = factory.Create(settings); + + // Then + await Verify(new + { + Reporter = reporter.GetType().Name, + SameAsLogsIngestion = ReferenceEquals(reporter, logsIngestionReporter) + }); + } + + [Fact] + public async Task Factory_ShouldReturnDataCollectorReporter_WhenModeIsDataCollector() + { + // Given + var (factory, dataCollectorReporter, context, httpClientFactory) = ServiceProviderFixture + .GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + workspaceId: "workspace-id", + sharedKey: Convert.ToBase64String("shared-key"u8.ToArray())); + settings.Validate(); + + // When + var reporter = factory.Create(settings); + + // Then + await Verify(new + { + Reporter = reporter.GetType().Name, + SameAsDataCollector = ReferenceEquals(reporter, dataCollectorReporter) + }); + } + + [Fact] + public async Task LogsIngestionReporter_ShouldUploadRecordsToConfiguredRule() + { + // Given + var (reporter, uploader, context, httpClientFactory) = ServiceProviderFixture + .GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "https://example.ingest.monitor.azure.com", + ruleId: "dcr-123"); + settings.Validate(); + var records = CreateRecords(); + IReadOnlyList? uploaded = null; + uploader.UploadAsync( + settings, + Arg.Do>(value => uploaded = value), + Arg.Any(), + Arg.Any() + ).Returns(Task.CompletedTask); + + // When + await reporter.ReportAsync(settings, records, TestContext.Current.CancellationToken); + + // Then + await Verify(new + { + settings.RuleId, + settings.Stream, + UploadedCount = uploaded?.Count + }); + } + + [Fact] + public async Task LogsIngestionReporter_ShouldSkipUpload_WhenThereAreNoRecords() + { + // Given + var (reporter, uploader, context, httpClientFactory) = ServiceProviderFixture + .GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "https://example.ingest.monitor.azure.com", + ruleId: "dcr-123"); + settings.Validate(); + var uploaded = false; + uploader.UploadAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any() + ).Returns(_ => + { + uploaded = true; + return Task.CompletedTask; + }); + + // When + await reporter.ReportAsync(settings, Array.Empty(), TestContext.Current.CancellationToken); + + // Then + await Verify(new + { + uploaded, + settings.Stream + }); + } + + [Fact] + public async Task LogsIngestionReporter_ShouldWrapFailedUploads() + { + // Given + var (reporter, uploader, context, httpClientFactory) = ServiceProviderFixture + .GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + endpoint: "https://example.ingest.monitor.azure.com", + ruleId: "dcr-123"); + settings.Validate(); + uploader.UploadAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any() + ).ThrowsAsync(new RequestFailedException(403, "Forbidden")); + + // When + var exception = await Assert.ThrowsAsync( + () => reporter.ReportAsync(settings, CreateRecords(), TestContext.Current.CancellationToken) + ); + + // Then + await Verify(new + { + exception.Message, + Inner = exception.InnerException?.GetType().Name + }); + } + + [Fact] + public async Task DataCollectorReporter_ShouldPostEachRecordAndWarn() + { + // Given + var (reporter, context, httpClientFactory) = ServiceProviderFixture + .GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + workspaceId: "workspace-id", + sharedKey: Convert.ToBase64String("shared-key"u8.ToArray())); + settings.Validate(); + + // When + await reporter.ReportAsync(settings, CreateRecords(), TestContext.Current.CancellationToken); + + // Then + await Verify(new + { + settings.WorkspaceId, + settings.LogType + }); + } + + [Fact] + public async Task DataCollectorReporter_GetSignature_ShouldUseSharedKeyHeader() + { + // Given + var (context, httpClientFactory) = ServiceProviderFixture.GetRequiredService(); + var settings = ReportSettingsFactory.Create( + context, + httpClientFactory, + workspaceId: "workspace-id", + sharedKey: Convert.ToBase64String("shared-key"u8.ToArray())); + + // When + var signature = DataCollectorReporter.GetSignature( + "POST", + 12, + "Thu, 16 Oct 2026 12:00:00 GMT", + "/api/logs", + settings); + + // Then + await Verify(new + { + Prefix = signature[..(signature.IndexOf(':', StringComparison.Ordinal) + 1)] + }); + } + + private static PackageReference[] CreateRecords() + => + [ + new( + Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + BuildProvider.Local, + PlatformFamily.Windows, + "1", + "scm", + "1.0.0", + NuGetSourceType.CSProj, + FilePath.FromString("src/A.csproj"), + "net8.0", + "Package.A", + "1.0.0"), + new( + Guid.Parse("bbbbbbbb-cccc-dddd-eeee-ffffffffffff"), + BuildProvider.Local, + PlatformFamily.Windows, + "1", + "scm", + "1.0.0", + NuGetSourceType.CSProj, + FilePath.FromString("src/B.csproj"), + "net8.0", + "Package.B", + "2.0.0") + ]; +} diff --git a/src/DPI.Tests/VerifyChecksTests.cs b/src/DPI.Tests/VerifyChecksTests.cs new file mode 100644 index 0000000..b104e58 --- /dev/null +++ b/src/DPI.Tests/VerifyChecksTests.cs @@ -0,0 +1,6 @@ +public class VerifyChecksTests +{ + [Fact] + public Task Run() => + VerifyChecks.Run(); +} diff --git a/src/DPI.Tests/VerifyConfig.cs b/src/DPI.Tests/VerifyConfig.cs new file mode 100644 index 0000000..b4c423c --- /dev/null +++ b/src/DPI.Tests/VerifyConfig.cs @@ -0,0 +1,35 @@ +using System.Runtime.CompilerServices; + +namespace DPI.Tests; + +public static class VerifyConfig +{ + [ModuleInitializer] + public static void Init() + { + VerifierSettings.InitializePlugins(); + VerifierSettings.DontIgnoreEmptyCollections(); + VerifierSettings.IgnoreStackTrace(); + Verifier.DerivePathInfo((_, projectDirectory, type, method) => + new PathInfo( + directory: System.IO.Path.Combine(projectDirectory, "Snapshots", TargetFrameworkMoniker, type.Name, method.Name), + typeName: "_", + methodName: "_")); + VerifierSettings.AddExtraSettings(settings => + { + settings.DefaultValueHandling = Argon.DefaultValueHandling.Include; + }); + VerifierSettings.ScrubMember("timestamp"); + VerifierSettings.ScrubMember("Computer"); + } + +#if NET11_0 + private const string TargetFrameworkMoniker = "net11.0"; +#elif NET10_0 + private const string TargetFrameworkMoniker = "net10.0"; +#elif NET9_0 + private const string TargetFrameworkMoniker = "net9.0"; +#else + private const string TargetFrameworkMoniker = "net8.0"; +#endif +} diff --git a/src/DPI.sln b/src/DPI.sln deleted file mode 100644 index 7927e94..0000000 --- a/src/DPI.sln +++ /dev/null @@ -1,34 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 -MinimumVisualStudioVersion = 15.0.26124.0 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DPI", "DPI\DPI.csproj", "{A8C64764-30BA-4267-B824-093FB244129D}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {A8C64764-30BA-4267-B824-093FB244129D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Debug|x64.ActiveCfg = Debug|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Debug|x64.Build.0 = Debug|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Debug|x86.ActiveCfg = Debug|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Debug|x86.Build.0 = Debug|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Release|Any CPU.Build.0 = Release|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Release|x64.ActiveCfg = Release|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Release|x64.Build.0 = Release|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Release|x86.ActiveCfg = Release|Any CPU - {A8C64764-30BA-4267-B824-093FB244129D}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/src/DPI.slnx b/src/DPI.slnx new file mode 100644 index 0000000..f540642 --- /dev/null +++ b/src/DPI.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/DPI/Commands/NuGet/NuGetReportCommand.cs b/src/DPI/Commands/NuGet/NuGetReportCommand.cs index 0ac3c5e..7df4a66 100644 --- a/src/DPI/Commands/NuGet/NuGetReportCommand.cs +++ b/src/DPI/Commands/NuGet/NuGetReportCommand.cs @@ -1,25 +1,15 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; +using DPI.Commands.NuGet.Reporting; using DPI.Commands.Settings.NuGet; -using DPI.Models; using DPI.OutputConverters; using DPI.Parsers.NuGet; -using Microsoft.Extensions.Logging; -using static System.FormattableString; namespace DPI.Commands.NuGet { // ReSharper disable once ClassNeverInstantiated.Global public class NuGetReportCommand : NuGetAnalyzeCommand { + private LogAnalyticsReporterFactory ReporterFactory { get; } + protected override async Task OutputResult(NuGetReportSettings settings, IEnumerable results) { var enumeratedResult = results as T[] ?? results.ToArray(); @@ -30,75 +20,17 @@ await Task.WhenAll( ); } - protected virtual async Task ReportResult(NuGetReportSettings settings, IEnumerable results) - { - var dateString = DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture); - var uri = Invariant( - $"https://{Uri.EscapeDataString(settings.WorkspaceId)}.ods.opinsights.azure.com/api/logs?api-version={Uri.EscapeDataString(settings.ApiVersion)}" - ); - - using var client = settings.HttpClientFactory.CreateClient(nameof(NuGetReportCommand)); - client.DefaultRequestHeaders.Add("Log-Type", settings.LogType); - client.DefaultRequestHeaders.Add("x-ms-date", dateString); - - foreach (var logValue in results) - { - await PostLog(settings, logValue, dateString, uri, client); - } - - settings.Logger.LogInformation("Reported to Azure Log Analytics Workspace"); - } - - private static async Task PostLog(NuGetReportSettings settings, T logValue, string dateString, string uri, - HttpClient client) - { - var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(logValue); - var signature = GetSignature( - "POST", - jsonBytes.Length, - dateString, - "/api/logs", - settings - ); - - using var request = new HttpRequestMessage(HttpMethod.Post, uri) - { - Content = new ByteArrayContent(jsonBytes) - { - Headers = - { - ContentType = new MediaTypeHeaderValue(settings.ContentType), - ContentLength = jsonBytes.Length - } - }, - Headers = {{"Authorization", signature}} - }; - - var response = await client.SendAsync(request); - - response.EnsureSuccessStatusCode(); - } - - private static string GetSignature( - string method, - int contentLength, - string date, - string resource, - NuGetReportSettings settings - ) - { - var message = Invariant($"{method}\n{contentLength}\n{settings.ContentType}\nx-ms-date:{date}\n{resource}"); - var bytes = Encoding.UTF8.GetBytes(message); - using var hmacSha256 = new HMACSHA256(Convert.FromBase64String(settings.SharedKey)); - return Invariant($"SharedKey {settings.WorkspaceId}:{Convert.ToBase64String(hmacSha256.ComputeHash(bytes))}"); - } + protected virtual Task ReportResult(NuGetReportSettings settings, IEnumerable results) + => ReporterFactory.Create(settings).ReportAsync(settings, results as IReadOnlyList ?? results.ToArray()); public NuGetReportCommand( NuGetParsers nuGetParsers, - ILookup outputConverterLookup + ILookup outputConverterLookup, + LogAnalyticsReporterFactory reporterFactory ) : base(nuGetParsers, outputConverterLookup) { + ReporterFactory = reporterFactory; } } -} \ No newline at end of file +} diff --git a/src/DPI/Commands/NuGet/Reporting/DataCollectorReporter.cs b/src/DPI/Commands/NuGet/Reporting/DataCollectorReporter.cs new file mode 100644 index 0000000..539e473 --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/DataCollectorReporter.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using DPI.Commands.Settings.NuGet; +using Microsoft.Extensions.Logging; +using static System.FormattableString; + +namespace DPI.Commands.NuGet.Reporting; + +public sealed class DataCollectorReporter : ILogAnalyticsReporter +{ + public async Task ReportAsync( + NuGetReportSettings settings, + IReadOnlyList results, + CancellationToken cancellationToken = default) + { + settings.Logger.LogWarning( + "HTTP Data Collector API is deprecated. Migrate to Logs Ingestion API settings (--endpoint and --rule-id)." + ); + + var dateString = DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture); + var uri = Invariant( + $"https://{Uri.EscapeDataString(settings.WorkspaceId!)}.ods.opinsights.azure.com/api/logs?api-version={Uri.EscapeDataString(settings.ApiVersion)}" + ); + + using var client = settings.HttpClientFactory.CreateClient(nameof(NuGetReportCommand)); + client.DefaultRequestHeaders.Add("Log-Type", settings.LogType); + client.DefaultRequestHeaders.Add("x-ms-date", dateString); + + foreach (var logValue in results) + { + cancellationToken.ThrowIfCancellationRequested(); + await PostLog(settings, logValue, dateString, uri, client, cancellationToken).ConfigureAwait(false); + } + + settings.Logger.LogInformation("Reported to Azure Log Analytics Workspace"); + } + + private static async Task PostLog( + NuGetReportSettings settings, + T logValue, + string dateString, + string uri, + HttpClient client, + CancellationToken cancellationToken) + { + var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(logValue); + var signature = GetSignature( + "POST", + jsonBytes.Length, + dateString, + "/api/logs", + settings + ); + + using var request = new HttpRequestMessage(HttpMethod.Post, uri) + { + Content = new ByteArrayContent(jsonBytes) + { + Headers = + { + ContentType = new MediaTypeHeaderValue(settings.ContentType), + ContentLength = jsonBytes.Length + } + }, + Headers = {{"Authorization", signature}} + }; + + var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + } + + public static string GetSignature( + string method, + int contentLength, + string date, + string resource, + NuGetReportSettings settings + ) + { + var message = Invariant($"{method}\n{contentLength}\n{settings.ContentType}\nx-ms-date:{date}\n{resource}"); + var bytes = Encoding.UTF8.GetBytes(message); + using var hmacSha256 = new HMACSHA256(Convert.FromBase64String(settings.SharedKey!)); + return Invariant($"SharedKey {settings.WorkspaceId}:{Convert.ToBase64String(hmacSha256.ComputeHash(bytes))}"); + } +} diff --git a/src/DPI/Commands/NuGet/Reporting/DefaultLogsIngestionClientFactory.cs b/src/DPI/Commands/NuGet/Reporting/DefaultLogsIngestionClientFactory.cs new file mode 100644 index 0000000..ee1573e --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/DefaultLogsIngestionClientFactory.cs @@ -0,0 +1,11 @@ +using Azure.Identity; +using Azure.Monitor.Ingestion; +using DPI.Commands.Settings.NuGet; + +namespace DPI.Commands.NuGet.Reporting; + +public sealed class DefaultLogsIngestionClientFactory : ILogsIngestionClientFactory +{ + public LogsIngestionClient Create(NuGetReportSettings settings) + => new(new Uri(settings.Endpoint!), new DefaultAzureCredential()); +} diff --git a/src/DPI/Commands/NuGet/Reporting/ILogAnalyticsReporter.cs b/src/DPI/Commands/NuGet/Reporting/ILogAnalyticsReporter.cs new file mode 100644 index 0000000..87aaafb --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/ILogAnalyticsReporter.cs @@ -0,0 +1,11 @@ +using DPI.Commands.Settings.NuGet; + +namespace DPI.Commands.NuGet.Reporting; + +public interface ILogAnalyticsReporter +{ + Task ReportAsync( + NuGetReportSettings settings, + IReadOnlyList results, + CancellationToken cancellationToken = default); +} diff --git a/src/DPI/Commands/NuGet/Reporting/ILogsIngestionClientFactory.cs b/src/DPI/Commands/NuGet/Reporting/ILogsIngestionClientFactory.cs new file mode 100644 index 0000000..8b8ae34 --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/ILogsIngestionClientFactory.cs @@ -0,0 +1,9 @@ +using Azure.Monitor.Ingestion; +using DPI.Commands.Settings.NuGet; + +namespace DPI.Commands.NuGet.Reporting; + +public interface ILogsIngestionClientFactory +{ + LogsIngestionClient Create(NuGetReportSettings settings); +} diff --git a/src/DPI/Commands/NuGet/Reporting/ILogsIngestionUploader.cs b/src/DPI/Commands/NuGet/Reporting/ILogsIngestionUploader.cs new file mode 100644 index 0000000..6f0d6d3 --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/ILogsIngestionUploader.cs @@ -0,0 +1,13 @@ +using Azure.Monitor.Ingestion; +using DPI.Commands.Settings.NuGet; + +namespace DPI.Commands.NuGet.Reporting; + +public interface ILogsIngestionUploader +{ + Task UploadAsync( + NuGetReportSettings settings, + IReadOnlyList records, + LogsUploadOptions options, + CancellationToken cancellationToken = default); +} diff --git a/src/DPI/Commands/NuGet/Reporting/LogAnalyticsReportMode.cs b/src/DPI/Commands/NuGet/Reporting/LogAnalyticsReportMode.cs new file mode 100644 index 0000000..1403dd3 --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/LogAnalyticsReportMode.cs @@ -0,0 +1,7 @@ +namespace DPI.Commands.NuGet.Reporting; + +public enum LogAnalyticsReportMode +{ + LogsIngestion, + DataCollector +} diff --git a/src/DPI/Commands/NuGet/Reporting/LogAnalyticsReporterFactory.cs b/src/DPI/Commands/NuGet/Reporting/LogAnalyticsReporterFactory.cs new file mode 100644 index 0000000..c4b8681 --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/LogAnalyticsReporterFactory.cs @@ -0,0 +1,16 @@ +using DPI.Commands.Settings.NuGet; + +namespace DPI.Commands.NuGet.Reporting; + +public sealed class LogAnalyticsReporterFactory( + LogsIngestionReporter logsIngestionReporter, + DataCollectorReporter dataCollectorReporter) +{ + public ILogAnalyticsReporter Create(NuGetReportSettings settings) + => settings.ReportMode switch + { + LogAnalyticsReportMode.LogsIngestion => logsIngestionReporter, + LogAnalyticsReportMode.DataCollector => dataCollectorReporter, + _ => throw new InvalidOperationException($"Unsupported report mode '{settings.ReportMode}'.") + }; +} diff --git a/src/DPI/Commands/NuGet/Reporting/LogsIngestionReporter.cs b/src/DPI/Commands/NuGet/Reporting/LogsIngestionReporter.cs new file mode 100644 index 0000000..4acaa28 --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/LogsIngestionReporter.cs @@ -0,0 +1,51 @@ +using Azure; +using Azure.Monitor.Ingestion; +using DPI.Commands.Settings.NuGet; +using Microsoft.Extensions.Logging; + +namespace DPI.Commands.NuGet.Reporting; + +public sealed class LogsIngestionReporter(ILogsIngestionUploader uploader) : ILogAnalyticsReporter +{ + public async Task ReportAsync( + NuGetReportSettings settings, + IReadOnlyList results, + CancellationToken cancellationToken = default) + { + var options = new LogsUploadOptions + { + MaxConcurrency = 4 + }; + + if (results.Count == 0) + { + settings.Logger.LogInformation( + "Reported {Count} records to Azure Monitor Logs Ingestion API", + results.Count + ); + return; + } + + try + { + await uploader.UploadAsync( + settings, + results, + options, + cancellationToken + ).ConfigureAwait(false); + } + catch (RequestFailedException exception) + { + throw new InvalidOperationException( + $"Failed to upload {results.Count} records to Azure Monitor Logs Ingestion API (status {exception.Status}).", + exception + ); + } + + settings.Logger.LogInformation( + "Reported {Count} records to Azure Monitor Logs Ingestion API", + results.Count + ); + } +} diff --git a/src/DPI/Commands/NuGet/Reporting/LogsIngestionUploader.cs b/src/DPI/Commands/NuGet/Reporting/LogsIngestionUploader.cs new file mode 100644 index 0000000..b51f6eb --- /dev/null +++ b/src/DPI/Commands/NuGet/Reporting/LogsIngestionUploader.cs @@ -0,0 +1,19 @@ +using Azure.Monitor.Ingestion; +using DPI.Commands.Settings.NuGet; + +namespace DPI.Commands.NuGet.Reporting; + +public sealed class LogsIngestionUploader(ILogsIngestionClientFactory clientFactory) : ILogsIngestionUploader +{ + public Task UploadAsync( + NuGetReportSettings settings, + IReadOnlyList records, + LogsUploadOptions options, + CancellationToken cancellationToken = default) + => clientFactory.Create(settings).UploadAsync( + settings.RuleId!, + settings.Stream, + records, + options, + cancellationToken); +} diff --git a/src/DPI/Commands/Settings/NuGet/NuGetReportSettings.cs b/src/DPI/Commands/Settings/NuGet/NuGetReportSettings.cs index fffd4a6..32368cf 100644 --- a/src/DPI/Commands/Settings/NuGet/NuGetReportSettings.cs +++ b/src/DPI/Commands/Settings/NuGet/NuGetReportSettings.cs @@ -1,7 +1,6 @@ -using System; using System.ComponentModel; -using System.Net.Http; using Cake.Core; +using DPI.Commands.NuGet.Reporting; using Microsoft.Extensions.Logging; using Spectre.Console; using Spectre.Console.Cli; @@ -12,10 +11,17 @@ namespace DPI.Commands.Settings.NuGet public class NuGetReportSettings : NuGetAnalyzeSettings { private const string Prefix = nameof(NuGetReportSettings); - private const string WorkspaceIdEnvKey = Prefix + "_" + nameof(WorkspaceId); + internal const string WorkspaceIdEnvKey = Prefix + "_" + nameof(WorkspaceId); private const string WorkspaceIdArgument = "--workspace "; - private const string SharedKeyEnvKey = Prefix + "_" + nameof(SharedKey); + internal const string SharedKeyEnvKey = Prefix + "_" + nameof(SharedKey); private const string SharedKeyArgument = "--sharedkey "; + internal const string EndpointEnvKey = Prefix + "_" + nameof(Endpoint); + private const string EndpointArgument = "--endpoint "; + internal const string RuleIdEnvKey = Prefix + "_" + nameof(RuleId); + private const string RuleIdArgument = "--rule-id "; + internal const string StreamEnvKey = Prefix + "_" + nameof(Stream); + private const string StreamArgument = "--stream "; + public const string DefaultStream = "Custom-NuGetReport"; public IHttpClientFactory HttpClientFactory { get; } public string ApiVersion { get; } = "2016-04-01"; @@ -24,37 +30,94 @@ public class NuGetReportSettings : NuGetAnalyzeSettings #pragma warning disable 8601 [CommandOption(WorkspaceIdArgument)] - [Description("Specifies Azure Log Analytics workspace id to log to, defaults to environment variable " + WorkspaceIdEnvKey + ".")] - public string WorkspaceId { get; init; } = Environment.GetEnvironmentVariable(WorkspaceIdEnvKey); + [Description("Specifies Azure Log Analytics workspace id to log to, defaults to environment variable " + WorkspaceIdEnvKey + ". Deprecated; use --endpoint and --rule-id.")] + public string? WorkspaceId { get; init; } = Environment.GetEnvironmentVariable(WorkspaceIdEnvKey); [CommandOption(SharedKeyArgument)] - [Description("Specifies Azure Log Analytics shared key, defaults to environment variable " + SharedKeyEnvKey + ".")] + [Description("Specifies Azure Log Analytics shared key, defaults to environment variable " + SharedKeyEnvKey + ". Deprecated; use Azure identity instead.")] + public string? SharedKey { get; init; } = Environment.GetEnvironmentVariable(SharedKeyEnvKey); - public string SharedKey { get; init; } = Environment.GetEnvironmentVariable(SharedKeyEnvKey); -#pragma warning restore 8601 + [CommandOption(EndpointArgument)] + [Description("Specifies the Logs Ingestion API endpoint, defaults to environment variable " + EndpointEnvKey + ".")] + public string? Endpoint { get; init; } = Environment.GetEnvironmentVariable(EndpointEnvKey); + + [CommandOption(RuleIdArgument)] + [Description("Specifies the data collection rule immutable ID, defaults to environment variable " + RuleIdEnvKey + ".")] + public string? RuleId { get; init; } = Environment.GetEnvironmentVariable(RuleIdEnvKey); + + [CommandOption(StreamArgument)] + [Description("Specifies the data collection rule input stream name, defaults to environment variable " + StreamEnvKey + " or " + DefaultStream + ".")] + public string Stream + { + get => HasValue(_stream) ? _stream! : DefaultStream; + init => _stream = value; + } - + private readonly string? _stream = Environment.GetEnvironmentVariable(StreamEnvKey); +#pragma warning restore 8601 + public LogAnalyticsReportMode ReportMode { get; private set; } = LogAnalyticsReportMode.LogsIngestion; public override ValidationResult Validate() { - if (string.IsNullOrWhiteSpace(WorkspaceId)) + var hasLogsIngestionSettings = HasValue(Endpoint) || HasValue(RuleId); + var hasDataCollectorSettings = HasValue(WorkspaceId) || HasValue(SharedKey); + + if (hasLogsIngestionSettings && hasDataCollectorSettings) { return ValidationResult.Error( - $"{nameof(WorkspaceId)} not specified as argument {WorkspaceIdArgument}, nor environment variable {WorkspaceIdEnvKey}" + "Specify either Logs Ingestion API settings (--endpoint and --rule-id) or deprecated Data Collector API settings (--workspace and --sharedkey), not both." ); } - if (string.IsNullOrWhiteSpace(SharedKey)) + if (hasLogsIngestionSettings) + { + if (!HasValue(Endpoint)) + { + return ValidationResult.Error( + $"{nameof(Endpoint)} not specified as argument {EndpointArgument}, nor environment variable {EndpointEnvKey}" + ); + } + + if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpointUri) || + endpointUri.Scheme != Uri.UriSchemeHttps) + { + return ValidationResult.Error( + $"{nameof(Endpoint)} must be an absolute HTTPS URI." + ); + } + + if (!HasValue(RuleId)) + { + return ValidationResult.Error( + $"{nameof(RuleId)} not specified as argument {RuleIdArgument}, nor environment variable {RuleIdEnvKey}" + ); + } + + ReportMode = LogAnalyticsReportMode.LogsIngestion; + return base.Validate(); + } + + if (!HasValue(WorkspaceId)) { return ValidationResult.Error( - $"{nameof(SharedKey)} not specified as argument {SharedKeyArgument}, nor environment variable {SharedKeyEnvKey}" + $"{nameof(WorkspaceId)} not specified as argument {WorkspaceIdArgument}, nor environment variable {WorkspaceIdEnvKey}. Prefer Logs Ingestion API settings {EndpointArgument} and {RuleIdArgument}." ); } + if (!HasValue(SharedKey)) + { + return ValidationResult.Error( + $"{nameof(SharedKey)} not specified as argument {SharedKeyArgument}, nor environment variable {SharedKeyEnvKey}. Prefer Logs Ingestion API settings {EndpointArgument} and {RuleIdArgument}." + ); + } + + ReportMode = LogAnalyticsReportMode.DataCollector; return base.Validate(); } + private static bool HasValue(string? value) => !string.IsNullOrWhiteSpace(value); + public NuGetReportSettings( ICakeContext context, ILogger logger, @@ -64,4 +127,4 @@ IHttpClientFactory httpClientFactory HttpClientFactory = httpClientFactory; } } -} \ No newline at end of file +} diff --git a/src/DPI/DPI.csproj b/src/DPI/DPI.csproj index 44ff623..7684633 100644 --- a/src/DPI/DPI.csproj +++ b/src/DPI/DPI.csproj @@ -8,9 +8,14 @@ enable README.md 12 + true + DPI + dpi + + diff --git a/src/DPI/Program.cs b/src/DPI/Program.cs index fce4140..4bd3a72 100644 --- a/src/DPI/Program.cs +++ b/src/DPI/Program.cs @@ -1,4 +1,5 @@ using DPI.Commands.NuGet; +using DPI.Commands.NuGet.Reporting; using DPI.Commands.Settings.NuGet; using DPI.OutputConverters; using DPI.Parsers.NuGet; @@ -9,6 +10,11 @@ static partial void AddServices(IServiceCollection services) services.AddHttpClient() .AddTransient() .AddTransient() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() @@ -34,7 +40,7 @@ static partial void ConfigureApp(AppServiceConfig appServiceConfig) .WithExample(["nuget", "", "analyze"]); nuGet.AddCommand("report") - .WithDescription("Inventories NuGet packages and reports to Azure Log Analytics") + .WithDescription("Inventories NuGet packages and reports to Azure Monitor Logs") .WithExample(["nuget", "", "report"]); }); }