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"]); }); }