diff --git a/Directory.Packages.props b/Directory.Packages.props index 75bf6e3ce9..92440fc2b8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,7 @@ + diff --git a/NuGet.config b/NuGet.config index 6c74936cac..b23b52a674 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,4 +1,4 @@ - + @@ -27,6 +27,7 @@ + diff --git a/arcade-services.sln b/arcade-services.sln index e21d170e84..03f9ccf517 100644 --- a/arcade-services.sln +++ b/arcade-services.sln @@ -34,32 +34,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution eng\Versions.props = eng\Versions.props EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Validation", "Validation", "{F92E1B5C-26D4-4244-A4F9-41E22BA8E1CE}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scenarios", "Scenarios", "{939DD755-B5FC-46EF-AE8B-5073DA6D4490}" - ProjectSection(SolutionItems) = preProject - src\Maestro\tests\Scenarios\all.ps1 = src\Maestro\tests\Scenarios\all.ps1 - src\Maestro\tests\Scenarios\arcade-update.ps1 = src\Maestro\tests\Scenarios\arcade-update.ps1 - src\Maestro\tests\Scenarios\azdoflow-batched.ps1 = src\Maestro\tests\Scenarios\azdoflow-batched.ps1 - src\Maestro\tests\Scenarios\azdoflow-feedflow.ps1 = src\Maestro\tests\Scenarios\azdoflow-feedflow.ps1 - src\Maestro\tests\Scenarios\azdoflow-nonbatched-all-checks-successful.ps1 = src\Maestro\tests\Scenarios\azdoflow-nonbatched-all-checks-successful.ps1 - src\Maestro\tests\Scenarios\azdoflow-nonbatched.ps1 = src\Maestro\tests\Scenarios\azdoflow-nonbatched.ps1 - src\Maestro\tests\Scenarios\builds.ps1 = src\Maestro\tests\Scenarios\builds.ps1 - src\Maestro\tests\Scenarios\channels.ps1 = src\Maestro\tests\Scenarios\channels.ps1 - src\Maestro\tests\Scenarios\clone.ps1 = src\Maestro\tests\Scenarios\clone.ps1 - src\Maestro\tests\Scenarios\common.ps1 = src\Maestro\tests\Scenarios\common.ps1 - src\Maestro\tests\Scenarios\default-channels.ps1 = src\Maestro\tests\Scenarios\default-channels.ps1 - src\Maestro\tests\Scenarios\dependencies.ps1 = src\Maestro\tests\Scenarios\dependencies.ps1 - src\Maestro\tests\Scenarios\githubflow-batched.ps1 = src\Maestro\tests\Scenarios\githubflow-batched.ps1 - src\Maestro\tests\Scenarios\githubflow-nonbatched-all-checks-successful.ps1 = src\Maestro\tests\Scenarios\githubflow-nonbatched-all-checks-successful.ps1 - src\Maestro\tests\Scenarios\githubflow-nonbatched-with-coherency.ps1 = src\Maestro\tests\Scenarios\githubflow-nonbatched-with-coherency.ps1 - src\Maestro\tests\Scenarios\githubflow-nonbatched.ps1 = src\Maestro\tests\Scenarios\githubflow-nonbatched.ps1 - src\Maestro\tests\Scenarios\githubflow-release-pipeline-nonbatched.ps1 = src\Maestro\tests\Scenarios\githubflow-release-pipeline-nonbatched.ps1 - src\Maestro\tests\Scenarios\README.md = src\Maestro\tests\Scenarios\README.md - src\Maestro\tests\Scenarios\repo-policies.ps1 = src\Maestro\tests\Scenarios\repo-policies.ps1 - src\Maestro\tests\Scenarios\subscriptions.ps1 = src\Maestro\tests\Scenarios\subscriptions.ps1 - EndProjectSection -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DependencyUpdateErrorProcessor", "src\Maestro\DependencyUpdateErrorProcessor\DependencyUpdateErrorProcessor.csproj", "{7D7E290E-7A94-4F7B-AA05-27156C4A3CAC}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FeedCleanerService", "src\Maestro\FeedCleanerService\FeedCleanerService.csproj", "{C7E8C999-F8AE-427D-B748-6BCF7202B476}" @@ -144,7 +118,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProductConstructionService. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProductConstructionService.ScenarioTests", "test\ProductConstructionService.ScenarioTests\ProductConstructionService.ScenarioTests.csproj", "{12D91D30-EC50-4D2B-8D67-0C19FCD2303F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProductConstructionService.Deployment", "src\ProductConstructionService\ProductConstructionService.Deployment\ProductConstructionService.Deployment.csproj", "{A4125B78-593D-4659-AA28-0E176D4644E5}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProductConstructionService.Cli", "src\ProductConstructionService\ProductConstructionService.Cli\ProductConstructionService.Cli.csproj", "{A4125B78-593D-4659-AA28-0E176D4644E5}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProductConstructionService.BarViz", "src\ProductConstructionService\ProductConstructionService.BarViz\ProductConstructionService.BarViz.csproj", "{B993607A-BD63-47A8-AF5D-7B49ACF0AF21}" EndProject @@ -749,7 +723,6 @@ Global {EDF632B3-48E2-43FD-B014-79CDED8F4240} = {AE791E26-78E1-4936-BCF4-1BF5152CBBD6} {37D70EEA-9621-44EB-921A-5D303917F851} = {AE791E26-78E1-4936-BCF4-1BF5152CBBD6} {93F066A5-A2D8-4926-A255-81077AEE5972} = {AE791E26-78E1-4936-BCF4-1BF5152CBBD6} - {939DD755-B5FC-46EF-AE8B-5073DA6D4490} = {F92E1B5C-26D4-4244-A4F9-41E22BA8E1CE} {7D7E290E-7A94-4F7B-AA05-27156C4A3CAC} = {AE791E26-78E1-4936-BCF4-1BF5152CBBD6} {C7E8C999-F8AE-427D-B748-6BCF7202B476} = {AE791E26-78E1-4936-BCF4-1BF5152CBBD6} {AD4D66B9-6CAB-4729-A5D1-57A859E06801} = {AE791E26-78E1-4936-BCF4-1BF5152CBBD6} diff --git a/azure-pipelines-product-construction-service.yml b/azure-pipelines-product-construction-service.yml index 2cb6a712a9..12bffc0d32 100644 --- a/azure-pipelines-product-construction-service.yml +++ b/azure-pipelines-product-construction-service.yml @@ -27,7 +27,7 @@ variables: value: .NETCore - name: _SignType value: test -- ${{ if ne(variables['Build.SourceBranch'], 'refs/heads/production') }}: +- ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/heads/production'), startsWith(variables['Build.SourceBranch'], 'refs/heads/production-'), eq(variables['Build.SourceBranch'], 'refs/heads/production'))) }}: - name: subscriptionId value: e6b5f9f5-0ca4-4351-879b-014d78400ec2 - name: containerappName @@ -126,8 +126,8 @@ stages: -restore -build -configuration $(BuildConfig) - -projects .\src\ProductConstructionService\ProductConstructionService.Deployment\ProductConstructionService.Deployment.csproj - displayName: Build ProductConsturctionService.Deployment + -projects .\src\ProductConstructionService\ProductConstructionService.Cli\ProductConstructionService.Cli.csproj + displayName: Build ProductConstructionService.Cli - publish: $(Build.SourcesDirectory)\artifacts\bin\ProductConstructionService.ScenarioTests\$(BuildConfig)\net8.0\publish artifact: ProductConstructionService.ScenarioTests @@ -135,8 +135,8 @@ stages: - publish: $(Build.SourcesDirectory)\artifacts\packages\$(BuildConfig)\NonShipping artifact: PackageArtifacts - - publish: $(Build.SourcesDirectory)\artifacts\bin\ProductConstructionService.Deployment\$(BuildConfig)\net8.0 - artifact: ProductConstructionService.Deployment + - publish: $(Build.SourcesDirectory)\artifacts\bin\ProductConstructionService.Cli\$(BuildConfig)\net8.0 + artifact: ProductConstructionService.Cli - job: BuildAndPublishDocker @@ -154,122 +154,117 @@ stages: dockerImageName: $(dockerRegistryUrl)/$(containerName) - ${{ if notin(variables['Build.Reason'], 'PullRequest') }}: - - ${{ if ne(variables['Build.SourceBranch'], 'refs/heads/production') }}: - - stage: DeployPCS - dependsOn: - - Build - displayName: Deploy Product Construction Service - - jobs: - - job: Deploy - displayName: Deploy container app - pool: - name: NetCore1ESPool-Internal - demands: ImageOverride -equals 1es-windows-2019 - variables: - newDockerImageTag: $[stageDependencies.Build.BuildAndPublishDocker.outputs['DockerTag.newDockerImageTag']] + - stage: DeployPCS + dependsOn: + - Build + displayName: Deploy Product Construction Service + + jobs: + - job: Deploy + displayName: Deploy container app + pool: + name: NetCore1ESPool-Internal + demands: ImageOverride -equals 1es-windows-2019 + variables: + newDockerImageTag: $[stageDependencies.Build.BuildAndPublishDocker.outputs['DockerTag.newDockerImageTag']] - steps: - - powershell: | - Write-Host $(containerjobNames) - $az = Get-Command az.cmd - "##vso[task.setvariable variable=azCliPath]$($az.Source)" + steps: + - powershell: | + Write-Host $(containerjobNames) + $az = Get-Command az.cmd + "##vso[task.setvariable variable=azCliPath]$($az.Source)" - - powershell: Write-Host $(azCliPath) + - powershell: Write-Host $(azCliPath) - - task: AzureCLI@2 - inputs: - azureSubscription: $(serviceConnectionName) - scriptType: pscore - scriptLocation: inlineScript - inlineScript: | - New-Item -ItemType Directory -Path $(diffFolder) - $before = az containerapp show --name $(containerappName) -g $(resourceGroupName) --output json - Set-Content -Path $(diffFolder)/before.json -Value $before - displayName: Snapshot configuration (before) + - task: AzureCLI@2 + inputs: + azureSubscription: $(serviceConnectionName) + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + New-Item -ItemType Directory -Path $(diffFolder) + $before = az containerapp show --name $(containerappName) -g $(resourceGroupName) --output json + Set-Content -Path $(diffFolder)/before.json -Value $before + displayName: Snapshot configuration (before) - - download: current - displayName: Download ProductConstructionService.Deployment - artifact: ProductConstructionService.Deployment + - download: current + displayName: Download ProductConstructionService.Cli + artifact: ProductConstructionService.Cli - - task: NuGetToolInstaller@1 - displayName: Use NuGet - inputs: - versionSpec: 5.3.x + - task: NuGetToolInstaller@1 + displayName: Use NuGet + inputs: + versionSpec: 5.3.x - - powershell: | - . .\eng\common\tools.ps1 - InitializeDotNetCli -install:$true - .\.dotnet\dotnet workload install aspire - displayName: Install .NET and Aspire Workload + - powershell: | + . .\eng\common\tools.ps1 + InitializeDotNetCli -install:$true + .\.dotnet\dotnet workload install aspire + displayName: Install .NET and Aspire Workload - - powershell: .\eng\common\build.ps1 -restore - displayName: Install .NET + # We'll need to give this service connection permission to get an auth token for PCS + - task: AzureCLI@2 + inputs: + azureSubscription: $(serviceConnectionName) + scriptType: pscore + scriptLocation: inlineScript + inlineScript: > + $(Pipeline.Workspace)/ProductConstructionService.Cli/ProductConstructionService.Cli.exe + deploy + --subscriptionId $(subscriptionId) + --resourceGroupName $(resourceGroupName) + --containerRegistryName $(containerRegistryName) + --containerAppName $(containerappName) + --workspaceName $(containerappWorkspaceName) + --containerJobNames $(containerjobNames) + --newImageTag $(newDockerImageTag) + --imageName $(containerName) + --azCliPath "$(azCliPath)" + --redisConnectionString $(redisConnectionString) + displayName: Deploy container app - # We'll need to give this service connection permission to get an auth token for PCS - - task: AzureCLI@2 - inputs: - azureSubscription: $(serviceConnectionName) - scriptType: pscore - scriptLocation: inlineScript - inlineScript: | - $(Pipeline.Workspace)/ProductConstructionService.Deployment/ProductConstructionService.Deployment.exe ` - --subscriptionId $(subscriptionId) ` - --resourceGroupName $(resourceGroupName) ` - --containerRegistryName $(containerRegistryName) ` - --containerAppName $(containerappName) ` - --workspaceName $(containerappWorkspaceName) ` - --containerJobNames $(containerjobNames) ` - --newImageTag $(newDockerImageTag) ` - --imageName $(containerName) ` - --azCliPath "$(azCliPath)" ` - --isCi true ` - --entraAppId $(MaestroAppId) ` - --redisConnectionString $(redisConnectionString) - displayName: Deploy container app + - task: AzureCLI@2 + inputs: + azureSubscription: $(serviceConnectionName) + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + $after = az containerapp show --name $(containerappName) -g $(resourceGroupName) --output json + Set-Content -Path $(diffFolder)/after.json -Value $after + displayName: Snapshot configuration (after) - - task: AzureCLI@2 - inputs: - azureSubscription: $(serviceConnectionName) - scriptType: pscore - scriptLocation: inlineScript - inlineScript: | - $after = az containerapp show --name $(containerappName) -g $(resourceGroupName) --output json - Set-Content -Path $(diffFolder)/after.json -Value $after - displayName: Snapshot configuration (after) + # git diff will set the exit code to 1, since the files are different, we have to manually set it back to 0 + - powershell: | + $diff = git diff before.json after.json + $LASTEXITCODE = 0 + Set-Content -Path diff -Value $diff + displayName: Diff configuration snapshots + workingDirectory: $(diffFolder) - # git diff will set the exit code to 1, since the files are different, we have to manually set it back to 0 - - powershell: | - $diff = git diff before.json after.json - $LASTEXITCODE = 0 - Set-Content -Path diff -Value $diff - displayName: Diff configuration snapshots - workingDirectory: $(diffFolder) + - publish: $(diffFolder) + displayName: Upload snapshot diff + artifact: DeploymentDiff - - publish: $(diffFolder) - displayName: Upload snapshot diff - artifact: DeploymentDiff + - stage: TestPCS + displayName: Run E2E Product Construction Service Tests + dependsOn: + - DeployPCS - - stage: TestPCS - displayName: Run E2E Product Construction Service Tests - dependsOn: - - DeployPCS - - jobs: - - template: /eng/templates/jobs/e2e-pcs-tests.yml - parameters: - name: scenarioTests_GitHub - displayName: GitHub tests - testFilter: 'TestCategory=GitHub' + jobs: + - template: /eng/templates/jobs/e2e-pcs-tests.yml + parameters: + name: scenarioTests_GitHub + displayName: GitHub tests + testFilter: 'TestCategory=GitHub' - - template: /eng/templates/jobs/e2e-pcs-tests.yml - parameters: - name: scenarioTests_AzDO - displayName: AzDO tests - testFilter: 'TestCategory=AzDO' + - template: /eng/templates/jobs/e2e-pcs-tests.yml + parameters: + name: scenarioTests_AzDO + displayName: AzDO tests + testFilter: 'TestCategory=AzDO' - - template: /eng/templates/jobs/e2e-pcs-tests.yml - parameters: - name: scenarioTests_Other - displayName: Other tests - testFilter: 'TestCategory!=GitHub&TestCategory!=AzDO' + - template: /eng/templates/jobs/e2e-pcs-tests.yml + parameters: + name: scenarioTests_Other + displayName: Other tests + testFilter: 'TestCategory!=GitHub&TestCategory!=AzDO' diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index dbc38558d8..62d13492a2 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -91,29 +91,29 @@ - + https://github.com/dotnet/arcade - e5b13e054339e41d422212a0ecaf24fec20cb5a1 + f7fb1fec01b91be69e4dcc5290a0bff3f28e214f - + https://github.com/dotnet/arcade - e5b13e054339e41d422212a0ecaf24fec20cb5a1 + f7fb1fec01b91be69e4dcc5290a0bff3f28e214f - + https://github.com/dotnet/arcade - e5b13e054339e41d422212a0ecaf24fec20cb5a1 + f7fb1fec01b91be69e4dcc5290a0bff3f28e214f - + https://github.com/dotnet/arcade - e5b13e054339e41d422212a0ecaf24fec20cb5a1 + f7fb1fec01b91be69e4dcc5290a0bff3f28e214f - + https://github.com/dotnet/arcade - e5b13e054339e41d422212a0ecaf24fec20cb5a1 + f7fb1fec01b91be69e4dcc5290a0bff3f28e214f - + https://github.com/dotnet/arcade - e5b13e054339e41d422212a0ecaf24fec20cb5a1 + f7fb1fec01b91be69e4dcc5290a0bff3f28e214f https://github.com/dotnet/dnceng diff --git a/eng/Versions.props b/eng/Versions.props index 1392924cd6..20e032b5e8 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -9,11 +9,11 @@ true 1.0.0-preview.1 - 8.0.0-beta.24508.1 - 8.0.0-beta.24508.1 - 8.0.0-beta.24508.1 - 8.0.0-beta.24508.1 - 8.0.0-beta.24508.1 + 8.0.0-beta.24516.1 + 8.0.0-beta.24516.1 + 8.0.0-beta.24516.1 + 8.0.0-beta.24516.1 + 8.0.0-beta.24516.1 17.4.1 1.1.0-beta.24376.1 1.1.0-beta.24376.1 diff --git a/eng/common/templates-official/steps/get-delegation-sas.yml b/eng/common/templates-official/steps/get-delegation-sas.yml index c0e8f91317..c690cc0a07 100644 --- a/eng/common/templates-official/steps/get-delegation-sas.yml +++ b/eng/common/templates-official/steps/get-delegation-sas.yml @@ -28,7 +28,16 @@ steps: # Calculate the expiration of the SAS token and convert to UTC $expiry = (Get-Date).AddHours(${{ parameters.expiryInHours }}).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - $sas = az storage container generate-sas --account-name ${{ parameters.storageAccount }} --name ${{ parameters.container }} --permissions ${{ parameters.permissions }} --expiry $expiry --auth-mode login --as-user -o tsv + # Temporarily work around a helix issue where SAS tokens with / in them will cause incorrect downloads + # of correlation payloads. https://github.com/dotnet/dnceng/issues/3484 + $sas = "" + do { + $sas = az storage container generate-sas --account-name ${{ parameters.storageAccount }} --name ${{ parameters.container }} --permissions ${{ parameters.permissions }} --expiry $expiry --auth-mode login --as-user -o tsv + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to generate SAS token." + exit 1 + } + } while($sas.IndexOf('/') -ne -1) if ($LASTEXITCODE -ne 0) { Write-Error "Failed to generate SAS token." diff --git a/eng/common/templates/steps/get-delegation-sas.yml b/eng/common/templates/steps/get-delegation-sas.yml index c0e8f91317..c690cc0a07 100644 --- a/eng/common/templates/steps/get-delegation-sas.yml +++ b/eng/common/templates/steps/get-delegation-sas.yml @@ -28,7 +28,16 @@ steps: # Calculate the expiration of the SAS token and convert to UTC $expiry = (Get-Date).AddHours(${{ parameters.expiryInHours }}).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") - $sas = az storage container generate-sas --account-name ${{ parameters.storageAccount }} --name ${{ parameters.container }} --permissions ${{ parameters.permissions }} --expiry $expiry --auth-mode login --as-user -o tsv + # Temporarily work around a helix issue where SAS tokens with / in them will cause incorrect downloads + # of correlation payloads. https://github.com/dotnet/dnceng/issues/3484 + $sas = "" + do { + $sas = az storage container generate-sas --account-name ${{ parameters.storageAccount }} --name ${{ parameters.container }} --permissions ${{ parameters.permissions }} --expiry $expiry --auth-mode login --as-user -o tsv + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to generate SAS token." + exit 1 + } + } while($sas.IndexOf('/') -ne -1) if ($LASTEXITCODE -ne 0) { Write-Error "Failed to generate SAS token." diff --git a/eng/templates/jobs/e2e-pcs-tests.yml b/eng/templates/jobs/e2e-pcs-tests.yml index 05ead7ae11..fbda672064 100644 --- a/eng/templates/jobs/e2e-pcs-tests.yml +++ b/eng/templates/jobs/e2e-pcs-tests.yml @@ -17,7 +17,7 @@ jobs: # https://dev.azure.com/dnceng/internal/_library?itemType=VariableGroups&view=VariableGroupView&variableGroupId=20&path=Publish-Build-Assets # Required for MaestroAppClientId, MaestroStagingAppClientId - group: Publish-Build-Assets - - ${{ if ne(variables['Build.SourceBranch'], 'refs/heads/production') }}: + - ${{ if not(or(startsWith(variables['Build.SourceBranch'], 'refs/heads/production'), startsWith(variables['Build.SourceBranch'], 'refs/heads/production-'), eq(variables['Build.SourceBranch'], 'refs/heads/production'))) }}: - group: MaestroInt KeyVault - name: PcsTestEndpoint value: https://product-construction-int.delightfuldune-c0f01ab0.westus2.azurecontainerapps.io diff --git a/global.json b/global.json index 132f6a8675..0b2da6033a 100644 --- a/global.json +++ b/global.json @@ -15,6 +15,6 @@ } }, "msbuild-sdks": { - "Microsoft.DotNet.Arcade.Sdk": "8.0.0-beta.24508.1" + "Microsoft.DotNet.Arcade.Sdk": "8.0.0-beta.24516.1" } } diff --git a/src/Maestro/FeedCleanerService/.config/settings.Development.json b/src/Maestro/FeedCleanerService/.config/settings.Development.json index d8f688287d..7f5f4c0dd5 100644 --- a/src/Maestro/FeedCleanerService/.config/settings.Development.json +++ b/src/Maestro/FeedCleanerService/.config/settings.Development.json @@ -8,7 +8,9 @@ "ConnectionString": "Data Source=localhost\\SQLEXPRESS;Initial Catalog=BuildAssetRegistry;Integrated Security=true" }, "AzureDevOps": { - "default": { + // The list of organizations needs to be explicit in here + // The feed cleaner uses the list to process those org's feeds + "dnceng": { "UseLocalCredentials": true } } diff --git a/src/Maestro/FeedCleanerService/.config/settings.Production.json b/src/Maestro/FeedCleanerService/.config/settings.Production.json index 8b1b69796e..3d95f6c7e5 100644 --- a/src/Maestro/FeedCleanerService/.config/settings.Production.json +++ b/src/Maestro/FeedCleanerService/.config/settings.Production.json @@ -11,8 +11,10 @@ "ConnectionString": "Data Source=tcp:maestro-prod.database.windows.net,1433; Initial Catalog=BuildAssetRegistry; Authentication=Active Directory Managed Identity; Persist Security Info=False; MultipleActiveResultSets=True; Connect Timeout=30; Encrypt=True; TrustServerCertificate=False;" }, "AzureDevOps": { - "default": { + // The list of organizations needs to be explicit in here + // The feed cleaner uses the list to process those org's feeds + "dnceng": { "ManagedIdentityId": "system" } } -} \ No newline at end of file +} diff --git a/src/Maestro/FeedCleanerService/FeedCleanerService.cs b/src/Maestro/FeedCleanerService/FeedCleanerService.cs index 0b51be8dd7..441026fd74 100644 --- a/src/Maestro/FeedCleanerService/FeedCleanerService.cs +++ b/src/Maestro/FeedCleanerService/FeedCleanerService.cs @@ -61,52 +61,64 @@ public Task RunAsync(CancellationToken cancellationToken) [CronSchedule("0 0 2 1/1 * ? *", TimeZones.PST)] public async Task CleanManagedFeedsAsync() { - if (Options.Enabled) + if (!Options.Enabled) { - Dictionary>> packagesInReleaseFeeds = - await GetPackagesForReleaseFeedsAsync(); + Logger.LogInformation("Feed cleaner service is disabled in this environment"); + return; + } + + Logger.LogInformation("Loading packages in release feeds..."); + + Dictionary>> packagesInReleaseFeeds = + await GetPackagesForReleaseFeedsAsync(); + + Logger.LogInformation("Loaded {versionCount} versions of {packageCount} packages from {feedCount} release feeds", + packagesInReleaseFeeds.Sum(feed => feed.Value.Sum(package => package.Value.Count)), + packagesInReleaseFeeds.Sum(feed => feed.Value.Keys.Count), + packagesInReleaseFeeds.Keys.Count); - foreach (var azdoAccount in Options.AzdoAccounts) + foreach (var azdoAccount in Options.AzdoAccounts) + { + Logger.LogInformation("Processing feeds for {account}...", azdoAccount); + + List allFeeds; + try + { + allFeeds = await _azureDevOpsClient.GetFeedsAsync(azdoAccount); + Logger.LogInformation("Found {count} feeds for {account}...", allFeeds.Count, azdoAccount); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to get feeds for account {azdoAccount}", azdoAccount); + continue; + } + + IEnumerable managedFeeds = allFeeds.Where(f => Regex.IsMatch(f.Name, FeedConstants.MaestroManagedFeedNamePattern)); + + foreach (var feed in managedFeeds) { - List allFeeds; try { - allFeeds = await _azureDevOpsClient.GetFeedsAsync(azdoAccount); - } - catch (Exception ex) - { - Logger.LogError(ex, $"Failed to get feeds for account {azdoAccount}"); - continue; - } - IEnumerable managedFeeds = allFeeds.Where(f => Regex.IsMatch(f.Name, FeedConstants.MaestroManagedFeedNamePattern)); + var packages = await _azureDevOpsClient.GetPackagesForFeedAsync(feed.Account, feed.Project?.Name, feed.Name); - foreach (var feed in managedFeeds) - { - try + Logger.LogInformation("Cleaning feed {feed} with {count} packages...", feed.Name, packages.Count); + + foreach (var package in packages) { - await PopulatePackagesForFeedAsync(feed); - foreach (var package in feed.Packages) - { - HashSet updatedVersions = - await UpdateReleasedVersionsForPackageAsync(feed, package, packagesInReleaseFeeds); + HashSet updatedVersions = + await UpdateReleasedVersionsForPackageAsync(feed, package, packagesInReleaseFeeds); - await DeletePackageVersionsFromFeedAsync(feed, package.Name, updatedVersions); - } - // We may have deleted all packages in the previous operation, if so, we should delete the feed, - // refresh the packages in the feed to check this. - await PopulatePackagesForFeedAsync(feed); + await DeletePackageVersionsFromFeedAsync(feed, package.Name, updatedVersions); } - catch (Exception ex) - { - Logger.LogError(ex, $"Something failed while trying to update the released packages in feed {feed.Name}"); - } + + Logger.LogInformation("Feed {feed} cleaning finished", feed.Name); + } + catch (Exception ex) + { + Logger.LogError(ex, "Something failed while trying to update the released packages in feed {feed}", feed.Name); } } } - else - { - Logger.LogInformation("Feed cleaner service is disabled in this environment"); - } } /// @@ -321,14 +333,4 @@ private async Task IsPackageAvailableInNugetOrgAsync(string name, string v return false; } } - - /// - /// Populates the packages and versions for a given feed - /// - /// Feed to populate - /// - private async Task PopulatePackagesForFeedAsync(AzureDevOpsFeed feed) - { - feed.Packages = await _azureDevOpsClient.GetPackagesForFeedAsync(feed.Account, feed.Project?.Name, feed.Name); - } } diff --git a/src/ProductConstructionService/ProductConstructionService.Api/Controllers/StatusController.cs b/src/ProductConstructionService/ProductConstructionService.Api/Controllers/StatusController.cs new file mode 100644 index 0000000000..2b6e55168b --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Api/Controllers/StatusController.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Microsoft.AspNetCore.ApiVersioning; +using Microsoft.AspNetCore.ApiVersioning.Swashbuckle; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ProductConstructionService.WorkItems; + +namespace ProductConstructionService.Api.Controllers; + +[Route("status")] +[ApiVersion("2020-02-20")] +public class StatusController(IReplicaWorkItemProcessorStateCacheFactory replicaWorkItemProcessorStateCacheFactory) : ControllerBase +{ + private readonly IReplicaWorkItemProcessorStateCacheFactory _replicaWorkItemProcessorStateCacheFactory = replicaWorkItemProcessorStateCacheFactory; + + [AllowAnonymous] + [HttpGet(Name = "Status")] + [SwaggerApiResponse(HttpStatusCode.OK, Type = typeof(Dictionary), Description = "Returns PCS replica states")] + public async Task GetPcsWorkItemProcessorStatus() + { + return Ok(await PerformActionOnAllProcessors(async stateCache => + { + var state = await stateCache.GetStateAsync(); + return (stateCache.ReplicaName, state ?? WorkItemProcessorState.Stopped); + })); + } + + [HttpPut("start", Name = "Start")] + [SwaggerApiResponse(HttpStatusCode.OK, Type = typeof(Dictionary), Description = "Starts all PCS replicas")] + public async Task StartPcsWorkItemProcessors() + { + return Ok(await PerformActionOnAllProcessors(async stateCache => + { + await stateCache.SetStateAsync(WorkItemProcessorState.Working); + return (stateCache.ReplicaName, WorkItemProcessorState.Working); + })); + } + + [HttpPut("stop", Name = "Stop")] + [SwaggerApiResponse(HttpStatusCode.OK, Type = typeof(Dictionary), Description = "Tells all PCS replicas to stop after finishing their current work item")] + public async Task StopPcsWorkItemProcessors() + { + return Ok(await PerformActionOnAllProcessors(async stateCache => + { + await stateCache.SetStateAsync(WorkItemProcessorState.Stopping); + return (stateCache.ReplicaName, WorkItemProcessorState.Stopping); + })); + } + + private async Task> PerformActionOnAllProcessors(Func> action) + { + var tasks = (await _replicaWorkItemProcessorStateCacheFactory.GetAllWorkItemProcessorStateCachesAsync()) + .Select(async processorStateCache => await action(processorStateCache)) + .ToArray(); + + await Task.WhenAll(tasks); + + return tasks.Select(task => task.Result) + .ToDictionary(res => res.replicaName, res => res.state); + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.Api/Telemetry/TelemetryConfiguration.cs b/src/ProductConstructionService/ProductConstructionService.Api/Telemetry/TelemetryConfiguration.cs index a6e1dbd666..f444345269 100644 --- a/src/ProductConstructionService/ProductConstructionService.Api/Telemetry/TelemetryConfiguration.cs +++ b/src/ProductConstructionService/ProductConstructionService.Api/Telemetry/TelemetryConfiguration.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.ApplicationInsights.Extensibility; using Microsoft.DotNet.DarcLib; namespace ProductConstructionService.Api.Telemetry; @@ -9,6 +10,7 @@ public static class TelemetryConfiguration { public static void AddTelemetry(this WebApplicationBuilder builder) { + builder.Services.AddSingleton(); builder.Services.AddApplicationInsightsTelemetry(); builder.Services.AddApplicationInsightsTelemetryProcessor(); builder.Services.AddSingleton(); diff --git a/src/ProductConstructionService/ProductConstructionService.Api/Telemetry/TelemetryRoleNameInitializer.cs b/src/ProductConstructionService/ProductConstructionService.Api/Telemetry/TelemetryRoleNameInitializer.cs new file mode 100644 index 0000000000..095f965893 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Api/Telemetry/TelemetryRoleNameInitializer.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.ApplicationInsights.Channel; + +namespace ProductConstructionService.Api.Telemetry; + +internal class TelemetryRoleNameInitializer : ITelemetryInitializer +{ + public void Initialize(ITelemetry telemetry) + { + telemetry.Context.Cloud.RoleName = "pcs"; + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.Api/appsettings.Production.json b/src/ProductConstructionService/ProductConstructionService.Api/appsettings.Production.json index 3291ac6785..9e8ce4e25d 100644 --- a/src/ProductConstructionService/ProductConstructionService.Api/appsettings.Production.json +++ b/src/ProductConstructionService/ProductConstructionService.Api/appsettings.Production.json @@ -24,5 +24,8 @@ "default": { "ManagedIdentityId": "e49bf24a-ec75-490b-803b-6fad99d19159" } - } + }, + "SubscriptionId": "fbd6122a-9ad3-42e4-976e-bccb82486856", + "ResourceGroupName": "product-construction-service", + "ContainerAppName": "product-construction-prod" } diff --git a/src/ProductConstructionService/ProductConstructionService.Api/appsettings.Staging.json b/src/ProductConstructionService/ProductConstructionService.Api/appsettings.Staging.json index 4ece3ccc3e..4667202414 100644 --- a/src/ProductConstructionService/ProductConstructionService.Api/appsettings.Staging.json +++ b/src/ProductConstructionService/ProductConstructionService.Api/appsettings.Staging.json @@ -24,7 +24,10 @@ "default": { "ManagedIdentityId": "1d43ba8a-c2a6-4fad-b064-6d8c16fc0745" } - } + }, + "SubscriptionId": "e6b5f9f5-0ca4-4351-879b-014d78400ec2", + "ResourceGroupName": "product-construction-service", + "ContainerAppName": "product-construction-int" // "ApiRedirect": { // "Uri": "https://maestro.dot.net/", // "ManagedIdentityClientId": "1d43ba8a-c2a6-4fad-b064-6d8c16fc0745" diff --git a/src/ProductConstructionService/ProductConstructionService.Deployment/Deployer.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/DeploymentOperation.cs similarity index 82% rename from src/ProductConstructionService/ProductConstructionService.Deployment/Deployer.cs rename to src/ProductConstructionService/ProductConstructionService.Cli/Operations/DeploymentOperation.cs index f72620ce82..07b5c64d71 100644 --- a/src/ProductConstructionService/ProductConstructionService.Deployment/Deployer.cs +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/DeploymentOperation.cs @@ -8,44 +8,38 @@ using Azure.ResourceManager.AppContainers.Models; using Azure.ResourceManager.Resources; using Microsoft.DotNet.DarcLib.Helpers; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ProductConstructionService.Common; +using ProductConstructionService.Cli.Options; using ProductConstructionService.WorkItems; -namespace ProductConstructionService.Deployment; -public class Deployer +namespace ProductConstructionService.Cli.Operations; +internal class DeploymentOperation : IOperation { private readonly DeploymentOptions _options; - private ContainerAppResource _containerApp; private readonly ResourceGroupResource _resourceGroup; + private ContainerAppResource _containerApp; private readonly IProcessManager _processManager; - private readonly IRedisCacheFactory _redisCacheFactory; - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; + private readonly ILogger _logger; + private readonly IReplicaWorkItemProcessorStateCacheFactory _replicaWorkItemProcessorStateFactory; private const int SleepTimeSeconds = 10; private const int MaxStopAttempts = 100; - public Deployer( + public DeploymentOperation( DeploymentOptions options, IProcessManager processManager, - ArmClient armClient, - IRedisCacheFactory redisCacheFactory, - IServiceProvider serviceProvider, - ILogger logger) + ILogger logger, + ResourceGroupResource resourceGroup, + IReplicaWorkItemProcessorStateCacheFactory replicaWorkItemProcessorStateFactory, + ContainerAppResource containerApp) { _options = options; _processManager = processManager; - - ArmClient client = armClient; - SubscriptionResource subscription = client.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{_options.SubscriptionId}")); - - _resourceGroup = subscription.GetResourceGroups().Get(_options.ResourceGroupName); - _containerApp = _resourceGroup.GetContainerApp(_options.ContainerAppName).Value; - _redisCacheFactory = redisCacheFactory; - _serviceProvider = serviceProvider; _logger = logger; + _resourceGroup = resourceGroup; + _replicaWorkItemProcessorStateFactory = replicaWorkItemProcessorStateFactory; + _containerApp = containerApp; } private string[] DefaultAzCliParameters => [ @@ -54,9 +48,9 @@ public Deployer( ]; private readonly RevisionRunningState RunningAtMaxScaleState = new RevisionRunningState("RunningAtMaxScale"); - public async Task DeployAsync() + public async Task RunAsync() { - List trafficWeights = _containerApp.Data.Configuration.Ingress.Traffic.ToList(); + var trafficWeights = _containerApp.Data.Configuration.Ingress.Traffic.ToList(); var activeRevisionTrafficWeight = trafficWeights.FirstOrDefault(weight => weight.Weight == 100) ?? throw new ArgumentException("Container app has no active revision, please investigate manually"); @@ -99,7 +93,7 @@ public async Task DeployAsync() await DeployContainerJobs(newImageFullUrl); // Wait for the new app revision to become active - bool newRevisionActive = await WaitForRevisionToBecomeActive(newRevisionName); + var newRevisionActive = await WaitForRevisionToBecomeActive(newRevisionName); // If the new revision is active, the rollout succeeded, assign a label, and transfer all traffic to it if (newRevisionActive) @@ -236,7 +230,7 @@ private async Task DeactivateFailedRevisionAndGetLogs(string revisionName) private string GetLogsUri(string revisionName) { - string query = """ + var query = """ ContainerAppConsoleLogs_CL ` | where RevisionName_s == '$revisionName' ` | project TimeGenerated, Log_s @@ -263,20 +257,20 @@ private async Task StopProcessingNewJobs(string activeRevisionName) { _logger.LogInformation("Stopping the service from processing new jobs"); - var replicas = await GetRevisionReplicaStates(activeRevisionName); + var replicaStateCaches = await _replicaWorkItemProcessorStateFactory.GetAllWorkItemProcessorStateCachesAsync(); try { - foreach (var replica in replicas) + foreach (var replicaStateCache in replicaStateCaches) { - await replica.FinishWorkItemAndStopAsync(); + await replicaStateCache.SetStateAsync(WorkItemProcessorState.Stopping); } int count; for (count = 0; count < MaxStopAttempts; count++) { - var states = replicas.Select(replica => replica.GetStateAsync()).ToArray(); + var states = replicaStateCaches.Select(replica => replica.GetStateAsync()).ToArray(); - Task.WaitAll(states); + await Task.WhenAll(states); if (states.All(state => state.Result == WorkItemProcessorState.Stopped)) { @@ -298,20 +292,6 @@ private async Task StopProcessingNewJobs(string activeRevisionName) } } - private async Task> GetRevisionReplicaStates(string revisionName) - { - var activeRevision = (await _containerApp.GetContainerAppRevisionAsync(revisionName)).Value; - return activeRevision.GetContainerAppReplicas() - // Without this, VS can't distinguish between Enumerable and AsyncEnumerable in the Select bellow - .ToEnumerable() - .Select(replica => new WorkItemProcessorState( - _redisCacheFactory, - replica.Data.Name, - new AutoResetEvent(false), - _serviceProvider.GetRequiredService>())) - .ToList(); - } - private async Task StartActiveRevision() { // refresh the containerApp resource @@ -322,9 +302,9 @@ private async Task StartActiveRevision() .Single(trafficWeight => trafficWeight.Weight == 100); _logger.LogInformation("Starting all replicas of the {revisionName} revision", activeRevisionTrafficWeight.RevisionName); - var replicaStates = await GetRevisionReplicaStates(activeRevisionTrafficWeight.RevisionName); - var tasks = replicaStates.Select(replicaState => replicaState.SetStartAsync()).ToArray(); + var replicaStateCaches = await _replicaWorkItemProcessorStateFactory.GetAllWorkItemProcessorStateCachesAsync(); + var tasks = replicaStateCaches.Select(replicaStateCache => replicaStateCache.SetStateAsync(WorkItemProcessorState.Working)).ToArray(); - Task.WaitAll(tasks); + await Task.WhenAll(tasks); } } diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Operations/GetPCSStatusOperation.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/GetPCSStatusOperation.cs new file mode 100644 index 0000000000..e5b50723cc --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/GetPCSStatusOperation.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using ProductConstructionService.Client; + +namespace ProductConstructionService.Cli.Operations; +internal class GetPcsStatusOperation : IOperation +{ + private readonly IProductConstructionServiceApi _client; + private readonly ILogger _logger; + + public GetPcsStatusOperation(IProductConstructionServiceApi client, ILogger logger) + { + _client = client; + _logger = logger; + } + + public async Task RunAsync() + { + var statuses = await _client.Status.GetPcsWorkItemProcessorStatusAsync(); + foreach (var stat in statuses) + { + _logger.LogInformation("Replica {replica} has status {status}", stat.Key, stat.Value); + } + return 0; + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Operations/IOperation.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/IOperation.cs new file mode 100644 index 0000000000..6d8c37d6ab --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/IOperation.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace ProductConstructionService.Cli.Operations; + +internal interface IOperation +{ + Task RunAsync(); +} diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Operations/StartPCSOperation.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/StartPCSOperation.cs new file mode 100644 index 0000000000..29ca47dbde --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/StartPCSOperation.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using ProductConstructionService.Client; + +namespace ProductConstructionService.Cli.Operations; +internal class StartPCSOperation : IOperation +{ + private readonly IProductConstructionServiceApi _client; + private readonly ILogger _logger; + + public StartPCSOperation(IProductConstructionServiceApi client, ILogger logger) + { + _client = client; + _logger = logger; + } + + public async Task RunAsync() + { + var statuses = await _client.Status.StartPcsWorkItemProcessorsAsync(); + foreach (var stat in statuses) + { + _logger.LogInformation("Replica {replica} has status {status}", stat.Key, stat.Value); + } + return 0; + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Operations/StopPCSOperation.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/StopPCSOperation.cs new file mode 100644 index 0000000000..86405e9c15 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Operations/StopPCSOperation.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using ProductConstructionService.Client; + +namespace ProductConstructionService.Cli.Operations; +internal class StopPcsOperation : IOperation +{ + private readonly IProductConstructionServiceApi _client; + private readonly ILogger _logger; + + public StopPcsOperation(ILogger logger, IProductConstructionServiceApi client) + { + _logger = logger; + _client = client; + } + + public async Task RunAsync() + { + var statuses = await _client.Status.StopPcsWorkItemProcessorsAsync(); + foreach (var stat in statuses) + { + _logger.LogInformation("Replica {replica} has status {status}", stat.Key, stat.Value); + } + return 0; + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.Deployment/DeploymentOptions.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Options/DeploymentOptions.cs similarity index 67% rename from src/ProductConstructionService/ProductConstructionService.Deployment/DeploymentOptions.cs rename to src/ProductConstructionService/ProductConstructionService.Cli/Options/DeploymentOptions.cs index a7f109183c..f54ef6adbc 100644 --- a/src/ProductConstructionService/ProductConstructionService.Deployment/DeploymentOptions.cs +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Options/DeploymentOptions.cs @@ -1,17 +1,24 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Azure.Core; using Azure.Identity; using Azure.ResourceManager; +using Azure.ResourceManager.AppContainers; +using Azure.ResourceManager.Resources; using CommandLine; using Microsoft.DotNet.DarcLib.Helpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ProductConstructionService.Common; +using ProductConstructionService.Cli.Operations; +using ProductConstructionService.WorkItems; using StackExchange.Redis; -namespace ProductConstructionService.Deployment; -public class DeploymentOptions +namespace ProductConstructionService.Cli.Options; + +[Verb("deploy", HelpText = "Deploy PCS with the specified options")] +internal class DeploymentOptions : Options { [Option("subscriptionId", Required = true, HelpText = "Azure subscription ID")] public required string SubscriptionId { get; init; } @@ -31,22 +38,27 @@ public class DeploymentOptions public required string ContainerJobNames { get; init; } [Option("azCliPath", Required = true, HelpText = "Path to az.cmd")] public required string AzCliPath { get; init; } - [Option("isCi", Required = true, HelpText = "Is running in CI")] - public required bool IsCi { get; init; } - [Option("entraAppId", Required = true, HelpText = "Entra app ID")] - public required string EntraAppId { get; init; } [Option("redisConnectionString", Required = true, HelpText = "Redis Cache connection string")] public required string RedisConnectionString { get; init; } - public async Task RegisterServices(IServiceCollection services) - { - services.AddLogging(logging => logging.AddConsole()); + public override IOperation GetOperation(IServiceProvider sp) => ActivatorUtilities.CreateInstance(sp, this); + public override async Task RegisterServices(IServiceCollection services) + { services.AddTransient(sp => new ProcessManager(sp.GetRequiredService>(), "git")); DefaultAzureCredential credential = new(); services.AddSingleton(credential); services.AddTransient(sp => new(sp.GetRequiredService())); + services.AddTransient(sp => + { + return new ArmClient(credential) + .GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{SubscriptionId}")) + .GetResourceGroups().Get(ResourceGroupName); + }); + services.AddTransient(sp => + sp.GetRequiredService().GetContainerApp(ContainerAppName).Value); + services.AddTransient(); var redisConfig = ConfigurationOptions.Parse(RedisConnectionString); await redisConfig.ConfigureForAzureWithTokenCredentialAsync(credential); @@ -55,7 +67,7 @@ public async Task RegisterServices(IServiceCollection servic services.AddSingleton(); services.AddSingleton(this); - - return services; + + return await base.RegisterServices(services); } } diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Options/GetPCSStatusOptions.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Options/GetPCSStatusOptions.cs new file mode 100644 index 0000000000..2cc1daa637 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Options/GetPCSStatusOptions.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using CommandLine; +using Microsoft.Extensions.DependencyInjection; +using ProductConstructionService.Cli.Operations; + +namespace ProductConstructionService.Cli.Options; + +[Verb("get-status", HelpText = "Get PCS status")] +internal class GetPcsStatusOptions : PcsStatusOptions +{ + public override IOperation GetOperation(IServiceProvider sp) => ActivatorUtilities.CreateInstance(sp); +} diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Options/Options.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Options/Options.cs new file mode 100644 index 0000000000..41cc56b4ee --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Options/Options.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ProductConstructionService.Cli.Operations; + +namespace ProductConstructionService.Cli.Options; +internal abstract class Options +{ + public virtual Task RegisterServices(IServiceCollection services) + { + services.AddLogging(logging => logging.AddConsole()); + return Task.FromResult(services); + } + + public abstract IOperation GetOperation(IServiceProvider sp); +} diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Options/PCSStatusOptions.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Options/PCSStatusOptions.cs new file mode 100644 index 0000000000..7c3075de88 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Options/PCSStatusOptions.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using CommandLine; +using Microsoft.Extensions.DependencyInjection; +using ProductConstructionService.Client; + +namespace ProductConstructionService.Cli.Options; + +internal abstract class PcsStatusOptions : Options +{ + [Option("isCi", Required = false, HelpText = "Is running in CI, defaults to false")] + public required bool IsCi { get; init; } = false; + [Option("pcsUri", Required = false, HelpText = "PCS base URI, defaults to the Prod PCS")] + public required string? pcsUri { get; init; } + + public override Task RegisterServices(IServiceCollection services) + { + services.AddSingleton( + string.IsNullOrEmpty(pcsUri) ? + PcsApiFactory.GetAuthenticated( + accessToken: null, + managedIdentityId: null, + disableInteractiveAuth: IsCi) : + PcsApiFactory.GetAuthenticated( + pcsUri, + accessToken: null, + managedIdentityId: null, + disableInteractiveAuth: IsCi)); + return base.RegisterServices(services); + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Options/StartPCSOptions.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Options/StartPCSOptions.cs new file mode 100644 index 0000000000..2fcebf2710 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Options/StartPCSOptions.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using CommandLine; +using Microsoft.Extensions.DependencyInjection; +using ProductConstructionService.Cli.Operations; + +namespace ProductConstructionService.Cli.Options; + +[Verb("start", HelpText = "Start PCS")] +internal class StartPcsOptions : PcsStatusOptions +{ + public override IOperation GetOperation(IServiceProvider sp) => ActivatorUtilities.CreateInstance(sp); +} diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Options/StopPCSOptions.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Options/StopPCSOptions.cs new file mode 100644 index 0000000000..ef902eabe3 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Options/StopPCSOptions.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using CommandLine; +using Microsoft.Extensions.DependencyInjection; +using ProductConstructionService.Cli.Operations; + +namespace ProductConstructionService.Cli.Options; + +[Verb("stop", HelpText = "Stop PCS")] +internal class StopPcsOptions : PcsStatusOptions +{ + public override IOperation GetOperation(IServiceProvider sp) => ActivatorUtilities.CreateInstance(sp); +} diff --git a/src/ProductConstructionService/ProductConstructionService.Deployment/ProductConstructionService.Deployment.csproj b/src/ProductConstructionService/ProductConstructionService.Cli/ProductConstructionService.Cli.csproj similarity index 91% rename from src/ProductConstructionService/ProductConstructionService.Deployment/ProductConstructionService.Deployment.csproj rename to src/ProductConstructionService/ProductConstructionService.Cli/ProductConstructionService.Cli.csproj index 5604dd15ad..3c3ea7818c 100644 --- a/src/ProductConstructionService/ProductConstructionService.Deployment/ProductConstructionService.Deployment.csproj +++ b/src/ProductConstructionService/ProductConstructionService.Cli/ProductConstructionService.Cli.csproj @@ -19,9 +19,9 @@ - + diff --git a/src/ProductConstructionService/ProductConstructionService.Cli/Program.cs b/src/ProductConstructionService/ProductConstructionService.Cli/Program.cs new file mode 100644 index 0000000000..02eb5083a2 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Cli/Program.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using CommandLine; +using Microsoft.Extensions.DependencyInjection; +using ProductConstructionService.Cli.Options; + +return Parser.Default.ParseArguments(args, GetOptions()) + .MapResult((Options options) => + { + async Task RunAsync() + { + IServiceCollection services = new ServiceCollection(); + + await options.RegisterServices(services); + + var provider = services.BuildServiceProvider(); + + var operation = options.GetOperation(provider); + return await operation.RunAsync(); + } + + return RunAsync().GetAwaiter().GetResult(); + }, + (_) => -1); + +Type[] GetOptions() => + [ + typeof(DeploymentOptions), + typeof(GetPcsStatusOptions), + typeof(StartPcsOptions), + typeof(StopPcsOptions), + ]; diff --git a/src/ProductConstructionService/ProductConstructionService.Client/Generated/ProductConstructionServiceApi.cs b/src/ProductConstructionService/ProductConstructionService.Client/Generated/ProductConstructionServiceApi.cs index d907620152..45903e8be8 100644 --- a/src/ProductConstructionService/ProductConstructionService.Client/Generated/ProductConstructionServiceApi.cs +++ b/src/ProductConstructionService/ProductConstructionService.Client/Generated/ProductConstructionServiceApi.cs @@ -35,6 +35,7 @@ public partial interface IProductConstructionServiceApi IGoal Goal { get; } IPipelines Pipelines { get; } IRepository Repository { get; } + IStatus Status { get; } ISubscriptions Subscriptions { get; } } @@ -129,6 +130,8 @@ public HttpPipeline Pipeline public IRepository Repository { get; } + public IStatus Status { get; } + public ISubscriptions Subscriptions { get; } @@ -149,6 +152,7 @@ public ProductConstructionServiceApi(ProductConstructionServiceApiOptions option Goal = new Goal(this); Pipelines = new Pipelines(this); Repository = new Repository(this); + Status = new Status(this); Subscriptions = new Subscriptions(this); SerializerSettings = new JsonSerializerSettings { diff --git a/src/ProductConstructionService/ProductConstructionService.Client/Generated/Status.cs b/src/ProductConstructionService/ProductConstructionService.Client/Generated/Status.cs new file mode 100644 index 0000000000..c745505bd1 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.Client/Generated/Status.cs @@ -0,0 +1,250 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Immutable; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Core; + + + +namespace ProductConstructionService.Client +{ + public partial interface IStatus + { + Task> GetPcsWorkItemProcessorStatusAsync( + CancellationToken cancellationToken = default + ); + + Task> StartPcsWorkItemProcessorsAsync( + CancellationToken cancellationToken = default + ); + + Task> StopPcsWorkItemProcessorsAsync( + CancellationToken cancellationToken = default + ); + + } + + internal partial class Status : IServiceOperations, IStatus + { + public Status(ProductConstructionServiceApi client) + { + Client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public ProductConstructionServiceApi Client { get; } + + partial void HandleFailedRequest(RestApiException ex); + + partial void HandleFailedGetPcsWorkItemProcessorStatusRequest(RestApiException ex); + + public async Task> GetPcsWorkItemProcessorStatusAsync( + CancellationToken cancellationToken = default + ) + { + + const string apiVersion = "2020-02-20"; + + var _baseUri = Client.Options.BaseUri; + var _url = new RequestUriBuilder(); + _url.Reset(_baseUri); + _url.AppendPath( + "/api/status", + false); + + _url.AppendQuery("api-version", Client.Serialize(apiVersion)); + + + using (var _req = Client.Pipeline.CreateRequest()) + { + _req.Uri = _url; + _req.Method = RequestMethod.Get; + + using (var _res = await Client.SendAsync(_req, cancellationToken).ConfigureAwait(false)) + { + if (_res.Status < 200 || _res.Status >= 300) + { + await OnGetPcsWorkItemProcessorStatusFailed(_req, _res).ConfigureAwait(false); + } + + if (_res.ContentStream == null) + { + await OnGetPcsWorkItemProcessorStatusFailed(_req, _res).ConfigureAwait(false); + } + + using (var _reader = new StreamReader(_res.ContentStream)) + { + var _content = await _reader.ReadToEndAsync().ConfigureAwait(false); + var _body = Client.Deserialize>(_content); + return _body; + } + } + } + } + + internal async Task OnGetPcsWorkItemProcessorStatusFailed(Request req, Response res) + { + string content = null; + if (res.ContentStream != null) + { + using (var reader = new StreamReader(res.ContentStream)) + { + content = await reader.ReadToEndAsync().ConfigureAwait(false); + } + } + + var ex = new RestApiException( + req, + res, + content, + Client.Deserialize(content) + ); + HandleFailedGetPcsWorkItemProcessorStatusRequest(ex); + HandleFailedRequest(ex); + Client.OnFailedRequest(ex); + throw ex; + } + + partial void HandleFailedStartPcsWorkItemProcessorsRequest(RestApiException ex); + + public async Task> StartPcsWorkItemProcessorsAsync( + CancellationToken cancellationToken = default + ) + { + + const string apiVersion = "2020-02-20"; + + var _baseUri = Client.Options.BaseUri; + var _url = new RequestUriBuilder(); + _url.Reset(_baseUri); + _url.AppendPath( + "/api/status/start", + false); + + _url.AppendQuery("api-version", Client.Serialize(apiVersion)); + + + using (var _req = Client.Pipeline.CreateRequest()) + { + _req.Uri = _url; + _req.Method = RequestMethod.Put; + + using (var _res = await Client.SendAsync(_req, cancellationToken).ConfigureAwait(false)) + { + if (_res.Status < 200 || _res.Status >= 300) + { + await OnStartPcsWorkItemProcessorsFailed(_req, _res).ConfigureAwait(false); + } + + if (_res.ContentStream == null) + { + await OnStartPcsWorkItemProcessorsFailed(_req, _res).ConfigureAwait(false); + } + + using (var _reader = new StreamReader(_res.ContentStream)) + { + var _content = await _reader.ReadToEndAsync().ConfigureAwait(false); + var _body = Client.Deserialize>(_content); + return _body; + } + } + } + } + + internal async Task OnStartPcsWorkItemProcessorsFailed(Request req, Response res) + { + string content = null; + if (res.ContentStream != null) + { + using (var reader = new StreamReader(res.ContentStream)) + { + content = await reader.ReadToEndAsync().ConfigureAwait(false); + } + } + + var ex = new RestApiException( + req, + res, + content, + Client.Deserialize(content) + ); + HandleFailedStartPcsWorkItemProcessorsRequest(ex); + HandleFailedRequest(ex); + Client.OnFailedRequest(ex); + throw ex; + } + + partial void HandleFailedStopPcsWorkItemProcessorsRequest(RestApiException ex); + + public async Task> StopPcsWorkItemProcessorsAsync( + CancellationToken cancellationToken = default + ) + { + + const string apiVersion = "2020-02-20"; + + var _baseUri = Client.Options.BaseUri; + var _url = new RequestUriBuilder(); + _url.Reset(_baseUri); + _url.AppendPath( + "/api/status/stop", + false); + + _url.AppendQuery("api-version", Client.Serialize(apiVersion)); + + + using (var _req = Client.Pipeline.CreateRequest()) + { + _req.Uri = _url; + _req.Method = RequestMethod.Put; + + using (var _res = await Client.SendAsync(_req, cancellationToken).ConfigureAwait(false)) + { + if (_res.Status < 200 || _res.Status >= 300) + { + await OnStopPcsWorkItemProcessorsFailed(_req, _res).ConfigureAwait(false); + } + + if (_res.ContentStream == null) + { + await OnStopPcsWorkItemProcessorsFailed(_req, _res).ConfigureAwait(false); + } + + using (var _reader = new StreamReader(_res.ContentStream)) + { + var _content = await _reader.ReadToEndAsync().ConfigureAwait(false); + var _body = Client.Deserialize>(_content); + return _body; + } + } + } + } + + internal async Task OnStopPcsWorkItemProcessorsFailed(Request req, Response res) + { + string content = null; + if (res.ContentStream != null) + { + using (var reader = new StreamReader(res.ContentStream)) + { + content = await reader.ReadToEndAsync().ConfigureAwait(false); + } + } + + var ex = new RestApiException( + req, + res, + content, + Client.Deserialize(content) + ); + HandleFailedStopPcsWorkItemProcessorsRequest(ex); + HandleFailedRequest(ex); + Client.OnFailedRequest(ex); + throw ex; + } + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.Client/ProductConstructionServiceApiOptions.cs b/src/ProductConstructionService/ProductConstructionService.Client/ProductConstructionServiceApiOptions.cs index ca4f0a75a5..94edce4d20 100644 --- a/src/ProductConstructionService/ProductConstructionService.Client/ProductConstructionServiceApiOptions.cs +++ b/src/ProductConstructionService/ProductConstructionService.Client/ProductConstructionServiceApiOptions.cs @@ -68,7 +68,7 @@ public ProductConstructionServiceApiOptions(string baseUri, string accessToken, /// Managed Identity to use for the auth /// Whether to include interactive login flows public ProductConstructionServiceApiOptions(string accessToken, string managedIdentityId, bool disableInteractiveAuth) - : this(PcsStagingUri, accessToken, managedIdentityId, disableInteractiveAuth) + : this(PcsProdUri, accessToken, managedIdentityId, disableInteractiveAuth) { } diff --git a/src/ProductConstructionService/ProductConstructionService.Common/IRedisCacheFactory.cs b/src/ProductConstructionService/ProductConstructionService.Common/IRedisCacheFactory.cs index af93ddfd04..63d11d6362 100644 --- a/src/ProductConstructionService/ProductConstructionService.Common/IRedisCacheFactory.cs +++ b/src/ProductConstructionService/ProductConstructionService.Common/IRedisCacheFactory.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Medallion.Threading.Redis; using Microsoft.Extensions.Logging; using StackExchange.Redis; @@ -10,11 +11,15 @@ public interface IRedisCacheFactory { IRedisCache Create(string stateKey, bool includeTypeInKey = true) where T : class; IRedisCache Create(string stateKey); + Task TryAcquireLock( + string lockKey, + TimeSpan expiration, + CancellationToken cancellationToken = default); } public class RedisCacheFactory : IRedisCacheFactory { - private readonly IConnectionMultiplexer _connection; + private readonly ConnectionMultiplexer _connection; private readonly ILogger _logger; public RedisCacheFactory(ConfigurationOptions options, ILogger logger) @@ -37,4 +42,15 @@ public IRedisCache Create(string stateKey) { return new RedisCache(_connection, stateKey); } + + public async Task TryAcquireLock( + string lockKey, + TimeSpan expiration, + CancellationToken cancellationToken = default) + { + _logger.LogInformation("Acquiring distributed lock {key}", lockKey); + + return await new RedisDistributedLock(lockKey, _connection.GetDatabase()) + .TryAcquireAsync(expiration, cancellationToken); + } } diff --git a/src/ProductConstructionService/ProductConstructionService.Common/ProductConstructionService.Common.csproj b/src/ProductConstructionService/ProductConstructionService.Common/ProductConstructionService.Common.csproj index 7e9036850c..d08147857b 100644 --- a/src/ProductConstructionService/ProductConstructionService.Common/ProductConstructionService.Common.csproj +++ b/src/ProductConstructionService/ProductConstructionService.Common/ProductConstructionService.Common.csproj @@ -9,6 +9,7 @@ + diff --git a/src/ProductConstructionService/ProductConstructionService.Common/ProductConstructionServiceExtension.cs b/src/ProductConstructionService/ProductConstructionService.Common/ProductConstructionServiceExtension.cs index 04d2d1d4fa..a8c7c40bdc 100644 --- a/src/ProductConstructionService/ProductConstructionService.Common/ProductConstructionServiceExtension.cs +++ b/src/ProductConstructionService/ProductConstructionService.Common/ProductConstructionServiceExtension.cs @@ -32,8 +32,12 @@ public static void AddBuildAssetRegistry(this IHostApplicationBuilder builder) builder.Services.TryAddTransient(); builder.Services.AddDbContext(options => { - // Do not log DB context initialization - options.ConfigureWarnings(w => w.Ignore(CoreEventId.ContextInitialized)); + // Do not log DB context initialization and command executed events + options.ConfigureWarnings(w => + { + w.Ignore(CoreEventId.ContextInitialized); + w.Ignore(RelationalEventId.CommandExecuted); + }); options.UseSqlServer(databaseConnectionString, sqlOptions => { diff --git a/src/ProductConstructionService/ProductConstructionService.Common/RedisMutex.cs b/src/ProductConstructionService/ProductConstructionService.Common/RedisMutex.cs deleted file mode 100644 index aedf8d4f3b..0000000000 --- a/src/ProductConstructionService/ProductConstructionService.Common/RedisMutex.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.Extensions.Logging; - -namespace ProductConstructionService.Common; - -public interface IRedisMutex -{ - public Task EnterWhenAvailable(string mutexName, Func> action, TimeSpan lockTime = default); -} - -/// -/// Mutex implemented using a redis cache meant for synchronization between replicas -/// -public class RedisMutex : IRedisMutex -{ - private readonly IRedisCacheFactory _cacheFactory; - private readonly ILogger _logger; - - private const int MutexWakeUpTimeSeconds = 5; - - public RedisMutex(IRedisCacheFactory cacheFactory, ILogger logger) - { - _cacheFactory = cacheFactory; - _logger = logger; - } - - public async Task EnterWhenAvailable( - string mutexName, - Func> action, - TimeSpan lockTime = default) - { - if (lockTime == default) - { - lockTime = TimeSpan.FromHours(1); - } - - IRedisCache mutexCache = _cacheFactory.Create($"Mutex_{mutexName}"); - - try - { - // Check if someone else has already taken the mutex - string? state; - do - { - state = await mutexCache.GetAsync(); - } while (await Utility.SleepIfTrue( - () => !string.IsNullOrEmpty(state), - MutexWakeUpTimeSeconds, - () => _logger.LogInformation("Waiting for mutex {mutexName} mutexName to become available", mutexName))); - _logger.LogInformation("Taking mutex {mutexName}", mutexName); - - // If for whatever reason we get stuck in action, we don't want the mutex to lock forever - // It will release the lock after lockTime - await mutexCache.SetAsync("busy", lockTime); - - return await action(); - } - finally - { - // When we're done, or get an exception, release the mutex and let others try - _logger.LogInformation("Releasing mutex {mutexName}", mutexName); - await mutexCache.TryDeleteAsync(); - } - } -} diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/DependencyFlowConfiguration.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/DependencyFlowConfiguration.cs index 2e1f8f57bc..942e5da147 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/DependencyFlowConfiguration.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/DependencyFlowConfiguration.cs @@ -6,7 +6,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -using ProductConstructionService.Common; using ProductConstructionService.DependencyFlow.WorkItemProcessors; using ProductConstructionService.DependencyFlow.WorkItems; using ProductConstructionService.WorkItems; @@ -25,7 +24,6 @@ public static void AddDependencyFlowProcessors(this IServiceCollection services) services.TryAddTransient(); services.TryAddTransient(); services.TryAddScoped(); - services.TryAddSingleton(); services.AddWorkItemProcessor(); services.AddWorkItemProcessor(); diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/ProductConstructionService.DependencyFlow.csproj b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/ProductConstructionService.DependencyFlow.csproj index 5331d100c9..bf026ded33 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/ProductConstructionService.DependencyFlow.csproj +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/ProductConstructionService.DependencyFlow.csproj @@ -11,6 +11,7 @@ + diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestPolicyFailureNotifier.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestPolicyFailureNotifier.cs index 6eef47b593..c932cecf5a 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestPolicyFailureNotifier.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestPolicyFailureNotifier.cs @@ -107,7 +107,7 @@ public async Task TagSourceRepositoryGitHubContactsAsync(InProgressPullRequest p var githubToken = await _gitHubTokenProvider.GetTokenForRepository(targetRepository); var gitHubClient = _gitHubClientFactory.CreateGitHubClient(githubToken); - var sourceRepoNotificationComment = @$""" + var sourceRepoNotificationComment = $""" #### Notification for subscribed users from {sourceRepository}: {string.Join($", {Environment.NewLine}", tagsToNotify)} diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestUpdater.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestUpdater.cs index 4174534a16..61b07c9f1c 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestUpdater.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/PullRequestUpdater.cs @@ -23,7 +23,11 @@ namespace ProductConstructionService.DependencyFlow; /// internal abstract class PullRequestUpdater : IPullRequestUpdater { +#if DEBUG + private static readonly TimeSpan DefaultReminderDelay = TimeSpan.FromMinutes(3); +#else private static readonly TimeSpan DefaultReminderDelay = TimeSpan.FromMinutes(5); +#endif private readonly IMergePolicyEvaluator _mergePolicyEvaluator; private readonly IRemoteFactory _remoteFactory; @@ -159,6 +163,7 @@ public async Task CheckPullRequestAsync(PullRequestCheck pullRequestCheck) if (inProgressPr == null) { _logger.LogInformation("No in-progress pull request found for a PR check"); + await ClearAllStateAsync(); return false; } @@ -168,20 +173,7 @@ public async Task CheckPullRequestAsync(PullRequestCheck pullRequestCheck) protected virtual async Task CheckInProgressPullRequestAsync(InProgressPullRequest pullRequestCheck) { _logger.LogInformation("Checking in-progress pull request {url}", pullRequestCheck.Url); - var status = await GetPullRequestStatusAsync(pullRequestCheck); - - // Schedule another check if PR is not merged yet - if (status == PullRequestStatus.InProgressCanUpdate || status == PullRequestStatus.InProgressCannotUpdate) - { - _logger.LogInformation("Pull request {url} still active - keeping tracking it", pullRequestCheck.Url); - await SetPullRequestCheckReminder(pullRequestCheck); - } - else - { - _logger.LogInformation("Pull request {url} was completed/closed - stopped tracking it", pullRequestCheck.Url); - } - return status != PullRequestStatus.Invalid; } @@ -230,9 +222,13 @@ await AddDependencyFlowEventsAsync( case MergePolicyCheckResult.NoPolicies: case MergePolicyCheckResult.FailedToMerge: + _logger.LogInformation("Pull request {url} still active (updatable) - keeping tracking it", pr.Url); + await SetPullRequestCheckReminder(pr); return PullRequestStatus.InProgressCanUpdate; case MergePolicyCheckResult.PendingPolicies: + _logger.LogInformation("Pull request {url} still active (not updatable at the moment) - keeping tracking it", pr.Url); + await SetPullRequestCheckReminder(pr); return PullRequestStatus.InProgressCannotUpdate; default: @@ -258,6 +254,8 @@ await AddDependencyFlowEventsAsync( pr.MergePolicyResult, pr.Url); + _logger.LogInformation("PR {url} has been manually {action}. Stopping tracking it", pr.Url, status.ToString().ToLowerInvariant()); + await ClearAllStateAsync(); // Also try to clean up the PR branch. @@ -271,7 +269,6 @@ await AddDependencyFlowEventsAsync( _logger.LogInformation("Failed to delete branch associated with pull request {url}", pr.Url); } - _logger.LogInformation("PR has been manually {action}", status); return PullRequestStatus.Completed; default: @@ -435,6 +432,7 @@ public async Task UpdateAssetsAsync( if (pr != null && !canUpdate) { await _pullRequestUpdateReminders.SetReminderAsync(update, DefaultReminderDelay); + await _pullRequestCheckReminders.UnsetReminderAsync(); _logger.LogInformation("Pull request '{prUrl}' cannot be updated, update queued", pr!.Url); return true; } @@ -822,7 +820,12 @@ await UpdateSubscriptionsForMergedPRAsync( private async Task SetPullRequestCheckReminder(InProgressPullRequest prState) { - await _pullRequestCheckReminders.SetReminderAsync(new() { UpdaterId = Id.ToString() }, DefaultReminderDelay); + var reminder = new PullRequestCheck() + { + UpdaterId = Id.ToString(), + Url = prState.Url, + }; + await _pullRequestCheckReminders.SetReminderAsync(reminder, DefaultReminderDelay); await _pullRequestState.SetAsync(prState); } diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/SubscriptionTriggerer.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/SubscriptionTriggerer.cs index 3caae0e2af..878ad6b25b 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/SubscriptionTriggerer.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/SubscriptionTriggerer.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Threading; using Maestro.Contracts; using Maestro.Data; using Maestro.Data.Models; @@ -14,23 +15,23 @@ namespace ProductConstructionService.DependencyFlow; internal class SubscriptionTriggerer : ISubscriptionTriggerer { private readonly IPullRequestUpdaterFactory _updaterFactory; + private readonly IRedisCacheFactory _cacheFactory; private readonly BuildAssetRegistryContext _context; private readonly ILogger _logger; private readonly Guid _subscriptionId; - private readonly IRedisMutex _redisMutex; public SubscriptionTriggerer( BuildAssetRegistryContext context, IPullRequestUpdaterFactory updaterFactory, + IRedisCacheFactory cacheFactory, ILogger logger, - Guid subscriptionId, - IRedisMutex redisMutex) + Guid subscriptionId) { _context = context; _updaterFactory = updaterFactory; + _cacheFactory = cacheFactory; _logger = logger; _subscriptionId = subscriptionId; - _redisMutex = redisMutex; } public async Task UpdateForMergedPullRequestAsync(int updateBuildId) @@ -149,10 +150,19 @@ await AddDependencyFlowEventAsync( }) .ToList(); - await _redisMutex.EnterWhenAvailable( - pullRequestUpdater.Id.ToString(), - async () => + + IAsyncDisposable? @lock; + var mutexKey = pullRequestUpdater.Id.ToString(); + do + { + await using (@lock = await _cacheFactory.TryAcquireLock(mutexKey, TimeSpan.FromHours(1))) { + if (@lock == null) + { + // Lock not acquired + continue; + } + _logger.LogInformation("Running asset update for {subscriptionId}", _subscriptionId); await pullRequestUpdater.UpdateAssetsAsync( @@ -166,8 +176,7 @@ await pullRequestUpdater.UpdateAssetsAsync( assets); _logger.LogInformation("Asset update complete for {subscriptionId}", _subscriptionId); - - return true; - }); + } + } while (@lock == null); } } diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/BuildCoherencyInfoProcessor.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/BuildCoherencyInfoProcessor.cs index 3e4442a574..04cbc2fbf8 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/BuildCoherencyInfoProcessor.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/BuildCoherencyInfoProcessor.cs @@ -85,4 +85,11 @@ public override async Task ProcessWorkItemAsync( return true; } + + protected override Dictionary GetLoggingContextData(BuildCoherencyInfoWorkItem workItem) + { + var data = base.GetLoggingContextData(workItem); + data["BuildId"] = workItem.BuildId; + return data; + } } diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/DependencyFlowUpdateProcessor.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/DependencyFlowUpdateProcessor.cs new file mode 100644 index 0000000000..946d2798af --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/DependencyFlowUpdateProcessor.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using ProductConstructionService.DependencyFlow.WorkItems; +using ProductConstructionService.WorkItems; + +namespace ProductConstructionService.DependencyFlow.WorkItemProcessors; + +public abstract class DependencyFlowUpdateProcessor + : WorkItemProcessor where TWorkItem : DependencyFlowWorkItem +{ + protected override string? GetSynchronizationKey(TWorkItem workItem) => "DependencyUpdate_" + workItem.UpdaterId; + + protected override Dictionary GetLoggingContextData(TWorkItem workItem) + { + var data = base.GetLoggingContextData(workItem); + data["UpdaterId"] = workItem.UpdaterId; + return data; + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/PullRequestCheckProcessor.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/PullRequestCheckProcessor.cs index 47945c01d8..c8b4118a34 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/PullRequestCheckProcessor.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/PullRequestCheckProcessor.cs @@ -1,41 +1,27 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using ProductConstructionService.Common; using ProductConstructionService.DependencyFlow.WorkItems; -using ProductConstructionService.WorkItems; namespace ProductConstructionService.DependencyFlow.WorkItemProcessors; -public class PullRequestCheckProcessor : WorkItemProcessor +public class PullRequestCheckProcessor(IPullRequestUpdaterFactory updaterFactory) + : DependencyFlowUpdateProcessor { - private readonly IPullRequestUpdaterFactory _updaterFactory; - private readonly IRedisMutex _redisMutex; - private readonly IReminderManagerFactory _reminderFactory; - - public PullRequestCheckProcessor( - IPullRequestUpdaterFactory updaterFactory, - IRedisMutex redisMutex, - IReminderManagerFactory reminderFactory) - { - _updaterFactory = updaterFactory; - _redisMutex = redisMutex; - _reminderFactory = reminderFactory; - } + private readonly IPullRequestUpdaterFactory _updaterFactory = updaterFactory; public override async Task ProcessWorkItemAsync( PullRequestCheck workItem, CancellationToken cancellationToken) { - return await _redisMutex.EnterWhenAvailable( - workItem.UpdaterId, - async () => - { - var reminders = _reminderFactory.CreateReminderManager(workItem.UpdaterId); - await reminders.ReminderReceivedAsync(); + var updater = _updaterFactory.CreatePullRequestUpdater(PullRequestUpdaterId.Parse(workItem.UpdaterId)); + return await updater.CheckPullRequestAsync(workItem); + } - var updater = _updaterFactory.CreatePullRequestUpdater(PullRequestUpdaterId.Parse(workItem.UpdaterId)); - return await updater.CheckPullRequestAsync(workItem); - }); + protected override Dictionary GetLoggingContextData(PullRequestCheck workItem) + { + var data = base.GetLoggingContextData(workItem); + data["PrUrl"] = workItem.Url; + return data; } } diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/SubscriptionTriggerProcessor.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/SubscriptionTriggerProcessor.cs index b523f6a859..2ba2cef0f7 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/SubscriptionTriggerProcessor.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/SubscriptionTriggerProcessor.cs @@ -42,6 +42,8 @@ public override async Task ProcessWorkItemAsync( return await StartSubscriptionUpdateAsync(workItem.SubscriptionId); } + protected override string? GetSynchronizationKey(SubscriptionTriggerWorkItem workItem) => "SubscriptionTrigger_" + workItem.SubscriptionId; + /// /// Run a single subscription, only accept the build Id specified /// @@ -120,4 +122,17 @@ private async Task UpdateSubscriptionAsync(Guid subscriptionId, int buildI } } } + + protected override Dictionary GetLoggingContextData(SubscriptionTriggerWorkItem workItem) + { + var data = base.GetLoggingContextData(workItem); + data["SubscriptionId"] = workItem.SubscriptionId; + + if (workItem.BuildId.HasValue) + { + data["BuildId"] = workItem.BuildId.Value; + } + + return data; + } } diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/SubscriptionUpdateProcessor.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/SubscriptionUpdateProcessor.cs index ca01e45029..3c7a52f61f 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/SubscriptionUpdateProcessor.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItemProcessors/SubscriptionUpdateProcessor.cs @@ -1,34 +1,29 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using ProductConstructionService.Common; using ProductConstructionService.DependencyFlow.WorkItems; -using ProductConstructionService.WorkItems; namespace ProductConstructionService.DependencyFlow.WorkItemProcessors; -public class SubscriptionUpdateProcessor : WorkItemProcessor +public class SubscriptionUpdateProcessor(IPullRequestUpdaterFactory updaterFactory) + : DependencyFlowUpdateProcessor { - private readonly IPullRequestUpdaterFactory _updaterFactory; - private readonly IRedisMutex _redisMutex; - - public SubscriptionUpdateProcessor(IPullRequestUpdaterFactory updaterFactory, IRedisMutex redisMutex) - { - _updaterFactory = updaterFactory; - _redisMutex = redisMutex; - } + private readonly IPullRequestUpdaterFactory _updaterFactory = updaterFactory; public override async Task ProcessWorkItemAsync( SubscriptionUpdateWorkItem workItem, CancellationToken cancellationToken) { - return await _redisMutex.EnterWhenAvailable( - workItem.UpdaterId, - async () => - { - var updater = _updaterFactory.CreatePullRequestUpdater(PullRequestUpdaterId.Parse(workItem.UpdaterId)); - await updater.ProcessPendingUpdatesAsync(workItem); - return true; - }); + var updater = _updaterFactory.CreatePullRequestUpdater(PullRequestUpdaterId.Parse(workItem.UpdaterId)); + await updater.ProcessPendingUpdatesAsync(workItem); + return true; + } + + protected override Dictionary GetLoggingContextData(SubscriptionUpdateWorkItem workItem) + { + var data = base.GetLoggingContextData(workItem); + data["SubscriptionId"] = workItem.SubscriptionId; + data["BuildId"] = workItem.BuildId; + return data; } } diff --git a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItems/PullRequestCheck.cs b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItems/PullRequestCheck.cs index 5b1ef25a79..83c924381a 100644 --- a/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItems/PullRequestCheck.cs +++ b/src/ProductConstructionService/ProductConstructionService.DependencyFlow/WorkItems/PullRequestCheck.cs @@ -9,4 +9,6 @@ namespace ProductConstructionService.DependencyFlow.WorkItems; [DataContract] public class PullRequestCheck : DependencyFlowWorkItem { + [DataMember] + public required string Url { get; set; } } diff --git a/src/ProductConstructionService/ProductConstructionService.Deployment/Program.cs b/src/ProductConstructionService/ProductConstructionService.Deployment/Program.cs deleted file mode 100644 index b2c223fcbd..0000000000 --- a/src/ProductConstructionService/ProductConstructionService.Deployment/Program.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using CommandLine; -using Microsoft.Extensions.DependencyInjection; -using ProductConstructionService.Deployment; - -return Parser.Default.ParseArguments(args) - .MapResult((options) => - { - async Task RunAsync() - { - IServiceCollection services = new ServiceCollection(); - - await options.RegisterServices(services); - - var provider = services.BuildServiceProvider(); - - var deployer = ActivatorUtilities.CreateInstance(provider); - await deployer.DeployAsync(); - } - - RunAsync().GetAwaiter().GetResult(); - - return 0; - }, - (_) => -1); - - diff --git a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FakeAzureDevOpsClient.cs b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FakeAzureDevOpsClient.cs deleted file mode 100644 index 315ff7c34a..0000000000 --- a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FakeAzureDevOpsClient.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.DotNet.DarcLib; -using Newtonsoft.Json.Linq; - -namespace ProductConstructionService.FeedCleaner; - -// TODO (https://github.com/dotnet/arcade-services/issues/3808) delete this class and use the normal AzureDevOpsClient -internal class FakeAzureDevOpsClient : IAzureDevOpsClient -{ - public Task AdjustReleasePipelineArtifactSourceAsync(string accountName, string projectName, AzureDevOpsReleaseDefinition releaseDefinition, AzureDevOpsBuild build) - => Task.FromResult(releaseDefinition); - public Task DeleteFeedAsync(string accountName, string project, string feedIdentifier) => Task.CompletedTask; - public Task DeleteNuGetPackageVersionFromFeedAsync(string accountName, string project, string feedIdentifier, string packageName, string version) - => Task.CompletedTask; - public Task> GetBuildArtifactsAsync(string accountName, string projectName, int buildId, int maxRetries = 15) - => Task.FromResult>([]); - public Task GetBuildAsync(string accountName, string projectName, long buildId) - => Task.FromResult(new AzureDevOpsBuild()); - public Task GetBuildsAsync(string account, string project, int definitionId, string branch, int count, string status) - => Task.FromResult(new JObject()); - public Task GetFeedAndPackagesAsync(string accountName, string project, string feedIdentifier) - => Task.FromResult(new AzureDevOpsFeed("fake", "fake", "fake")); - public Task GetFeedAsync(string accountName, string project, string feedIdentifier) - => Task.FromResult(new AzureDevOpsFeed("fake", "fake", "fake")); - public Task> GetFeedsAndPackagesAsync(string accountName) - => Task.FromResult>([]); - public Task> GetFeedsAsync(string accountName) - => Task.FromResult>([]); - public Task> GetPackagesForFeedAsync(string accountName, string project, string feedIdentifier) - => Task.FromResult>([]); - public Task GetProjectIdAsync(string accountName, string projectName) => Task.FromResult(string.Empty); - public Task GetReleaseAsync(string accountName, string projectName, int releaseId) - => Task.FromResult(new AzureDevOpsRelease()); - public Task GetReleaseDefinitionAsync(string accountName, string projectName, long releaseDefinitionId) - => Task.FromResult(new AzureDevOpsReleaseDefinition()); - public Task StartNewBuildAsync(string accountName, string projectName, int buildDefinitionId, string sourceBranch, string sourceVersion, Dictionary queueTimeVariables, Dictionary templateParameters, Dictionary pipelineResources) - => Task.FromResult(0); - public Task StartNewReleaseAsync(string accountName, string projectName, AzureDevOpsReleaseDefinition releaseDefinition, int barBuildId) - => Task.FromResult(0); -} diff --git a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FeedCleaner.cs b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FeedCleaner.cs index 731b999ada..944ee035ad 100644 --- a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FeedCleaner.cs +++ b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FeedCleaner.cs @@ -4,21 +4,23 @@ using System.Net; using System.Text.RegularExpressions; using Maestro.Data; +using Maestro.Data.Models; using Microsoft.DotNet.DarcLib; using Microsoft.DotNet.DarcLib.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using PackagesInReleaseFeeds = System.Collections.Generic.Dictionary>>; namespace ProductConstructionService.FeedCleaner; public class FeedCleaner { - private BuildAssetRegistryContext _context; + private readonly BuildAssetRegistryContext _context; private readonly HttpClient _httpClient; private readonly IAzureDevOpsClient _azureDevOpsClient; private readonly IOptions _options; - private ILogger _logger; + private readonly ILogger _logger; public FeedCleaner( BuildAssetRegistryContext context, @@ -43,55 +45,82 @@ public async Task CleanManagedFeedsAsync() return; } - Dictionary>> packagesInReleaseFeeds = + _logger.LogInformation("Loading packages in release feeds..."); + + PackagesInReleaseFeeds packagesInReleaseFeeds = await GetPackagesForReleaseFeedsAsync(); + _logger.LogInformation("Loaded {versionCount} versions of {packageCount} packages from {feedCount} feeds", + packagesInReleaseFeeds.Sum(feed => feed.Value.Sum(package => package.Value.Count)), + packagesInReleaseFeeds.Sum(feed => feed.Value.Keys.Count), + packagesInReleaseFeeds.Keys.Count); + foreach (var azdoAccount in Options.AzdoAccounts) { + _logger.LogInformation("Processing feeds for {account}...", azdoAccount); + List allFeeds; try { allFeeds = await _azureDevOpsClient.GetFeedsAsync(azdoAccount); + _logger.LogInformation("Found {count} feeds for {account}...", allFeeds.Count, azdoAccount); } catch (Exception ex) { - _logger.LogError(ex, $"Failed to get feeds for account {azdoAccount}"); + _logger.LogError(ex, "Failed to get feeds for account {azdoAccount}", azdoAccount); continue; } + IEnumerable managedFeeds = allFeeds.Where(f => Regex.IsMatch(f.Name, FeedConstants.MaestroManagedFeedNamePattern)); foreach (var feed in managedFeeds) { - try - { - await PopulatePackagesForFeedAsync(feed); - foreach (var package in feed.Packages) - { - HashSet updatedVersions = - await UpdateReleasedVersionsForPackageAsync(feed, package, packagesInReleaseFeeds); - - await DeletePackageVersionsFromFeedAsync(feed, package.Name, updatedVersions); - } - // We may have deleted all packages in the previous operation, if so, we should delete the feed, - // refresh the packages in the feed to check this. - await PopulatePackagesForFeedAsync(feed); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Something failed while trying to update the released packages in feed {feed.Name}"); - } + await CleanFeedAsync(feed, packagesInReleaseFeeds); } } } + private async Task CleanFeedAsync(AzureDevOpsFeed feed, PackagesInReleaseFeeds packagesInReleaseFeeds) + { + try + { + var packages = await _azureDevOpsClient.GetPackagesForFeedAsync(feed.Account, feed.Project?.Name, feed.Name); + + _logger.LogInformation("Cleaning feed {feed} with {count} packages...", feed.Name, packages.Count); + + var updatedCount = 0; + + foreach (var package in packages) + { + HashSet updatedVersions = await UpdateReleasedVersionsForPackageAsync(feed, package, packagesInReleaseFeeds); + + await DeletePackageVersionsFromFeedAsync(feed, package.Name, updatedVersions); + updatedCount += updatedVersions.Count; + } + + _logger.LogInformation("Feed {feed} cleaning finished with {count} updated packages", feed.Name, updatedCount); + + packages = await _azureDevOpsClient.GetPackagesForFeedAsync(feed.Account, feed.Project?.Name, feed.Name); + if (!packages.Any(packages => packages.Versions.Any(v => !v.IsDeleted))) + { + _logger.LogInformation("Feed {feed} has no packages left, deleting the feed", feed.Name); + await _azureDevOpsClient.DeleteFeedAsync(feed.Account, feed.Project?.Name, feed.Name); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Something failed while trying to update the released packages in feed {feed}", feed.Name); + } + } + /// /// Get a mapping of feed -> (package, versions) for the release feeds so it /// can be easily queried whether a version of a package is in a feed. /// /// Mapping of packages to versions for the release feeds. - private async Task>>> GetPackagesForReleaseFeedsAsync() + private async Task GetPackagesForReleaseFeedsAsync() { - var packagesWithVersionsInReleaseFeeds = new Dictionary>>(); + var packagesWithVersionsInReleaseFeeds = new PackagesInReleaseFeeds(); IEnumerable dotnetManagedFeeds = Options.ReleasePackageFeeds; foreach ((string account, string project, string feedName) in dotnetManagedFeeds) { @@ -146,7 +175,7 @@ private async Task>> GetPackageVersionsForFee private async Task> UpdateReleasedVersionsForPackageAsync( AzureDevOpsFeed feed, AzureDevOpsPackage package, - Dictionary>> dotnetFeedsPackageMapping) + PackagesInReleaseFeeds dotnetFeedsPackageMapping) { var releasedVersions = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -162,8 +191,11 @@ private async Task> UpdateReleasedVersionsForPackageAsync( if (matchingAsset == null) { - _logger.LogError($"Unable to find asset {package.Name}.{version.Version} in feed {feed.Name} in BAR. " + - $"Unable to determine if it was released or update its locations."); + _logger.LogError("Unable to find asset {package}.{version} in feed {feed} in BAR. " + + "Unable to determine if it was released or update its locations.", + package.Name, + version.Version, + feed.Name); continue; } else @@ -171,7 +203,9 @@ private async Task> UpdateReleasedVersionsForPackageAsync( if (matchingAsset.Locations.Any(l => l.Location == FeedConstants.NuGetOrgLocation || dotnetFeedsPackageMapping.Any(f => l.Location == f.Key))) { - _logger.LogInformation($"Package {package.Name}.{version.Version} is already present in a public location."); + _logger.LogInformation("Package {package}.{version} is already present in a public location.", + package.Name, + version.Version); releasedVersions.Add(version.Version); } else @@ -189,7 +223,9 @@ private async Task> UpdateReleasedVersionsForPackageAsync( } catch (HttpRequestException e) { - _logger.LogInformation(e, $"Failed to determine if package {package.Name}.{version.Version} is present in NuGet.org"); + _logger.LogInformation(e, "Failed to determine if package {package}.{version} is present in NuGet.org", + package.Name, + version.Version); } @@ -198,21 +234,22 @@ private async Task> UpdateReleasedVersionsForPackageAsync( releasedVersions.Add(version.Version); foreach (string feedToAdd in feedsWherePackageIsAvailable) { - _logger.LogInformation($"Found package {package.Name}.{version.Version} in " + - $"{feedToAdd}, adding location to asset."); + _logger.LogInformation("Found package {package}.{version} in {feed}, adding location to asset.", + package.Name, + version.Version, + feedToAdd); - // TODO (https://github.com/dotnet/arcade-services/issues/3808) Don't actually do anything in BAR before we migrate fully to PCS - /*matchingAsset.Locations.Add(new AssetLocation() + matchingAsset.Locations.Add(new AssetLocation() { Location = feedToAdd, Type = LocationType.NugetFeed }); - await _context.SaveChangesAsync();*/ + await _context.SaveChangesAsync(); } } else { - _logger.LogInformation($"Unable to find {package.Name}.{version} in any of the release feeds"); + _logger.LogInformation("Unable to find {package}.{version} in any of the release feeds", package.Name, version); } } } @@ -233,7 +270,8 @@ private async Task DeletePackageVersionsFromFeedAsync(AzureDevOpsFeed feed, stri { try { - _logger.LogInformation($"Deleting package {packageName}.{version} from feed {feed.Name}"); + _logger.LogInformation("Deleting package {package}.{version} from feed {feed}", + packageName, version, feed.Name); await _azureDevOpsClient.DeleteNuGetPackageVersionFromFeedAsync(feed.Account, feed.Project?.Name, @@ -243,7 +281,10 @@ await _azureDevOpsClient.DeleteNuGetPackageVersionFromFeedAsync(feed.Account, } catch (HttpRequestException e) { - _logger.LogError(e, $"There was an error attempting to delete package {packageName}.{version} from the {feed.Name} feed. Skipping..."); + _logger.LogError(e, "There was an error attempting to delete package {package}.{version} from the {feed} feed. Skipping...", + packageName, + version, + feed.Name); } } } @@ -255,10 +296,10 @@ await _azureDevOpsClient.DeleteNuGetPackageVersionFromFeedAsync(feed.Account, /// Version to search for /// Feeds to search /// List of feeds in the package mappings where the provided package and version are available - private List GetReleaseFeedsWherePackageIsAvailable( + private static List GetReleaseFeedsWherePackageIsAvailable( string name, string version, - Dictionary>> packageMappings) + PackagesInReleaseFeeds packageMappings) { List feeds = []; foreach ((string feedName, Dictionary> packages) in packageMappings) @@ -288,23 +329,13 @@ private async Task IsPackageAvailableInNugetOrgAsync(string name, string v using HttpResponseMessage response = await _httpClient.SendAsync(headRequest); response.EnsureSuccessStatusCode(); - _logger.LogInformation($"Found {name}.{version} in nuget.org URI: {packageContentsUri}"); + _logger.LogInformation("Found {package}.{version} in nuget.org URI: {uri}", name, version, packageContentsUri); return true; } catch (HttpRequestException e) when (e.Message.Contains(((int)HttpStatusCode.NotFound).ToString())) { - _logger.LogInformation($"Unable to find {name}.{version} in nuget.org URI: {packageContentsUri}"); + _logger.LogInformation("Unable to find {package}.{version} in nuget.org URI: {uri}", name, version, packageContentsUri); return false; } } - - /// - /// Populates the packages and versions for a given feed - /// - /// Feed to populate - /// - private async Task PopulatePackagesForFeedAsync(AzureDevOpsFeed feed) - { - feed.Packages = await _azureDevOpsClient.GetPackagesForFeedAsync(feed.Account, feed.Project?.Name, feed.Name); - } } diff --git a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FeedCleanerConfiguration.cs b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FeedCleanerConfiguration.cs index d94fd9e744..bee10096bc 100644 --- a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FeedCleanerConfiguration.cs +++ b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/FeedCleanerConfiguration.cs @@ -26,14 +26,12 @@ public static void ConfigureFeedCleaner(this IHostApplicationBuilder builder, IT AzureDevOpsTokenProviderOptions azdoConfig = []; builder.Configuration.GetSection("AzureDevOps").Bind(azdoConfig); - options.AzdoAccounts = azdoConfig.Keys.ToList(); + options.AzdoAccounts = [.. azdoConfig.Keys]; }); builder.Services.AddTransient(); builder.Services.Configure("AzureDevOps", (o, s) => s.Bind(o)); - // TODO https://github.com/dotnet/arcade-services/issues/3808: - //builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddTransient(sp => sp.GetRequiredService>()); builder.Services.AddTransient(sp => ActivatorUtilities.CreateInstance(sp, "git")); diff --git a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/appsettings.Production.json b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/appsettings.Production.json index 9b74b68cbf..38a0cefabc 100644 --- a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/appsettings.Production.json +++ b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/appsettings.Production.json @@ -1,9 +1,14 @@ { "BuildAssetRegistrySqlConnectionString": "Data Source=tcp:maestro-prod.database.windows.net,1433; Initial Catalog=BuildAssetRegistry; Authentication=Active Directory Managed Identity; Persist Security Info=False; MultipleActiveResultSets=True; Connect Timeout=30; Encrypt=True; TrustServerCertificate=False; User Id=USER_ID_PLACEHOLDER", "AzureDevOps": { - "default": { + // The list of organizations needs to be explicit in here + // The feed cleaner uses the list to process those org's feeds + "dnceng": { "ManagedIdentityId": "520f92da-8df7-4bdf-afcd-400caf2c23b6" } }, + "FeedCleaner": { + "Enabled": true + }, "ManagedIdentityClientId": "520f92da-8df7-4bdf-afcd-400caf2c23b6" } diff --git a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/appsettings.Staging.json b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/appsettings.Staging.json index 538acac674..9f69c22035 100644 --- a/src/ProductConstructionService/ProductConstructionService.FeedCleaner/appsettings.Staging.json +++ b/src/ProductConstructionService/ProductConstructionService.FeedCleaner/appsettings.Staging.json @@ -1,7 +1,9 @@ { "BuildAssetRegistrySqlConnectionString": "Data Source=tcp:maestro-int-server.database.windows.net,1433; Initial Catalog=BuildAssetRegistry; Authentication=Active Directory Managed Identity; Persist Security Info=False; MultipleActiveResultSets=True; Connect Timeout=30; Encrypt=True; TrustServerCertificate=False; User Id=USER_ID_PLACEHOLDER", "AzureDevOps": { - "default": { + // The list of organizations needs to be explicit in here + // The feed cleaner uses the list to process those org's feeds + "dnceng": { "ManagedIdentityId": "52833a45-ec68-4d75-8c83-e7df24649158" } }, diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/LocalReplicaWorkItemProcessorStateCacheFactory.cs b/src/ProductConstructionService/ProductConstructionService.WorkItems/LocalReplicaWorkItemProcessorStateCacheFactory.cs new file mode 100644 index 0000000000..d1d46ced20 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/LocalReplicaWorkItemProcessorStateCacheFactory.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using ProductConstructionService.Common; + +namespace ProductConstructionService.WorkItems; +public class LocalReplicaWorkItemProcessorStateCacheFactory( + IRedisCacheFactory redisCacheFactory, + ILogger logger) + : IReplicaWorkItemProcessorStateCacheFactory +{ + public Task> GetAllWorkItemProcessorStateCachesAsync() + { + return Task.FromResult>([ new WorkItemProcessorStateCache( + redisCacheFactory, + WorkItemConfiguration.LocalReplicaName, + logger) ]); + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/ProductConstructionService.WorkItems.csproj b/src/ProductConstructionService/ProductConstructionService.WorkItems/ProductConstructionService.WorkItems.csproj index 9e3667ce00..9afd552412 100644 --- a/src/ProductConstructionService/ProductConstructionService.WorkItems/ProductConstructionService.WorkItems.csproj +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/ProductConstructionService.WorkItems.csproj @@ -11,6 +11,7 @@ + diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/ReminderManager.cs b/src/ProductConstructionService/ProductConstructionService.WorkItems/ReminderManager.cs index 9e0e1caa55..b86b7900e5 100644 --- a/src/ProductConstructionService/ProductConstructionService.WorkItems/ReminderManager.cs +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/ReminderManager.cs @@ -50,7 +50,7 @@ public async Task UnsetReminderAsync() { await client.DeleteWorkItemAsync(receipt.MessageId, receipt.PopReceipt); } - catch (RequestFailedException e) when (e.Message.Contains("The specified message does not exist")) + catch (RequestFailedException e) when (e.Message.Contains("The specified message does not exist") || e.Message.Contains("did not match the pop receipt")) { // The message was already deleted, so we can ignore this exception. } diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/ReplicaWorkItemProcessorStateCache.cs b/src/ProductConstructionService/ProductConstructionService.WorkItems/ReplicaWorkItemProcessorStateCache.cs new file mode 100644 index 0000000000..7a71f7a504 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/ReplicaWorkItemProcessorStateCache.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.ResourceManager.AppContainers; +using Azure.ResourceManager.AppContainers.Models; +using Microsoft.Extensions.Logging; +using ProductConstructionService.Common; + +namespace ProductConstructionService.WorkItems; +public interface IReplicaWorkItemProcessorStateCacheFactory +{ + /// + /// Returns a list of WorkItemProcessorStateCaches for all active Replicas + /// + Task> GetAllWorkItemProcessorStateCachesAsync(); +} + +public class ReplicaWorkItemProcessorStateCache : IReplicaWorkItemProcessorStateCacheFactory +{ + private readonly ContainerAppResource _containerApp; + private readonly IRedisCacheFactory _redisCacheFactory; + private readonly ILogger _logger; + + public ReplicaWorkItemProcessorStateCache( + ContainerAppResource containerApp, + IRedisCacheFactory redisCacheFactory, + ILogger logger) + { + _containerApp = containerApp; + _redisCacheFactory = redisCacheFactory; + _logger = logger; + } + + public async Task> GetAllWorkItemProcessorStateCachesAsync() + { + ContainerAppRevisionTrafficWeight activeRevisionTrafficWeight = _containerApp.Data.Configuration.Ingress.Traffic + .Single(traffic => traffic.Weight == 100); + + if (string.IsNullOrEmpty(activeRevisionTrafficWeight.RevisionName)) + { + throw new Exception("Current active revision has no revision name"); + } + + var activeRevision = (await _containerApp.GetContainerAppRevisionAsync(activeRevisionTrafficWeight.RevisionName)).Value; + return activeRevision.GetContainerAppReplicas() + // Without this, VS can't distinguish between Enumerable and AsyncEnumerable in the Select bellow + .ToEnumerable() + .Select(replica => new WorkItemProcessorStateCache( + _redisCacheFactory, + replica.Data.Name, + _logger)) + .ToList(); + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemConfiguration.cs b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemConfiguration.cs index e71f2b0285..686ec2d257 100644 --- a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemConfiguration.cs +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemConfiguration.cs @@ -2,7 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text.Json; +using Azure.Core; using Azure.Identity; +using Azure.ResourceManager; +using Azure.ResourceManager.AppContainers; using Azure.Storage.Queues; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -16,7 +19,11 @@ public static class WorkItemConfiguration public const string WorkItemQueueNameConfigurationKey = "WorkItemQueueName"; public const string WorkItemConsumerCountConfigurationKey = "WorkItemConsumerCount"; public const string ReplicaNameKey = "CONTAINER_APP_REPLICA_NAME"; + public const string SubscriptionIdKey = "SubscriptionId"; + public const string ResourceGroupNameKey = "ResourceGroupName"; + public const string ContainerAppNameKey = "ContainerAppName"; public const int PollingRateSeconds = 10; + public const string LocalReplicaName = "localReplica"; internal static readonly JsonSerializerOptions JsonSerializerOptions = new() { @@ -28,10 +35,11 @@ public static void AddWorkItemQueues(this IHostApplicationBuilder builder, Defau { builder.AddWorkItemProducerFactory(credential); - // When running the service locally, the WorkItemProcessor should start in the Working state + builder.Services.AddSingleton(sp => ActivatorUtilities.CreateInstance( + sp, + builder.Configuration[ReplicaNameKey] ?? LocalReplicaName)); builder.Services.AddSingleton(sp => ActivatorUtilities.CreateInstance( sp, - builder.Configuration[ReplicaNameKey] ?? "localReplica", new AutoResetEvent(false))); builder.Services.AddSingleton(sp => ActivatorUtilities.CreateInstance( sp, @@ -55,6 +63,20 @@ public static void AddWorkItemQueues(this IHostApplicationBuilder builder, Defau } builder.Services.AddTransient(); + if (builder.Environment.IsDevelopment()) + { + builder.Services.AddTransient(); + } + else + { + builder.Services.AddTransient(sp => + new ArmClient(credential) + .GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{builder.Configuration.GetRequiredValue(SubscriptionIdKey)}")) + .GetResourceGroups().Get(builder.Configuration.GetRequiredValue(ResourceGroupNameKey)).Value + .GetContainerApp(builder.Configuration.GetRequiredValue(ContainerAppNameKey)).Value + ); + builder.Services.AddTransient(); + } } public static void AddWorkItemProducerFactory(this IHostApplicationBuilder builder, DefaultAzureCredential credential) diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessor.cs b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessor.cs index 7f2b89ca09..34e6875ea8 100644 --- a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessor.cs +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessor.cs @@ -6,18 +6,51 @@ namespace ProductConstructionService.WorkItems; public interface IWorkItemProcessor { Task ProcessWorkItemAsync(WorkItem workItem, CancellationToken cancellationToken); + + Dictionary GetLoggingContextData(WorkItem workItem); + + /// + /// Returns the key to use for the Redis mutex in case we want to allow one processor to run per a given work item. + /// E.g. there might be several subscription triggers arriving at once and we want to ensure that they only run one at a time. + /// + /// Null if lock is not required, otherwise a key to synchronize the execution by + string? GetRedisMutexKey(WorkItem workItem); } public abstract class WorkItemProcessor : IWorkItemProcessor where TWorkItem : WorkItem { public abstract Task ProcessWorkItemAsync(TWorkItem workItem, CancellationToken cancellationToken); + + protected virtual Dictionary GetLoggingContextData(TWorkItem workItem) + { + return []; + } + + protected virtual string? GetSynchronizationKey(TWorkItem workItem) => null; + public async Task ProcessWorkItemAsync(WorkItem workItem, CancellationToken cancellationToken) + { + return await ProcessWorkItemAsync(GetTypedWorkItem(workItem), cancellationToken); + } + + public Dictionary GetLoggingContextData(WorkItem workItem) + { + return GetLoggingContextData(GetTypedWorkItem(workItem)); + } + + public string? GetRedisMutexKey(WorkItem workItem) + { + return GetSynchronizationKey(GetTypedWorkItem(workItem)); + } + + private static TWorkItem GetTypedWorkItem(WorkItem workItem) { if (workItem is not TWorkItem typedWorkItem) { throw new NonRetriableException($"Expected work item of type {typeof(TWorkItem)}, but got {workItem.GetType()}"); } - return await ProcessWorkItemAsync(typedWorkItem, cancellationToken); + + return typedWorkItem; } } diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessorState.cs b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessorState.cs index 42188ef738..2a14898d24 100644 --- a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessorState.cs +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessorState.cs @@ -1,31 +1,21 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.Extensions.Logging; using ProductConstructionService.Common; namespace ProductConstructionService.WorkItems; public class WorkItemProcessorState { - private readonly IRedisCache _cache; - // After 30 days the replica will be inactive for sure, so we can clean the state - private static TimeSpan StateExpirationTime = TimeSpan.FromDays(30); + private readonly WorkItemProcessorStateCache _stateCache; private readonly AutoResetEvent _autoResetEvent; - private readonly ILogger _logger; - - public string ReplicaName { get; } public WorkItemProcessorState( - IRedisCacheFactory cacheFactory, - string replicaName, AutoResetEvent autoResetEvent, - ILogger logger) + WorkItemProcessorStateCache stateCache) { - _cache = cacheFactory.Create(replicaName); - ReplicaName = replicaName; _autoResetEvent = autoResetEvent; - _logger = logger; + _stateCache = stateCache; } /// @@ -50,12 +40,12 @@ public WorkItemProcessorState( public async Task SetStartAsync() { - await ChangeStateAsync(Working); + await _stateCache.SetStateAsync(Working); } public async Task SetInitializingAsync() { - await ChangeStateAsync(Initializing); + await _stateCache.SetStateAsync(Initializing); } public async Task ReturnWhenWorkingAsync(int pollingRateSeconds) @@ -63,48 +53,42 @@ public async Task ReturnWhenWorkingAsync(int pollingRateSeconds) string? status; do { - status = await _cache.GetAsync(); + status = await _stateCache.GetStateAsync(); } while (_autoResetEvent.WaitIfTrue(() => status != Working, pollingRateSeconds)); } public async Task SetStoppedIfStoppingAsync() { - var status = await _cache.GetAsync(); + var status = await _stateCache.GetStateAsync(); if (!string.IsNullOrEmpty(status)) { if (status == Stopping) { - await ChangeStateAsync(Stopped); + await _stateCache.SetStateAsync(Stopped); } } } public async Task InitializationFinished() { - var status = await _cache.GetAsync(); + var status = await _stateCache.GetStateAsync(); if (!string.IsNullOrEmpty(status) && status == Initializing) { - await ChangeStateAsync(Stopped); + await _stateCache.SetStateAsync(Stopped); } } public async Task FinishWorkItemAndStopAsync() { - var status = await _cache.GetAsync(); + var status = await _stateCache.GetStateAsync(); if (string.IsNullOrEmpty(status) || status == Working || status == Initializing) { - await ChangeStateAsync(Stopping); + await _stateCache.SetStateAsync(Stopping); } } public async Task GetStateAsync() { - return await _cache.GetAsync() ?? Stopped; - } - - private async Task ChangeStateAsync(string value) - { - _logger.LogInformation("Changing replica {replicaName} state to {state}", ReplicaName, value); - await _cache.SetAsync(value, StateExpirationTime); + return await _stateCache.GetStateAsync() ?? Stopped; } } diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessorStateCache.cs b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessorStateCache.cs new file mode 100644 index 0000000000..a013fb2619 --- /dev/null +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemProcessorStateCache.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using ProductConstructionService.Common; + +namespace ProductConstructionService.WorkItems; +public class WorkItemProcessorStateCache +{ + public string ReplicaName { get; init; } + + private readonly IRedisCache _cache; + private readonly ILogger _logger; + + // After 60 days the replica will be inactive for sure, so we can clean the state + private static TimeSpan StateExpirationTime = TimeSpan.FromDays(60); + + public WorkItemProcessorStateCache(IRedisCacheFactory redisCacheFactory, string replicaName, ILogger logger) + { + _cache = redisCacheFactory.Create(replicaName); + ReplicaName = replicaName; + _logger = logger; + } + + public async Task GetStateAsync() + { + return await _cache.GetAsync(); + } + + public async Task SetStateAsync(string state) + { + _logger.LogInformation("Changing replica {replicaName} state to {state}", ReplicaName, state); + await _cache.SetAsync(state, StateExpirationTime); + } +} diff --git a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemScope.cs b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemScope.cs index 1500fc48f2..e35642aadd 100644 --- a/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemScope.cs +++ b/src/ProductConstructionService/ProductConstructionService.WorkItems/WorkItemScope.cs @@ -3,9 +3,13 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.DataContracts; using Microsoft.DotNet.DarcLib; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ProductConstructionService.Common; namespace ProductConstructionService.WorkItems; @@ -56,13 +60,41 @@ public async Task RunWorkItemAsync(JsonNode node, CancellationToken cancellation throw new NonRetriableException($"Failed to deserialize work item of type {type}: {node}"); } - using (ITelemetryScope telemetryScope = _telemetryRecorder.RecordWorkItemCompletion(type)) + var logger = _serviceScope.ServiceProvider.GetRequiredService>(); + var telemetryClient = _serviceScope.ServiceProvider.GetRequiredService(); + + async Task ProcessWorkItemAsync() { - var success = await processor.ProcessWorkItemAsync(workItem, cancellationToken); - if (success) + using (ITelemetryScope telemetryScope = _telemetryRecorder.RecordWorkItemCompletion(type)) + using (var operation = telemetryClient.StartOperation(type)) + using (logger.BeginScope(processor.GetLoggingContextData(workItem))) { - telemetryScope.SetSuccess(); + var success = await processor.ProcessWorkItemAsync(workItem, cancellationToken); + if (success) + { + telemetryScope.SetSuccess(); + } } } + + if (processor.GetRedisMutexKey(workItem) is not string mutexKey) + { + await ProcessWorkItemAsync(); + return; + } + + var cache = _serviceScope.ServiceProvider.GetRequiredService(); + + IAsyncDisposable? @lock; + do + { + await using (@lock = await cache.TryAcquireLock(mutexKey, TimeSpan.FromHours(1), cancellationToken)) + { + if (@lock != null) + { + await ProcessWorkItemAsync(); + } + } + } while (@lock == null); } } diff --git a/src/ProductConstructionService/Readme.md b/src/ProductConstructionService/Readme.md index 017d3e35df..6c0fde4ca4 100644 --- a/src/ProductConstructionService/Readme.md +++ b/src/ProductConstructionService/Readme.md @@ -171,6 +171,11 @@ The Product Construction Service uses the [Blue-Green](https://learn.microsoft.c - Starts the JobProcessor once the service is ready. - If there are any failures during the deployment, the old revision is started, and the deployment is cleaned up. +## Deploying to prod + +While we're still in the early development phase we want to be able to publish to production easily (as it's not used by anything). We've temporarily made it so so any branch that starts with `production/` +or `production-` deploys to prod + # Debugging ## Getting container logs (when service does not start) diff --git a/test/Maestro.ScenarioTests/ScenarioTests_MergePolicies.cs b/test/Maestro.ScenarioTests/ScenarioTests_MergePolicies.cs index 5d656664b5..7614afcf06 100644 --- a/test/Maestro.ScenarioTests/ScenarioTests_MergePolicies.cs +++ b/test/Maestro.ScenarioTests/ScenarioTests_MergePolicies.cs @@ -37,14 +37,10 @@ public Task DisposeAsync() } private string GetTestChannelName() - { - return "Test Channel " + _random.Next(int.MaxValue); - } + => "Test Channel " + _random.Next(int.MaxValue); - private string GetTargetBranch() - { - return Guid.NewGuid().ToString(); - } + private static string GetTargetBranch() + => Guid.NewGuid().ToString(); [Test] public async Task Darc_GitHubFlow_AutoMerge_GithubChecks_AllChecksSuccessful() @@ -88,17 +84,19 @@ public async Task AutoMergeFlowTestBase(string targetRepo, string sourceRepo, st var sourceBranch = "dependencyflow-tests"; var sourceCommit = "0b36b99e29b1751403e23cfad0a7dff585818051"; var sourceBuildNumber = _random.Next(int.MaxValue).ToString(); - ImmutableList sourceAssets = ImmutableList.Create() - .Add(new AssetData(true) - { - Name = "Foo", - Version = "1.1.0", - }) - .Add(new AssetData(true) - { - Name = "Bar", - Version = "2.1.0", - }); + ImmutableList sourceAssets = + [ + new AssetData(true) + { + Name = "Foo", + Version = "1.1.0", + }, + new AssetData(true) + { + Name = "Bar", + Version = "2.1.0", + }, + ]; TestContext.WriteLine($"Creating test channel {testChannelName}"); await using AsyncDisposableValue channel = await CreateTestChannelAsync(testChannelName); diff --git a/test/ProductConstructionService.DependencyFlow.Tests/MockRedisCacheFactory.cs b/test/ProductConstructionService.DependencyFlow.Tests/MockRedisCacheFactory.cs index e793bfd0f2..85497673f7 100644 --- a/test/ProductConstructionService.DependencyFlow.Tests/MockRedisCacheFactory.cs +++ b/test/ProductConstructionService.DependencyFlow.Tests/MockRedisCacheFactory.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Moq; using ProductConstructionService.Common; namespace ProductConstructionService.DependencyFlow.Tests; @@ -18,4 +19,7 @@ public IRedisCache Create(string key, bool includeTypeInKey = true) where { return new MockRedisCache(key, Data); } + + public Task TryAcquireLock(string lockKey, TimeSpan expiration, CancellationToken cancellationToken = default) + => Task.FromResult(Mock.Of()); } diff --git a/test/ProductConstructionService.DependencyFlow.Tests/PullRequestUpdaterTests.cs b/test/ProductConstructionService.DependencyFlow.Tests/PullRequestUpdaterTests.cs index 4db816e6fc..e4c3a06b03 100644 --- a/test/ProductConstructionService.DependencyFlow.Tests/PullRequestUpdaterTests.cs +++ b/test/ProductConstructionService.DependencyFlow.Tests/PullRequestUpdaterTests.cs @@ -451,7 +451,11 @@ protected void AndShouldHavePullRequestCheckReminder() ? VmrPullRequestUrl : InProgressPrUrl; - SetExpectedReminder(Subscription, CreatePullRequestCheck()); + SetExpectedReminder(Subscription, new PullRequestCheck() + { + UpdaterId = GetPullRequestUpdaterId().ToString(), + Url = prUrl, + }); } protected void AndShouldHaveInProgressPullRequestState( @@ -545,10 +549,4 @@ protected InProgressPullRequest CreatePullRequestState( CoherencyErrors = coherencyErrors, Url = prUrl, }; - - protected PullRequestCheck CreatePullRequestCheck() - => new() - { - UpdaterId = GetPullRequestUpdaterId().ToString(), - }; } diff --git a/test/ProductConstructionService.FeedCleaner.Tests/FeedCleanerTests.cs b/test/ProductConstructionService.FeedCleaner.Tests/FeedCleanerTests.cs index 5fae4b4909..9a93f54526 100644 --- a/test/ProductConstructionService.FeedCleaner.Tests/FeedCleanerTests.cs +++ b/test/ProductConstructionService.FeedCleaner.Tests/FeedCleanerTests.cs @@ -96,7 +96,6 @@ public async Task OnlyDeletesReleasedPackagesFromManagedFeeds() } [Test] - [Ignore("(https://github.com/dotnet/arcade-services/issues/3808) ignore till this is resolved")] public async Task UpdatesAssetLocationsForReleasedPackages() { await _feedCleaner!.CleanManagedFeedsAsync(); diff --git a/test/ProductConstructionService.WorkItem.Tests/WorkItemScopeTests.cs b/test/ProductConstructionService.WorkItem.Tests/WorkItemScopeTests.cs index 143b84039a..b4a842526f 100644 --- a/test/ProductConstructionService.WorkItem.Tests/WorkItemScopeTests.cs +++ b/test/ProductConstructionService.WorkItem.Tests/WorkItemScopeTests.cs @@ -3,6 +3,8 @@ using System.Text.Json; using FluentAssertions; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; using Microsoft.DotNet.DarcLib; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -17,6 +19,7 @@ public class WorkItemScopeTests private ServiceCollection _services = new(); private WorkItemProcessorState _state = null!; + private WorkItemProcessorStateCache _stateCache = null!; [SetUp] public void TestSetup() @@ -24,15 +27,19 @@ public void TestSetup() _services = new(); _services.AddOptions(); _services.AddLogging(); + _services.AddSingleton(new TelemetryClient(new TelemetryConfiguration())); Mock cacheFactory = new(); cacheFactory.Setup(f => f.Create(It.IsAny())).Returns(new FakeRedisCache()); - _state = new( + _stateCache = new( cacheFactory.Object, - string.Empty, + "testReplica", + new Mock>().Object); + + _state = new( new AutoResetEvent(false), - new Mock>().Object); + _stateCache); } [Test] @@ -219,6 +226,9 @@ public TestWorkItemProcessor2(Action action) _action = action; } + public Dictionary GetLoggingContextData(WorkItems.WorkItem workItem) => []; + public string? GetRedisMutexKey(WorkItems.WorkItem workItem) => null; + public Task ProcessWorkItemAsync(WorkItems.WorkItem workItem, CancellationToken cancellationToken) { switch (workItem) diff --git a/test/ProductConstructionService.WorkItem.Tests/WorkItemsProcessorScopeManagerTests.cs b/test/ProductConstructionService.WorkItem.Tests/WorkItemsProcessorScopeManagerTests.cs index a9d97c53cd..6db5edb3f6 100644 --- a/test/ProductConstructionService.WorkItem.Tests/WorkItemsProcessorScopeManagerTests.cs +++ b/test/ProductConstructionService.WorkItem.Tests/WorkItemsProcessorScopeManagerTests.cs @@ -15,6 +15,7 @@ public class WorkItemsProcessorScopeManagerTests { private IServiceProvider _serviceProvider = null!; WorkItemProcessorState _state = null!; + WorkItemProcessorStateCache _stateCache = null!; WorkItemScopeManager _scopeManager = null!; AutoResetEvent _autoResetEvent = null!; @@ -34,11 +35,14 @@ public void SetUp() cacheFactory.Setup(f => f.Create(It.IsAny())).Returns(new FakeRedisCache()); _autoResetEvent = new(false); - _state = new( + _stateCache = new( cacheFactory.Object, - string.Empty, + "testReplica", + new Mock>().Object); + + _state = new( _autoResetEvent, - new Mock>().Object); + _stateCache); _scopeManager = new(_serviceProvider, _state, -1); }