diff --git a/eng/common/instructions/azsdk-tools/create-release-plan.instructions.md b/eng/common/instructions/azsdk-tools/create-release-plan.instructions.md index 6d82d3272eca..8301a6dd8c2c 100644 --- a/eng/common/instructions/azsdk-tools/create-release-plan.instructions.md +++ b/eng/common/instructions/azsdk-tools/create-release-plan.instructions.md @@ -29,10 +29,9 @@ If any details are missing, prompt the user accordingly: - **Service Tree ID**: GUID format identifier for the service in Service Tree. Before creating release plan, always show the value to user and ask them to confirm it's a valid value in service tree. - **Product Service Tree ID**: GUID format identifier for the product in Service Tree. Before creating release plan, always show the value to user and ask them to confirm it's a valid value in service tree. - **Expected Release Timeline**: Format must be in "Month YYYY" -- **API Version**: The version of the API being released - **SDK Release Type**: Value must be beta or stable. - - "beta" for preview API versions - - "stable" for GA API versions + - "beta" + - "stable" ## Step 4: Create Release Plan - If the user doesn't know the required details, direct them to create a release plan using the release planner diff --git a/eng/common/instructions/azsdk-tools/local-sdk-workflow.instructions.md b/eng/common/instructions/azsdk-tools/local-sdk-workflow.instructions.md index 507e9a6cce4b..ff0e5c173ddd 100644 --- a/eng/common/instructions/azsdk-tools/local-sdk-workflow.instructions.md +++ b/eng/common/instructions/azsdk-tools/local-sdk-workflow.instructions.md @@ -8,9 +8,11 @@ Help the user generate and build SDKs locally from TypeSpec API specifications u High level steps involved: 1. Generate SDK locally 2. Build / Compile SDK locally -3. Run package checks -4. Run package tests -5. Update change log, metadata and version +3. Commit generated changes checkpoint +4. Run package checks +5. Run package tests +6. Update changelog, metadata and version +7. Commit final changes checkpoint --- @@ -21,7 +23,7 @@ High level steps involved: **Goal**: Ensure the user understands the overall SDK generation and build process before starting. **Actions**: -- Present the high-level steps involved in generating and building SDK locally: +- **MUST** present the high-level steps involved in generating and building SDK locally: 1. Select target language 2. Verify SDK repository 3. Validate repository path @@ -30,7 +32,11 @@ High level steps involved: 6. Generate SDK using `azsdk_package_generate_code` MCP tool 7. Identify SDK project path 8. Build/Compile SDK using `azsdk_package_build_code` MCP tool -- Ask the user to confirm readiness to proceed. + 9. Commit generated changes checkpoint + 10. Run package checks + 11. Run package tests + 12. Update changelog, metadata and version + 13. Commit final changes checkpoint --- @@ -132,7 +138,23 @@ High level steps involved: --- -### Step 3: Run package validation +### Step 3: Stage checkpoint — Commit generated changes + +**Goal**: Prompt the user to commit the changes produced by the generation and build steps before proceeding to validation and testing. +**Actions**: + +- **MUST** inform the user that SDK generation and build have completed successfully. +- **MUST** prompt the user to decide if they want to commit the changes now. Do NOT skip this prompt. +- If the user chooses to commit: + - Check if the user is on the `main` branch. If so, prompt: *"You are currently on the main branch. Please create a new branch using `git checkout -b ` before proceeding."* Suggest a reasonable default branch name based on the generation context (e.g., `sdk//`) and provide the exact `git checkout -b ` command for the user to run. Allow the user to provide a preferred branch name instead. Wait for user confirmation before continuing. + - Run `git add ` to stage the changed files. + - Prompt the user for a commit message. + - Run `git commit -m ""`. +- If the user chooses to skip, acknowledge and proceed to the next step. + +--- + +### Step 4: Run package checks **Actions**: @@ -140,7 +162,7 @@ High level steps involved: --- -### Step 4: Run package tests +### Step 5: Run package tests **Actions**: @@ -148,10 +170,26 @@ High level steps involved: --- -### Step 5: Update change log, metadata and version +### Step 6: Update changelog, metadata and version **Actions**: +- Run `azsdk_package_update_changelog_content` MCP tool to update changelog in the identified project directory. - Run `azsdk_package_update_metadata` MCP tool to update metadata in the identified project directory. -- Run `azsdk_package_update_changelog_content` MCP tool to update change log in the identified project directory. -- Run `azsdk_package_update_version` MCP tool to update version in the identified project directory. \ No newline at end of file +- Run `azsdk_package_update_version` MCP tool to update version in the identified project directory. + +--- + +### Step 7: Stage checkpoint — Commit final changes + +**Goal**: Prompt the user to commit the final set of changes after all updates are complete. +**Actions**: + +- **MUST** inform the user that changelog, metadata, and version updates are complete. +- **MUST** prompt the user to decide if they want to commit the changes now. Do NOT skip this prompt. +- If the user chooses to commit: + - Check if the user is on the `main` branch. If so, prompt: *"You are currently on the main branch. Please create a new branch using `git checkout -b ` before proceeding."* Suggest a reasonable default branch name based on the generation context (e.g., `sdk//`) and provide the exact `git checkout -b ` command for the user to run. Allow the user to provide a preferred branch name instead. Wait for user confirmation before continuing. + - Run `git add ` to stage the changed files. + - Prompt the user for a commit message. + - Run `git commit -m ""`. +- If the user chooses to skip, acknowledge and proceed. \ No newline at end of file diff --git a/eng/common/instructions/azsdk-tools/sdk-details-in-release-plan.instructions.md b/eng/common/instructions/azsdk-tools/sdk-details-in-release-plan.instructions.md index bbf8b4841196..cc31df4085b6 100644 --- a/eng/common/instructions/azsdk-tools/sdk-details-in-release-plan.instructions.md +++ b/eng/common/instructions/azsdk-tools/sdk-details-in-release-plan.instructions.md @@ -1,32 +1,12 @@ --- -description: 'Identify languages configured in the TypeSpec project and add it to release plan' +description: 'Update SDK details in a release plan from a TypeSpec project' --- -# Step 1: Find the list of languages and package names -**Goal**: Identify languages configured in the TypeSpec project and generate the json object with language and package name. -1. Identify the language emitter configuration in the `tspconfig.yaml` file in the TypeSpec project root. -2. Identify the package name or namespace for each language emitter. - - For Java and Python, use `emitter-output-dir` for package name if it exists. Otherwise use `package-dir` to get the package name as fallback approach. - - For .NET, use namespace property to get package name. - - For JavaScript, use `packagedetails:name` property to get package name. - - For Go, use module name and remove `github.com/Azure/azure-sdk-for-go/` to get package name. -3. Map the language name in emitter to one of the following in Pascal case(except .NET): - - .NET - - Java - - Python - - JavaScript - - Go -4. Create a JSON array object with the following structure: - ```json - [ - { - "language": "", - "packageName": "" - }, - ... - ] - ``` -5. If no languages are configured, inform the user: "No languages configured in TypeSpec project. Please add at least one language emitter in tspconfig.yaml." -**Success Criteria**: JSON object with languages and package names created. +# Step 1: Identify the TypeSpec project path +**Goal**: Identify the path to the TypeSpec project that contains the `tspconfig.yaml` file. +1. Identify the TypeSpec project directory that contains a `tspconfig.yaml` file. +2. If a TypeSpec project path is not provided or known, ask the user for the path. +3. If no `tspconfig.yaml` exists at the given path, inform the user: "No tspconfig.yaml found at the given path. Please provide a valid TypeSpec project path." +**Success Criteria**: Valid TypeSpec project path identified. # Step 2: Check if release plan exists **Goal**: Determine if a release plan exists for the API spec pull request or work item Id or release plan Id in current context. @@ -36,17 +16,7 @@ description: 'Identify languages configured in the TypeSpec project and add it t **Success Criteria**: Release plan exists or user informed to create one. # Step 3: Update Release Plan with SDK Information -> **(MANDATORY - DO NOT SKIP) ALWAYS validate all package names against the format rules AND the examples table before calling any update tool, even if the user provides SDK details directly. Auto-correct and inform the user of invalid package names.** -> - **JavaScript**: Must start with `@azure/` -> - **Go**: Must start with `sdk/` -> -> **Valid package name examples (compare against these to catch invalid formats):** -> | Language | Valid | Invalid | -> |----------|-------|---------| -> | JavaScript | `@azure/arm-compute` | `arm-compute`, `azure/arm-compute`,`@azure-arm-compute` | -> | Go (management plane) | `sdk/resourcemanager/compute/armcompute` | `sdk/armcompute`, `/sdk/compute`, `github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute` | - -**Goal**: Update the release plan with the languages and package names identified in Step 1. -1. Use `azsdk_update_sdk_details_in_release_plan` to update the release plan work item with the JSON object created in Step 1. +**Goal**: Update the release plan with the SDK package names resolved from the TypeSpec project. +1. Use `azsdk_update_sdk_details_in_release_plan` with the release plan work item ID and the TypeSpec project path from Step 1. 2. Confirm successful update of the release plan with the SDK information and summary of languages and package names. -**Success Criteria**: Release plan updated with languages and package names. \ No newline at end of file +**Success Criteria**: Release plan updated with languages and package names resolved from the TypeSpec project. \ No newline at end of file diff --git a/eng/common/instructions/azsdk-tools/typespec-to-sdk.instructions.md b/eng/common/instructions/azsdk-tools/typespec-to-sdk.instructions.md index 15d8b22acf06..2508cd43242c 100644 --- a/eng/common/instructions/azsdk-tools/typespec-to-sdk.instructions.md +++ b/eng/common/instructions/azsdk-tools/typespec-to-sdk.instructions.md @@ -44,7 +44,7 @@ Follow the steps in #file:local-sdk-workflow.instructions.md to generate and bui - Execute the SDK generation pipeline with the following required parameters for all languages: - TypeSpec project root path - API spec pull request number (if the API spec is not merged to the main branch, otherwise use 0) - - API version + - API version (optional) - SDK release type (`beta` for preview API versions, `stable` otherwise) - Language options: For management plane: `Python`, `.NET`, `JavaScript`, `Java`, `Go` diff --git a/eng/common/instructions/azsdk-tools/validate-codeowners.instructions.md b/eng/common/instructions/azsdk-tools/validate-codeowners.instructions.md deleted file mode 100644 index 83167f459d02..000000000000 --- a/eng/common/instructions/azsdk-tools/validate-codeowners.instructions.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -mode: 'agent' -tools: ['azsdk_check_service_label', 'azsdk_engsys_validate_codeowners_entry_for_service', 'azsdk_engsys_codeowner_update'] ---- - -## Goal: -Validate service label and ensure at least 2 valid code owners exist for SDK repositories. - -## Step 1: Validate Service Label -Use `azsdk_check_service_label` to verify the service label exists: -- **DoesNotExist/NotAServiceLabel**: Direct user to create valid service label first. Stop validation process until service label is created. -- **Exists/InReview**: Proceed to Step 2 - -## Step 2: Validate Code Owners -Ask user to specify SDK repository they want to validate codeowners for or detect from context. - -Repository name mapping: -- .NET/dotnet: use "azure-sdk-for-net" -- Python: use "azure-sdk-for-python" -- Java: use "azure-sdk-for-java" -- JavaScript: use "azure-sdk-for-js" -- Go: use "azure-sdk-for-go" - -Use `azsdk_engsys_validate_codeowners_entry_for_service` with either `serviceLabel` OR `repoPath` or both, but at least one must be used. If one isn't provided, leave the parameter field empty. - -**If entry exists**: Go to Step 3 -**If no entry exists**: Go to Step 4 - -## Step 3: Check Existing Code Owners -Valid code owners must be: -- PUBLIC members of Microsoft and Azure GitHub organizations -- Have write access to the SDK repository - -**If at least 2 valid owners**: Success - optionally add or delete additional owners -**If less than 2 valid owners**: CRITICAL - must fix before proceeding: - -After any changes, re-validate with `azsdk_engsys_validate_codeowners_entry_for_service`. - -## Step 4: Create New Code Owner Entry -When no CODEOWNERS entry exists yet: -1. Ensure you have the following information - - repo - **Required** - Repository name mapping: - - .NET/dotnet: use "azure-sdk-for-net" - - Python: use "azure-sdk-for-python" - - Java: use "azure-sdk-for-java" - - JavaScript: use "azure-sdk-for-js" - - Go: use "azure-sdk-for-go" - - typeSpecProjectRoot - **Optional** This should be acquired only if the information is present in the previous chat history, if not, ignore and input `""`. - - path - **Optional** only if there is a service label and we're not making a new entry - This should be acquired when creating a new code owner entry, if no information is present ask the user. Typically looks like `/sdk/projectpath` - - serviceLabel - **Optional** only if there is a path and we're not making a new entry - This should be acquired from the previous step of Check or Create Service Label. - - serviceOwners - **Optional** if no ServiceLabel is present. Can be either owners to add or delete, depending on isAdding. - - sourceOwners - **Optional** if no path or PRLabel are present. Can be either owners to add or delete, depending on isAdding. - - isAdding - **Required** Should be true if adding owners to an existing entry, false if deleting owners from an existing entry. Should also be false when adding a brand new entry. -1. Provide information to the user about what codeowners is for: - - [Learn about CODEOWNERS](https://eng.ms/docs/products/azure-developer-experience/develop/supporting-sdk-customers/overview) - - Service owners is for getting mentioned on issues. - - Source owners is for getting mentioned in PRs. -2. Collect service owners and source owners (GitHub usernames) -3. Use `azsdk_engsys_codeowner_update` with required parameters -4. Must have at least 2 valid owners from the start - -### Fix Options: -1. **Fix invalid owners** - If there are invalid owners after modifing the CODEOWNERS file ALWAYS provide guidance. - Follow instructions [here](https://aka.ms/azsdk/access) for: - - Joining Microsoft and Azure GitHub orgs - - Setting public visibility - - Requesting write access -2. **Add new owners** using `azsdk_engsys_codeowner_update` with `isAdding: true` -3. **Remove invalid + add valid** owners using `azsdk_engsys_codeowner_update` - -## Requirements -- **MINIMUM**: At least 2 valid code owners at all times -- **NO EXCEPTIONS**: Cannot proceed with insufficient owners -- **RESPONSE HANDLING**: If any exception occurs during validation or creation, ALWAYS provide documentation link [CODEOWNERS documentation](https://eng.ms/docs/products/azure-developer-experience/develop/supporting-sdk-customers/codeowners) \ No newline at end of file diff --git a/eng/common/mcp/azure-sdk-mcp.ps1 b/eng/common/mcp/azure-sdk-mcp.ps1 index 6a3c23ee6eaf..fd46dbe9db8b 100755 --- a/eng/common/mcp/azure-sdk-mcp.ps1 +++ b/eng/common/mcp/azure-sdk-mcp.ps1 @@ -4,8 +4,6 @@ #Requires -PSEdition Core param( - [string]$FileName = 'Azure.Sdk.Tools.Cli', - [string]$Package = 'azsdk', [string]$Version, # Default to latest [string]$InstallDirectory = '', [string]$Repository = 'Azure/azure-sdk-tools', @@ -15,11 +13,14 @@ param( [switch]$UpdatePathInProfile ) -$ErrorActionPreference = "Stop" +$ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot '..' 'scripts' 'Helpers' 'AzSdkTool-Helpers.ps1') $toolInstallDirectory = $InstallDirectory ? $InstallDirectory : (Get-CommonInstallDirectory) +$packageName = 'azsdk' +$packageFileName = 'Azure.Sdk.Tools.Cli' + $mcpMode = $Run # Log to console or MCP client json-rpc @@ -103,14 +104,40 @@ $tmp = $env:TEMP ? $env:TEMP : [System.IO.Path]::GetTempPath() $guid = [System.Guid]::NewGuid() $tempInstallDirectory = Join-Path $tmp "azsdk-install-$($guid)" +# If already installed, use first class version mechanism +$azsdkCmd = Get-Command -ErrorAction Ignore $packageName +if ($azsdkCmd -and !$InstallDirectory) { + $ErrorActionPreference = "Stop" + $upgrade = & $packageName upgrade --check --output json | out-string + if (!$LASTEXITCODE) { + $ErrorActionPreference = 'Ignore' + $localVersion = $upgrade | ConvertFrom-Json -AsHashtable + $ErrorActionPreference = 'Stop' + if ($localVersion -and $localVersion.old_version -and $localVersion.old_version -eq ($Version ? $Version : $localVersion.new_version)) { + log "Version up to date at $($localVersion.old_version)" + if ($Run) { + $proc = Start-Process -PassThru -WorkingDirectory $RunDirectory -FilePath $azsdkCmd.Path -ArgumentList 'mcp' -NoNewWindow -Wait + exit $proc.ExitCode + } + exit 0 + } + if ($localVersion) { + log "Version not up to date at " + $localVersion.old_version + } else { + log "Failed to parse version:" + log $upgrade + } + } +} + if ($mcpMode) { try { # Swallow all output and re-log so we can wrap any # output from the inner function as json-rpc $tempExe = Install-Standalone-Tool ` -Version $Version ` - -FileName $FileName ` - -Package $Package ` + -FileName $packageFileName ` + -Package $packageName ` -Directory $tempInstallDirectory ` -Repository $Repository ` *>&1 @@ -126,8 +153,8 @@ if ($mcpMode) { else { $tempExe = Install-Standalone-Tool ` -Version $Version ` - -FileName $FileName ` - -Package $Package ` + -FileName $packageFileName ` + -Package $packageName ` -Directory $tempInstallDirectory ` -Repository $Repository ` @@ -164,7 +191,7 @@ if (Test-Path $tempInstallDirectory) { } if ($updateSucceeded) { - log "Executable $package is installed at $exeDestination" + log "Executable $packageName is installed at $exeDestination" } if (!$UpdatePathInProfile) { log -warn "To add the tool to PATH for new shell sessions, re-run with -UpdatePathInProfile to modify the shell profile file." @@ -175,5 +202,6 @@ else { } if ($Run) { - Start-Process -WorkingDirectory $RunDirectory -FilePath $exeDestination -ArgumentList 'start' -NoNewWindow -Wait + $proc = Start-Process -PassThru -WorkingDirectory $RunDirectory -FilePath $exeDestination -ArgumentList 'mcp' -NoNewWindow -Wait + exit $proc.ExitCode } diff --git a/eng/common/pipelines/templates/jobs/prepare-pipelines.yml b/eng/common/pipelines/templates/jobs/prepare-pipelines.yml index 906a9bfcf1fe..46d2d0babb7f 100644 --- a/eng/common/pipelines/templates/jobs/prepare-pipelines.yml +++ b/eng/common/pipelines/templates/jobs/prepare-pipelines.yml @@ -107,8 +107,8 @@ jobs: } "rust" { $generatePublicCIPipeline = 'false' - $internalVariableGroups = '$(AzureSDK_CratesIo_Release_Pipeline_Secrets) $(Release_Secrets_for_GitHub) $(APIReview_AutoCreate_Configurations) $(Secrets_for_Resource_Provisioner)' $generateUnifiedWeekly = 'true' + $internalVariableGroups = '$(Release_Secrets_for_GitHub) $(APIReview_AutoCreate_Configurations)' } "net" { $generatePublicCIPipeline = 'false' diff --git a/eng/common/pipelines/templates/steps/bypass-local-dns.yml b/eng/common/pipelines/templates/steps/bypass-local-dns.yml deleted file mode 100644 index 6e30c91fd258..000000000000 --- a/eng/common/pipelines/templates/steps/bypass-local-dns.yml +++ /dev/null @@ -1,11 +0,0 @@ - -steps: - # https://github.com/actions/virtual-environments/issues/798 - - script: sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf - displayName: Bypass local DNS server to workaround issue resolving cognitiveservices names - condition: | - and( - succeededOrFailed(), - or(contains(variables['OSVmImage'], 'ubuntu'),contains(variables['OSVmImage'], 'linux')), - eq(variables['Container'], '') - ) diff --git a/eng/common/pipelines/templates/steps/create-authenticated-npmrc.yml b/eng/common/pipelines/templates/steps/create-authenticated-npmrc.yml index b13ed7666e31..d742bd72b7f2 100644 --- a/eng/common/pipelines/templates/steps/create-authenticated-npmrc.yml +++ b/eng/common/pipelines/templates/steps/create-authenticated-npmrc.yml @@ -21,7 +21,7 @@ steps: New-Item -Path $parentFolder -ItemType Directory | Out-Null } - $content = "registry=${{ parameters.registryUrl }}`n`nalways-auth=true" + $content = "registry=${{ parameters.registryUrl }}" $content | Out-File '${{ parameters.npmrcPath }}' displayName: 'Create .npmrc' condition: ${{ parameters.CustomCondition }} diff --git a/eng/common/pipelines/templates/steps/create-tags-and-git-release.yml b/eng/common/pipelines/templates/steps/create-tags-and-git-release.yml index dc15bbdc44d7..e33100ea167c 100644 --- a/eng/common/pipelines/templates/steps/create-tags-and-git-release.yml +++ b/eng/common/pipelines/templates/steps/create-tags-and-git-release.yml @@ -25,6 +25,7 @@ steps: timeoutInMinutes: 5 env: GH_TOKEN: $(azuresdk-github-pat) + SYSTEM_ACCESSTOKEN: $(System.AccessToken) ${{ if ne(parameters.NpmConfigUserConfig, '') }}: NPM_CONFIG_USERCONFIG: ${{ parameters.NpmConfigUserConfig }} ${{ if ne(parameters.NpmConfigRegistry, '') }}: diff --git a/eng/common/pipelines/templates/steps/install-azsdk-cli.yml b/eng/common/pipelines/templates/steps/install-azsdk-cli.yml index 3cbd66d9c4e5..6d26fc488f03 100644 --- a/eng/common/pipelines/templates/steps/install-azsdk-cli.yml +++ b/eng/common/pipelines/templates/steps/install-azsdk-cli.yml @@ -1,10 +1,12 @@ parameters: InstallDirectory: $(Agent.TempDirectory) SourceRootPath: '$(System.DefaultWorkingDirectory)' + Condition: succeeded() steps: - task: Powershell@2 displayName: 'Install Azure SDK Tools CLI' + condition: ${{ parameters.Condition }} inputs: targetType: 'inline' script: | diff --git a/eng/common/pipelines/templates/steps/publish-blobs.yml b/eng/common/pipelines/templates/steps/publish-blobs.yml index a78d629e586b..f84e989111ee 100644 --- a/eng/common/pipelines/templates/steps/publish-blobs.yml +++ b/eng/common/pipelines/templates/steps/publish-blobs.yml @@ -26,3 +26,4 @@ steps: pwsh: true env: AZCOPY_AUTO_LOGIN_TYPE: 'PSCRED' + SYSTEM_ACCESSTOKEN: $(System.AccessToken) diff --git a/eng/common/pipelines/templates/steps/verify-agent-os.yml b/eng/common/pipelines/templates/steps/verify-agent-os.yml index a9109cf51803..5c5fe54c957d 100644 --- a/eng/common/pipelines/templates/steps/verify-agent-os.yml +++ b/eng/common/pipelines/templates/steps/verify-agent-os.yml @@ -14,5 +14,3 @@ steps: filePath: ${{ parameters.ScriptDirectory }}/Verify-AgentOS.ps1 arguments: > -AgentImage "${{ parameters.AgentImage }}" - - - template: /eng/common/pipelines/templates/steps/bypass-local-dns.yml diff --git a/eng/common/pipelines/templates/steps/verify-codeowners-sections.yml b/eng/common/pipelines/templates/steps/verify-codeowners-sections.yml new file mode 100644 index 000000000000..229d24f996a4 --- /dev/null +++ b/eng/common/pipelines/templates/steps/verify-codeowners-sections.yml @@ -0,0 +1,101 @@ +parameters: + SourceRootPath: '$(System.DefaultWorkingDirectory)' + TempDirectory: '$(Agent.TempDirectory)/codeowners-check' + Sections: + - 'Client Libraries' + - 'Management Top Level Owners' + - 'Management Libraries' + +steps: + - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: + # 1. Check if CODEOWNERS changed; if so, check whether the PR was opened by azure-sdk + - pwsh: | + $scriptPath = [System.IO.Path]::Combine("${{ parameters.SourceRootPath }}", "eng", "common", "scripts", "get-changedfiles.ps1") + try { + $changedFiles = & $scriptPath ` + -DiffPath ".github/CODEOWNERS" ` + -DiffFilterType '' + + if ($LASTEXITCODE -gt 1) { + Write-Host "git diff returned exit code $LASTEXITCODE, which indicates an error. Output: $changedFiles" + exit 1 + } + } catch { + Write-Host "Error running get-changedfiles.ps1: $_" + exit 1 + } + + if (!$changedFiles) { + Write-Host "CODEOWNERS file has not changed." + Write-Host "##vso[task.setvariable variable=ShouldCheckCodeownersEdits]false" + exit 0 + } + + Write-Host "CODEOWNERS file has changed." + + $prNumber = "$(System.PullRequest.PullRequestNumber)" + $repoName = "$(Build.Repository.Name)" + $prUrl = "https://api.github.com/repos/$repoName/pulls/$prNumber" + $prData = Invoke-RestMethod -Uri $prUrl -MaximumRetryCount 10 -RetryIntervalSec 6 + $prAuthor = $prData.user.login + Write-Host "PR author: $prAuthor" + + if ($prAuthor -ieq "azure-sdk") { + Write-Host "PR opened by azure-sdk. Skipping CODEOWNERS section check." + Write-Host "##vso[task.setvariable variable=ShouldCheckCodeownersEdits]false" + exit 0 + } + + Write-Host "##vso[task.setvariable variable=ShouldCheckCodeownersEdits]true" + displayName: 'Check if CODEOWNERS changed' + workingDirectory: ${{ parameters.SourceRootPath }} + condition: and(succeeded(), ne(variables['Skip.VerifyCodeownersSections'], 'true')) + + # 2. Install the azsdk CLI (only if CODEOWNERS changed) + - template: /eng/common/pipelines/templates/steps/install-azsdk-cli.yml + parameters: + SourceRootPath: ${{ parameters.SourceRootPath }} + Condition: and(succeeded(), eq(variables['ShouldCheckCodeownersEdits'], 'true')) + + # 3. Prepare before/after CODEOWNERS files + - pwsh: | + $tempDir = "${{ parameters.TempDirectory }}" + if (Test-Path $tempDir) { Remove-Item -Recurse -Force $tempDir } + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + $afterFile = Join-Path $tempDir "CODEOWNERS.after" + $beforeFile = Join-Path $tempDir "CODEOWNERS.before" + + # Copy current (PR head) version + Copy-Item -Path ".github/CODEOWNERS" -Destination $afterFile -Force + Write-Host "Copied current CODEOWNERS to $afterFile" + + # Extract parent commit version + git show "HEAD~1:.github/CODEOWNERS" > $beforeFile + Write-Host "Retrieved parent commit CODEOWNERS to $beforeFile" + + if ($LASTEXITCODE) { + Write-Host "Could not retrieve CODEOWNERS from HEAD~1. The file may be newly added. Skipping." + Write-Host "##vso[task.setvariable variable=ShouldCheckCodeownersEdits]false" + exit 0 + } + Write-Host "Extracted parent CODEOWNERS to $beforeFile" + displayName: 'Prepare CODEOWNERS before/after files' + workingDirectory: ${{ parameters.SourceRootPath }} + condition: and(succeeded(), eq(variables['ShouldCheckCodeownersEdits'], 'true')) + + # 4. Run the section comparison script + - task: Powershell@2 + displayName: 'Test protected CODEOWNERS sections' + inputs: + targetType: 'filePath' + filePath: '${{ parameters.SourceRootPath }}/eng/common/scripts/Test-CodeownersSections.ps1' + arguments: >- + -AzsdkCliPath "$(AZSDK)" + -BeforeFile "${{ parameters.TempDirectory }}/CODEOWNERS.before" + -AfterFile "${{ parameters.TempDirectory }}/CODEOWNERS.after" + -Sections "${{ join('","', parameters.Sections) }}" + -TempDirectory "${{ parameters.TempDirectory }}" + pwsh: true + workingDirectory: ${{ parameters.SourceRootPath }} + condition: and(succeeded(), eq(variables['ShouldCheckCodeownersEdits'], 'true')) diff --git a/eng/common/pipelines/templates/steps/verify-codeowners.yml b/eng/common/pipelines/templates/steps/verify-codeowners.yml new file mode 100644 index 000000000000..b272329bb375 --- /dev/null +++ b/eng/common/pipelines/templates/steps/verify-codeowners.yml @@ -0,0 +1,32 @@ +parameters: + - name: ArtifactPath + type: string + default: $(Build.ArtifactStagingDirectory)/PackageInfo + - name: Repo + type: string + default: $(Build.Repository.Name) + - name: SdkTypes + type: object + default: + - client + - compat + - data + - functions + - datamovement + +steps: + - template: /eng/common/pipelines/templates/steps/install-azsdk-cli.yml + parameters: + Condition: and(succeeded(), ne(variables['Skip.VerifyCodeowners'], 'true')) + + - task: PowerShell@2 + displayName: Verify Codeowners + condition: and(succeeded(), ne(variables['Skip.VerifyCodeowners'], 'true')) + inputs: + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/common/scripts/Test-CodeownersForArtifacts.ps1 + arguments: >- + -AzsdkPath '$(AZSDK)' + -PackageInfoDirectory '${{ parameters.ArtifactPath }}' + -SdkTypes ${{ join(',', parameters.SdkTypes) }} + -Repo '${{ parameters.Repo }}' diff --git a/eng/common/scripts/Helpers/ApiView-Helpers.ps1 b/eng/common/scripts/Helpers/ApiView-Helpers.ps1 index c7b1f313b19e..b6bbb9dc9658 100644 --- a/eng/common/scripts/Helpers/ApiView-Helpers.ps1 +++ b/eng/common/scripts/Helpers/ApiView-Helpers.ps1 @@ -127,7 +127,8 @@ function Set-ApiViewCommentForRelatedIssues { [string]$APIViewHost = "https://apiview.dev", [ValidateNotNullOrEmpty()] [Parameter(Mandatory = $true)] - $AuthToken + $AuthToken, + [string]$GitHubActionRunUrl = "" ) . ${PSScriptRoot}\..\common.ps1 $issuesForCommit = $null @@ -144,7 +145,7 @@ function Set-ApiViewCommentForRelatedIssues { } $issuesForCommit.items | ForEach-Object { $urlParts = $_.url -split "/" - Set-ApiViewCommentForPR -RepoOwner $urlParts[4] -RepoName $urlParts[5] -PrNumber $urlParts[7] -HeadCommitish $HeadCommitish -APIViewHost $APIViewHost -AuthToken $AuthToken + Set-ApiViewCommentForPR -RepoOwner $urlParts[4] -RepoName $urlParts[5] -PrNumber $urlParts[7] -HeadCommitish $HeadCommitish -APIViewHost $APIViewHost -AuthToken $AuthToken -GitHubActionRunUrl $GitHubActionRunUrl } } @@ -162,7 +163,8 @@ function Set-ApiViewCommentForPR { [string]$APIViewHost, [ValidateNotNullOrEmpty()] [Parameter(Mandatory = $true)] - $AuthToken + $AuthToken, + [string]$GitHubActionRunUrl = "" ) $repoFullName = "$RepoOwner/$RepoName" $apiviewEndpoint = "$APIViewHost/api/pullrequests?pullRequestNumber=$PrNumber&repoName=$repoFullName&commitSHA=$HeadCommitish" @@ -208,6 +210,11 @@ function Set-ApiViewCommentForPR { } $commentText += "" + if (-not [string]::IsNullOrWhiteSpace($GitHubActionRunUrl)) { + $commentText += "" + $commentText += "---" + $commentText += "Comment generated by [After APIView]($GitHubActionRunUrl) workflow run." + } $commentText = $commentText -join "`r`n" $existingComment = $null; $existingAPIViewComment = $null; diff --git a/eng/common/scripts/Helpers/Resource-Helpers.ps1 b/eng/common/scripts/Helpers/Resource-Helpers.ps1 index 5fca15845aeb..64ea0aeaac28 100644 --- a/eng/common/scripts/Helpers/Resource-Helpers.ps1 +++ b/eng/common/scripts/Helpers/Resource-Helpers.ps1 @@ -434,11 +434,17 @@ function RemoveStorageAccount($Account) { try { foreach ($container in $containers) { - $blobs = $container | Get-AzStorageBlob - foreach ($blob in $blobs) { - $shouldDelete = EnableBlobDeletion -Blob $blob -Container $container -StorageAccountName $Account.StorageAccountName -ResourceGroupName $Account.ResourceGroupName - if ($shouldDelete) { - $deleteNow += $blob + # VLW containers need version-aware cleanup: soft-delete causes deleted blobs to linger + # as non-current versions that block container deletion. See Remove-VlwContainerBlobs. + if (($container | Get-Member 'BlobContainerProperties') -and $container.BlobContainerProperties.HasImmutableStorageWithVersioning) { + Remove-VlwContainerBlobs -Container $container -StorageAccountName $Account.StorageAccountName -ResourceGroupName $Account.ResourceGroupName + } else { + $blobs = $container | Get-AzStorageBlob + foreach ($blob in $blobs) { + $shouldDelete = EnableBlobDeletion -Blob $blob -Container $container -StorageAccountName $Account.StorageAccountName -ResourceGroupName $Account.ResourceGroupName + if ($shouldDelete) { + $deleteNow += $blob + } } } } @@ -524,6 +530,41 @@ function EnableBlobDeletion($Blob, $Container, $StorageAccountName, $ResourceGro return $forceBlobDeletion } +# In VLW (Versioned-Level WORM) containers with soft-delete enabled, deleting a blob creates a +# non-current version instead of truly removing it. A standard Get-AzStorageBlob listing can't +# see these leftovers, but they still block container deletion (409 Conflict on the management +# plane DELETE). Listing with -IncludeVersion -IncludeDeleted makes them visible so we can clear +# immutability policies / legal holds and delete each version individually. Multiple passes handle +# new non-current versions that surface after each round of deletions. +function Remove-VlwContainerBlobs($Container, $StorageAccountName, $ResourceGroupName) { + Write-Host "Cleaning VLW container '$($Container.Name)' versions and soft-deleted blobs in account '$StorageAccountName', group: $ResourceGroupName" + + for ($round = 0; $round -lt 5; $round++) { + $found = $false + $blobs = @($Container | Get-AzStorageBlob -IncludeVersion -IncludeDeleted -ErrorAction SilentlyContinue) + + foreach ($blob in $blobs) { + $found = $true + + # Unconditionally clear legal holds and immutability policies. Errors are expected for + # soft-deleted blobs or blobs that don't have these set. + try { $blob | Set-AzStorageBlobLegalHold -DisableLegalHold | Out-Null } catch { } + try { $blob | Remove-AzStorageBlobImmutabilityPolicy | Out-Null } catch { } + try { + $blob | Remove-AzStorageBlob -Force + } catch { + # Deleting the current version by version ID returns 403 + # (OperationNotAllowedOnRootBlob); fall back to base blob deletion. + try { + Remove-AzStorageBlob -Container $Container.Name -Blob $blob.Name -Context $Container.Context -Force + } catch { } + } + } + + if (-not $found) { break } + } +} + function DoesSubnetOverlap([string]$ipOrCidr, [string]$overlapIp) { [System.Net.IPAddress]$overlapIpAddress = $overlapIp $parsed = $ipOrCidr -split '/' @@ -543,4 +584,4 @@ function DoesSubnetOverlap([string]$ipOrCidr, [string]$overlapIp) { } return $baseIp.Address -eq ($overlapIpAddress.Address -band ([System.Net.IPAddress]$mask).Address) -} +} \ No newline at end of file diff --git a/eng/common/scripts/Test-CodeownersForArtifacts.ps1 b/eng/common/scripts/Test-CodeownersForArtifacts.ps1 new file mode 100644 index 000000000000..3c83173c21d2 --- /dev/null +++ b/eng/common/scripts/Test-CodeownersForArtifacts.ps1 @@ -0,0 +1,59 @@ +[CmdletBinding()] +param( + [string] $AzsdkPath, + [string] $PackageInfoDirectory, + [array] $SdkTypes, + [string] $Repo +) + +. "$PSScriptRoot/common.ps1" + +Set-StrictMode -Version 3 +$ErrorActionPreference = 'Stop' + +$failedPackages = @() + +foreach ($pkgPropertiesFile in Get-ChildItem -Path $PackageInfoDirectory -Filter '*.json' -File) { + $pkgProperties = Get-Content -Raw -Path $pkgPropertiesFile | ConvertFrom-Json + if ($SdkTypes -notcontains $pkgProperties.SdkType) { + Write-Host "Skipping package: $($pkgProperties.Name) $($pkgProperties.DirectoryPath) because its SdkType '$($pkgProperties.SdkType)' is not in the list of SdkTypes to validate." + continue + } + + Write-Host "Validating codeowners for package: $($pkgProperties.Name) $($pkgProperties.DirectoryPath)" + + if (!$pkgProperties.ReleaseStatus) { + LogError "Package $($pkgProperties.Name) at $($pkgProperties.DirectoryPath) is missing a ReleaseStatus property." + $failedPackages += $pkgProperties.DirectoryPath + continue + } + + # Validate packages with a release date (intended to release) + if ($pkgProperties.ReleaseStatus -ne "Unreleased") { + $output = & $AzsdkPath config codeowners check-package ` + --directory-path $pkgProperties.DirectoryPath ` + --repo $Repo ` + --output json 2>&1 + + if ($LASTEXITCODE) { + LogError "Codeowners validation failed for package: $($pkgProperties.DirectoryPath)" + $output | Write-Host + $failedPackages += $pkgProperties.DirectoryPath + } else { + Write-Host " Codeowners validation succeeded for package: $($pkgProperties.DirectoryPath)" + } + } else { + Write-Host " Skipping CODEOWNERS validation, package is not intended to release." + } +} + +if ($failedPackages.Count -gt 0) { + Write-Host "" + Write-Host "Failed Packages:" + foreach ($directoryPath in $failedPackages) { + LogError " - $directoryPath does not have sufficient code owners coverage" + } + LogError "Codeowners validation failed for one or more packages. See http://aka.ms/azsdk/codeowners for instructions to fix the issue." + exit 1 +} +exit 0 diff --git a/eng/common/scripts/Test-CodeownersSections.ps1 b/eng/common/scripts/Test-CodeownersSections.ps1 new file mode 100644 index 000000000000..bf3b02189078 --- /dev/null +++ b/eng/common/scripts/Test-CodeownersSections.ps1 @@ -0,0 +1,129 @@ +# cSpell:ignore CODEOWNERS +<# + .SYNOPSIS + Tests that specified CODEOWNERS sections are identical between two file versions. + + .DESCRIPTION + Uses the azsdk CLI to export named sections from a "before" and "after" copy of + the CODEOWNERS file. If any of the specified sections differ between the two + files the script exits with code 1. + + All filesystem and git setup (creating the before/after files, installing the + CLI, etc.) is expected to be done by the calling pipeline step template. + + .PARAMETER AzsdkCliPath + Path to the azsdk CLI executable. + + .PARAMETER BeforeFile + Path to the CODEOWNERS file representing the base state (e.g. parent commit). + + .PARAMETER AfterFile + Path to the CODEOWNERS file representing the current state (e.g. PR head). + + .PARAMETER Sections + An array of section names to compare (e.g. "Client Libraries"). + + .PARAMETER TempDirectory + Scratch directory for intermediate section export files. +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [string] $AzsdkCliPath, + + [Parameter(Mandatory)] + [string] $BeforeFile, + + [Parameter(Mandatory)] + [string] $AfterFile, + + [Parameter(Mandatory)] + [string[]] $Sections, + + [string] $TempDirectory = (Join-Path ([System.IO.Path]::GetTempPath()) "codeowners-check") +) + +."$PSScriptRoot\common.ps1" + +Set-StrictMode -Version 3 +$ErrorActionPreference = "Stop" + +# --------------------------------------------------------------------------- +# 1. Validate inputs +# --------------------------------------------------------------------------- +if (-not (Test-Path $BeforeFile)) { + Write-Error "BeforeFile not found: $BeforeFile" + exit 1 +} +if (-not (Test-Path $AfterFile)) { + Write-Error "AfterFile not found: $AfterFile" + exit 1 +} +if (-not (Test-Path $AzsdkCliPath)) { + Write-Error "azsdk CLI not found: $AzsdkCliPath" + exit 1 +} + +# --------------------------------------------------------------------------- +# 2. Ensure temp directory exists +# --------------------------------------------------------------------------- +if (-not (Test-Path $TempDirectory)) { + New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null +} + +# --------------------------------------------------------------------------- +# 3. Export and compare each section +# --------------------------------------------------------------------------- +$failed = $false + +$beforePath = Resolve-Path $BeforeFile +Write-Host "Before file: $beforePath" +$afterPath = Resolve-Path $AfterFile +Write-Host "After file: $afterPath" + +foreach ($section in $Sections) { + $safeName = $section -replace ' ', '_' + $beforeSection = Join-Path $TempDirectory "before.${safeName}.txt" + $afterSection = Join-Path $TempDirectory "after.${safeName}.txt" + + Write-Host "Exporting section '$section' from before file..." + & $AzsdkCliPath config codeowners export-section --codeowners-path $beforePath --section $section --output-file $beforeSection + if ($LASTEXITCODE) { + LogError "Failed to export section '$section' from before file (exit code $LASTEXITCODE)." + exit 1 + } + + Write-Host "Exporting section '$section' from after file..." + & $AzsdkCliPath config codeowners export-section --codeowners-path $afterPath --section $section --output-file $afterSection + if ($LASTEXITCODE) { + LogError "Failed to export section '$section' from after file (exit code $LASTEXITCODE)." + exit 1 + } + + $beforeContent = Get-Content -Path $beforeSection -Raw + $afterContent = Get-Content -Path $afterSection -Raw + + if ($beforeContent -ne $afterContent) { + LogError "Protected CODEOWNERS section '$section' has been modified. Changes to this section are not allowed through normal PRs. To update CODEOWNERS, follow instructions at https://aka.ms/azsdk/codeowners" + Write-Host "--- Diff for section '$section' ---" + Write-Host "" + git diff --no-index -- $beforeSection $afterSection + $failed = $true + } else { + Write-Host "Section '$section' is unchanged." + } +} + +# --------------------------------------------------------------------------- +# 4. Exit +# --------------------------------------------------------------------------- +if ($failed) { + $sectionList = ($Sections | ForEach-Object { "'$_'" }) -join ", " + + Write-Host "" + LogError "##vso[task.LogIssue type=error;]One or more protected CODEOWNERS sections have been modified. Please revert changes to the $sectionList sections." + exit 1 +} + +Write-Host "All protected CODEOWNERS sections are unchanged. Check passed." +exit 0 diff --git a/eng/common/scripts/Update-DocsMsToc.ps1 b/eng/common/scripts/Update-DocsMsToc.ps1 index aa84b1c6b8c3..70f8efd0ae4f 100644 --- a/eng/common/scripts/Update-DocsMsToc.ps1 +++ b/eng/common/scripts/Update-DocsMsToc.ps1 @@ -55,7 +55,10 @@ param( . $PSScriptRoot/common.ps1 . $PSScriptRoot/Helpers/PSModule-Helpers.ps1 -Install-ModuleIfNotInstalled "powershell-yaml" "0.4.7" | Import-Module +Install-ModuleIfNotInstalled "powershell-yaml" "0.4.12" | Import-Module + +$loadedModule = Get-Module powershell-yaml +Write-Host "powershell-yaml version loaded: $($loadedModule.Version) from $($loadedModule.ModuleBase)" Set-StrictMode -Version 3 @@ -68,8 +71,11 @@ function GetPackageNode($package) { return [PSCustomObject]@{ name = $packageInfo.PackageTocHeader href = $packageInfo.PackageLevelReadmeHref - # This is always one package and it must be an array - children = $packageInfo.TocChildren + # This is always one package and it must be an array. + # Cast to [string[]] to strip PSObject wrapping added by Sort-Object, + # which causes ConvertTo-Yaml to serialize string properties (e.g. Length) + # instead of string values. + children = [string[]]$packageInfo.TocChildren }; } diff --git a/eng/common/scripts/Verify-Readme.ps1 b/eng/common/scripts/Verify-Readme.ps1 index 05779d9aa3dc..ff81c82cceff 100644 --- a/eng/common/scripts/Verify-Readme.ps1 +++ b/eng/common/scripts/Verify-Readme.ps1 @@ -10,6 +10,7 @@ param ( ) . (Join-Path $PSScriptRoot common.ps1) $DefaultDocWardenVersion = "0.7.3" +$FeedUrl = "https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/" $script:FoundError = $false function Test-Readme-Files { @@ -81,16 +82,17 @@ if ($RepoRoot) { Write-Host "ScanPath=$ScanPaths" Write-Host "Installing setup tools and DocWarden" -Write-Host "pip install setuptools wheel --quiet" -pip install setuptools wheel --quiet +Write-Host "pip install setuptools wheel --quiet --index-url $FeedUrl" +pip install setuptools wheel --quiet --index-url $FeedUrl if ($LASTEXITCODE -ne 0) { - LogError "pip install setuptools wheel --quiet failed with exit code $LASTEXITCODE" + LogError "pip install setuptools wheel --quiet --index-url $FeedUrl failed with exit code $LASTEXITCODE" exit 1 } -Write-Host "pip install doc-warden==$DocWardenVersion --quiet" -pip install doc-warden==$DocWardenVersion --quiet + +Write-Host "pip install doc-warden==$DocWardenVersion --quiet --index-url $FeedUrl" +pip install doc-warden==$DocWardenVersion --quiet --index-url $FeedUrl if ($LASTEXITCODE -ne 0) { - LogError "pip install doc-warden==$DocWardenVersion --quiet failed with exit code $LASTEXITCODE" + LogError "pip install doc-warden==$DocWardenVersion --quiet --index-url $FeedUrl failed with exit code $LASTEXITCODE" exit 1 } diff --git a/eng/common/scripts/azsdk_prompt_telemetry.ps1 b/eng/common/scripts/azsdk_prompt_telemetry.ps1 new file mode 100644 index 000000000000..dca9e12ef76b --- /dev/null +++ b/eng/common/scripts/azsdk_prompt_telemetry.ps1 @@ -0,0 +1,77 @@ +$ErrorActionPreference = "SilentlyContinue" +. (Join-Path $PSScriptRoot '..' 'scripts' 'Helpers' 'AzSdkTool-Helpers.ps1') + +$cliPath = Get-CommonInstallDirectory +$cliPath = Join-Path $cliPath "azsdk" + +# Skip telemetry if opted out +if ($env:AZSDKTOOLS_COLLECT_TELEMETRY -eq "false") +{ + Write-Success +} + +# Return success and exit +function Write-Success +{ + Write-Output '{"continue":true}' + exit 0 +} + +# Read entire stdin at once - hooks send one complete JSON per invocation +try +{ + $rawInput = [Console]::In.ReadToEnd() +} catch +{ + Write-Success +} +# Return success and exit if no input +if ([string]::IsNullOrWhiteSpace($rawInput)) +{ + Write-Success +} + +try +{ + $inputData = $rawInput | ConvertFrom-Json +} catch { + Write-Success +} +$prompt = $inputData.prompt +$sessionId = $null +$eventType = "user_prompt" +$clientType = $null + +# Session id +if ($inputData.PSObject.Properties['sessionId']) +{ + $sessionId = $inputData.sessionId + $clientType = "copilot-cli" +} +if (-not $sessionId -and $inputData.PSObject.Properties['session_id']) +{ + $sessionId = $inputData.session_id + $clientType = "vscode" +} + +# === STEP 2: Publish event === +# Build MCP command arguments +$cliArgs = @( + "ingest-telemetry", + "--client-type", $clientType, + "--event-type", $eventType, + "--session-id", $sessionId, + "--body", "'$prompt'" +) + +# run azsdk cli to ingest telemetry (non-blocking) +try +{ + Start-Process -FilePath $cliPath -ArgumentList $cliArgs -NoNewWindow +} +catch +{ + Write-Success +} +# Output success to stdout (required by hooks) +Write-Success \ No newline at end of file diff --git a/eng/common/scripts/azsdk_tool_telemetry.ps1 b/eng/common/scripts/azsdk_tool_telemetry.ps1 new file mode 100644 index 000000000000..04bff02dccbc --- /dev/null +++ b/eng/common/scripts/azsdk_tool_telemetry.ps1 @@ -0,0 +1,187 @@ +$ErrorActionPreference = "SilentlyContinue" +. (Join-Path $PSScriptRoot '..' 'scripts' 'Helpers' 'AzSdkTool-Helpers.ps1') + +$cliPath = Get-CommonInstallDirectory +# check for azsdk.exe on Windows or azsdk on Linux/Mac in the install directory +if ($IsWindows) +{ + $cliPath = Join-Path $cliPath "azsdk.exe" +} +else +{ + $cliPath = Join-Path $cliPath "azsdk" +} + +if (-not (Test-Path $cliPath)) +{ + Write-Success +} + +# Skip telemetry if opted out +if ($env:AZSDKTOOLS_COLLECT_TELEMETRY -eq "false") +{ + Write-Success +} + +$toolsToIgnore = @( "run_in_terminal", "apply_patch") + +# Return success and exit +function Write-Success +{ + Write-Output '{"continue":true}' + exit 0 +} + +# Read entire stdin at once - hooks send one complete JSON per invocation +try +{ + $rawInput = [Console]::In.ReadToEnd() +} catch +{ + Write-Success +} +# Return success and exit if no input +if ([string]::IsNullOrWhiteSpace($rawInput)) +{ + Write-Success +} + +try +{ + $inputData = $rawInput | ConvertFrom-Json +} catch { + Write-Success +} + +$toolName = $null +$sessionId = $null + +# Get tool name. Both vscode and copilot-cli uses different property names, so check for both. +if ($inputData.PSObject.Properties['toolName']) +{ + $toolName = $inputData.toolName +} +if (-not $toolName -and $inputData.PSObject.Properties['tool_name']) +{ + $toolName = $inputData.tool_name +} + +# Skip if no tool name found in either format +if (-not $toolName) +{ + Write-Success +} + +# Check if tool name is in ignore list +if ($toolsToIgnore -contains $toolName) +{ + Write-Success +} + +# Session id +if ($inputData.PSObject.Properties['sessionId']) +{ + $sessionId = $inputData.sessionId +} +if (-not $sessionId -and $inputData.PSObject.Properties['session_id']) +{ + $sessionId = $inputData.session_id +} + +# Get tool input arguments +$toolInput = $null +if ($inputData.PSObject.Properties['toolArgs']) +{ + $toolInput = $inputData.toolArgs +} +if (-not $toolInput -and $inputData.PSObject.Properties['tool_input']) +{ + $toolInput = $inputData.tool_input +} + +# Helper to extract path from tool input (handles 'path', 'filePath', 'file_path') +function Get-ToolInputPath +{ + if ($toolInput.path) { return $toolInput.path } + if ($toolInput.filePath) { return $toolInput.filePath } + if ($toolInput.file_path) { return $toolInput.file_path } + return $null +} + +# === STEP 2: Determine what to track === + +$shouldTrack = $false +$eventType = $null +$skillName = $null +$clientType = $null + +# check if hook is triggered from vscode or copilot-cli. Azure sdk tools hook is not considering claude at this point. +if ($inputData.PSObject.Properties['tool_use_id'] -and $inputData.tool_use_id -match ".*_vscode-.*") +{ + $clientType = "vscode" +} +else +{ + $clientType = "copilot-cli" +} + + +# Process skill invocations (looking for "azsdk" prefix in skill name) +if ($toolName -eq "skill") +{ + $skillName = $toolInput.skill + if ($skillName) + { + $eventType = "skill_invocation" + $shouldTrack = $true + } +} + +# Check for skill invocation (reading SKILL.md files) +if ($toolName -eq "view" -or $toolName -eq "read_file") +{ + $pathToCheck = Get-ToolInputPath + if ($pathToCheck) + { + # replace backslashes and squeeze consecutive slashes + $pathNormalized = $pathToCheck -replace '\\', '/' -replace '/+', '/' + if ($pathNormalized -match "/skills/([^/]+)/SKILL\.md$") + { + $skillName = $Matches[1] + $eventType = "skill_invocation" + $shouldTrack = $true + } + } +} + +# Check for Azure SDK Tools MCP invocation +# Skip mcp tool telemetry since it's already tracked by MCP server +if ($toolName.StartsWith("mcp_azure-sdk") -or $toolName.StartsWith("azure-sdk-mcp")) +{ + Write-Success +} + +# === STEP 3: Publish event === +if ($shouldTrack) +{ + # Build MCP command arguments + $cliArgs = @( + "ingest-telemetry", + "--client-type", $clientType + ) + + if ($eventType) { $cliArgs += "--event-type"; $cliArgs += $eventType } + if ($sessionId) { $cliArgs += "--session-id"; $cliArgs += $sessionId } + if ($skillName) { $cliArgs += "--skill-name"; $cliArgs += $skillName } + + # run azsdk cli to ingest telemetry (non-blocking) + try + { + Start-Process -FilePath $cliPath -ArgumentList $cliArgs -NoNewWindow + } + catch { + Write-Success + } +} +# Output success to stdout (required by hooks) +Write-Success \ No newline at end of file diff --git a/eng/common/spelling/package-lock.json b/eng/common/spelling/package-lock.json index 844fe3ac77b5..00a059c60781 100644 --- a/eng/common/spelling/package-lock.json +++ b/eng/common/spelling/package-lock.json @@ -247,9 +247,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-en_us": { - "version": "4.4.31", - "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.31.tgz", - "integrity": "sha512-MdV6mbrSkyIqn9+6F5poCyHIPqWB3H/wlDL9LXfjgAxNFBdYRE767xJtIYunzUqhUiJHSJgZajEFdTtMDw441Q==", + "version": "4.4.32", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.32.tgz", + "integrity": "sha512-37NZrwnI9FyZLtUolgOtA2Xy2u1dYIiwQMqwVFKKd8tcyaM8SgONFqbs6c/rzr2JcL0n6ziCy/ptBQSREgfBMw==", "license": "MIT" }, "node_modules/@cspell/dict-en-common-misspellings": { @@ -259,15 +259,15 @@ "license": "CC BY-SA 4.0" }, "node_modules/@cspell/dict-en-gb-mit": { - "version": "3.1.20", - "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.20.tgz", - "integrity": "sha512-rcWEKb1mwgK13CSHu6GwwFA/sWLRkB0twTzSfPxUOULJkhcUrL93ixohk416SBa0BVxixub+lOpTXKcCTbTXsA==", + "version": "3.1.21", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.21.tgz", + "integrity": "sha512-7Q4SDABCIk3ZAngrfSlSZEE+8Uiiu3wssBjC6t7iN++SM2wMjqSnHaHh1GJoLONI3+5m3XsYakZ5KnYuAXqncQ==", "license": "MIT" }, "node_modules/@cspell/dict-filetypes": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.17.tgz", - "integrity": "sha512-6f1gZf9o60fGQXGi/fZaryISUNDRmmJwGyr4QU1UvgUgOdpDHLVJtv/0wSL9q5F0wAkYhbXo/fNG8CcUTaf7Ww==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz", + "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==", "license": "MIT" }, "node_modules/@cspell/dict-flutter": { @@ -289,9 +289,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-fullstack": { - "version": "3.2.8", - "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.8.tgz", - "integrity": "sha512-J6EeoeThvx/DFrcA2rJiCA6vfqwJMbkG0IcXhlsmRZmasIpanmxgt90OEaUazbZahFiuJT8wrhgQ1QgD1MsqBw==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz", + "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==", "license": "MIT" }, "node_modules/@cspell/dict-gaming-terms": { @@ -409,9 +409,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-npm": { - "version": "5.2.37", - "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.37.tgz", - "integrity": "sha512-dYKrD0bI08YgebJcbjsIDpTMK6EH0wTkEyQLsaH0T4tmsLJE95coTYb3eE7giRRGJADvBqrjH4fz5+INd85QqQ==", + "version": "5.2.38", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.38.tgz", + "integrity": "sha512-21ucGRPYYhr91C2cDBoMPTrcIOStQv33xOqJB0JLoC5LAs2Sfj9EoPGhGb+gIFVHz6Ia7JQWE2SJsOVFJD1wmg==", "license": "MIT" }, "node_modules/@cspell/dict-php": { @@ -433,9 +433,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-python": { - "version": "4.2.25", - "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.25.tgz", - "integrity": "sha512-hDdN0YhKgpbtZVRjQ2c8jk+n0wQdidAKj1Fk8w7KEHb3YlY5uPJ0mAKJk7AJKPNLOlILoUmN+HAVJz+cfSbWYg==", + "version": "4.2.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.26.tgz", + "integrity": "sha512-hbjN6BjlSgZOG2dA2DtvYNGBM5Aq0i0dHaZjMOI9K/9vRicVvKbcCiBSSrR3b+jwjhQL5ff7HwG5xFaaci0GQA==", "license": "MIT", "dependencies": { "@cspell/dict-data-science": "^2.0.13" @@ -472,9 +472,9 @@ "license": "MIT" }, "node_modules/@cspell/dict-software-terms": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.2.0.tgz", - "integrity": "sha512-jyucc8KKxH5ClC4FPICISgcKAZU7UwgFdPkuuuK5nYIw0+l1umnUSn9IKCcOaurvXujvVP6mBfclXpUtmT6Vrw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.2.1.tgz", + "integrity": "sha512-a25D44ZcccvimNbUgpY94UnqRT46e2PjFf4dgxKXHoMCEcH0NqI4682k4PYsT1AmWMGn7EAA8tS2tN5yxhkm5g==", "license": "MIT" }, "node_modules/@cspell/dict-sql": { @@ -1007,9 +1007,9 @@ } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -1040,9 +1040,9 @@ } }, "node_modules/smol-toml": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", - "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -1092,9 +1092,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/eng/common/tsp-client/package-lock.json b/eng/common/tsp-client/package-lock.json index 1e87eb9417a6..ee4c242e2f1e 100644 --- a/eng/common/tsp-client/package-lock.json +++ b/eng/common/tsp-client/package-lock.json @@ -5,151 +5,30 @@ "packages": { "": { "dependencies": { - "@azure-tools/typespec-client-generator-cli": "0.31.0" - } - }, - "node_modules/@autorest/codemodel": { - "version": "4.20.1", - "resolved": "https://registry.npmjs.org/@autorest/codemodel/-/codemodel-4.20.1.tgz", - "integrity": "sha512-MdI4G0EdQ8yOxGzgT1rCOXxXkCrUQLjVykOvdAyByIgHbnpRop1UzUQuuKmXO8gQPSy7xwYhnfVSgETbHIJZgg==", - "license": "MIT", - "dependencies": { - "@azure-tools/codegen": "~2.10.1", - "js-yaml": "~4.1.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@autorest/core": { - "version": "3.10.8", - "resolved": "https://registry.npmjs.org/@autorest/core/-/core-3.10.8.tgz", - "integrity": "sha512-7tj+zPUYu42lrzOZUC2hNaH7Xt53IVaEbWzV23aEYzDhXF0zD9TTpVexFXKTT4idBV0njsAKEKjPMkmQuHLbgQ==", - "license": "MIT", - "bin": { - "autorest-core": "entrypoints/app.js", - "autorest-language-service": "entrypoints/language-service.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@autorest/extension-base": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@autorest/extension-base/-/extension-base-3.6.1.tgz", - "integrity": "sha512-FWfO6LM3p+R1dW87wnGdJsCpZw67/h1hj09LsQuY0ywKBKv9lrLAW6AlVPrFyvUUIaCMMgd01U6TifCz/FRG9g==", - "license": "MIT", - "dependencies": { - "@azure-tools/codegen": "~2.10.1", - "js-yaml": "~4.1.0", - "vscode-jsonrpc": "^3.5.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@autorest/openapi-to-typespec": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@autorest/openapi-to-typespec/-/openapi-to-typespec-0.11.13.tgz", - "integrity": "sha512-4ub+WpzV0Nt1x+ZiIaumysrl5ftrBjfd/I9R3Vj624dUzP9QAmP5xGaRMiayIMeSYVITurRsroJ8wb4PHoJmpA==", - "license": "MIT", - "dependencies": { - "@autorest/codemodel": "~4.20.1", - "@autorest/extension-base": "~3.6.1", - "@azure-tools/codegen": "~2.10.1", - "@azure-tools/openapi": "~3.6.1", - "@typespec/prettier-plugin-typespec": "^1.5.0", - "change-case-all": "~2.1.0", - "lodash": "~4.17.20", - "pluralize": "^8.0.0", - "prettier": "~3.5.3" - } - }, - "node_modules/@azure-tools/async-io": { - "version": "3.0.254", - "resolved": "https://registry.npmjs.org/@azure-tools/async-io/-/async-io-3.0.254.tgz", - "integrity": "sha512-X1C7XdyCuo50ch9FzKtTvmK18FgDxxf1Bbt3cSoknQqeDaRegHSSCO+zByq2YA4NvUzKXeZ1engh29IDxZXgpQ==", - "license": "MIT", - "dependencies": { - "@azure-tools/tasks": "~3.0.255", - "proper-lockfile": "~2.0.1" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/@azure-tools/codegen": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@azure-tools/codegen/-/codegen-2.10.1.tgz", - "integrity": "sha512-fZfREKjQnBTscjObgK4LuyZNFaofoCNQDNz0jl1i8fYNwCM5EOF9BXwtEtobuEyCpPUNDxQ/KKO65eWzirqk4w==", - "license": "MIT", - "dependencies": { - "@azure-tools/async-io": "~3.0.0", - "js-yaml": "~4.1.0", - "semver": "^7.7.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure-tools/json": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure-tools/json/-/json-1.3.1.tgz", - "integrity": "sha512-0f4kQ6c513ycuk0Z29Nm09D/3dQHrHkduUW8wsFR1QTQ5uqgdYaDWg5I4cZbA8OkOIrJG73TzB/3G0liVCQ+Fw==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure-tools/jsonschema": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure-tools/jsonschema/-/jsonschema-1.3.1.tgz", - "integrity": "sha512-P4KnJzZJjCATcn3nRcF5MPja2wrPdP48Us643+0eqGtNBL4O20CFVEm6WFeFeR8JhvNCsZfeayHiE6VOspe1rg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure-tools/openapi": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@azure-tools/openapi/-/openapi-3.6.1.tgz", - "integrity": "sha512-vkIu0CUg09bzxqrlrNHdoOPu9AFhObp0FqG40M2WaF2dcVgLalsUc+wK5s4LpftlZAxcBmzVHna22JhI5/0X9g==", - "license": "MIT", - "dependencies": { - "@azure-tools/json": "~1.3.1", - "@azure-tools/jsonschema": "~1.3.1" + "@azure-tools/typespec-client-generator-cli": "0.32.1" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@azure-tools/tasks": { - "version": "3.0.255", - "resolved": "https://registry.npmjs.org/@azure-tools/tasks/-/tasks-3.0.255.tgz", - "integrity": "sha512-GjALNLz7kWMEdRVbaN5g0cJHNAr3XVTbP0611Mv2UzMgGL6FOhNZJK+oPHJKLDR8EEDZNnkwPlyi7B+INXUSQA==", - "license": "MIT", - "engines": { - "node": ">=10.12.0" + "node": ">=20.19.0" } }, "node_modules/@azure-tools/typespec-autorest": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-autorest/-/typespec-autorest-0.66.0.tgz", - "integrity": "sha512-sznnlQ2Cyxny7bXSl+PzGu+qQf/rrSIvf2qR7G/bqWbK6MNykXwiDk9uR5q93Y8spA9vv4jk38Il4rSXqmAzLQ==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@azure-tools/typespec-autorest/-/typespec-autorest-0.67.0.tgz", + "integrity": "sha512-RP0TZB46tnYGfN5FKaaXDP5/rDff0PEERKz4epoYsm4RmXeRDYXVcOjw7DXLbcgFpMLTLBf/w/5dqJZBx03KpQ==", "license": "MIT", "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.66.0", - "@azure-tools/typespec-azure-resource-manager": "^0.66.0", - "@azure-tools/typespec-client-generator-core": "^0.66.0", - "@typespec/compiler": "^1.10.0", - "@typespec/http": "^1.10.0", - "@typespec/openapi": "^1.10.0", - "@typespec/rest": "^0.80.0", - "@typespec/versioning": "^0.80.0", - "@typespec/xml": "^0.80.0" + "@azure-tools/typespec-azure-core": "^0.67.0", + "@azure-tools/typespec-azure-resource-manager": "^0.67.0", + "@azure-tools/typespec-client-generator-core": "^0.67.0", + "@typespec/compiler": "^1.11.0", + "@typespec/http": "^1.11.0", + "@typespec/openapi": "^1.11.0", + "@typespec/rest": "^0.81.0", + "@typespec/versioning": "^0.81.0", + "@typespec/xml": "^0.81.0" }, "peerDependenciesMeta": { "@typespec/xml": { @@ -158,24 +37,24 @@ } }, "node_modules/@azure-tools/typespec-azure-core": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-azure-core/-/typespec-azure-core-0.66.0.tgz", - "integrity": "sha512-OBKxRN7AucK3snh+GtLKSDdcZTz08IgcSZlIO3c4KSlmcR5twT1NMyqf1+V8SAhyOdZimndb+ikzrkkgab+GpA==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@azure-tools/typespec-azure-core/-/typespec-azure-core-0.67.0.tgz", + "integrity": "sha512-6DO/fOlVihMlPG0oDXrgURf5MNF4iBzPx5SMA5aaFDx/fW6MjiD+TN9Yy9O+l9mVNh1XaEMjhjA8/lmnHZ/U0g==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0", - "@typespec/http": "^1.10.0", - "@typespec/rest": "^0.80.0" + "@typespec/compiler": "^1.11.0", + "@typespec/http": "^1.11.0", + "@typespec/rest": "^0.81.0" } }, "node_modules/@azure-tools/typespec-azure-resource-manager": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-azure-resource-manager/-/typespec-azure-resource-manager-0.66.0.tgz", - "integrity": "sha512-UbgYUaYTt7prsv+RYxd2kiOWjeEeoH56QOqgXnSOFhYzq/h9fyDaQAm6+CY7cklziED+MYy3uMQd1BG9mNwlfQ==", + "version": "0.67.0", + "resolved": "https://registry.npmjs.org/@azure-tools/typespec-azure-resource-manager/-/typespec-azure-resource-manager-0.67.0.tgz", + "integrity": "sha512-NFE1O4zlpo6Y+Lkh3XCo59g+7r141+oBomYib1LncbbpqoGDakHvBH4sLelt9ZCMnYAxlKGbjXrO9E6jd53P2Q==", "license": "MIT", "peer": true, "dependencies": { @@ -186,26 +65,23 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.66.0", - "@typespec/compiler": "^1.10.0", - "@typespec/http": "^1.10.0", - "@typespec/openapi": "^1.10.0", - "@typespec/rest": "^0.80.0", - "@typespec/versioning": "^0.80.0" + "@azure-tools/typespec-azure-core": "^0.67.0", + "@typespec/compiler": "^1.11.0", + "@typespec/http": "^1.11.0", + "@typespec/openapi": "^1.11.0", + "@typespec/rest": "^0.81.0", + "@typespec/versioning": "^0.81.0" } }, "node_modules/@azure-tools/typespec-client-generator-cli": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-client-generator-cli/-/typespec-client-generator-cli-0.31.0.tgz", - "integrity": "sha512-ukz9IROlYhr0TAXeVLT6oHb/SbZyDFp+rTKYw5XblNBJChLiq+PDalrjyWXsSF8J15TfKS4vixEOc+LZUQQb0A==", + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/@azure-tools/typespec-client-generator-cli/-/typespec-client-generator-cli-0.32.1.tgz", + "integrity": "sha512-BlPUKR3kJm/zTqwEX6zHAJyeEbpBd9pjZwKmODOj1OH38PYs8clUtoyuecQzvYuAJPDA2goIJdiO94uozSFJOQ==", "license": "MIT", "dependencies": { - "@autorest/core": "^3.10.2", - "@autorest/openapi-to-typespec": ">=0.10.6 <1.0.0", "@azure-tools/typespec-autorest": ">=0.53.0 <1.0.0", "@azure/core-rest-pipeline": "^1.12.0", "@types/yargs": "^17.0.32", - "autorest": "^3.7.1", "chalk": "^5.3.0", "dotenv": "^16.4.5", "prompt-sync": "^4.2.0", @@ -217,16 +93,16 @@ "tsp-client": "cmd/tsp-client.js" }, "engines": { - "node": "^18.19.0 || >=20.6.0" + "node": ">=20.19.0" }, "peerDependencies": { "@typespec/compiler": "1.0.0-rc.1 || >=1.0.0 <2.0.0" } }, "node_modules/@azure-tools/typespec-client-generator-core": { - "version": "0.66.2", - "resolved": "https://registry.npmjs.org/@azure-tools/typespec-client-generator-core/-/typespec-client-generator-core-0.66.2.tgz", - "integrity": "sha512-Qr5fstJ0yQiTYNvp/EuY3+mUBue2ri9qNZkT6aC+CsfBt5yjfdjo++3SuEsDQtELyS8pBoDOT3weLiB0N+/fSw==", + "version": "0.67.1", + "resolved": "https://registry.npmjs.org/@azure-tools/typespec-client-generator-core/-/typespec-client-generator-core-0.67.1.tgz", + "integrity": "sha512-Bh7M1KSrgBOMeueK+YiJiaZ+uo3119mNIcbHgU8006CSToDHSTeIM7rndUmCSn+leAKonpXhQ6eElOWj0teBWA==", "license": "MIT", "peer": true, "dependencies": { @@ -238,16 +114,16 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@azure-tools/typespec-azure-core": "^0.66.0", - "@typespec/compiler": "^1.10.0", - "@typespec/events": "^0.80.0", - "@typespec/http": "^1.10.0", - "@typespec/openapi": "^1.10.0", - "@typespec/rest": "^0.80.0", - "@typespec/sse": "^0.80.0", - "@typespec/streams": "^0.80.0", - "@typespec/versioning": "^0.80.0", - "@typespec/xml": "^0.80.0" + "@azure-tools/typespec-azure-core": "^0.67.0", + "@typespec/compiler": "^1.11.0", + "@typespec/events": "^0.81.0", + "@typespec/http": "^1.11.0", + "@typespec/openapi": "^1.11.0", + "@typespec/rest": "^0.81.0", + "@typespec/sse": "^0.81.0", + "@typespec/streams": "^0.81.0", + "@typespec/versioning": "^0.81.0", + "@typespec/xml": "^0.81.0" } }, "node_modules/@azure/abort-controller": { @@ -359,9 +235,9 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.4.tgz", - "integrity": "sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", "license": "MIT", "peer": true, "engines": { @@ -369,16 +245,16 @@ } }, "node_modules/@inquirer/checkbox": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.2.tgz", - "integrity": "sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.3.tgz", + "integrity": "sha512-+G7I8CT+EHv/hasNfUl3P37DVoMoZfpA+2FXmM54dA8MxYle1YqucxbacxHalw1iAFSdKNEDTGNV7F+j1Ldqcg==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -393,14 +269,14 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.10.tgz", - "integrity": "sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ==", + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.11.tgz", + "integrity": "sha512-pTpHjg0iEIRMYV/7oCZUMf27/383E6Wyhfc/MY+AVQGEoUobffIYWOK9YLP2XFRGz/9i6WlTQh1CkFVIo2Y7XA==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -415,15 +291,15 @@ } }, "node_modules/@inquirer/core": { - "version": "11.1.7", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.7.tgz", - "integrity": "sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ==", + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.8.tgz", + "integrity": "sha512-/u+yJk2pOKNDOh1ZgdUH2RQaRx6OOH4I0uwL95qPvTFTIL38YBsuSC4r1yXBB3Q6JvNqFFc202gk0Ew79rrcjA==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4", + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -442,15 +318,15 @@ } }, "node_modules/@inquirer/editor": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.10.tgz", - "integrity": "sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.0.tgz", + "integrity": "sha512-6wlkYl65Qfayy48gPCfU4D7li6KCAGN79mLXa/tYHZH99OfZ820yY+HA+DgE88r8YwwgeuY6PQgNqMeK6LuMmw==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/external-editor": "^2.0.4", - "@inquirer/type": "^4.0.4" + "@inquirer/core": "^11.1.8", + "@inquirer/external-editor": "^3.0.0", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -465,14 +341,14 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.10.tgz", - "integrity": "sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.12.tgz", + "integrity": "sha512-vOfrB33b7YIZfDauXS8vNNz2Z86FozTZLIt7e+7/dCaPJ1RXZsHCuI9TlcERzEUq57vkM+UdnBgxP0rFd23JYQ==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -487,9 +363,9 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.4.tgz", - "integrity": "sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", "license": "MIT", "peer": true, "dependencies": { @@ -509,9 +385,9 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.4.tgz", - "integrity": "sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", "license": "MIT", "peer": true, "engines": { @@ -519,14 +395,14 @@ } }, "node_modules/@inquirer/input": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.10.tgz", - "integrity": "sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.11.tgz", + "integrity": "sha512-twUWidn4ocPO8qi6fRM7tNWt7W1FOnOZqQ+/+PsfLUacMR5rFLDPK9ql0nBPwxi0oELbo8T5NhRs8B2+qQEqFQ==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -541,14 +417,14 @@ } }, "node_modules/@inquirer/number": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.10.tgz", - "integrity": "sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA==", + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.11.tgz", + "integrity": "sha512-Vscmim9TCksQsfjPtka/JwPUcbLhqWYrgfPf1cHrCm24X/F2joFwnageD50yMKsaX14oNGOyKf/RNXAFkNjWpA==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -563,15 +439,15 @@ } }, "node_modules/@inquirer/password": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.10.tgz", - "integrity": "sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.11.tgz", + "integrity": "sha512-9KZFeRaNHIcejtPb0wN4ddFc7EvobVoAFa049eS3LrDZFxI8O7xUXiITEOinBzkZFAIwY5V4yzQae/QfO9cbbg==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -586,22 +462,22 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.2.tgz", - "integrity": "sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.1.tgz", + "integrity": "sha512-AH5xPQ997K7e0F0vulPlteIHke2awMkFi8F0dBemrDfmvtPmHJo82mdHbONC4F/t8d1NHwrbI5cGVI+RbLWdoQ==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/checkbox": "^5.1.2", - "@inquirer/confirm": "^6.0.10", - "@inquirer/editor": "^5.0.10", - "@inquirer/expand": "^5.0.10", - "@inquirer/input": "^5.0.10", - "@inquirer/number": "^4.0.10", - "@inquirer/password": "^5.0.10", - "@inquirer/rawlist": "^5.2.6", - "@inquirer/search": "^4.1.6", - "@inquirer/select": "^5.1.2" + "@inquirer/checkbox": "^5.1.3", + "@inquirer/confirm": "^6.0.11", + "@inquirer/editor": "^5.1.0", + "@inquirer/expand": "^5.0.12", + "@inquirer/input": "^5.0.11", + "@inquirer/number": "^4.0.11", + "@inquirer/password": "^5.0.11", + "@inquirer/rawlist": "^5.2.7", + "@inquirer/search": "^4.1.7", + "@inquirer/select": "^5.1.3" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -616,14 +492,14 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.6.tgz", - "integrity": "sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w==", + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.7.tgz", + "integrity": "sha512-AqRMiD9+uE1lskDPrdqHwrV/EUmxKEBLX44SR7uxK3vD2413AmVfE5EQaPeNzYf5Pq5SitHJDYUFVF0poIr09w==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" + "@inquirer/core": "^11.1.8", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -638,15 +514,15 @@ } }, "node_modules/@inquirer/search": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.6.tgz", - "integrity": "sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.7.tgz", + "integrity": "sha512-1y7+0N65AWk5RdlXH/Kn13txf3IjIQ7OEfhCEkDTU+h5wKMLq8DUF3P6z+/kLSxDGDtQT1dRBWEUC3o/VvImsQ==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -661,16 +537,16 @@ } }, "node_modules/@inquirer/select": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.2.tgz", - "integrity": "sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.3.tgz", + "integrity": "sha512-zYyqWgGQi3NhBcNq4Isc5rB3oEdQEh1Q/EcAnOW0FK4MpnXWkvSBYgA4cYrTM4A9UB573omouZbnL9JJ74Mq3A==", "license": "MIT", "peer": true, "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.8", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -685,9 +561,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.4.tgz", - "integrity": "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", "license": "MIT", "peer": true, "engines": { @@ -768,6 +644,21 @@ "node": ">= 8" } }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.2.tgz", + "integrity": "sha512-nEFVejViHUoL8wU8GTcwqrvqfUG40S5ts6S4fr1u1Ki5CklXlRDYThPVA/qurTmCYFGnaX3XpVUmICLHdvhLaA==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.0.3.tgz", + "integrity": "sha512-NMKv9sJcSN2VvnPT9Ja7eKfGy8Q8mMFLwPTCcuZMtv3+mYcLIZflg31S/tp2XCCyiY7YAx6cgBHQ0fwA2fWHpQ==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.2" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -797,25 +688,25 @@ "license": "MIT" }, "node_modules/@typespec/compiler": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@typespec/compiler/-/compiler-1.10.0.tgz", - "integrity": "sha512-R6BATDkughntPpaxeESJF+wxma5PEjgmnnKvH0/ByqUH8VyhIckQWE9kkP0Uc/EJ0o0VYhe8qCwWQvV70k5lTw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@typespec/compiler/-/compiler-1.11.0.tgz", + "integrity": "sha512-4vuWtoepc4rYJ81K+P7xn2ByXIRhBM40rfzAGnpagNuGSVHuKEC6lqJqs3ePvhCpnxiYAC8XWpaOi+BEDzyhnQ==", "license": "MIT", "peer": true, "dependencies": { "@babel/code-frame": "~7.29.0", - "@inquirer/prompts": "^8.0.1", + "@inquirer/prompts": "^8.3.0", "ajv": "~8.18.0", "change-case": "~5.4.4", "env-paths": "^4.0.0", - "globby": "~16.1.0", + "globby": "~16.1.1", "is-unicode-supported": "^2.1.0", "mustache": "~4.2.0", "picocolors": "~1.1.1", - "prettier": "~3.8.0", - "semver": "^7.7.1", - "tar": "^7.5.2", - "temporal-polyfill": "^0.3.0", + "prettier": "~3.8.1", + "semver": "^7.7.4", + "tar": "^7.5.11", + "temporal-polyfill": "^0.3.2", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.12", "yaml": "~2.8.2", @@ -877,22 +768,6 @@ "license": "MIT", "peer": true }, - "node_modules/@typespec/compiler/node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "license": "MIT", - "peer": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/@typespec/compiler/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -974,30 +849,30 @@ } }, "node_modules/@typespec/events": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@typespec/events/-/events-0.80.0.tgz", - "integrity": "sha512-FrWEUwxhDNbE2YN4fyqV5Qrz9qFJbvPoiKrJM7dexkb7eyhepq3dbc5zZgAm/qFBQ+XxGQQVJ4swXxKT+338fw==", + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@typespec/events/-/events-0.81.0.tgz", + "integrity": "sha512-ee9QSBL+k6ccPlbJICZzaGt4iC1nTIl+J9sELY9yJNISvOvUEzY5MU8c7HaISB10cUESRJW+oaLWwyc8XjwHng==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0" + "@typespec/compiler": "^1.11.0" } }, "node_modules/@typespec/http": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@typespec/http/-/http-1.10.0.tgz", - "integrity": "sha512-/fj55fmUj4m/FmNdfH0V52menVrmS2r5Xj9d1H+pnjQbxvvaxS906RSRcoF8kbg3PvlibP/Py5u82TAk53AyqA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@typespec/http/-/http-1.11.0.tgz", + "integrity": "sha512-/DOkN2+MUZyLdmqYmSMZDjxikJTOuNxikTeOwG2fVOibnu8e6S1jzPAuN/mn6YyQBKeBCItMPmUOXIj61Wy8Bg==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0", - "@typespec/streams": "^0.80.0" + "@typespec/compiler": "^1.11.0", + "@typespec/streams": "^0.81.0" }, "peerDependenciesMeta": { "@typespec/streams": { @@ -1006,90 +881,66 @@ } }, "node_modules/@typespec/openapi": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@typespec/openapi/-/openapi-1.10.0.tgz", - "integrity": "sha512-tukmyp+c9CFlA2FdF61XfT9eTe5WXWz6J8pOrJ9+IYg0BcBwhJkvDj6BYpDD6SjxbRr1wO5ZL2Whe6MequsyVw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@typespec/openapi/-/openapi-1.11.0.tgz", + "integrity": "sha512-xUQrHExKBh0XSP4cn+HcondDXjHJM5HCq2Xfy9tB1QflsFh5uP1JJt1+67g73VmHlhZVSUDcoFrnU95pfjyubg==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0", - "@typespec/http": "^1.10.0" - } - }, - "node_modules/@typespec/prettier-plugin-typespec": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@typespec/prettier-plugin-typespec/-/prettier-plugin-typespec-1.10.0.tgz", - "integrity": "sha512-X4/YjQIxa+uQ4bPrOIcjn6ucIX09SlBWOb8GEwbwqkfZqDvbJEevA2A3OmgI0cyhIndo4PdrkBydWtoK0J0Z+A==", - "license": "MIT", - "dependencies": { - "prettier": "~3.8.0" - } - }, - "node_modules/@typespec/prettier-plugin-typespec/node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "@typespec/compiler": "^1.11.0", + "@typespec/http": "^1.11.0" } }, "node_modules/@typespec/rest": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@typespec/rest/-/rest-0.80.0.tgz", - "integrity": "sha512-xczXLoB2akSIDner41gQYTS9CG6TdCN0QHYvXBT6ZrYEnBh+pMvdymW//5CSOTamZLOGo9AOJVJaFfwbFA4vQQ==", + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@typespec/rest/-/rest-0.81.0.tgz", + "integrity": "sha512-qQXZRKEvq5aNlDFEUqBiiXXPIFyr/+PWgBY0kIrnhyZzMjfUqPInkB12QgXpVp2O2Wm3jmETJD45SaLHTCYBbg==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0", - "@typespec/http": "^1.10.0" + "@typespec/compiler": "^1.11.0", + "@typespec/http": "^1.11.0" } }, "node_modules/@typespec/sse": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@typespec/sse/-/sse-0.80.0.tgz", - "integrity": "sha512-/lxYgMaxgEcjBVhep9tf/VnFD2wnkZlkmjUHLeZL8Cuf+qip61Ren6Ml91YtNnnIFYsuuymDzRclrA073ZBR6Q==", + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@typespec/sse/-/sse-0.81.0.tgz", + "integrity": "sha512-VinoeN+5ClKlGXf77fWayAQna8SaYtvEBhnLR8t8FdvmMsL6ce1LghR2kAL3ARbNXfwMZRmQiq+ajKKebDLIng==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0", - "@typespec/events": "^0.80.0", - "@typespec/http": "^1.10.0", - "@typespec/streams": "^0.80.0" + "@typespec/compiler": "^1.11.0", + "@typespec/events": "^0.81.0", + "@typespec/http": "^1.11.0", + "@typespec/streams": "^0.81.0" } }, "node_modules/@typespec/streams": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@typespec/streams/-/streams-0.80.0.tgz", - "integrity": "sha512-lNvzrvX/ZRIxRpxIBZu90XNsT+uWsMbLtxHd9edspHAiID3c9WKZbl2fnLcPqdR/60odqKve4yGzB9gF58GUDQ==", + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@typespec/streams/-/streams-0.81.0.tgz", + "integrity": "sha512-IIEKq18aqAtM65f8ZLs3Kzua97wjkr8fTehqPs/Q4neWo2UkDJp64LfA37iXJzaku8xMFSwXdVu4EW8wo+KV8w==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0" + "@typespec/compiler": "^1.11.0" } }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.4.tgz", - "integrity": "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz", + "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==", "license": "MIT", "dependencies": { "http-proxy-agent": "^7.0.0", @@ -1101,29 +952,29 @@ } }, "node_modules/@typespec/versioning": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@typespec/versioning/-/versioning-0.80.0.tgz", - "integrity": "sha512-WQCT0jN2lSRfwOy+Cd1KUYzenpKR5TdoX0uW6zQdvxQ9nQZIXoaSaReh9/ldhmSV4xv3p2dqF9oq1cdbVGfJTg==", + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@typespec/versioning/-/versioning-0.81.0.tgz", + "integrity": "sha512-5bha4t64xA85zLY8VGm/6jNd2kwPHzjPq/dlCUjtgGfGXv2R6Ow/YIukqhqZnwnIgNAIlZ7nguekRMRx+2oO2w==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0" + "@typespec/compiler": "^1.11.0" } }, "node_modules/@typespec/xml": { - "version": "0.80.0", - "resolved": "https://registry.npmjs.org/@typespec/xml/-/xml-0.80.0.tgz", - "integrity": "sha512-Qfy5eyCcOF3xYOU/dejhpmmeY75U1Q9C8XBE+GvSZ3lakRfKBIpT+X6Q07qmKSAbGYJZKYLWCIAy/dgCuu/OAA==", + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@typespec/xml/-/xml-0.81.0.tgz", + "integrity": "sha512-4docnAcV1a8gE4c4TmYuirZf2PEzS4xHUH4QjHFU6hk6J2M6OMU6YG4iSq9tmlUzQ/2DraVcWNO/fsG8Lt383A==", "license": "MIT", "peer": true, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@typespec/compiler": "^1.10.0" + "@typespec/compiler": "^1.11.0" } }, "node_modules/agent-base": { @@ -1176,25 +1027,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/autorest": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/autorest/-/autorest-3.8.0.tgz", - "integrity": "sha512-FwpPuDGXuLLnBAR3SCGQcQHPCRoyYXPTMnJ80kN6HRsK+b1/pJ1DtOOzqL4XTCdtq37gth1AoFCerSOwSc3iGQ==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "autorest": "entrypoints/app.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1224,19 +1056,8 @@ "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "license": "MIT" - }, - "node_modules/change-case-all": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-2.1.0.tgz", - "integrity": "sha512-v6b0WWWkZUMHVuYk82l+WROgkUm4qEN2w5hKRNWtEOYwWqUGoi8C6xH0l1RLF1EoWqDFK6MFclmN3od6ws3/uw==", "license": "MIT", - "dependencies": { - "change-case": "^5.2.0", - "sponge-case": "^2.0.2", - "swap-case": "^3.0.2", - "title-case": "^3.0.3" - } + "peer": true }, "node_modules/chardet": { "version": "2.1.1", @@ -1525,12 +1346,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1672,18 +1487,6 @@ "license": "MIT", "peer": true }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1691,12 +1494,6 @@ "license": "MIT", "peer": true }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "license": "MIT" - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1778,9 +1575,9 @@ "peer": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "peer": true, "engines": { @@ -1795,15 +1592,17 @@ "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=4" } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -1823,19 +1622,6 @@ "strip-ansi": "^5.0.0" } }, - "node_modules/proper-lockfile": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-2.0.1.tgz", - "integrity": "sha512-rjaeGbsmhNDcDInmwi4MuI6mRwJu6zq8GjYCLuSuE7GF+4UjgzkL69sVKKJ2T2xH61kK7rXvGYpvaTu909oXaQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "retry": "^0.10.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -1876,15 +1662,6 @@ "node": ">=0.10.0" } }, - "node_modules/retry": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.10.1.tgz", - "integrity": "sha512-ZXUSQYTHdl3uS7IuCehYfMzKyIDBNoAuUblvy5oGO5UJSUTmStUUVPXbA9Qxd173Bgre53yCQczQuHgRWAdvJQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -1932,6 +1709,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -1953,13 +1731,15 @@ } }, "node_modules/simple-git": { - "version": "3.33.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", - "integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==", + "version": "3.35.2", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.35.2.tgz", + "integrity": "sha512-ZMjl06lzTm1EScxEGuM6+mEX+NQd14h/B3x0vWU+YOXAMF8sicyi1K4cjTfj5is+35ChJEHDl1EjypzYFWH2FA==", "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.2", + "@simple-git/argv-parser": "^1.0.3", "debug": "^4.4.0" }, "funding": { @@ -1980,12 +1760,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/sponge-case": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-2.0.3.tgz", - "integrity": "sha512-i4h9ZGRfxV6Xw3mpZSFOfbXjf0cQcYmssGWutgNIfFZ2VM+YIWfD71N/kjjwK6X/AAHzBr+rciEcn/L34S8TGw==", - "license": "MIT" - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2033,16 +1807,10 @@ "node": ">=6" } }, - "node_modules/swap-case": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz", - "integrity": "sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA==", - "license": "MIT" - }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "license": "BlueOak-1.0.0", "peer": true, "dependencies": { @@ -2073,15 +1841,6 @@ "license": "ISC", "peer": true }, - "node_modules/title-case": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", - "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2115,12 +1874,13 @@ } }, "node_modules/vscode-jsonrpc": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-3.6.2.tgz", - "integrity": "sha512-T24Jb5V48e4VgYliUXMnZ379ItbrXgOimweKaJshD84z+8q7ZOZjJan0MeDe+Ugb+uqERDVV8SBmemaGMSMugA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", "license": "MIT", + "peer": true, "engines": { - "node": ">=4.0.0 || >=6.0.0" + "node": ">=14.0.0" } }, "node_modules/vscode-languageserver": { @@ -2147,16 +1907,6 @@ "vscode-languageserver-types": "3.17.5" } }, - "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", @@ -2229,9 +1979,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/eng/common/tsp-client/package.json b/eng/common/tsp-client/package.json index 290f69672cf1..5e79f394a12e 100644 --- a/eng/common/tsp-client/package.json +++ b/eng/common/tsp-client/package.json @@ -1,5 +1,8 @@ { "dependencies": { - "@azure-tools/typespec-client-generator-cli": "0.31.0" + "@azure-tools/typespec-client-generator-cli": "0.32.1" + }, + "engines": { + "node": ">=20.19.0" } } diff --git a/eng/pipelines/pullrequest.yml b/eng/pipelines/pullrequest.yml index 8ce1923d8cce..d17de1ab60f2 100644 --- a/eng/pipelines/pullrequest.yml +++ b/eng/pipelines/pullrequest.yml @@ -10,27 +10,6 @@ pr: paths: include: - "*" - # Note: The ExcludePaths template below needs to duplicate - # any excludes here. The reason being is that we can't access - # pr->paths->exclude. Path matching is done with startsWith - # meaning that path entries should end with a trailing "/" in - # order to prevent greedy matching. Centralized versioning files - # are added to this list to prevent the PR pipeline from running - # when it shouldn't. When someone updates these files there will - # be other updates that'll cause the PR pipeline, or the appropriate - # pipeline if not PR, to run. - exclude: - - eng/versioning/external_dependencies.txt - - eng/versioning/version_client.txt - - eng/versioning/version_java_files.txt - - sdk/batch/microsoft-azure-batch/ # track 1 - - sdk/boms/ # pom only release pipeline - - sdk/cosmos/ # emulator tests - - sdk/e2e/ # no pipeline, nothing to build - - sdk/eventhubs/microsoft-azure-eventhubs/ # track 1 - - sdk/eventhubs/microsoft-azure-eventhubs-eph/ # track 1 - - sdk/servicebus/microsoft-azure-servicebus/ # track 1 - - sdk/spring/ parameters: - name: Service @@ -72,7 +51,7 @@ extends: - eng/versioning/version_java_files.txt - sdk/batch/microsoft-azure-batch/ # track 1 - sdk/boms/ # pom only release pipeline - - sdk/cosmos/ # emulator tests + - sdk/cosmos/ # cosmos emulator unsupported. java - cosmos - ci still triggers - sdk/e2e/ # no pipeline, nothing to build - sdk/eventhubs/microsoft-azure-eventhubs/ # track 1 - sdk/eventhubs/microsoft-azure-eventhubs-eph/ # track 1 diff --git a/eng/pipelines/templates/jobs/ci.yml b/eng/pipelines/templates/jobs/ci.yml index 95233db735c1..987f15ee9ed1 100644 --- a/eng/pipelines/templates/jobs/ci.yml +++ b/eng/pipelines/templates/jobs/ci.yml @@ -100,6 +100,14 @@ jobs: os: linux steps: + # Fail fast if this is a manual release build but no packages were selected. + # When manually triggering the pipeline, at least one release_* parameter must be checked. + - ${{ if and(eq(variables['System.TeamProject'], 'internal'), eq(variables['Build.Reason'], 'Manual'), eq(length(parameters.ReleaseArtifacts), 0)) }}: + - pwsh: | + Write-Host "##vso[task.logissue type=error]No packages selected for release. When manually queuing this pipeline, please select at least one package by checking the appropriate package-name parameter(s)." + exit 1 + displayName: 'Validate: at least one package is selected for release' + # Skip sparse checkout for the `azure-sdk-for--pr` private mirrored repositories # as we require the GitHub service connection to be loaded. - ${{ if not(contains(variables['Build.DefinitionName'], 'java-pr')) }}: @@ -201,6 +209,11 @@ jobs: parameters: PackagePropertiesFolder: $(Build.ArtifactStagingDirectory)/PackageInfo + - task: PipAuthenticate@1 + displayName: 'Pip Authenticate to Azure Artifacts' + inputs: + artifactFeeds: 'public/azure-sdk-for-python' + - script: | python -m pip install markdown2==2.4.6 BeautifulSoup4==4.11.1 displayName: 'pip install markdown2 and BeautifulSoup4' @@ -416,6 +429,14 @@ jobs: version: 22.x displayName: Use Node.js 22.x + # Authenticate npm to Azure Artifacts + - template: /eng/common/pipelines/templates/steps/create-authenticated-npmrc.yml + parameters: + npmrcPath: $(Agent.TempDirectory)/analyze-job/.npmrc + + # Authenticate with Azure Artifacts + - template: /eng/pipelines/templates/steps/maven-authenticate.yml + - task: PowerShell@2 displayName: Verify Swagger and TypeSpec Code Generation inputs: @@ -424,9 +445,8 @@ jobs: arguments: > -ServiceDirectories '$(PRServiceDirectories)' -RegenerationType 'All' - - # Authenticate with Azure Artifacts - - template: /eng/pipelines/templates/steps/maven-authenticate.yml + env: + NPM_CONFIG_USERCONFIG: $(Agent.TempDirectory)/analyze-job/.npmrc - template: /eng/pipelines/templates/steps/run-and-validate-linting.yml parameters: diff --git a/eng/pipelines/templates/stages/archetype-java-release-batch.yml b/eng/pipelines/templates/stages/archetype-java-release-batch.yml index 1bdb6ff2e196..4815f1afb6ac 100644 --- a/eng/pipelines/templates/stages/archetype-java-release-batch.yml +++ b/eng/pipelines/templates/stages/archetype-java-release-batch.yml @@ -65,6 +65,11 @@ stages: displayName: 'Download Signed Artifacts' artifact: packages-signed + # Setup Maven mirror settings and authenticate with Azure Artifacts + - template: /eng/pipelines/templates/steps/maven-authenticate.yml + parameters: + SourceDirectory: $(Pipeline.Workspace)/azure-sdk-for-java + # gpg-sign and create the flattened directory for ESRP bulk publish # Note: The maven release requires the files to be local GPG signed # Dev feed publishes use the gpg-sign-and-deply to do it in one step @@ -260,6 +265,9 @@ stages: - download: current displayName: 'Download Artifacts' artifact: packages-signed + - template: /eng/pipelines/templates/steps/maven-authenticate.yml + parameters: + SourceDirectory: $(Pipeline.Workspace)/azure-sdk-for-java - template: tools/gpg/gpg.yml@azure-sdk-build-tools - ${{ if ne(parameters.PublicFeedUrl, 'maven.org') }}: @@ -364,6 +372,15 @@ stages: - template: /eng/pipelines/templates/steps/mvn-linux-repository-settings.yml + # maven-authenticate.yml cannot be used here because it overwrites ~/.m2/settings.xml + # with eng/settings.xml, which would discard the docs-specific repository config + # (e.g. docs-public-packages feed) set up by mvn-linux-repository-settings.yml above. + # Instead, we only run MavenAuthenticate to inject credentials into the existing settings. + - task: MavenAuthenticate@0 + displayName: 'Maven Authenticate' + inputs: + artifactsFeeds: 'azure-sdk-for-java' + - template: /eng/pipelines/templates/steps/install-rex-validation-tool.yml - template: /eng/common/pipelines/templates/steps/update-docsms-metadata.yml @@ -453,6 +470,9 @@ stages: displayName: Setup TargetFeed + - template: /eng/pipelines/templates/steps/maven-authenticate.yml + parameters: + SourceDirectory: $(Pipeline.Workspace)/azure-sdk-for-java - template: tools/gpg/gpg.yml@azure-sdk-build-tools - ${{ each artifact in parameters.Artifacts }}: @@ -485,6 +505,15 @@ stages: - template: /eng/pipelines/templates/steps/mvn-linux-repository-settings.yml + # maven-authenticate.yml cannot be used here because it overwrites ~/.m2/settings.xml + # with eng/settings.xml, which would discard the docs-specific repository config + # (e.g. docs-public-packages feed) set up by mvn-linux-repository-settings.yml above. + # Instead, we only run MavenAuthenticate to inject credentials into the existing settings. + - task: MavenAuthenticate@0 + displayName: 'Maven Authenticate' + inputs: + artifactsFeeds: 'azure-sdk-for-java' + - template: /eng/pipelines/templates/steps/install-rex-validation-tool.yml - template: /eng/common/pipelines/templates/steps/update-docsms-metadata.yml diff --git a/eng/pipelines/templates/stages/cosmos-sdk-client.yml b/eng/pipelines/templates/stages/cosmos-sdk-client.yml index ea9b86583e14..e93a86154e23 100644 --- a/eng/pipelines/templates/stages/cosmos-sdk-client.yml +++ b/eng/pipelines/templates/stages/cosmos-sdk-client.yml @@ -157,44 +157,43 @@ extends: displayName: "Print ScalaTest report files" errorActionPreference: continue condition: always() - - ${{ if eq(variables['System.TeamProject'], 'internal') }}: - - ${{ each mode in parameters.VnextEmulatorModes }}: - - stage: - displayName: Test VNext Emulator with ${{ mode }} - dependsOn: [ ] - condition: and(succeeded(), ne(variables['Skip.VnextEmulator'], 'true')) - variables: - - template: /eng/pipelines/templates/variables/globals.yml - - template: /eng/pipelines/templates/variables/image.yml - - name: ArtifactsJson - value: '${{ convertToJson(parameters.Artifacts) }}' - - name: AdditionalModulesJson - value: '${{ convertToJson(parameters.AdditionalModules) }}' - jobs: - - template: /eng/common/pipelines/templates/jobs/generate-job-matrix.yml - parameters: - JobTemplatePath: /eng/pipelines/templates/jobs/live.tests.yml - MatrixConfigs: - - Name: Cosmos_vnext_emulator_http_integration - Path: eng/pipelines/templates/stages/cosmos-emulator-vnext-matrix.json - Selection: all - GenerateVMJobs: true - MatrixFilters: - - ${{ parameters.LanguageFilter }} - - ${{ parameters.MatrixFilters }} - AdditionalParameters: - BuildParallelization: 2 - DisableAzureResourceCreation: true - ServiceDirectory: cosmos - TimeoutInMinutes: 120 - ${{ if eq(mode, 'Https') }}: - TestOptions: '$(ProfileFlag) $(AdditionalArgs) -DACCOUNT_HOST=https://localhost:8081/ -DCOSMOS.EMULATOR_SERVER_CERTIFICATE_VALIDATION_DISABLED=true' - ${{ else }}: - TestOptions: '$(ProfileFlag) $(AdditionalArgs) -DACCOUNT_HOST=http://localhost:8081/ -DCOSMOS.HTTP_CONNECTION_WITHOUT_TLS_ALLOWED=true' - PreSteps: - - template: /eng/pipelines/templates/steps/cosmos-vnext-emulator.yml - parameters: - Https: ${{ eq(mode, 'Https') }} + - ${{ each mode in parameters.VnextEmulatorModes }}: + - stage: + displayName: Test VNext Emulator with ${{ mode }} + dependsOn: [ ] + condition: and(succeeded(), ne(variables['Skip.VnextEmulator'], 'true')) + variables: + - template: /eng/pipelines/templates/variables/globals.yml + - template: /eng/pipelines/templates/variables/image.yml + - name: ArtifactsJson + value: '${{ convertToJson(parameters.Artifacts) }}' + - name: AdditionalModulesJson + value: '${{ convertToJson(parameters.AdditionalModules) }}' + jobs: + - template: /eng/common/pipelines/templates/jobs/generate-job-matrix.yml + parameters: + JobTemplatePath: /eng/pipelines/templates/jobs/live.tests.yml + MatrixConfigs: + - Name: Cosmos_vnext_emulator_http_integration + Path: eng/pipelines/templates/stages/cosmos-emulator-vnext-matrix.json + Selection: all + GenerateVMJobs: true + MatrixFilters: + - ${{ parameters.LanguageFilter }} + - ${{ parameters.MatrixFilters }} + AdditionalParameters: + BuildParallelization: 2 + DisableAzureResourceCreation: true + ServiceDirectory: cosmos + TimeoutInMinutes: 120 + ${{ if eq(mode, 'Https') }}: + TestOptions: '$(ProfileFlag) $(AdditionalArgs) -DACCOUNT_HOST=https://localhost:8081/ -DCOSMOS.EMULATOR_SERVER_CERTIFICATE_VALIDATION_DISABLED=true' + ${{ else }}: + TestOptions: '$(ProfileFlag) $(AdditionalArgs) -DACCOUNT_HOST=http://localhost:8081/ -DCOSMOS.HTTP_CONNECTION_WITHOUT_TLS_ALLOWED=true' + PreSteps: + - template: /eng/pipelines/templates/steps/cosmos-vnext-emulator.yml + parameters: + Https: ${{ eq(mode, 'Https') }} # The Prerelease and Release stages are conditioned on whether we are building a pull request and the branch. - ${{if and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['System.TeamProject'], 'internal'))}}: diff --git a/eng/pipelines/templates/steps/install-rex-validation-tool.yml b/eng/pipelines/templates/steps/install-rex-validation-tool.yml index 868a468ebd7d..46d8e3c1f6a8 100644 --- a/eng/pipelines/templates/steps/install-rex-validation-tool.yml +++ b/eng/pipelines/templates/steps/install-rex-validation-tool.yml @@ -8,6 +8,12 @@ steps: - script: | java -version displayName: Print Java Version + # java2docfx internally runs 'mvn help:effective-pom' using the short 'help' prefix. + # Azure Artifacts feed metadata may not contain prefix-to-artifactId mappings for + # plugins that haven't been requested before. This fully-qualified invocation forces + # the feed to cache maven-help-plugin so the 'help' prefix resolves in java2docfx. + - script: mvn org.apache.maven.plugins:maven-help-plugin:help --batch-mode + displayName: 'Warm maven-help-plugin feed cache' # Create a java2docfx subdirectory in the $(Build.BinariesDirectory) and install the java2docfx there # This way, the jar file is in its own subdirectory and isolated. - pwsh: | diff --git a/eng/pipelines/templates/steps/maven-authenticate.yml b/eng/pipelines/templates/steps/maven-authenticate.yml index 906102779e59..f03caee204fc 100644 --- a/eng/pipelines/templates/steps/maven-authenticate.yml +++ b/eng/pipelines/templates/steps/maven-authenticate.yml @@ -1,6 +1,16 @@ +parameters: + SourceDirectory: $(Build.SourcesDirectory) + steps: + # Copy mirror settings to default Maven location so all requests go through CFS + - pwsh: | + $m2Dir = if ($env:USERPROFILE) { "$env:USERPROFILE\.m2" } else { "$HOME/.m2" } + New-Item -ItemType Directory -Force -Path $m2Dir | Out-Null + Copy-Item -Path "${{ parameters.SourceDirectory }}/eng/settings.xml" -Destination "$m2Dir/settings.xml" + displayName: 'Setup Maven mirror settings' + # Authenticate with Azure Artifacts feeds - # Repo URLs are defined in azure-sdk-parent pom.xml with id 'azure-sdk-for-java' + # MavenAuthenticate adds entries to ~/.m2/settings.xml matching mirror id 'azure-sdk-for-java' - task: MavenAuthenticate@0 displayName: 'Maven Authenticate' inputs: diff --git a/sdk/spring/CHANGELOG.md b/sdk/spring/CHANGELOG.md index 206775073ed1..8b05233a4fc0 100644 --- a/sdk/spring/CHANGELOG.md +++ b/sdk/spring/CHANGELOG.md @@ -1,4 +1,34 @@ # Release History +## 6.3.0-beta.1 (Unreleased) + +### Spring Cloud Azure Autoconfigure + +#### Bugs Fixed + +- Fixed `KeyVaultJcaProvider` being registered as the highest-priority JCA security provider, which overrides standard JCA services (`KeyManagerFactory.SunX509`, `Signature` algorithms) and breaks mTLS with standard keystores (JKS, PKCS12). The provider is now added at the end of the provider list, allowing JCA's delayed provider selection to route `KeyVaultPrivateKey` signing operations to the KeyVault implementations without interfering with standard SSL/TLS operations. [#48183](https://github.com/Azure/azure-sdk-for-java/issues/48183) + +### Spring Cloud Azure Appconfiguration Config +This section includes changes in `spring-cloud-azure-appconfiguration-config` module. + +### Features Added + +- Added support for filtering configuration settings and feature flags by tags. Tags can be configured via `spring.cloud.azure.appconfiguration.stores[0].selects[0].tags-filter` for key-value settings and `spring.cloud.azure.appconfiguration.stores[0].feature-flags.selects[0].tags-filter` for feature flags. The value is a list of `tag=value` pairs (e.g., `["env=prod", "team=backend"]`) combined with AND logic. [#47985](https://github.com/Azure/azure-sdk-for-java/pull/47985) +- Added `startup-timeout` configuration option that enables automatic retry with backoff when transient failures occur during application startup. The provider will continue retrying until the timeout expires (default: 100 seconds). [#47857](https://github.com/Azure/azure-sdk-for-java/pull/47857).- + +### Bugs Fixed + +- Fixes a bug where ';' was ignored in JSON content type checking. [#48448](https://github.com/Azure/azure-sdk-for-java/pull/48448) +- Fixed an issue where feature flag–based refresh did not work when load balancing was enabled with a single configuration store. Feature flag refresh now uses the same load-balanced client selection as configuration refresh, including the single-store scenario. [#48121](https://github.com/Azure/azure-sdk-for-java/pull/48121) +- Fixed YAML configuration binding for `label-filter` by adding standard no-arg getter methods to `AppConfigurationKeyValueSelector` and `FeatureFlagKeyValueSelector`, enabling proper type resolution by Spring Boot's `@ConfigurationProperties` binder. [#47985](https://github.com/Azure/azure-sdk-for-java/pull/47985) +- Fixed bug where connection string validation occurred even when `spring.cloud.azure.appconfiguration.enabled` is `false`. [#47587](https://github.com/Azure/azure-sdk-for-java/issues/47587) + +### Spring Messaging Azure Service Bus + +This section includes changes in `spring-messaging-azure-servicebus` module. + +#### Bugs Fixed + +- Fixed `DefaultServiceBusNamespaceProcessorFactory` not removing closed/disposed `ServiceBusProcessorClient` instances from its internal cache, causing subsequent `createProcessor()` calls to return stale, non-functional processors. [#48030](https://github.com/Azure/azure-sdk-for-java/issues/48030) ## 6.2.0 (2026-03-25) - This release is compatible with Spring Boot 3.5.0-3.5.8. (Note: 3.5.x (x>8) should be supported, but they aren't tested with this release.) diff --git a/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md b/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md index 59a09bf8f4be..367d13debc85 100644 --- a/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md +++ b/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md @@ -8,6 +8,9 @@ #### Bugs Fixed +* Fixing bug where count query defined in a Java text block in `@Query` causes a class cast exception - See [Bug #47910](https://github.com/Azure/azure-sdk-for-java/issues/47910). +* Also fixed the same bug for sum query. + #### Other Changes ### 6.2.0 (2026-03-25) diff --git a/sdk/spring/azure-spring-data-cosmos/src/main/java/com/azure/spring/data/cosmos/repository/support/StringBasedCosmosQuery.java b/sdk/spring/azure-spring-data-cosmos/src/main/java/com/azure/spring/data/cosmos/repository/support/StringBasedCosmosQuery.java index 713a7bf1a486..80b3ca48e591 100644 --- a/sdk/spring/azure-spring-data-cosmos/src/main/java/com/azure/spring/data/cosmos/repository/support/StringBasedCosmosQuery.java +++ b/sdk/spring/azure-spring-data-cosmos/src/main/java/com/azure/spring/data/cosmos/repository/support/StringBasedCosmosQuery.java @@ -30,8 +30,8 @@ * Cosmos query class to handle the annotated queries. This overrides the execution and runs the query directly */ public class StringBasedCosmosQuery extends AbstractCosmosQuery { - private static final Pattern COUNT_QUERY_PATTERN = Pattern.compile("^\\s*select\\s+value\\s+count.*", Pattern.CASE_INSENSITIVE); - private static final Pattern SUM_QUERY_PATTERN = Pattern.compile("^\\s*select\\s+value\\s+sum.*", Pattern.CASE_INSENSITIVE); + private static final Pattern COUNT_QUERY_PATTERN = Pattern.compile("^\\s*select\\s+value\\s+count.*", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Pattern SUM_QUERY_PATTERN = Pattern.compile("^\\s*select\\s+value\\s+sum.*", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); private final String query; diff --git a/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/repository/integration/AddressRepositoryIT.java b/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/repository/integration/AddressRepositoryIT.java index e76518ac9384..38e26fa28fc3 100644 --- a/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/repository/integration/AddressRepositoryIT.java +++ b/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/repository/integration/AddressRepositoryIT.java @@ -86,11 +86,11 @@ public void setUp() { @Test public void testFindById() { - // test findById (ID id) cross partition - final Address result = repository.findById(TEST_ADDRESS1_PARTITION1.getPostalCode()).get(); + // test findById (ID id) cross partition using a postal code that is unique across all test addresses + final Address result = repository.findById(TEST_ADDRESS1_PARTITION2.getPostalCode()).get(); assertThat(responseDiagnosticsTestUtils.getCosmosResponseStatistics()).isNotNull(); assertThat(responseDiagnosticsTestUtils.getCosmosDiagnostics().toString().contains("\"requestOperationType\":\"Query\"")).isTrue(); - assertThat(result).isEqualTo(TEST_ADDRESS1_PARTITION1); + assertThat(result).isEqualTo(TEST_ADDRESS1_PARTITION2); } @Test @@ -150,7 +150,7 @@ public void testFindByPostalCodeAndCityIn() { final List
result = TestUtils.toList(repository.findByPostalCodeInAndCity(postalCodes, city)); assertThat(result.size()).isEqualTo(2); - assertThat(result).isEqualTo(Lists.newArrayList(TEST_ADDRESS1_PARTITION1, TEST_ADDRESS2_PARTITION1)); + assertThat(result).containsExactlyInAnyOrder(TEST_ADDRESS1_PARTITION1, TEST_ADDRESS2_PARTITION1); } @Test @@ -367,12 +367,10 @@ public void testFindTopByOrderByStreetDesc() { @Test public void testFindAllByStreetNotNull() { - Address TEST_ADDRESS_TEMP = new Address( - TestConstants.POSTAL_CODE, null, TestConstants.CITY); final List
result = TestUtils.toList(repository.findAllByStreetNotNull()); assertThat(result.size()).isEqualTo(4); - assertThat(result).isEqualTo(Lists.newArrayList(TEST_ADDRESS1_PARTITION1, TEST_ADDRESS1_PARTITION2, - TEST_ADDRESS2_PARTITION1, TEST_ADDRESS4_PARTITION3)); + assertThat(result).containsExactlyInAnyOrder(TEST_ADDRESS1_PARTITION1, TEST_ADDRESS1_PARTITION2, + TEST_ADDRESS2_PARTITION1, TEST_ADDRESS4_PARTITION3); } @@ -393,8 +391,6 @@ public void testFindTop3ByOrderByStreetDesc() { @Test public void testCountByStreetNotNull() { - Address TEST_ADDRESS_TEMP = new Address( - TestConstants.POSTAL_CODE, null, TestConstants.CITY); final Long result = repository.countByStreetNotNull(); assertThat(result).isEqualTo(4); } diff --git a/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/repository/support/StringBasedCosmosQueryUnitTest.java b/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/repository/support/StringBasedCosmosQueryUnitTest.java index 081296b18a7e..8de4ade0e9c0 100644 --- a/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/repository/support/StringBasedCosmosQueryUnitTest.java +++ b/sdk/spring/azure-spring-data-cosmos/src/test/java/com/azure/spring/data/cosmos/repository/support/StringBasedCosmosQueryUnitTest.java @@ -48,4 +48,28 @@ public void testStripExtraWhitespaceFromString() throws NoSuchMethodException, I String result3 = (String) method.invoke(sbcq, args3); assertThat(result3).isEqualTo(expectedResult); } + + @Test + public void testTextBlockCountQuery() { + String countQuery = """ + SELECT VALUE COUNT(1) + FROM a + WHERE a.city = @city + AND a.state = @state + """; + boolean result = StringBasedCosmosQuery.isCountQuery(countQuery, long.class); + assertThat(result).isTrue(); + } + + @Test + public void testTextBlockSumQuery() { + String sumQuery = """ + SELECT VALUE SUM(a.population) + FROM a + WHERE a.city = @city + AND a.state = @state + """; + boolean result = StringBasedCosmosQuery.isSumQuery(sumQuery, long.class); + assertThat(result).isTrue(); + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java index 9b0d4be6c0a0..f977dd619675 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java @@ -15,6 +15,7 @@ import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationPullRefresh; import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationRefreshUtil; import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationReplicaClientFactory; +import com.azure.spring.cloud.appconfiguration.config.implementation.StateHolder; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; @@ -37,11 +38,20 @@ public AppConfigurationWatchAutoConfiguration() { @Bean @ConditionalOnMissingBean AppConfigurationRefresh appConfigurationRefresh(AppConfigurationProperties properties, BootstrapContext context) { - AppConfigurationReplicaClientFactory clientFactory = context - .get(AppConfigurationReplicaClientFactory.class); - ReplicaLookUp replicaLookUp = context.get(ReplicaLookUp.class); + AppConfigurationReplicaClientFactory clientFactory = context.getOrElse(AppConfigurationReplicaClientFactory.class, null); + ReplicaLookUp replicaLookUp = context.getOrElse(ReplicaLookUp.class, null); + + StateHolder stateHolder = context.getOrElse(StateHolder.class, null); + + if (clientFactory == null || replicaLookUp == null) { + return null; + } + + if (stateHolder == null) { + stateHolder = new StateHolder(); + } return new AppConfigurationPullRefresh(clientFactory, properties.getRefreshInterval(), replicaLookUp, - new AppConfigurationRefreshUtil()); + stateHolder, new AppConfigurationRefreshUtil(stateHolder)); } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java index 4ac64874a871..4fcc931cb93e 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java @@ -43,14 +43,18 @@ class AppConfigurationApplicationSettingPropertySource extends AppConfigurationP private final String[] labelFilters; + private final List tagsFilter; + AppConfigurationApplicationSettingPropertySource(String name, AppConfigurationReplicaClient replicaClient, - AppConfigurationKeyVaultClientFactory keyVaultClientFactory, String keyFilter, String[] labelFilters) { + AppConfigurationKeyVaultClientFactory keyVaultClientFactory, String keyFilter, String[] labelFilters, + List tagsFilter) { // The context alone does not uniquely define a PropertySource, append storeName // and label to uniquely define a PropertySource super(name + getLabelName(labelFilters), replicaClient); this.keyVaultClientFactory = keyVaultClientFactory; this.keyFilter = keyFilter; this.labelFilters = labelFilters; + this.tagsFilter = tagsFilter; } /** @@ -61,6 +65,7 @@ class AppConfigurationApplicationSettingPropertySource extends AppConfigurationP * @param keyPrefixTrimValues prefixs to trim from key values * @throws InvalidConfigurationPropertyValueException thrown if fails to parse Json content type */ + @Override public void initProperties(List keyPrefixTrimValues, Context context) throws InvalidConfigurationPropertyValueException { List labels = Arrays.asList(labelFilters); @@ -70,6 +75,10 @@ public void initProperties(List keyPrefixTrimValues, Context context) th for (String label : labels) { SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter + "*").setLabelFilter(label); + if (tagsFilter != null && !tagsFilter.isEmpty()) { + settingSelector.setTagsFilter(tagsFilter); + } + // * for wildcard match processConfigurationSettings(replicaClient.listSettings(settingSelector, context), settingSelector.getKeyFilter(), keyPrefixTrimValues); @@ -128,7 +137,6 @@ private void handleKeyVaultReference(String key, SecretReferenceConfigurationSet void handleFeatureFlag(String key, FeatureFlagConfigurationSetting setting, List trimStrings) throws InvalidConfigurationPropertyValueException { // Feature Flags aren't loaded as configuration, but are loaded as feature flags when loading a snapshot. - return; } private void handleJson(ConfigurationSetting setting, List keyPrefixTrimValues) diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java index cb5d5735d39b..2b37e4ccfd5b 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java @@ -36,7 +36,6 @@ public class AppConfigurationPullRefresh implements AppConfigurationRefresh { * Publisher for Spring refresh events. */ private ApplicationEventPublisher publisher; - private final Long defaultMinBackoff = (long) 30; /** * Default minimum backoff duration in seconds when refresh operations fail. @@ -63,19 +62,30 @@ public class AppConfigurationPullRefresh implements AppConfigurationRefresh { */ private final AppConfigurationRefreshUtil refreshUtils; + /** + * Holds configuration state between refreshes. + */ + private final StateHolder stateHolder; + /** * Creates a new AppConfigurationPullRefresh component. * * @param clientFactory factory for creating App Configuration clients to connect to stores * @param refreshInterval time duration between refresh interval checks * @param replicaLookUp component for handling replica lookup and failover + * @param stateHolder holds configuration state between refreshes * @param refreshUtils utility component for refresh operations */ public AppConfigurationPullRefresh(AppConfigurationReplicaClientFactory clientFactory, Duration refreshInterval, - ReplicaLookUp replicaLookUp, AppConfigurationRefreshUtil refreshUtils) { + ReplicaLookUp replicaLookUp, StateHolder stateHolder, AppConfigurationRefreshUtil refreshUtils) { this.refreshInterval = refreshInterval; this.clientFactory = clientFactory; this.replicaLookUp = replicaLookUp; + if (stateHolder == null) { + // StateHolder is null if all stores are disabled. + stateHolder = new StateHolder(); + } + this.stateHolder = stateHolder; this.refreshUtils = refreshUtils; } @@ -96,6 +106,7 @@ public void setApplicationEventPublisher(ApplicationEventPublisher applicationEv * @return a Mono containing a boolean indicating if a RefreshEvent was published. Returns {@code false} if * refreshConfigurations is currently being executed elsewhere. */ + @Override public Mono refreshConfigurations() { return Mono.just(refreshStores()); } @@ -107,6 +118,7 @@ public Mono refreshConfigurations() { * @param endpoint the Config Store endpoint to expire refresh interval on * @param syncToken the syncToken to verify the latest changes are available on pull */ + @Override public void expireRefreshInterval(String endpoint, String syncToken) { LOGGER.debug("Expiring refresh interval for " + endpoint); @@ -114,7 +126,7 @@ public void expireRefreshInterval(String endpoint, String syncToken) { clientFactory.updateSyncToken(originEndpoint, endpoint, syncToken); - StateHolder.getCurrentState().expireState(originEndpoint); + stateHolder.expireState(originEndpoint); } /** @@ -135,7 +147,7 @@ private boolean refreshStores() { } catch (Exception e) { LOGGER.warn("Error occurred during configuration refresh, will retry at next interval", e); // The next refresh will happen sooner if refresh interval is expired. - StateHolder.getCurrentState().updateNextRefreshTime(refreshInterval, DEFAULT_MIN_BACKOFF_SECONDS); + stateHolder.updateNextRefreshTime(refreshInterval, DEFAULT_MIN_BACKOFF_SECONDS); throw e; } finally { running.set(false); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java index d25e616ced98..abec63fa1ba8 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java @@ -17,8 +17,8 @@ import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore; @@ -30,6 +30,33 @@ public class AppConfigurationRefreshUtil { private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationRefreshUtil.class); + private static final String FEATURE_FLAG_PREFIX = ".appconfig.featureflag/*"; + + private final StateHolder stateHolder; + + /** + * Creates a new AppConfigurationRefreshUtil with the specified state holder. + * + * @param stateHolder the state holder for managing configuration and feature flag states + */ + public AppConfigurationRefreshUtil(StateHolder stateHolder) { + if (stateHolder == null) { + // This is a fallback if all stores are disabled. + stateHolder = new StateHolder(); + } + this.stateHolder = stateHolder; + } + + /** + * Functional interface for refresh operations that can throw AppConfigurationStatusException. + */ + @FunctionalInterface + private interface RefreshOperation { + + void execute(AppConfigurationReplicaClient client, RefreshEventData eventData, Context context) + throws AppConfigurationStatusException; + } + /** * Checks all configured stores to determine if any configurations need to be refreshed. * @@ -44,8 +71,8 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF RefreshEventData eventData = new RefreshEventData(); try { - if (refreshInterval != null && StateHolder.getNextForcedRefresh() != null - && Instant.now().isAfter(StateHolder.getNextForcedRefresh())) { + if (refreshInterval != null && stateHolder.getNextForcedRefresh() != null + && Instant.now().isAfter(stateHolder.getNextForcedRefresh())) { String eventDataInfo = "Minimum refresh period reached. Refreshing configurations."; LOGGER.info(eventDataInfo); @@ -57,8 +84,6 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF for (Entry entry : clientFactory.getConnections().entrySet()) { String originEndpoint = entry.getKey(); ConnectionManager connection = entry.getValue(); - // For safety reset current used replica. - clientFactory.setCurrentConfigStoreClient(originEndpoint, originEndpoint); AppConfigurationStoreMonitoring monitor = connection.getMonitoring(); @@ -67,77 +92,109 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF if ((notification.getPrimaryToken() != null && StringUtils.hasText(notification.getPrimaryToken().getName())) || (notification.getSecondaryToken() != null - && StringUtils.hasText(notification.getPrimaryToken().getName()))) { + && StringUtils.hasText(notification.getSecondaryToken().getName()))) { pushRefresh = true; } Context context = new Context("refresh", true).addData(PUSH_REFRESH, pushRefresh); - - clientFactory.findActiveClients(originEndpoint); - AppConfigurationReplicaClient client = clientFactory.getNextActiveClient(originEndpoint, false); + clientFactory.findActiveClients(originEndpoint); - if (monitor.isEnabled() && StateHolder.getLoadState(originEndpoint)) { - while (client != null) { - try { - refreshWithTime(client, StateHolder.getState(originEndpoint), monitor.getRefreshInterval(), - eventData, replicaLookUp, context); - if (eventData.getDoRefresh()) { - clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint()); - return eventData; + if (monitor.isEnabled() && stateHolder.getLoadState(originEndpoint)) { + RefreshEventData result = executeRefreshWithRetry( + clientFactory, + originEndpoint, + (client, data, ctx) -> { + if (stateHolder.getState(originEndpoint) == null) { + LOGGER.debug( + "Skipping configuration refresh check for {} because monitoring state is not initialized.", + originEndpoint); + return; } - // If check didn't throw an error other clients don't need to be checked. - break; - } catch (HttpResponseException e) { - LOGGER.warn( - "Failed to connect to App Configuration store {} during configuration refresh check. " - + "Status: {}, Message: {}", - client.getEndpoint(), e.getResponse().getStatusCode(), e.getMessage()); - - clientFactory.backoffClient(originEndpoint, client.getEndpoint()); - client = clientFactory.getNextActiveClient(originEndpoint, false); - } + refreshWithTime(client, stateHolder.getState(originEndpoint), + monitor.getRefreshInterval(), data, replicaLookUp, ctx); + }, + eventData, + context, + "configuration refresh check", + false); + if (result != null) { + return result; } } else { - LOGGER.debug("Skipping configuration refresh check for " + originEndpoint); + LOGGER.debug("Skipping configuration refresh check for {}", originEndpoint); } FeatureFlagStore featureStore = connection.getFeatureFlagStore(); - if (featureStore.getEnabled() && StateHolder.getStateFeatureFlag(originEndpoint) != null) { - client = clientFactory.getNextActiveClient(originEndpoint, false); - while (client != null) { - try { - refreshWithTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(originEndpoint), - monitor.getFeatureFlagRefreshInterval(), eventData, replicaLookUp, context); - if (eventData.getDoRefresh()) { - clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint()); - return eventData; - } - // If check didn't throw an error other clients don't need to be checked. - break; - } catch (HttpResponseException e) { - LOGGER.warn( - "Failed to connect to App Configuration store {} during feature flag refresh check. " - + "Status: {}, Message: {}", - client.getEndpoint(), e.getResponse().getStatusCode(), e.getMessage()); - - clientFactory.backoffClient(originEndpoint, client.getEndpoint()); - client = clientFactory.getNextActiveClient(originEndpoint, false); - } + if (featureStore.getEnabled() && stateHolder.getStateFeatureFlag(originEndpoint) != null) { + RefreshEventData result = executeRefreshWithRetry( + clientFactory, + originEndpoint, + (client, data, ctx) -> refreshWithTimeFeatureFlags(client, + stateHolder.getStateFeatureFlag(originEndpoint), + monitor.getFeatureFlagRefreshInterval(), data, replicaLookUp, ctx), + eventData, + context, + "feature flag refresh check", + true); + if (result != null) { + return result; } } else { - LOGGER.debug("Skipping feature flag refresh check for " + originEndpoint); + LOGGER.debug("Skipping feature flag refresh check for {}", originEndpoint); } } } catch (Exception e) { // The next refresh will happen sooner if refresh interval is expired. - StateHolder.getCurrentState().updateNextRefreshTime(refreshInterval, defaultMinBackoff); + stateHolder.updateNextRefreshTime(refreshInterval, defaultMinBackoff); throw e; } return eventData; } + /** + * Executes a refresh operation with automatic retry logic across replica clients. + * + * @param clientFactory factory for accessing App Configuration clients + * @param originEndpoint the endpoint of the origin configuration store + * @param operation the refresh operation to execute + * @param eventData the refresh event data to update + * @param context the operation context + * @param checkType description of the check type for logging (e.g., "configuration refresh check") + * @param useLastActive whether to reuse the last active client + * @return the eventData if refresh is needed, null otherwise + */ + private RefreshEventData executeRefreshWithRetry( + AppConfigurationReplicaClientFactory clientFactory, + String originEndpoint, + RefreshOperation operation, + RefreshEventData eventData, + Context context, + String checkType, + boolean useLastActive) { + AppConfigurationReplicaClient client = clientFactory.getNextActiveClient(originEndpoint, useLastActive); + + while (client != null) { + try { + operation.execute(client, eventData, context); + if (eventData.getDoRefresh()) { + return eventData; + } + // If check didn't throw an error, other clients don't need to be checked. + break; + } catch (HttpResponseException e) { + LOGGER.warn( + "Failed to connect to App Configuration store {} during {}. Status: {}, Message: {}", + client.getEndpoint(), checkType, e.getResponse().getStatusCode(), e.getMessage()); + + clientFactory.backoffClient(originEndpoint, client.getEndpoint()); + client = clientFactory.getNextActiveClient(originEndpoint, false); + } + } + return null; + } + /** * Performs a refresh check for a specific store client without time constraints. This method is used for refresh * failure scenarios only. @@ -145,12 +202,20 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF * @param client the client for checking refresh status * @param originEndpoint the original config store endpoint * @param context the operation context + * @param stateHolder the state holder instance * @return true if a refresh should be triggered, false otherwise */ - static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String originEndpoint, Context context) { + static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String originEndpoint, Context context, + StateHolder stateHolder) { RefreshEventData eventData = new RefreshEventData(); - if (StateHolder.getLoadState(originEndpoint)) { - refreshWithoutTime(client, StateHolder.getState(originEndpoint).getWatchKeys(), eventData, context); + if (stateHolder.getLoadState(originEndpoint)) { + State state = stateHolder.getState(originEndpoint); + if (state != null) { + refreshWithoutTime(client, state.getWatchKeys(), eventData, context); + } else { + LOGGER.debug("Skipping configuration refresh check for {} as no watched state is available", + originEndpoint); + } } return eventData.getDoRefresh(); } @@ -158,21 +223,21 @@ static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String or /** * Performs a feature flag refresh check for a specific store client. This method is used for refresh failure * scenarios only. - * + * * @param featureStoreEnabled whether feature store is enabled * @param client the client for checking refresh status * @param context the operation context * @return true if a refresh should be triggered, false otherwise */ - static boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled, + boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled, AppConfigurationReplicaClient client, Context context) { RefreshEventData eventData = new RefreshEventData(); String endpoint = client.getEndpoint(); - if (featureStoreEnabled && StateHolder.getStateFeatureFlag(endpoint) != null) { - refreshWithoutTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(endpoint), eventData, context); + if (featureStoreEnabled && stateHolder.getStateFeatureFlag(endpoint) != null) { + refreshWithoutTimeFeatureFlags(client, stateHolder.getStateFeatureFlag(endpoint), eventData, context); } else { - LOGGER.debug("Skipping feature flag refresh check for " + endpoint); + LOGGER.debug("Skipping feature flag refresh check for {}", endpoint); } return eventData.getDoRefresh(); } @@ -189,14 +254,22 @@ static boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled, * @param context the operation context * @throws AppConfigurationStatusException if there's an error during the refresh check */ - private static void refreshWithTime(AppConfigurationReplicaClient client, State state, Duration refreshInterval, + private void refreshWithTime(AppConfigurationReplicaClient client, State state, Duration refreshInterval, RefreshEventData eventData, ReplicaLookUp replicaLookUp, Context context) throws AppConfigurationStatusException { if (Instant.now().isAfter(state.getNextRefreshCheck())) { replicaLookUp.updateAutoFailoverEndpoints(); - refreshWithoutTime(client, state.getWatchKeys(), eventData, context); - StateHolder.getCurrentState().updateStateRefresh(state, refreshInterval); + // Check watched configuration settings first if configured + List watchedSettings = state.getCollectionWatchKeys(); + if (watchedSettings != null && !watchedSettings.isEmpty()) { + refreshWithoutTimeWatchedConfigurationSettings(client, watchedSettings, eventData, context); + } else { + // Fall back to traditional watch key monitoring + refreshWithoutTime(client, state.getWatchKeys(), eventData, context); + } + + stateHolder.updateStateRefresh(state, refreshInterval); } } @@ -226,6 +299,34 @@ private static void refreshWithoutTime(AppConfigurationReplicaClient client, Lis } } + /** + * Checks configuration watched configuration settings for etag changes without time validation. This method + * immediately checks all watched configuration settings selectors for changes regardless of refresh intervals. + * + * @param client the App Configuration client to use for checking + * @param watchedSettings the list of watched configuration settings to watch for changes + * @param eventData the refresh event data to update if changes are detected + * @param context the operation context + * @throws AppConfigurationStatusException if there's an error during the refresh check + */ + private static void refreshWithoutTimeWatchedConfigurationSettings(AppConfigurationReplicaClient client, + List watchedSettings, RefreshEventData eventData, Context context) + throws AppConfigurationStatusException { + for (WatchedConfigurationSettings watchedSetting : watchedSettings) { + if (client.checkWatchKeys(watchedSetting.getSettingSelector(), context)) { + String eventDataInfo = watchedSetting.getSettingSelector().getKeyFilter(); + + // Only one refresh event needs to be called to update all of the + // stores, not one for each. + LOGGER.info("Configuration Refresh Event triggered by watched configuration settings: {}", + eventDataInfo); + + eventData.setMessage(eventDataInfo); + return; + } + } + } + /** * Checks feature flag refresh triggers with time-based validation. Only performs the refresh check if the refresh * interval has elapsed. @@ -238,28 +339,26 @@ private static void refreshWithoutTime(AppConfigurationReplicaClient client, Lis * @param context the operation context * @throws AppConfigurationStatusException if there's an error during the refresh check */ - private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState state, + private void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState state, Duration refreshInterval, RefreshEventData eventData, ReplicaLookUp replicaLookUp, Context context) throws AppConfigurationStatusException { Instant date = Instant.now(); if (date.isAfter(state.getNextRefreshCheck())) { replicaLookUp.updateAutoFailoverEndpoints(); - for (FeatureFlags featureFlags : state.getWatchKeys()) { + for (WatchedConfigurationSettings featureFlags : state.getWatchKeys()) { if (client.checkWatchKeys(featureFlags.getSettingSelector(), context)) { - String eventDataInfo = ".appconfig.featureflag/*"; - - // Only one refresh Event needs to be call to update all of the + // Only one refresh event needs to be called to update all of the // stores, not one for each. - LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo); + LOGGER.info("Configuration Refresh Event triggered by {}", FEATURE_FLAG_PREFIX); - eventData.setMessage(eventDataInfo); + eventData.setMessage(FEATURE_FLAG_PREFIX); return; } } - StateHolder.getCurrentState().updateFeatureFlagStateRefresh(state, refreshInterval); + stateHolder.updateFeatureFlagStateRefresh(state, refreshInterval); } } @@ -276,15 +375,13 @@ private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient cl private static void refreshWithoutTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState watchKeys, RefreshEventData eventData, Context context) throws AppConfigurationStatusException { - for (FeatureFlags featureFlags : watchKeys.getWatchKeys()) { + for (WatchedConfigurationSettings featureFlags : watchKeys.getWatchKeys()) { if (client.checkWatchKeys(featureFlags.getSettingSelector(), context)) { - String eventDataInfo = ".appconfig.featureflag/*"; - - // Only one refresh Event needs to be call to update all of the + // Only one refresh event needs to be called to update all of the // stores, not one for each. - LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo); + LOGGER.info("Configuration Refresh Event triggered by {}", FEATURE_FLAG_PREFIX); - eventData.setMessage(eventDataInfo); + eventData.setMessage(FEATURE_FLAG_PREFIX); } } @@ -292,7 +389,7 @@ private static void refreshWithoutTimeFeatureFlags(AppConfigurationReplicaClient /** * Checks the etag values between watched and current configuration settings to determine if a refresh is needed. - * + * * @param watchSetting the configuration setting being watched for changes * @param currentTriggerConfiguration the current configuration setting from the store * @param endpoint the endpoint of the configuration store @@ -313,9 +410,9 @@ private static void checkETag(ConfigurationSetting watchSetting, ConfigurationSe String eventDataInfo = watchSetting.getKey(); - // Only one refresh Event needs to be call to update all of the + // Only one refresh event needs to be called to update all of the // stores, not one for each. - LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo); + LOGGER.info("Configuration Refresh Event triggered by {}", eventDataInfo); eventData.setMessage(eventDataInfo); } } @@ -325,7 +422,7 @@ private static void checkETag(ConfigurationSetting watchSetting, ConfigurationSe */ static class RefreshEventData { - private static final String MSG_TEMPLATE = "Some keys matching %s has been updated since last check."; + private static final String MSG_TEMPLATE = "Some keys matching %s have been updated since last check."; private String message; @@ -340,7 +437,7 @@ static class RefreshEventData { /** * Sets the refresh message using the standard message template. - * + * * @param prefix the prefix to include in the message (typically a key name) * @return this RefreshEventData instance for method chaining */ @@ -351,7 +448,7 @@ RefreshEventData setMessage(String prefix) { /** * Sets the full refresh message and marks that a refresh should occur. - * + * * @param message the complete message describing the refresh event * @return this RefreshEventData instance for method chaining */ @@ -363,7 +460,7 @@ private RefreshEventData setFullMessage(String message) { /** * Gets the refresh event message. - * + * * @return the message describing what triggered the refresh */ public String getMessage() { @@ -372,7 +469,7 @@ public String getMessage() { /** * Indicates whether a refresh should be performed. - * + * * @return true if a refresh is needed, false otherwise */ public boolean getDoRefresh() { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java index 0cde712d5867..edb761e0e737 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java @@ -21,7 +21,7 @@ import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.data.appconfiguration.models.SnapshotComposition; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import io.netty.handler.codec.http.HttpResponseStatus; @@ -150,15 +150,47 @@ List listSettings(SettingSelector settingSelector, Context } } + /** + * Gets configuration settings using watched configuration settings. This method retrieves all settings matching the + * selector and captures ETags for collection-based refresh monitoring. + * + * @param settingSelector selector criteria for configuration settings + * @param context Azure SDK context for request correlation + * @return WatchedConfigurationSettings containing the retrieved configuration settings and match conditions + * @throws HttpResponseException if the request fails + */ + WatchedConfigurationSettings loadWatchedSettings(SettingSelector settingSelector, Context context) { + List configurationSettings = new ArrayList<>(); + List checks = new ArrayList<>(); + try { + client.listConfigurationSettings(settingSelector, context).streamByPage().forEach(pagedResponse -> { + checks.add( + new MatchConditions().setIfNoneMatch(pagedResponse.getHeaders().getValue(HttpHeaderName.ETAG))); + for (ConfigurationSetting setting : pagedResponse.getValue()) { + configurationSettings.add(NormalizeNull.normalizeNullLabel(setting)); + } + }); + + // Needs to happen after or we don't know if the request succeeded or failed. + this.failedAttempts = 0; + settingSelector.setMatchConditions(checks); + return new WatchedConfigurationSettings(settingSelector, configurationSettings); + } catch (HttpResponseException e) { + throw handleHttpResponseException(e); + } catch (UncheckedIOException e) { + throw new AppConfigurationStatusException(e.getMessage(), null, null); + } + } + /** * Lists feature flags from the Azure App Configuration store. * * @param settingSelector selector criteria for feature flags * @param context Azure SDK context for request correlation - * @return FeatureFlags containing the retrieved feature flags and match conditions + * @return WatchedConfigurationSettings containing the retrieved feature flags and match conditions * @throws HttpResponseException if the request fails */ - FeatureFlags listFeatureFlags(SettingSelector settingSelector, Context context) + WatchedConfigurationSettings listFeatureFlags(SettingSelector settingSelector, Context context) throws HttpResponseException { List configurationSettings = new ArrayList<>(); List checks = new ArrayList<>(); @@ -175,7 +207,7 @@ FeatureFlags listFeatureFlags(SettingSelector settingSelector, Context context) // Needs to happen after or we don't know if the request succeeded or failed. this.failedAttempts = 0; settingSelector.setMatchConditions(checks); - return new FeatureFlags(settingSelector, configurationSettings); + return new WatchedConfigurationSettings(settingSelector, configurationSettings); } catch (HttpResponseException e) { throw handleHttpResponseException(e); } catch (UncheckedIOException e) { @@ -217,7 +249,7 @@ boolean checkWatchKeys(SettingSelector settingSelector, Context context) { List> results = client .listConfigurationSettings(settingSelector, context) .streamByPage().filter(pagedResponse -> pagedResponse.getStatusCode() != HTTP_NOT_MODIFIED).toList(); - return results.size() > 0; + return !results.isEmpty(); } /** diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java index 8bd7792707e9..c2c7e7939048 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java @@ -108,13 +108,13 @@ String findOriginForEndpoint(String endpoint) { } /** - * Sets the current active replica for a configuration store. + * Gets the duration in milliseconds until the next client becomes available for the specified store. * * @param originEndpoint the origin configuration store endpoint - * @param replicaEndpoint the replica endpoint that was successfully connected to + * @return duration in milliseconds until next client is available, or 0 if one is available now */ - void setCurrentConfigStoreClient(String originEndpoint, String replicaEndpoint) { - CONNECTIONS.get(originEndpoint).setCurrentClient(replicaEndpoint); + long getMillisUntilNextClientAvailable(String originEndpoint) { + return CONNECTIONS.get(originEndpoint).getMillisUntilNextClientAvailable(); } /** diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientsBuilder.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientsBuilder.java index 6b4d42827626..8832196d5dc2 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientsBuilder.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientsBuilder.java @@ -8,6 +8,7 @@ import java.net.URL; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.function.Function; import java.util.regex.Matcher; @@ -156,7 +157,8 @@ List buildClients(ConfigStore configStore) { LOGGER.debug("Connecting to " + endpoint + " using Connecting String."); ConfigurationClientBuilder builder = createBuilderInstance().connectionString(connectionString); - clients.add(modifyAndBuildClient(builder, endpoint, configStore.getEndpoint(), connectionStrings.size() - 1)); + clients.add( + modifyAndBuildClient(builder, endpoint, configStore.getEndpoint(), connectionStrings.size() - 1)); } } else { DefaultAzureCredential defautAzureCredential = new DefaultAzureCredentialBuilder().build(); @@ -171,6 +173,11 @@ List buildClients(ConfigStore configStore) { clients.add(modifyAndBuildClient(builder, endpoint, configStore.getEndpoint(), endpoints.size() - 1)); } } + + if (configStore.isLoadBalancingEnabled()) { + Collections.shuffle(clients); + } + return clients; } @@ -196,7 +203,8 @@ AppConfigurationReplicaClient buildClient(String failoverEndpoint, ConfigStore c } } - private AppConfigurationReplicaClient modifyAndBuildClient(ConfigurationClientBuilder builder, String endpoint, String originEndpoint, + private AppConfigurationReplicaClient modifyAndBuildClient(ConfigurationClientBuilder builder, String endpoint, + String originEndpoint, Integer replicaCount) { TracingInfo tracingInfo = new TracingInfo(isKeyVaultConfigured, replicaCount, Configuration.getGlobalConfiguration()); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java index e585997e05a5..47c39c3fd920 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java @@ -10,7 +10,7 @@ import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; /** * Azure App Configuration PropertySource unique per Store Label(Profile) combo. @@ -32,7 +32,7 @@ final class AppConfigurationSnapshotPropertySource extends AppConfigurationAppli FeatureFlagClient featureFlagClient) { // The context alone does not uniquely define a PropertySource, append storeName // and label to uniquely define a PropertySource - super(name, replicaClient, keyVaultClientFactory, null, null); + super(name, replicaClient, keyVaultClientFactory, null, null, null); this.snapshotName = snapshotName; this.featureFlagClient = featureFlagClient; } @@ -49,7 +49,7 @@ final class AppConfigurationSnapshotPropertySource extends AppConfigurationAppli public void initProperties(List trim, Context context) throws InvalidConfigurationPropertyValueException { processConfigurationSettings(replicaClient.listSettingSnapshot(snapshotName, context), null, trim); - FeatureFlags featureFlags = new FeatureFlags(null, featureFlagsList); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, featureFlagsList); featureFlagClient.proccessFeatureFlags(featureFlags, replicaClient.getEndpoint()); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBootstrapRegistrar.java similarity index 91% rename from sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java rename to sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBootstrapRegistrar.java index fb0a090f0910..f43186e32714 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBootstrapRegistrar.java @@ -6,6 +6,7 @@ import org.springframework.boot.context.config.ConfigDataLocationResolverContext; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.util.StringUtils; import com.azure.data.appconfiguration.ConfigurationClientBuilder; @@ -51,6 +52,17 @@ static void register(ConfigDataLocationResolverContext context, Binder binder, InstanceSupplier.from(() -> keyVaultClientFactory)); context.getBootstrapContext().registerIfAbsent(AppConfigurationReplicaClientFactory.class, InstanceSupplier.from(() -> buildClientFactory(replicaClientsBuilder, properties, replicaLookup))); + + // Register StateHolder and promote it to ApplicationContext on close + context.getBootstrapContext().registerIfAbsent(StateHolder.class, + InstanceSupplier.from(StateHolder::new)); + context.getBootstrapContext().addCloseListener(event -> { + StateHolder stateHolder = event.getBootstrapContext().get(StateHolder.class); + ConfigurableApplicationContext applicationContext = event.getApplicationContext(); + if (!applicationContext.getBeanFactory().containsBean("appConfigurationStateHolder")) { + applicationContext.getBeanFactory().registerSingleton("appConfigurationStateHolder", stateHolder); + } + }); } private static AppConfigurationKeyVaultClientFactory appConfigurationKeyVaultClientFactory( diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java index 8daa346b2d34..bf6c337d9d24 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.time.Instant; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import org.apache.commons.logging.Log; @@ -22,7 +23,8 @@ import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.data.appconfiguration.models.SettingSelector; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification; @@ -30,7 +32,7 @@ /** * Azure App Configuration data loader implementation for Spring Boot's ConfigDataLoader. - * + * * @since 6.0.0 */ @@ -59,7 +61,7 @@ public class AzureAppConfigDataLoader implements ConfigDataLoader featureFlagClient)); } - // Reset telemetry usage for refresh featureFlagClient.resetTelemetry(); + List> sourceList = new ArrayList<>(); if (resource.isConfigStoreEnabled()) { - replicaClientFactory = context.getBootstrapContext() - .get(AppConfigurationReplicaClientFactory.class); - keyVaultClientFactory = context.getBootstrapContext() - .get(AppConfigurationKeyVaultClientFactory.class); - - boolean reloadFailed = false; - boolean pushRefresh = false; - Exception lastException = null; - PushNotification notification = resource.getMonitoring().getPushNotification(); - if ((notification.getPrimaryToken() != null - && StringUtils.hasText(notification.getPrimaryToken().getName())) - || (notification.getSecondaryToken() != null - && StringUtils.hasText(notification.getPrimaryToken().getName()))) { - pushRefresh = true; + replicaClientFactory = context.getBootstrapContext().get(AppConfigurationReplicaClientFactory.class); + keyVaultClientFactory = context.getBootstrapContext().get(AppConfigurationKeyVaultClientFactory.class); + + Exception loadException = loadConfiguration(sourceList); + if (loadException != null) { + if (resource.isRefresh()) { + logger.warn("Azure App Configuration failed during refresh for store: " + + resource.getEndpoint() + ". Continuing with existing configuration."); + } else { + logger.error("Azure App Configuration failed to load configuration during startup for store: " + + resource.getEndpoint() + ". Application cannot start without required configuration."); + failedToGeneratePropertySource(loadException); + } } - // Feature Management needs to be set in the last config store. - requestContext = new Context("refresh", resource.isRefresh()).addData(PUSH_REFRESH, pushRefresh); + } + if (!featureFlagClient.getFeatureFlags().isEmpty()) { + sourceList.add(new AppConfigurationFeatureManagementPropertySource(featureFlagClient)); + } + return new ConfigData(sourceList); + } + + /** + * Loads configuration from Azure App Configuration with replica failover support. + * + * @param sourceList the list to populate with property sources + * @return the exception if loading failed, null on success + */ + private Exception loadConfiguration(List> sourceList) { + PushNotification notification = resource.getMonitoring().getPushNotification(); + boolean pushRefresh = (notification.getPrimaryToken() != null + && StringUtils.hasText(notification.getPrimaryToken().getName())) + || (notification.getSecondaryToken() != null + && StringUtils.hasText(notification.getSecondaryToken().getName())); + + requestContext = new Context("refresh", resource.isRefresh()).addData(PUSH_REFRESH, pushRefresh); + + // During refresh, attempt once without retry + if (resource.isRefresh()) { replicaClientFactory.findActiveClients(resource.getEndpoint()); + return attemptLoadFromClients(sourceList); + } - AppConfigurationReplicaClient client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), - true); + // During startup, retry with backoff until deadline + Instant startTime = Instant.now(); + Instant deadline = startTime.plus(resource.getStartupTimeout()); + Exception lastException = null; + int postFixedWindowAttempts = 0; - while (client != null) { - final AppConfigurationReplicaClient currentClient = client; + while (Instant.now().isBefore(deadline)) { + replicaClientFactory.findActiveClients(resource.getEndpoint()); + lastException = attemptLoadFromClients(sourceList); - if (reloadFailed - && !AppConfigurationRefreshUtil.refreshStoreCheck(currentClient, - replicaClientFactory.findOriginForEndpoint(currentClient.getEndpoint()), requestContext)) { - // This store doesn't have any changes where to refresh store did. Skipping to next client. - client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), false); - continue; + if (lastException == null) { + return null; // Success + } + + // All clients failed, use fixed backoff based on elapsed time + if (Instant.now().isBefore(deadline)) { + long elapsedSeconds = Instant.now().getEpochSecond() - startTime.getEpochSecond(); + Long backoffSeconds = getBackoffDuration(elapsedSeconds); + + // If backoff is null, elapsed time exceeds fixed intervals - use exponential backoff + if (backoffSeconds == null) { + postFixedWindowAttempts++; + // Convert nanoseconds to seconds + backoffSeconds = BackoffTimeCalculator.calculateBackoff(postFixedWindowAttempts) / 1_000_000_000L; + } + + // Don't wait longer than remaining time until deadline + long remainingSeconds = deadline.getEpochSecond() - Instant.now().getEpochSecond(); + long waitSeconds = Math.min(backoffSeconds, remainingSeconds); + + if (waitSeconds > 0) { + logger.debug("All replicas in backoff for store: " + resource.getEndpoint() + + ". Waiting " + waitSeconds + "s before retry (elapsed: " + elapsedSeconds + "s)."); + try { + Thread.sleep(waitSeconds * 1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return lastException; + } } + } + } + return lastException; + } - // Reverse in order to add Profile specific properties earlier, and last profile comes first - try { - sourceList.addAll(createSettings(currentClient)); - List featureFlags = createFeatureFlags(currentClient); + /** + * Gets the backoff duration based on elapsed time since startup began. + * + * @param elapsedSeconds the number of seconds elapsed since startup began + * @return the backoff duration in seconds, or null if elapsed time exceeds all thresholds + */ + private Long getBackoffDuration(long elapsedSeconds) { + for (int[] interval : STARTUP_BACKOFF_INTERVALS) { + if (elapsedSeconds < interval[0]) { + return (long) interval[1]; + } + } + // Return null when elapsed time exceeds all defined thresholds + return null; + } - logger.debug("PropertySource context."); - AppConfigurationStoreMonitoring monitoring = resource.getMonitoring(); + /** + * Attempts to load configuration from available clients. + * + * @param sourceList the list to populate with property sources + * @return the exception if all clients failed, null on success + */ + private Exception attemptLoadFromClients(List> sourceList) { + boolean reloadFailed = false; + Exception lastException = null; + AppConfigurationReplicaClient client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), true); + List> tempSourceList = new ArrayList<>(); + + while (client != null) { + tempSourceList.clear(); + final AppConfigurationReplicaClient currentClient = client; + + if (reloadFailed && !AppConfigurationRefreshUtil.refreshStoreCheck(currentClient, + replicaClientFactory.findOriginForEndpoint(currentClient.getEndpoint()), requestContext, storeState)) { + client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), false); + continue; + } - storeState.setStateFeatureFlag(resource.getEndpoint(), featureFlags, - monitoring.getFeatureFlagRefreshInterval()); + try { + tempSourceList.addAll(createSettings(currentClient)); + List featureFlags = createFeatureFlags(currentClient); - if (monitoring.isEnabled()) { - // Setting new ETag values for Watch - List watchKeysSettings = monitoring.getTriggers().stream() - .map(trigger -> currentClient.getWatchKey(trigger.getKey(), trigger.getLabel(), - requestContext)) - .toList(); + AppConfigurationStoreMonitoring monitoring = resource.getMonitoring(); - storeState.setState(resource.getEndpoint(), watchKeysSettings, monitoring.getRefreshInterval()); - } - storeState.setLoadState(resource.getEndpoint(), true); // Success - configuration loaded, exit loop - lastException = null; - // Break out of the loop since we have successfully loaded configuration - break; - } catch (AppConfigurationStatusException e) { - reloadFailed = true; - replicaClientFactory.backoffClient(resource.getEndpoint(), currentClient.getEndpoint()); - lastException = e; - // Log the specific replica failure with context - AppConfigurationReplicaClient nextClient = replicaClientFactory - .getNextActiveClient(resource.getEndpoint(), false); - logReplicaFailure(currentClient, "status exception", nextClient != null, e); - client = nextClient; - } catch (Exception e) { - // Store the exception to potentially use if all replicas fail - lastException = e; // Log the specific replica failure with context - replicaClientFactory.backoffClient(resource.getEndpoint(), currentClient.getEndpoint()); - AppConfigurationReplicaClient nextClient = replicaClientFactory - .getNextActiveClient(resource.getEndpoint(), false); - logReplicaFailure(currentClient, "exception", nextClient != null, e); - client = nextClient; + storeState.setStateFeatureFlag(resource.getEndpoint(), featureFlags, + monitoring.getFeatureFlagRefreshInterval()); + + if (monitoring.isEnabled()) { + setupMonitoringState(currentClient, monitoring); } - } // Check if all replicas failed - if (lastException != null && !resource.isRefresh()) { - // During startup, if all replicas failed, fail the application - logger.error("Azure App Configuration failed to load configuration during startup for store: " - + resource.getEndpoint() + ". Application cannot start without required configuration."); - failedToGeneratePropertySource(lastException); - } else if (lastException != null && resource.isRefresh()) { - // During refresh, log warning but don't fail the application - logger.warn("Azure App Configuration failed during refresh for store: " - + resource.getEndpoint() + ". Continuing with existing configuration."); + + storeState.setLoadState(resource.getEndpoint(), true); + sourceList.addAll(tempSourceList); + return null; // Success + } catch (AppConfigurationStatusException e) { + reloadFailed = true; + lastException = e; + client = handleReplicaFailure(currentClient, "status exception", e); + } catch (Exception e) { + lastException = e; + client = handleReplicaFailure(currentClient, "exception", e); } } - StateHolder.updateState(storeState); - if (featureFlagClient.getFeatureFlags().size() > 0) { - // Don't add feature flags if there are none, otherwise the local file can't load them. - sourceList.add(new AppConfigurationFeatureManagementPropertySource(featureFlagClient)); + return lastException; + } + + /** + * Sets up the monitoring state based on the configuration. + * + * @param client the replica client + * @param monitoring the monitoring configuration + * @throws Exception if setting up monitoring fails + */ + private void setupMonitoringState(AppConfigurationReplicaClient client, AppConfigurationStoreMonitoring monitoring) + throws Exception { + if (monitoring.getTriggers().isEmpty()) { + // Use watched configuration settings for refresh + List watchedConfigurationSettingsList = getWatchedConfigurationSettings( + client); + storeState.setState(resource.getEndpoint(), Collections.emptyList(), + watchedConfigurationSettingsList, monitoring.getRefreshInterval()); + return; } - return new ConfigData(sourceList); + // Use traditional watch key monitoring + List watchKeysSettings = monitoring.getTriggers().stream() + .map(trigger -> client.getWatchKey(trigger.getKey(), trigger.getLabel(), requestContext)) + .toList(); + + storeState.setState(resource.getEndpoint(), watchKeysSettings, monitoring.getRefreshInterval()); + } + + /** + * Handles a replica failure by backing off the client and getting the next available replica. + * + * @param client the failed client + * @param exceptionType a description of the exception type + * @param exception the exception that occurred + * @return the next available client, or null if none available + */ + private AppConfigurationReplicaClient handleReplicaFailure(AppConfigurationReplicaClient client, + String exceptionType, Exception exception) { + replicaClientFactory.backoffClient(resource.getEndpoint(), client.getEndpoint()); + AppConfigurationReplicaClient nextClient = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), + false); + + String scenario = resource.isRefresh() ? "refresh" : "startup"; + String nextAction = nextClient != null ? "Trying next replica." : "No more replicas available."; + logger.warn("Azure App Configuration replica " + client.getEndpoint() + + " failed during " + scenario + " with " + exceptionType + ". " + + nextAction + " Store: " + resource.getEndpoint(), exception); + + return nextClient; } /** @@ -239,7 +361,7 @@ private List createSettings(AppConfigurationRepl List profiles = resource.getProfiles().getActive(); for (AppConfigurationKeyValueSelector selectedKeys : selects) { - AppConfigurationPropertySource propertySource = null; + AppConfigurationPropertySource propertySource; if (StringUtils.hasText(selectedKeys.getSnapshotName())) { propertySource = new AppConfigurationSnapshotPropertySource( @@ -248,7 +370,8 @@ private List createSettings(AppConfigurationRepl } else { propertySource = new AppConfigurationApplicationSettingPropertySource( selectedKeys.getKeyFilter() + resource.getEndpoint() + "/", client, keyVaultClientFactory, - selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles)); + selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), + selectedKeys.getTagsFilter()); } propertySource.initProperties(resource.getTrimKeyPrefix(), requestContext); sourceList.add(propertySource); @@ -260,17 +383,18 @@ private List createSettings(AppConfigurationRepl * Creates a list of feature flags from Azure App Configuration. * * @param client client for connecting to App Configuration - * @return a list of FeatureFlags + * @return a list of WatchedConfigurationSettings * @throws Exception creating feature flags failed */ - private List createFeatureFlags(AppConfigurationReplicaClient client) + private List createFeatureFlags(AppConfigurationReplicaClient client) throws Exception { - List featureFlagWatchKeys = new ArrayList<>(); + List featureFlagWatchKeys = new ArrayList<>(); List profiles = resource.getProfiles().getActive(); for (FeatureFlagKeyValueSelector selectedKeys : resource.getFeatureFlagSelects()) { - List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client, - selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), requestContext); + List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client, + selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), + selectedKeys.getTagsFilter(), requestContext); featureFlagWatchKeys.addAll(storesFeatureFlags); } @@ -278,21 +402,42 @@ private List createFeatureFlags(AppConfigurationReplicaClient clie } /** - * Logs a replica failure with contextual information about the failure scenario and available replicas. + * Creates a list of watched configuration settings for configuration settings from Azure App Configuration. This is + * used for collection-based refresh monitoring as an alternative to individual watch keys. * - * @param client the replica client that failed - * @param exceptionType a brief description of the exception type (e.g., "status exception", "exception") - * @param hasMoreReplicas whether there are additional replicas available to try - * @param exception the exception that caused the failure + * @param client client for connecting to App Configuration + * @return a list of WatchedConfigurationSettings for configuration settings + * @throws Exception creating watched configuration settings failed */ - private void logReplicaFailure(AppConfigurationReplicaClient client, String exceptionType, - boolean hasMoreReplicas, Exception exception) { - String scenario = resource.isRefresh() ? "refresh" : "startup"; - String nextAction = hasMoreReplicas ? "Trying next replica." : "No more replicas available."; + private List getWatchedConfigurationSettings(AppConfigurationReplicaClient client) + throws Exception { + List watchedConfigurationSettingsList = new ArrayList<>(); + List selects = resource.getSelects(); + List profiles = resource.getProfiles().getActive(); - logger.warn("Azure App Configuration replica " + client.getEndpoint() - + " failed during " + scenario + " with " + exceptionType + ". " - + nextAction + " Store: " + resource.getEndpoint(), exception); + for (AppConfigurationKeyValueSelector selectedKeys : selects) { + // Skip snapshots - they don't support watched configuration settings + if (StringUtils.hasText(selectedKeys.getSnapshotName())) { + continue; + } + + // Create watched configuration settings for each label + for (String label : selectedKeys.getLabelFilter(profiles)) { + SettingSelector settingSelector = new SettingSelector() + .setKeyFilter(selectedKeys.getKeyFilter() + "*") + .setLabelFilter(label); + + if (selectedKeys.getTagsFilter() != null && !selectedKeys.getTagsFilter().isEmpty()) { + settingSelector.setTagsFilter(selectedKeys.getTagsFilter()); + } + + WatchedConfigurationSettings watchedConfigurationSettings = client.loadWatchedSettings(settingSelector, + requestContext); + watchedConfigurationSettingsList.add(watchedConfigurationSettings); + } + } + + return watchedConfigurationSettingsList; } /** diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java index b4ecd1b477ba..75452808a5e9 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java @@ -57,8 +57,16 @@ public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDat return false; } + Binder binder = context.getBinder(); + + // Check if Azure App Configuration is enabled + Boolean enabled = binder.bind(AppConfigurationProperties.CONFIG_PREFIX + ".enabled", Boolean.class).orElse(true); + if (!enabled) { + return false; + } + // Check if the configuration properties for Azure App Configuration are present - return hasValidStoreConfiguration(context.getBinder()); + return hasValidStoreConfiguration(binder); } /** @@ -120,7 +128,7 @@ public List resolveProfileSpecific( for (ConfigStore store : properties.getStores()) { locations.add( - new AzureAppConfigDataResource(properties.isEnabled(), store, profiles, START_UP.get(), properties.getRefreshInterval())); + new AzureAppConfigDataResource(properties.isEnabled(), store, profiles, START_UP.get(), properties.getRefreshInterval(), properties.getStartupTimeout())); } START_UP.set(false); return locations; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java index ab1557a2a38b..dd5c5f8de978 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java @@ -47,16 +47,21 @@ public class AzureAppConfigDataResource extends ConfigDataResource { /** The interval at which configuration should be refreshed from the store. */ private final Duration refreshInterval; + /** The timeout duration for retry attempts during startup. */ + private final Duration startupTimeout; + /** * Constructs a new AzureAppConfigDataResource with the specified configuration store settings. * + * @param appConfigEnabled true if Azure App Configuration is globally enabled * @param configStore the configuration store settings containing endpoint, selectors, and other options * @param profiles the Spring Boot profiles for conditional configuration loading * @param startup true if this is a startup load operation, false if it is a refresh operation * @param refreshInterval the interval at which configuration should be refreshed + * @param startupTimeout the timeout duration for retry attempts during startup */ AzureAppConfigDataResource(boolean appConfigEnabled, ConfigStore configStore, Profiles profiles, boolean startup, - Duration refreshInterval) { + Duration refreshInterval, Duration startupTimeout) { this.configStoreEnabled = appConfigEnabled && configStore.isEnabled(); this.endpoint = configStore.getEndpoint(); this.selects = configStore.getSelects(); @@ -66,6 +71,7 @@ public class AzureAppConfigDataResource extends ConfigDataResource { this.profiles = profiles; this.isRefresh = !startup; this.refreshInterval = refreshInterval; + this.startupTimeout = startupTimeout; } /** @@ -148,4 +154,13 @@ public boolean isRefresh() { public Duration getRefreshInterval() { return refreshInterval; } + + /** + * Gets the timeout duration for retry attempts during startup. + * + * @return the startup timeout duration + */ + public Duration getStartupTimeout() { + return startupTimeout; + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java index 051600d02241..36049ed52fc8 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java @@ -34,9 +34,6 @@ class ConnectionManager { /** Map of auto-discovered failover clients, keyed by endpoint URL. */ private final Map autoFailoverClients; - /** Currently active replica endpoint being used for requests. */ - private String currentReplica; - /** Current health status of the App Configuration store connection. */ private AppConfigurationStoreHealth health; @@ -67,7 +64,6 @@ class ConnectionManager { this.configStore = configStore; this.originEndpoint = configStore.getEndpoint(); this.health = AppConfigurationStoreHealth.NOT_LOADED; - this.currentReplica = configStore.getEndpoint(); this.autoFailoverClients = new HashMap<>(); this.replicaLookUp = replicaLookUp; this.activeClients = new ArrayList<>(); @@ -83,15 +79,6 @@ AppConfigurationStoreHealth getHealth() { return this.health; } - /** - * Sets the current active replica endpoint for client routing. - * - * @param replicaEndpoint the endpoint URL to set as current; may be null to reset to primary endpoint - */ - void setCurrentClient(String replicaEndpoint) { - this.currentReplica = replicaEndpoint; - } - /** * Retrieves the primary (origin) endpoint URL for the App Configuration store. * @@ -108,17 +95,18 @@ String getMainEndpoint() { * @return the next active AppConfigurationReplicaClient */ AppConfigurationReplicaClient getNextActiveClient(boolean useLastActive) { - if (activeClients.isEmpty()) { - lastActiveClient = ""; - return null; - } else if (useLastActive) { + if (useLastActive) { List clients = getAvailableClients(); - for (AppConfigurationReplicaClient client: clients) { + for (AppConfigurationReplicaClient client : clients) { if (client.getEndpoint().equals(lastActiveClient)) { return client; } } } + if (activeClients.isEmpty()) { + lastActiveClient = ""; + return null; + } if (!configStore.isLoadBalancingEnabled()) { if (!activeClients.isEmpty()) { @@ -127,6 +115,8 @@ AppConfigurationReplicaClient getNextActiveClient(boolean useLastActive) { return null; } + // Remove the current client from the list. The list will be rebuilt and rotated on the next refresh cycle by + // findActiveClients(). AppConfigurationReplicaClient nextClient = activeClients.remove(0); lastActiveClient = nextClient.getEndpoint(); return nextClient; @@ -136,27 +126,17 @@ AppConfigurationReplicaClient getNextActiveClient(boolean useLastActive) { * Finds the currently active clients for a given origin endpoint. */ void findActiveClients() { - activeClients = getAvailableClients(); - - if (!configStore.isLoadBalancingEnabled() || lastActiveClient.isEmpty()) { + // Load balancing enabled: only refresh if no active clients (rotation happens in getNextActiveClient) + if (configStore.isLoadBalancingEnabled()) { + if (activeClients.isEmpty()) { + activeClients = getAvailableClients(); + } return; } - - for (int i = 0; i < activeClients.size(); i++) { - AppConfigurationReplicaClient client = activeClients.get(i); - if (client.getEndpoint().equals(lastActiveClient)) { - int swapPoint = (i + 1) % activeClients.size(); - List rotatedClients = new ArrayList<>(); - - // Add elements from swapPoint to end - rotatedClients.addAll(activeClients.subList(swapPoint, activeClients.size())); - - // Add elements from beginning to swapPoint - rotatedClients.addAll(activeClients.subList(0, swapPoint)); - - activeClients = rotatedClients; - return; - } + // Load balancing disabled: always refresh to use the most preferred available client + List availableClients = getAvailableClients(); + if (!availableClients.isEmpty()) { + activeClients = availableClients; } } @@ -180,7 +160,7 @@ public List getAvailableClients() { if (clients.get(0).getBackoffEndTime().isBefore(Instant.now())) { availableClients.add(clients.get(0)); } - } else if (clients.size() > 0 && !configStore.isLoadBalancingEnabled()) { + } else if (!clients.isEmpty() && !configStore.isLoadBalancingEnabled()) { for (AppConfigurationReplicaClient replicaClient : clients) { if (replicaClient.getBackoffEndTime().isBefore(Instant.now())) { LOGGER.debug("Using Client: " + replicaClient.getEndpoint()); @@ -188,17 +168,17 @@ public List getAvailableClients() { } } } else if (configStore.isLoadBalancingEnabled()) { - for (AppConfigurationReplicaClient client: clients) { + for (AppConfigurationReplicaClient client : clients) { if (client.getBackoffEndTime().isBefore(Instant.now())) { availableClients.add(client); } } } - if (availableClients.size() == 0 || configStore.isLoadBalancingEnabled()) { + if (availableClients.isEmpty() || configStore.isLoadBalancingEnabled()) { List autoFailoverEndpoints = replicaLookUp.getAutoFailoverEndpoints(configStore.getEndpoint()); - if (autoFailoverEndpoints.size() > 0) { + if (!autoFailoverEndpoints.isEmpty()) { for (String failoverEndpoint : autoFailoverEndpoints) { AppConfigurationReplicaClient client = autoFailoverClients.get(failoverEndpoint); if (client == null) { @@ -212,9 +192,9 @@ public List getAvailableClients() { } } } - if (clients.size() > 0 && availableClients.size() == 0) { + if (!clients.isEmpty() && availableClients.isEmpty()) { this.health = AppConfigurationStoreHealth.DOWN; - } else if (clients.size() > 0) { + } else if (!clients.isEmpty()) { this.health = AppConfigurationStoreHealth.UP; } @@ -242,6 +222,51 @@ void backoffClient(String endpoint) { autoFailoverClients.get(endpoint).updateBackoffEndTime(Instant.now().plusNanos(backoffTime)); } + /** + * Gets the duration in milliseconds until the next client becomes available (exits backoff). + * Returns 0 if a client is already available, or the minimum wait time if all clients are in backoff. + * + * @return duration in milliseconds until next client is available, or 0 if one is available now + */ + long getMillisUntilNextClientAvailable() { + Instant now = Instant.now(); + Instant earliestAvailable = Instant.MAX; + + // Check configured clients + if (clients != null) { + for (AppConfigurationReplicaClient client : clients) { + Instant backoffEnd = client.getBackoffEndTime(); + if (!backoffEnd.isAfter(now)) { + return 0; // Client available now + } + if (backoffEnd.isBefore(earliestAvailable)) { + earliestAvailable = backoffEnd; + } + } + } + + // Check auto-failover clients + for (AppConfigurationReplicaClient client : autoFailoverClients.values()) { + Instant backoffEnd = client.getBackoffEndTime(); + if (!backoffEnd.isAfter(now)) { + return 0; // Client available now + } + if (backoffEnd.isBefore(earliestAvailable)) { + earliestAvailable = backoffEnd; + } + } + + // If no clients were found or no backoff times were set, avoid calling toEpochMilli on Instant.MAX. + if (Instant.MAX.equals(earliestAvailable)) { + // No clients are currently tracked; treat as no wait required. + return 0L; + } + + long millisUntilNext = earliestAvailable.toEpochMilli() - now.toEpochMilli(); + // Ensure we never return a negative duration, even in the presence of clock skew. + return Math.max(millisUntilNext, 0L); + } + /** * Updates the synchronization token for the specified client endpoint. * diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java index 44cb297b5e27..18049c5200af 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java @@ -37,7 +37,7 @@ import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagFilter; import com.azure.data.appconfiguration.models.SettingSelector; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Allocation; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.FeatureTelemetry; @@ -78,9 +78,9 @@ class FeatureFlagClient { *

* */ - List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter, - String[] labelFilter, Context context) { - List loadedFeatureFlags = new ArrayList<>(); + List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter, + String[] labelFilter, List tagsFilter, Context context) { + List loadedFeatureFlags = new ArrayList<>(); String keyFilter = SELECT_ALL_FEATURE_FLAGS; @@ -93,20 +93,22 @@ List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, for (String label : labels) { SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter).setLabelFilter(label); + + if (tagsFilter != null && !tagsFilter.isEmpty()) { + settingSelector.setTagsFilter(tagsFilter); + } + context.addData("FeatureFlagTracing", tracing); - FeatureFlags features = replicaClient.listFeatureFlags(settingSelector, context); - loadedFeatureFlags.addAll(proccessFeatureFlags(features, replicaClient.getOriginClient())); + WatchedConfigurationSettings features = replicaClient.listFeatureFlags(settingSelector, context); + loadedFeatureFlags.add(proccessFeatureFlags(features, replicaClient.getOriginClient())); } return loadedFeatureFlags; } - List proccessFeatureFlags(FeatureFlags features, String endpoint) { - List loadedFeatureFlags = new ArrayList<>(); - loadedFeatureFlags.add(features); - + WatchedConfigurationSettings proccessFeatureFlags(WatchedConfigurationSettings features, String endpoint) { // Reading In Features - for (ConfigurationSetting setting : features.getFeatureFlags()) { + for (ConfigurationSetting setting : features.getConfigurationSettings()) { if (setting instanceof FeatureFlagConfigurationSetting && FEATURE_FLAG_CONTENT_TYPE.equals(setting.getContentType())) { FeatureFlagConfigurationSetting featureFlag = (FeatureFlagConfigurationSetting) setting; @@ -114,7 +116,7 @@ List proccessFeatureFlags(FeatureFlags features, String endpoint) properties.put(featureFlag.getKey(), createFeature(featureFlag, endpoint)); } } - return loadedFeatureFlags; + return features; } /** diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java index 824f111ae238..a7d1a00a0748 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java @@ -2,10 +2,8 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; -import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; -import java.util.List; import java.util.Map; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; @@ -23,25 +21,29 @@ final class JsonConfigurationParser { private static final ObjectMapper MAPPER = JsonMapper.builder().enable(JsonReadFeature.ALLOW_JAVA_COMMENTS).build(); static boolean isJsonContentType(String contentType) { - String acceptedMainType = "application"; - String acceptedSubType = "json"; - if (!StringUtils.hasText(contentType)) { return false; } - if (contentType.contains("/")) { - String mainType = contentType.split("/")[0]; - String subType = contentType.split("/")[1]; + contentType = contentType.strip().toLowerCase(); + String mimeType = contentType.split(";")[0].strip(); - if (mainType.equalsIgnoreCase(acceptedMainType)) { - if (subType.contains("+")) { - List subtypes = Arrays.asList(subType.split("\\+")); - return subtypes.contains(acceptedSubType); - } else { - return subType.equalsIgnoreCase(acceptedSubType); - } - } + String[] typeParts = mimeType.split("/"); + if (typeParts.length != 2) { + return false; + } + + String mainType = typeParts[0]; + String subType = typeParts[1]; + + if (!"application".equals(mainType)) { + return false; + } + + String[] subTypes = subType.split("\\+"); + // Check if the last part (suffix) is "json" + if (subTypes.length > 0 && subTypes[subTypes.length - 1].equals("json")) { + return true; } return false; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java index 1d237001e78e..59a1b95150e9 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java @@ -6,72 +6,159 @@ import java.util.List; import com.azure.data.appconfiguration.models.ConfigurationSetting; - +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; + +/** + * Immutable representation of the refresh state for an Azure App Configuration store. + * + *

+ * Holds configuration watch keys, collection monitoring settings, refresh timing, and attempt tracking for a single + * configuration store endpoint. All fields are final to ensure thread-safety and immutability. + *

+ * + *

+ * State changes are made by creating new instances rather than mutating existing ones, following an immutable design + * pattern. + *

+ */ class State { + /** Configuration settings used as watch keys to trigger refresh events. */ private final List watchKeys; + /** Collection monitoring configurations that can trigger refresh events. */ + private final List collectionWatchKeys; + + /** The next time this store should be checked for refresh. */ private final Instant nextRefreshCheck; + /** The endpoint URL of the configuration store. */ private final String originEndpoint; - private Integer refreshAttempt; + /** Number of refresh attempts for exponential backoff calculation. */ + private int refreshAttempt; + /** The refresh interval in seconds. */ private final int refreshInterval; + /** + * Creates a new State for configuration watch keys without collection monitoring. + * @param watchKeys list of configuration watch keys that can trigger a refresh event + * @param refreshInterval refresh interval in seconds + * @param originEndpoint the endpoint URL of the configuration store + */ State(List watchKeys, int refreshInterval, String originEndpoint) { - this.watchKeys = watchKeys; - this.refreshInterval = refreshInterval; - nextRefreshCheck = Instant.now().plusSeconds(refreshInterval); - this.originEndpoint = originEndpoint; - this.refreshAttempt = 1; + this(watchKeys, null, refreshInterval, originEndpoint); } + /** + * Creates a new State with both configuration watch keys and collection monitoring. Sets the initial refresh + * attempt to 1 and calculates next refresh time from now. + * @param watchKeys list of configuration watch keys that can trigger a refresh event + * @param collectionWatchKeys list of collection monitoring configurations that can trigger a refresh event + * @param refreshInterval refresh interval in seconds + * @param originEndpoint the endpoint URL of the configuration store + */ + State(List watchKeys, List collectionWatchKeys, + int refreshInterval, String originEndpoint) { + this(watchKeys, collectionWatchKeys, refreshInterval, originEndpoint, + Instant.now().plusSeconds(refreshInterval), 1); + } + + /** + * Creates a new State from an existing state with an updated refresh time. Preserves the current refresh attempt + * count. + * @param oldState the existing State to copy from + * @param newRefresh the new refresh time + */ State(State oldState, Instant newRefresh) { - this.watchKeys = oldState.getWatchKeys(); - this.refreshInterval = oldState.getRefreshInterval(); - this.nextRefreshCheck = newRefresh; - this.originEndpoint = oldState.getOriginEndpoint(); - this.refreshAttempt = oldState.getRefreshAttempt(); + this(oldState, newRefresh, oldState.getRefreshAttempt()); + } + + /** + * Creates a new State from an existing state with updated refresh time and attempt count. Used when creating states + * with modified refresh attempts for backoff logic. + * @param oldState the existing State to copy from + * @param newRefresh the new refresh time + * @param refreshAttempt the refresh attempt count + */ + State(State oldState, Instant newRefresh, int refreshAttempt) { + this(oldState.getWatchKeys(), oldState.getCollectionWatchKeys(), oldState.getRefreshInterval(), + oldState.getOriginEndpoint(), newRefresh, refreshAttempt); + } + + /** + * Primary constructor that initializes all fields. All other constructors delegate to this one. This constructor is + * private to enforce the use of the public factory-style constructors. + * @param watchKeys list of configuration watch keys + * @param collectionWatchKeys list of collection monitoring configurations (may be null) + * @param refreshInterval refresh interval in seconds + * @param originEndpoint the endpoint URL of the configuration store + * @param nextRefreshCheck the next time to check for refresh + * @param refreshAttempt the current refresh attempt count + */ + private State(List watchKeys, List collectionWatchKeys, + int refreshInterval, String originEndpoint, Instant nextRefreshCheck, int refreshAttempt) { + this.watchKeys = watchKeys; + this.collectionWatchKeys = collectionWatchKeys; + this.refreshInterval = refreshInterval; + this.nextRefreshCheck = nextRefreshCheck; + this.originEndpoint = originEndpoint; + this.refreshAttempt = refreshAttempt; } /** - * @return the watchKeys + * Gets the configuration settings used as watch keys for this store. + * @return the list of configuration watch keys */ public List getWatchKeys() { return watchKeys; } /** - * @return the nextRefreshCheck + * Gets the collection monitoring configurations for this store. + * @return the list of collection monitoring configurations, or null if not configured + */ + public List getCollectionWatchKeys() { + return collectionWatchKeys; + } + + /** + * Gets the next time this store should be checked for refresh. + * @return the Instant of the next refresh check */ public Instant getNextRefreshCheck() { return nextRefreshCheck; } /** - * @return the originEndpoint + * Gets the endpoint URL of the configuration store. + * @return the origin endpoint */ public String getOriginEndpoint() { return originEndpoint; } /** - * @return the refreshAttempt + * Gets the number of refresh attempts. Used for exponential backoff calculation. + * @return the refresh attempt count */ - public Integer getRefreshAttempt() { + public int getRefreshAttempt() { return refreshAttempt; } /** - * Adds 1 to the number of refresh attempts + * Increments refresh attempt count. Used when a refresh fails to track attempts for backoff logic. + * @return the State instance with refreshAttempt incremented by 1 */ - public void incrementRefreshAttempt() { + public State incrementRefreshAttempt() { this.refreshAttempt += 1; + return this; } /** - * @return the refreshInterval + * Gets the refresh interval for this store. + * @return the refresh interval in seconds */ public int getRefreshInterval() { return refreshInterval; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java index 8d63ce85ac92..6ec13ae92cd9 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java @@ -11,97 +11,148 @@ import java.util.concurrent.ConcurrentHashMap; import com.azure.data.appconfiguration.models.ConfigurationSetting; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; - -final class StateHolder { +/** + * Thread-safe holder for managing refresh state of Azure App Configuration stores. + * + *

+ * Maintains state for configuration settings, feature flags, and refresh intervals across multiple configuration + * stores. Implements exponential backoff for failed refresh attempts and coordinates the timing of refresh operations. + *

+ * + *

+ * This class is registered in the BootstrapContext during initial configuration loading and promoted to the main + * ApplicationContext for use during refresh operations. + *

+ */ +public class StateHolder { + + /** Maximum jitter in seconds to add when expiring state to prevent thundering herd. */ private static final int MAX_JITTER = 15; - private static StateHolder currentState; - + /** Map of configuration store endpoints to their refresh state. */ private final Map state = new ConcurrentHashMap<>(); + /** Map of configuration store endpoints to their feature flag refresh state. */ private final Map featureFlagState = new ConcurrentHashMap<>(); + /** Map tracking whether each configuration store has been successfully loaded. */ private final Map loadState = new ConcurrentHashMap<>(); + /** Number of client-level refresh attempts for backoff calculation. */ private Integer clientRefreshAttempts = 1; + /** The next time a forced refresh should occur across all stores. */ private Instant nextForcedRefresh; - StateHolder() { - } - - static StateHolder getCurrentState() { - return currentState; - } - - static StateHolder updateState(StateHolder newState) { - currentState = newState; - return currentState; + /** + * Creates a new StateHolder instance. + */ + public StateHolder() { } /** + * Retrieves the refresh state for a specific configuration store. * @param originEndpoint the endpoint for the origin config store - * @return the state + * @return the State for the specified store, or null if not found */ - static State getState(String originEndpoint) { - return currentState.getFullState().get(originEndpoint); - } - - private Map getFullState() { - return state; + public State getState(String originEndpoint) { + return state.get(originEndpoint); } - private Map getFullFeatureFlagState() { - return featureFlagState; + /** + * Retrieves the feature flag refresh state for a specific configuration store. + * @param originEndpoint the endpoint for the origin config store + * @return the FeatureFlagState for the specified store, or null if not found + */ + public FeatureFlagState getStateFeatureFlag(String originEndpoint) { + return featureFlagState.get(originEndpoint); } - private Map getFullLoadState() { - return loadState; + /** + * Checks if a configuration store has been successfully loaded. + * @param originEndpoint the endpoint of the store to check + * @return true if the store has been loaded, false otherwise + */ + public boolean getLoadState(String originEndpoint) { + return loadState.getOrDefault(originEndpoint, false); } /** - * @param originEndpoint the endpoint for the origin config store - * @return the state + * Gets the next time a forced refresh should occur across all stores. + * @return the Instant of the next forced refresh, or null if not set */ - static FeatureFlagState getStateFeatureFlag(String originEndpoint) { - return currentState.getFullFeatureFlagState().get(originEndpoint); + public Instant getNextForcedRefresh() { + return nextForcedRefresh; } /** - * @param originEndpoint the stores origin endpoint + * Sets the refresh state for a configuration store. + * @param originEndpoint the store's origin endpoint * @param watchKeys list of configuration watch keys that can trigger a refresh event - * @param duration refresh duration. + * @param duration refresh duration */ - void setState(String originEndpoint, List watchKeys, Duration duration) { + public void setState(String originEndpoint, List watchKeys, Duration duration) { state.put(originEndpoint, new State(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint)); } /** - * @param originEndpoint the stores origin endpoint + * Sets the refresh state for a configuration store with collection monitoring. + * @param originEndpoint the store's origin endpoint * @param watchKeys list of configuration watch keys that can trigger a refresh event - * @param duration refresh duration. + * @param collectionWatchKeys list of collection monitoring configurations that can trigger a refresh event + * @param duration refresh duration */ - void setStateFeatureFlag(String originEndpoint, List watchKeys, + public void setState(String originEndpoint, List watchKeys, + List collectionWatchKeys, Duration duration) { + state.put(originEndpoint, + new State(watchKeys, collectionWatchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint)); + } + + /** + * Sets the feature flag refresh state for a configuration store. + * @param originEndpoint the store's origin endpoint + * @param watchKeys list of feature flag watch keys that can trigger a refresh event + * @param duration refresh duration + */ + public void setStateFeatureFlag(String originEndpoint, List watchKeys, Duration duration) { featureFlagState.put(originEndpoint, new FeatureFlagState(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint)); } + /** + * Updates the configuration state with a new refresh time based on the duration. + * @param state the current State to update + * @param duration the duration to add to the current time for the next refresh + */ void updateStateRefresh(State state, Duration duration) { this.state.put(state.getOriginEndpoint(), new State(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds())))); } - void updateFeatureFlagStateRefresh(FeatureFlagState state, Duration duration) { + /** + * Updates the feature flag state with a new refresh time based on the duration. + * @param state the current FeatureFlagState to update + * @param duration the duration to add to the current time for the next refresh + */ + public void updateFeatureFlagStateRefresh(FeatureFlagState state, Duration duration) { this.featureFlagState.put(state.getOriginEndpoint(), new FeatureFlagState(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds())))); } - void expireState(String originEndpoint) { + /** + * Expires the state for a configuration store by setting a new refresh time with random jitter. The jitter helps + * prevent thundering herd when multiple stores refresh simultaneously. + * @param originEndpoint the endpoint of the store to expire + */ + public void expireState(String originEndpoint) { State oldState = state.get(originEndpoint); + if (oldState == null) { + return; + } long wait = (long) (new SecureRandom().nextDouble() * MAX_JITTER); long timeLeft = (int) ((oldState.getNextRefreshCheck().toEpochMilli() - (Instant.now().toEpochMilli())) / 1000); @@ -110,31 +161,17 @@ void expireState(String originEndpoint) { } } - /** - * @return the loadState - */ - static boolean getLoadState(String originEndpoint) { - return currentState.getFullLoadState().getOrDefault(originEndpoint, false); - } - /** * @param originEndpoint the configuration store connected to. * @param loaded true if the configuration store was loaded. */ - void setLoadState(String originEndpoint, Boolean loaded) { + public void setLoadState(String originEndpoint, Boolean loaded) { loadState.put(originEndpoint, loaded); } /** - * @return the nextForcedRefresh - */ - public static Instant getNextForcedRefresh() { - return currentState.nextForcedRefresh; - } - - /** - * Set after load or refresh is successful. - * @param refreshPeriod the refreshPeriod to set + * Sets the next forced refresh time. Called after a successful load or refresh. + * @param refreshPeriod the duration from now until the next forced refresh; if null, no refresh is scheduled */ public void setNextForcedRefresh(Duration refreshPeriod) { if (refreshPeriod != null) { @@ -143,12 +180,13 @@ public void setNextForcedRefresh(Duration refreshPeriod) { } /** - * Sets a minimum value until the next refresh. If a refresh interval has passed or is smaller than the calculated - * backoff time, the refresh interval is set to the backoff time. - * @param refreshInterval period between refresh checks. - * @param defaultMinBackoff min backoff between checks + * Updates the next refresh time for all stores using exponential backoff on failures. Sets a minimum value until + * the next refresh. If a refresh interval has passed or is smaller than the calculated backoff time, the refresh + * interval is set to the backoff time. This prevents excessive refresh attempts during transient failures. + * @param refreshInterval period between refresh checks + * @param defaultMinBackoff minimum backoff duration between checks in seconds */ - void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) { + public void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) { if (refreshInterval != null) { Instant newForcedRefresh = getNextRefreshCheck(nextForcedRefresh, clientRefreshAttempts, refreshInterval.getSeconds(), defaultMinBackoff); @@ -160,14 +198,16 @@ void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) { } for (Entry entry : state.entrySet()) { - State state = entry.getValue(); - Instant newRefresh = getNextRefreshCheck(state.getNextRefreshCheck(), - state.getRefreshAttempt(), (long) state.getRefreshInterval(), defaultMinBackoff); - - if (newRefresh.compareTo(entry.getValue().getNextRefreshCheck()) != 0) { - state.incrementRefreshAttempt(); + State storeState = entry.getValue(); + Instant newRefresh = getNextRefreshCheck(storeState.getNextRefreshCheck(), + storeState.getRefreshAttempt(), (long) storeState.getRefreshInterval(), defaultMinBackoff); + + State updatedState; + if (newRefresh.compareTo(storeState.getNextRefreshCheck()) != 0) { + updatedState = new State(storeState.incrementRefreshAttempt(), newRefresh); + } else { + updatedState = new State(storeState, newRefresh); } - State updatedState = new State(state, newRefresh); this.state.put(entry.getKey(), updatedState); } } @@ -181,7 +221,7 @@ void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) { * @param defaultMinBackoff min backoff between checks * @return new Refresh Date */ - private Instant getNextRefreshCheck(Instant nextRefreshCheck, Integer attempt, Long interval, + private Instant getNextRefreshCheck(Instant nextRefreshCheck, int attempt, Long interval, Long defaultMinBackoff) { // The refresh interval is only updated if it is expired. if (!Instant.now().isAfter(nextRefreshCheck)) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtil.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtil.java new file mode 100644 index 000000000000..770a3b82c9a6 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtil.java @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config.implementation; + +import java.util.List; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Utility class for validating configuration properties. + */ +public final class ValidationUtil { + + private ValidationUtil() { + // Utility class, prevent instantiation + } + + /** + * Validates that tag filter entries follow the expected {@code tagName=tagValue} format. + * Each entry must contain an equals sign, with non-empty tag name and value on either side. + * + * @param tagsFilter the list of tag filter expressions to validate + * @throws IllegalArgumentException if any tag filter entry is invalid + */ + public static void validateTagsFilter(List tagsFilter) { + if (tagsFilter == null) { + return; + } + + for (String tagFilter : tagsFilter) { + Assert.isTrue(StringUtils.hasText(tagFilter), + "Tag filter entries must not be null or empty"); + Assert.isTrue(tagFilter.contains("="), + "Tag filter entries must be in tagName=tagValue format"); + String[] parts = tagFilter.split("=", 2); + Assert.isTrue(StringUtils.hasText(parts[0]), + "Tag name must not be empty in tag filter: " + tagFilter); + Assert.isTrue(parts.length == 2 && StringUtils.hasText(parts[1]), + "Tag value must not be empty in tag filter: " + tagFilter); + } + } +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java similarity index 54% rename from sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java rename to sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java index 2a1380a3d907..20b09087087e 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java @@ -1,21 +1,22 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation.feature; +package com.azure.spring.cloud.appconfiguration.config.implementation.configuration; import java.util.List; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.SettingSelector; -public class FeatureFlags { +public class WatchedConfigurationSettings { private SettingSelector settingSelector; - private List featureFlags; + private List configurationSettings; - public FeatureFlags(SettingSelector settingSelector, List featureFlags) { + public WatchedConfigurationSettings(SettingSelector settingSelector, + List configurationSettings) { this.settingSelector = settingSelector; - this.featureFlags = featureFlags; + this.configurationSettings = configurationSettings; } /** @@ -33,17 +34,17 @@ public void setSettingSelector(SettingSelector settingSelector) { } /** - * @return the featureFlags + * @return the configurationSettings */ - public List getFeatureFlags() { - return featureFlags; + public List getConfigurationSettings() { + return configurationSettings; } /** - * @param featureFlags the featureFlags to set + * @param configurations the configurations to set */ - public void setFeatureFlags(List featureFlags) { - this.featureFlags = featureFlags; + public void setConfigurationSettings(List configurations) { + this.configurationSettings = configurations; } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java index ddca37b32140..4df042e96d24 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java @@ -5,15 +5,17 @@ import java.time.Instant; import java.util.List; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; + public class FeatureFlagState { - private final List watchKeys; + private final List watchKeys; private final Instant nextRefreshCheck; private final String originEndpoint; - public FeatureFlagState(List watchKeys, int refreshInterval, String originEndpoint) { + public FeatureFlagState(List watchKeys, int refreshInterval, String originEndpoint) { this.watchKeys = watchKeys; nextRefreshCheck = Instant.now().plusSeconds(refreshInterval); this.originEndpoint = originEndpoint; @@ -28,7 +30,7 @@ public FeatureFlagState(FeatureFlagState oldState, Instant newRefresh) { /** * @return the watchKeys */ - public List getWatchKeys() { + public List getWatchKeys() { return watchKeys; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java index 58c4e8b5cb50..a26431966344 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java @@ -13,6 +13,8 @@ import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL; +import com.azure.spring.cloud.appconfiguration.config.implementation.ValidationUtil; + import jakarta.annotation.PostConstruct; import jakarta.validation.constraints.NotNull; @@ -33,41 +35,49 @@ public final class AppConfigurationKeyValueSelector { */ private static final String LABEL_SEPARATOR = ","; - @NotNull + /** - * Key filter to use when loading configurations. The default value is - * "/application/". The key filter is used to filter configurations by key. - * The key filter must be a non-null string that does not contain an asterisk. + * Filters configurations by key prefix. Defaults to {@code /application/} when + * not explicitly set. Must not be {@code null} or contain asterisks ({@code *}). */ + @NotNull private String keyFilter = ""; /** - * Label filter to use when loading configurations. The label filter is used to - * filter configurations by label. If the label filter is not set, the default - * value is the current active Spring profiles. If no active profiles are set, - * then all configurations with no label are loaded. The label filter must be a - * non-null string that does not contain an asterisk. + * Filters configurations by label. When unset, defaults to the active Spring + * profiles; if no profiles are active, only configurations with no label are + * loaded. Multiple labels can be specified as a comma-separated string. Must + * not contain asterisks ({@code *}). */ private String labelFilter; /** - * Snapshot name to use when loading configurations. The snapshot name is used - * to load configurations from a snapshot. If the snapshot name is set, the key - * and label filters must not be set. The snapshot name must be a non-null - * string that does not contain an asterisk. + * Filters configurations by tags. Each entry is interpreted as a tag-based filter, + * typically in the {@code tagName=tagValue} format. When multiple entries are + * provided, they are combined using AND logic. + */ + private List tagsFilter; + + /** + * Loads configurations from a named snapshot. Cannot be used together with + * key, label, or tag filters. */ private String snapshotName = ""; /** - * @return the keyFilter + * Returns the key filter, defaulting to {@code /application/} when not explicitly set. + * + * @return the key filter string */ public String getKeyFilter() { return StringUtils.hasText(keyFilter) ? keyFilter : APPLICATION_SETTING_DEFAULT_KEY_FILTER; } /** - * @param keyFilter the keyFilter to set - * @return AppConfigurationStoreSelects + * Sets the key filter used to select configurations by key prefix. + * + * @param keyFilter the key prefix to filter by + * @return this {@link AppConfigurationKeyValueSelector} for chaining */ public AppConfigurationKeyValueSelector setKeyFilter(String keyFilter) { this.keyFilter = keyFilter; @@ -75,10 +85,22 @@ public AppConfigurationKeyValueSelector setKeyFilter(String keyFilter) { } /** - * @param profiles List of current Spring profiles to default to using is null - * label is set. - * @return List of reversed label values, which are split by the separator, the - * latter label has higher priority + * Returns the raw label filter string, or {@code null} if not set. + * + * @return the label filter + */ + public String getLabelFilter() { + return labelFilter; + } + + /** + * Resolves the label filter into an array of labels. When no label filter is set, + * falls back to the active Spring profiles (in reverse priority order). If neither + * is available, returns the empty-label sentinel. Returns an empty array when a + * snapshot is configured. + * + * @param profiles the active Spring profiles to use as a fallback + * @return an array of resolved labels, ordered from lowest to highest priority */ public String[] getLabelFilter(List profiles) { if (StringUtils.hasText(snapshotName)) { @@ -110,8 +132,10 @@ public String[] getLabelFilter(List profiles) { } /** - * @param labelFilter the labelFilter to set - * @return AppConfigurationStoreSelects + * Sets the label filter used to select configurations by label. + * + * @param labelFilter a comma-separated string of labels to filter by + * @return this {@link AppConfigurationKeyValueSelector} for chaining */ public AppConfigurationKeyValueSelector setLabelFilter(String labelFilter) { this.labelFilter = labelFilter; @@ -119,21 +143,49 @@ public AppConfigurationKeyValueSelector setLabelFilter(String labelFilter) { } /** - * @return the snapshot + * Returns the list of tag filters, or {@code null} if not set. + * + * @return the tag filter list + */ + public List getTagsFilter() { + return tagsFilter; + } + + /** + * Sets the tag filters used to select configurations by tags. Each entry is + * interpreted as a tag-based filter, typically in the {@code tagName=tagValue} + * format. When multiple entries are provided, they are combined using AND logic. + * + * @param tagsFilter list of tag expressions, typically in {@code tagName=tagValue} format + * @return this {@link AppConfigurationKeyValueSelector} for chaining + */ + public AppConfigurationKeyValueSelector setTagsFilter(List tagsFilter) { + this.tagsFilter = tagsFilter; + return this; + } + + /** + * Returns the snapshot name, or an empty string if not set. + * + * @return the snapshot name */ public String getSnapshotName() { return snapshotName; } /** - * @param snapshot the snapshot to set + * Sets the snapshot name to load configurations from. + * + * @param snapshotName the snapshot name */ public void setSnapshotName(String snapshotName) { this.snapshotName = snapshotName; } /** - * Validates key-filter and label-filter are valid. + * Validates that key, label, tag, and snapshot filters are well-formed and + * mutually compatible. Asterisks are not allowed in key or label filters, + * and snapshots cannot be combined with any other filter type. */ @PostConstruct void validateAndInit() { @@ -145,6 +197,9 @@ void validateAndInit() { "Snapshots can't use key filters"); Assert.isTrue(!(StringUtils.hasText(labelFilter) && StringUtils.hasText(snapshotName)), "Snapshots can't use label filters"); + Assert.isTrue(!(tagsFilter != null && !tagsFilter.isEmpty() && StringUtils.hasText(snapshotName)), + "Snapshots can't use tag filters"); + ValidationUtil.validateTagsFilter(tagsFilter); } private String mapLabel(String label) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java index 8518170fc57c..adfe72d0627b 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java @@ -23,19 +23,24 @@ public class AppConfigurationProperties { /** - * Prefix for client configurations for connecting to configuration stores. + * Configuration property prefix for Azure App Configuration client settings. */ public static final String CONFIG_PREFIX = "spring.cloud.azure.appconfiguration"; private boolean enabled = true; /** - * List of Azure App Configuration stores to connect to. + * Azure App Configuration store connections. At least one store must be configured. */ private List stores = new ArrayList<>(); private Duration refreshInterval; + /** + * The timeout duration for retry attempts during startup. + */ + private Duration startupTimeout = Duration.ofSeconds(100); + /** * @return the enabled */ @@ -44,40 +49,64 @@ public boolean isEnabled() { } /** - * @param enabled the enabled to set + * Sets whether Azure App Configuration is enabled. + * + * @param enabled {@code true} to enable, {@code false} to disable */ public void setEnabled(boolean enabled) { this.enabled = enabled; } /** - * @return the stores + * Returns the list of configured App Configuration stores. + * + * @return the list of {@link ConfigStore} instances */ public List getStores() { return stores; } /** - * @param stores the stores to set + * Sets the list of App Configuration stores to connect to. + * + * @param stores the list of {@link ConfigStore} instances */ public void setStores(List stores) { this.stores = stores; } /** - * @return the refreshInterval + * Returns the interval between configuration refreshes. + * + * @return the refresh interval, or {@code null} if not set */ public Duration getRefreshInterval() { return refreshInterval; } /** - * @param refreshInterval the refreshInterval to set + * Sets the interval between configuration refreshes. Must be at least 1 second. + * + * @param refreshInterval the refresh interval duration */ public void setRefreshInterval(Duration refreshInterval) { this.refreshInterval = refreshInterval; } + /** + * @return the startupTimeout + */ + public Duration getStartupTimeout() { + return startupTimeout; + } + + /** + * @param startupTimeout the startupTimeout to set + */ + public void setStartupTimeout(Duration startupTimeout) { + this.startupTimeout = startupTimeout; + } + /** * Validates at least one store is configured for use, and that they are valid. * @throws IllegalArgumentException when duplicate endpoints are configured @@ -86,19 +115,24 @@ public void setRefreshInterval(Duration refreshInterval) { public void validateAndInit() { Assert.notEmpty(this.stores, "At least one config store has to be configured."); - this.stores.forEach(store -> { + for (ConfigStore store : this.stores) { + if (!store.isEnabled()) { + continue; + } Assert.isTrue( StringUtils.hasText(store.getEndpoint()) || StringUtils.hasText(store.getConnectionString()) - || store.getEndpoints().size() > 0 || store.getConnectionStrings().size() > 0, + || !store.getEndpoints().isEmpty() || !store.getConnectionStrings().isEmpty(), "Either configuration store name or connection string should be configured."); store.validateAndInit(); - }); + } Map existingEndpoints = new HashMap<>(); for (ConfigStore store : this.stores) { - - if (store.getEndpoints().size() > 0) { + if (!store.isEnabled()) { + continue; + } + if (!store.getEndpoints().isEmpty()) { for (String endpoint : store.getEndpoints()) { if (existingEndpoints.containsKey(endpoint)) { throw new IllegalArgumentException("Duplicate store name exists."); @@ -115,5 +149,12 @@ public void validateAndInit() { if (refreshInterval != null) { Assert.isTrue(refreshInterval.getSeconds() >= 1, "Minimum refresh interval time is 1 Second."); } + if (startupTimeout == null) { + throw new IllegalArgumentException("startupTimeout cannot be null."); + } + if (startupTimeout.compareTo(Duration.ofSeconds(30)) < 0 + || startupTimeout.compareTo(Duration.ofSeconds(600)) > 0) { + throw new IllegalArgumentException("startupTimeout must be between 30 and 600 seconds."); + } } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java index f90a3a573f12..ba5d495cf72e 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java @@ -11,116 +11,136 @@ import jakarta.annotation.PostConstruct; /** - * Properties for Monitoring an Azure App Configuration Store. + * Configuration properties for monitoring changes in an Azure App Configuration store. */ public final class AppConfigurationStoreMonitoring { /** - * If true, the application will check for updates to the configuration store. - * When set to true at least one trigger must be set. + * Enables monitoring for configuration changes. When enabled, at least one + * trigger must be configured. */ private boolean enabled = false; /** - * The minimum time between checks. The minimum valid time is 1s. The default refresh interval is 30s. + * Interval between configuration refresh checks. Must be at least 1 second. + * Defaults to 30 seconds. */ private Duration refreshInterval = Duration.ofSeconds(30); /** - * The minimum time between checks of feature flags. The minimum valid time is 1s. The default refresh interval is 30s. + * Interval between feature flag refresh checks. Must be at least 1 second. + * Defaults to 30 seconds. */ private Duration featureFlagRefreshInterval = Duration.ofSeconds(30); /** - * List of triggers that will cause a refresh of the configuration store. + * Sentinel keys that trigger a configuration refresh when their values change. */ private List triggers = new ArrayList<>(); /** - * Validation tokens for push notificaiton requests. + * Configuration for validating push notification refresh requests. */ private PushNotification pushNotification = new PushNotification(); /** - * @return the enabled + * Returns whether configuration monitoring is enabled. + * + * @return {@code true} if monitoring is enabled, {@code false} otherwise */ public boolean isEnabled() { return enabled; } /** - * @param enabled the enabled to set + * Sets whether configuration monitoring is enabled. + * + * @param enabled {@code true} to enable monitoring, {@code false} to disable */ public void setEnabled(boolean enabled) { this.enabled = enabled; } /** - * @return the refreshInterval + * Returns the interval between configuration refresh checks. + * + * @return the refresh interval */ public Duration getRefreshInterval() { return refreshInterval; } /** - * The minimum time between checks. The minimum valid time is 1s. The default refresh interval is 30s. + * Sets the interval between configuration refresh checks. Must be at least 1 second. * - * @param refreshInterval minimum time between refresh checks + * @param refreshInterval the refresh interval duration */ public void setRefreshInterval(Duration refreshInterval) { this.refreshInterval = refreshInterval; } /** - * @return the featureFlagRefreshInterval + * Returns the interval between feature flag refresh checks. + * + * @return the feature flag refresh interval */ public Duration getFeatureFlagRefreshInterval() { return featureFlagRefreshInterval; } /** - * The minimum time between checks of feature flags. The minimum valid time is 1s. The default refresh interval is 30s. - * @param featureFlagRefreshInterval minimum time between refresh checks for feature flags + * Sets the interval between feature flag refresh checks. Must be at least 1 second. + * + * @param featureFlagRefreshInterval the feature flag refresh interval duration */ public void setFeatureFlagRefreshInterval(Duration featureFlagRefreshInterval) { this.featureFlagRefreshInterval = featureFlagRefreshInterval; } /** - * @return the triggers + * Returns the list of triggers that initiate a configuration refresh. + * + * @return the list of {@link AppConfigurationStoreTrigger} instances */ public List getTriggers() { return triggers; } /** - * @param triggers the triggers to set + * Sets the list of triggers that initiate a configuration refresh. + * + * @param triggers the list of {@link AppConfigurationStoreTrigger} instances */ public void setTriggers(List triggers) { this.triggers = triggers; } /** - * @return the pushNotification + * Returns the push notification configuration. + * + * @return the {@link PushNotification} settings */ public PushNotification getPushNotification() { return pushNotification; } /** - * @param pushNotification the pushNotification to set + * Sets the push notification configuration. + * + * @param pushNotification the {@link PushNotification} settings */ public void setPushNotification(PushNotification pushNotification) { this.pushNotification = pushNotification; } /** - * Validates refreshIntervals are at least 1 second, and if enabled triggers are valid. + * Validates that refresh intervals are at least 1 second and, when monitoring + * is enabled, that all configured triggers are valid. */ @PostConstruct void validateAndInit() { if (enabled) { - Assert.notEmpty(triggers, "Triggers need to be set if refresh is enabled."); + // Triggers are not required defaults to use collection monitoring if not set for (AppConfigurationStoreTrigger trigger : triggers) { trigger.validateAndInit(); } @@ -130,43 +150,51 @@ void validateAndInit() { } /** - * Push Notification tokens for setting watch interval to 0. + * Access tokens used to validate push notification refresh requests. */ public static class PushNotification { /** - * Validation token for push notification requests. + * Primary token for validating push notification requests. */ private AccessToken primaryToken = new AccessToken(); /** - * Secondary validation token for push notification requests. + * Secondary (fallback) token for validating push notification requests. */ private AccessToken secondaryToken = new AccessToken(); /** - * @return the primaryToken + * Returns the primary access token. + * + * @return the primary {@link AccessToken} */ public AccessToken getPrimaryToken() { return primaryToken; } /** - * @param primaryToken the primaryToken to set + * Sets the primary access token. + * + * @param primaryToken the primary {@link AccessToken} */ public void setPrimaryToken(AccessToken primaryToken) { this.primaryToken = primaryToken; } /** - * @return the secondaryToken + * Returns the secondary access token. + * + * @return the secondary {@link AccessToken} */ public AccessToken getSecondaryToken() { return secondaryToken; } /** - * @param secondaryToken the secondaryToken to set + * Sets the secondary access token. + * + * @param secondaryToken the secondary {@link AccessToken} */ public void setSecondaryToken(AccessToken secondaryToken) { this.secondaryToken = secondaryToken; @@ -174,51 +202,60 @@ public void setSecondaryToken(AccessToken secondaryToken) { } /** - * Token used to verifying Push Refresh Requests + * A name/secret pair used to verify push notification refresh requests. */ public static class AccessToken { /** - * Name of the token. + * Identifier for this access token. */ private String name; /** - * Secret for the token. + * Secret value used for validation. */ private String secret; /** - * @return the name + * Returns the token name. + * + * @return the token name, or {@code null} if not set */ public String getName() { return name; } /** - * @param name the name to set + * Sets the token name. + * + * @param name the token name */ public void setName(String name) { this.name = name; } /** - * @return the secret + * Returns the token secret. + * + * @return the token secret, or {@code null} if not set */ public String getSecret() { return secret; } /** - * @param secret the secret to set + * Sets the token secret. + * + * @param secret the token secret */ public void setSecret(String secret) { this.secret = secret; } /** - * Checks if name and secret are not null. - * @return boolean true if name and secret are not null. + * Returns whether this token has both a name and secret configured. + * + * @return {@code true} if both name and secret are non-null */ public boolean isValid() { return this.name != null && this.secret != null; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java index 97499d8b3f23..39301961bd16 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java @@ -10,52 +10,62 @@ import jakarta.validation.constraints.NotNull; /** - * Properties on what Triggers are checked before a refresh is triggered. + * A sentinel configuration setting that is monitored for changes to trigger a + * configuration refresh. */ public final class AppConfigurationStoreTrigger { /** - * Key value of the configuration setting checked when looking for changes. + * Key of the sentinel configuration setting to monitor. Required. */ @NotNull private String key; /** - * Label value of the configuration setting checked when looking for changes. - * If the label is not set, the default value is no label. + * Label of the sentinel configuration setting to monitor. Defaults to the + * empty (no label) value when not set. */ private String label; /** - * @return the key + * Returns the key of the sentinel setting. + * + * @return the sentinel key */ public String getKey() { return key; } /** - * @param key the key to set + * Sets the key of the sentinel setting to monitor. + * + * @param key the sentinel key */ public void setKey(String key) { this.key = key; } /** - * @return the label + * Returns the label of the sentinel setting, defaulting to the empty-label + * value when not explicitly set. + * + * @return the resolved label */ public String getLabel() { return mapLabel(label); } /** - * @param label the label to set + * Sets the label of the sentinel setting to monitor. + * + * @param label the sentinel label */ public void setLabel(String label) { this.label = label; } /** - * Validates key isn't null + * Validates that the sentinel key is not null. */ @PostConstruct void validateAndInit() { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java index 6f8a3fa0bf7c..ffa0450762ad 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java @@ -15,186 +15,208 @@ import jakarta.annotation.PostConstruct; /** - * Config Store Properties for Requests to an Azure App Configuration Store. + * Connection and behavior properties for an Azure App Configuration store. */ public final class ConfigStore { - private static final String DEFAULT_KEYS = "/application/"; - /** - * Endpoint for the Azure Config Service. + * Primary endpoint URL for the App Configuration store. */ private String endpoint = ""; // Config store endpoint /** - * List of endpoints for geo-replicated config store instances. When connecting - * to Azure App Configuration, the endpoints will failover to the next endpoint - * in the list if the current endpoint is unreachable. + * Endpoint URLs for geo-replicated store instances. Requests fail over to the + * next endpoint in the list when the current one is unreachable. */ private List endpoints = new ArrayList<>(); /** - * Connection String for the Azure Config Service. + * Primary connection string for the App Configuration store. */ private String connectionString; /** - * List of connection strings for geo-replicated config store instances. When - * connecting to Azure App Configuration, the connection strings will failover - * to the next connection string in the list if the current connection string is - * unreachable. + * Connection strings for geo-replicated store instances. Requests fail over to + * the next connection string in the list when the current one is unreachable. */ private List connectionStrings = new ArrayList<>(); /** - * List of key selectors to filter the keys to be retrieved from the Azure - * Config Service. If no selectors are provided, the default selector will - * retrieve all keys with the prefix "/application/" and no label. + * Key/label selectors that determine which configuration settings to load. + * Defaults to a single selector matching the {@code /application/} prefix + * with no label. */ private List selects = new ArrayList<>(); private FeatureFlagStore featureFlags = new FeatureFlagStore(); /** - * If true, the Config Store will be enabled. If false, the Config Store will be - * disabled and no keys will be retrieved from the Config Store. + * Enables or disables this config store. When disabled, no settings are + * loaded from it. */ private boolean enabled = true; /** - * Options for monitoring the Config Store. + * Monitoring configuration for detecting configuration changes. */ private AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); /** - * List of values to be trimmed from key names before being set to - * `@ConfigurationProperties`. By default the prefix "/application/" is trimmed - * from key names. If any trimKeyPrefix values are provided, the default prefix - * will not be trimmed. + * Prefixes to strip from key names before binding to + * {@code @ConfigurationProperties}. When set, the default {@code /application/} + * prefix is no longer trimmed automatically. */ private List trimKeyPrefix; /** - * If true, the Config Store will attempt to discover the replica endpoints for - * the Config Store. If false, the Config Store will not attempt to discover the - * replica endpoints for the Config Store. + * Enables automatic discovery of geo-replicated store endpoints. */ private boolean replicaDiscoveryEnabled = true; /** - * If true, the Config Store will use load balancing to distribute requests - * across multiple endpoints. + * Enables request distribution across multiple endpoints via load balancing. */ private boolean loadBalancingEnabled = false; /** - * @return the endpoint + * Returns the primary endpoint URL. + * + * @return the endpoint URL */ public String getEndpoint() { return endpoint; } /** - * @param endpoint the endpoint to set + * Sets the primary endpoint URL. + * + * @param endpoint the endpoint URL */ public void setEndpoint(String endpoint) { this.endpoint = endpoint; } /** - * @return list of endpoints + * Returns the list of geo-replicated endpoint URLs. + * + * @return the endpoint URL list */ public List getEndpoints() { return endpoints; } /** - * @param endpoints list of endpoints to connect to geo-replicated config store - * instances. + * Sets the list of geo-replicated endpoint URLs. + * + * @param endpoints the endpoint URL list */ public void setEndpoints(List endpoints) { this.endpoints = endpoints; } /** - * @return the connectionString + * Returns the primary connection string. + * + * @return the connection string, or {@code null} if not set */ public String getConnectionString() { return connectionString; } /** - * @param connectionString the connectionString to set + * Sets the primary connection string. + * + * @param connectionString the connection string */ public void setConnectionString(String connectionString) { this.connectionString = connectionString; } /** - * @return connectionStrings + * Returns the list of geo-replicated connection strings. + * + * @return the connection string list */ public List getConnectionStrings() { return connectionStrings; } /** - * @param connectionStrings the connectionStrings to set + * Sets the list of geo-replicated connection strings. + * + * @param connectionStrings the connection string list */ public void setConnectionStrings(List connectionStrings) { this.connectionStrings = connectionStrings; } /** - * @return the enabled + * Returns whether this config store is enabled. + * + * @return {@code true} if enabled, {@code false} otherwise */ public boolean isEnabled() { return enabled; } /** - * @param enabled the enabled to set + * Sets whether this config store is enabled. + * + * @param enabled {@code true} to enable, {@code false} to disable */ public void setEnabled(boolean enabled) { this.enabled = enabled; } /** - * @return the selects + * Returns the key/label selectors for this store. + * + * @return the list of {@link AppConfigurationKeyValueSelector} instances */ public List getSelects() { return selects; } /** - * @param selects the selects to set + * Sets the key/label selectors for this store. + * + * @param selects the list of {@link AppConfigurationKeyValueSelector} instances */ public void setSelects(List selects) { this.selects = selects; } /** - * @return the monitoring + * Returns the monitoring configuration for this store. + * + * @return the {@link AppConfigurationStoreMonitoring} settings */ public AppConfigurationStoreMonitoring getMonitoring() { return monitoring; } /** - * @param monitoring the monitoring to set + * Sets the monitoring configuration for this store. + * + * @param monitoring the {@link AppConfigurationStoreMonitoring} settings */ public void setMonitoring(AppConfigurationStoreMonitoring monitoring) { this.monitoring = monitoring; } /** - * @return the featureFlags + * Returns the feature flag store configuration. + * + * @return the {@link FeatureFlagStore} settings */ public FeatureFlagStore getFeatureFlags() { return featureFlags; } /** - * @param featureFlags the featureFlags to set + * Sets the feature flag store configuration. + * + * @param featureFlags the {@link FeatureFlagStore} settings */ public void setFeatureFlags(FeatureFlagStore featureFlags) { this.featureFlags = featureFlags; @@ -208,55 +230,70 @@ public boolean containsEndpoint(String endpoint) { } /** - * @return the trimKeyPrefix + * Returns the key-name prefixes to strip before property binding. + * + * @return the prefix list, or {@code null} if not set */ public List getTrimKeyPrefix() { return trimKeyPrefix; } /** - * @param trimKeyPrefix the values to be trimmed from key names before being set - * to `@ConfigurationProperties` + * Sets the key-name prefixes to strip before property binding. + * + * @param trimKeyPrefix the prefix list */ public void setTrimKeyPrefix(List trimKeyPrefix) { this.trimKeyPrefix = trimKeyPrefix; } /** - * @return the replicaDiscoveryEnabled + * Returns whether automatic replica endpoint discovery is enabled. + * + * @return {@code true} if replica discovery is enabled */ public boolean isReplicaDiscoveryEnabled() { return replicaDiscoveryEnabled; } /** - * @param replicaDiscoveryEnabled the replicaDiscoveryEnabled to set + * Sets whether automatic replica endpoint discovery is enabled. + * + * @param replicaDiscoveryEnabled {@code true} to enable replica discovery */ public void setReplicaDiscoveryEnabled(boolean replicaDiscoveryEnabled) { this.replicaDiscoveryEnabled = replicaDiscoveryEnabled; } /** - * @return the loadBalancingEnabled + * Returns whether load balancing across endpoints is enabled. + * + * @return {@code true} if load balancing is enabled */ public boolean isLoadBalancingEnabled() { return loadBalancingEnabled; } /** - * @param loadBalancingEnabled the loadBalancingEnabled to set + * Sets whether load balancing across endpoints is enabled. + * + * @param loadBalancingEnabled {@code true} to enable load balancing */ public void setLoadBalancingEnabled(boolean loadBalancingEnabled) { this.loadBalancingEnabled = loadBalancingEnabled; } /** - * @throws IllegalStateException Connection String URL endpoint is invalid + * Initializes default selectors, validates connection settings, extracts + * the endpoint from connection strings, and delegates to monitoring and + * feature-flag validation. + * + * @throws IllegalStateException if a connection string contains an invalid endpoint URI */ @PostConstruct public void validateAndInit() { if (selects.isEmpty()) { - selects.add(new AppConfigurationKeyValueSelector().setKeyFilter(DEFAULT_KEYS)); + selects.add(new AppConfigurationKeyValueSelector()); } for (AppConfigurationKeyValueSelector selectedKeys : selects) { @@ -264,29 +301,29 @@ public void validateAndInit() { } if (StringUtils.hasText(connectionString)) { - String endpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connectionString)); + String validationEndpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connectionString)); try { // new URI is used to validate the endpoint as a valid URI - new URI(endpoint); - this.endpoint = endpoint; - } catch (URISyntaxException e) { + new URI(validationEndpoint).toURL(); + this.endpoint = validationEndpoint; + } catch (URISyntaxException | MalformedURLException | IllegalArgumentException e) { throw new IllegalStateException("Endpoint in connection string is not a valid URI.", e); } - } else if (connectionStrings.size() > 0) { + } else if (!connectionStrings.isEmpty()) { for (String connection : connectionStrings) { - String endpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connection)); + String validationEndpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connection)); try { // new URI is used to validate the endpoint as a valid URI - new URI(endpoint).toURL(); + new URI(validationEndpoint).toURL(); if (!StringUtils.hasText(this.endpoint)) { - this.endpoint = endpoint; + this.endpoint = validationEndpoint; } } catch (MalformedURLException | URISyntaxException | IllegalArgumentException e) { throw new IllegalStateException("Endpoint in connection string is not a valid URI.", e); } } - } else if (endpoints.size() > 0) { + } else if (!endpoints.isEmpty()) { endpoint = endpoints.get(0); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java index 4d1de6b90b11..356bf7fa3c00 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java @@ -13,42 +13,56 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import com.azure.spring.cloud.appconfiguration.config.implementation.ValidationUtil; + import jakarta.annotation.PostConstruct; /** - * Properties on what Selects are checked before loading configurations. + * Selector properties that control which feature flags are loaded from an + * Azure App Configuration store. */ public final class FeatureFlagKeyValueSelector { /** - * Label for requesting all configurations with (No Label) + * Sentinel array representing the empty (no label) value. */ private static final String[] EMPTY_LABEL_ARRAY = { EMPTY_LABEL }; /** - * Key filter to use when loading feature flags. The provided key filter is - * appended after the feature flag prefix, ".appconfig.featureflag/". By - * default, all feature flags are loaded. + * Filters feature flags by key suffix, appended after the + * {@code .appconfig.featureflag/} prefix. When empty, all feature flags + * are loaded. */ private String keyFilter = ""; /** - * Label filter to use when loading feature flags. By default, all feature flags - * with no label are loaded. The label filter must be a non-null string that - * does not contain an asterisk. + * Filters feature flags by label. When unset, only feature flags with no + * label are loaded. Multiple labels can be specified as a comma-separated + * string. Must not contain asterisks ({@code *}). */ private String labelFilter; /** - * @return the keyFilter + * Filters feature flags by tags. Each entry is interpreted as a tag-based filter, + * typically in the {@code tagName=tagValue} format. When multiple entries are + * provided, they are combined using AND logic. + */ + private List tagsFilter; + + /** + * Returns the key filter for feature flags. + * + * @return the key filter string */ public String getKeyFilter() { return keyFilter; } /** - * @param keyFilter the keyFilter to set - * @return AppConfigurationStoreSelects + * Sets the key filter for feature flags. + * + * @param keyFilter the key suffix to filter by + * @return this {@link FeatureFlagKeyValueSelector} for chaining */ public FeatureFlagKeyValueSelector setKeyFilter(String keyFilter) { this.keyFilter = keyFilter; @@ -56,8 +70,21 @@ public FeatureFlagKeyValueSelector setKeyFilter(String keyFilter) { } /** - * @param profiles List of current Spring profiles to default to using is null label is set. - * @return List of reversed label values, which are split by the separator, the latter label has higher priority + * Returns the raw label filter string, or {@code null} if not set. + * + * @return the label filter + */ + public String getLabelFilter() { + return labelFilter; + } + + /** + * Resolves the label filter into an array of labels. When no label filter is + * set, falls back to the active Spring profiles (in reverse priority order). + * If neither is available, returns the empty-label sentinel. + * + * @param profiles the active Spring profiles to use as a fallback + * @return an array of resolved labels, ordered from lowest to highest priority */ public String[] getLabelFilter(List profiles) { if (labelFilter == null && profiles.size() > 0) { @@ -83,18 +110,20 @@ public String[] getLabelFilter(List profiles) { } /** - * Get all labels as a single String - * - * @param profiles current user profiles - * @return comma separated list of labels + * Returns resolved labels as a single comma-separated string. + * + * @param profiles the active Spring profiles to use as a fallback + * @return comma-separated label string */ public String getLabelFilterText(List profiles) { return String.join(",", getLabelFilter(profiles)); } /** - * @param labelFilter the labelFilter to set - * @return AppConfigurationStoreSelects + * Sets the label filter for feature flags. + * + * @param labelFilter a comma-separated string of labels to filter by + * @return this {@link FeatureFlagKeyValueSelector} for chaining */ public FeatureFlagKeyValueSelector setLabelFilter(String labelFilter) { this.labelFilter = labelFilter; @@ -102,13 +131,37 @@ public FeatureFlagKeyValueSelector setLabelFilter(String labelFilter) { } /** - * Validates key-filter and label-filter are valid. + * Returns the list of tag filters, or {@code null} if not set. + * + * @return the tag filter list + */ + public List getTagsFilter() { + return tagsFilter; + } + + /** + * Sets the tag filters used to select feature flags by tags. Each entry is + * interpreted as a tag-based filter, typically in the {@code tagName=tagValue} + * format. When multiple entries are provided, they are combined using AND logic. + * + * @param tagsFilter list of tag expressions, typically in {@code tagName=tagValue} format + * @return this {@link FeatureFlagKeyValueSelector} for chaining + */ + public FeatureFlagKeyValueSelector setTagsFilter(List tagsFilter) { + this.tagsFilter = tagsFilter; + return this; + } + + /** + * Validates that the label filter does not contain asterisks and that tag filters + * follow the expected {@code tagName=tagValue} format. */ @PostConstruct void validateAndInit() { if (labelFilter != null) { Assert.isTrue(!labelFilter.contains("*"), "LabelFilter must not contain asterisk(*)"); } + ValidationUtil.validateTagsFilter(tagsFilter); } private String mapLabel(String label) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java index 18ee7c2f28b3..606fd920fd1c 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java @@ -8,45 +8,58 @@ import jakarta.annotation.PostConstruct; /** - * Properties for what needs to be requested from Azure App Configuration for Feature Flags. + * Configuration properties for loading feature flags from an Azure App + * Configuration store. */ public final class FeatureFlagStore { /** - * Boolean for if feature flag loading is enabled. + * Enables or disables feature flag loading from the store. */ private Boolean enabled = false; private List selects = new ArrayList<>(); /** - * @return the enabled + * Returns whether feature flag loading is enabled. + * + * @return {@code true} if enabled, {@code false} otherwise */ public Boolean getEnabled() { return enabled; } /** - * @param enabled the enabled to set + * Sets whether feature flag loading is enabled. + * + * @param enabled {@code true} to enable, {@code false} to disable */ public void setEnabled(Boolean enabled) { this.enabled = enabled; } /** - * @return the selects + * Returns the feature flag selectors. + * + * @return the list of {@link FeatureFlagKeyValueSelector} instances */ public List getSelects() { return selects; } /** - * @param selects the selects to set + * Sets the feature flag selectors. + * + * @param selects the list of {@link FeatureFlagKeyValueSelector} instances */ public void setSelects(List selects) { this.selects = selects; } + /** + * Adds a default selector when enabled with none configured, then + * validates all selectors. + */ @PostConstruct void validateAndInit() { if (enabled && selects.size() == 0) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfigurationTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfigurationTest.java new file mode 100644 index 000000000000..a1e6f61fe938 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfigurationTest.java @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.BootstrapContext; + +import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationReplicaClientFactory; +import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; + +public class AppConfigurationWatchAutoConfigurationTest { + + private AppConfigurationWatchAutoConfiguration autoConfiguration; + private AppConfigurationProperties properties; + private BootstrapContext bootstrapContext; + private AppConfigurationReplicaClientFactory clientFactory; + private ReplicaLookUp replicaLookUp; + + @BeforeEach + public void setup() { + autoConfiguration = new AppConfigurationWatchAutoConfiguration(); + properties = new AppConfigurationProperties(); + properties.setRefreshInterval(Duration.ofSeconds(30)); + + ConfigStore store = new ConfigStore(); + store.setEndpoint("https://test.azconfig.io"); + List stores = new ArrayList<>(); + stores.add(store); + properties.setStores(stores); + + bootstrapContext = mock(BootstrapContext.class); + clientFactory = mock(AppConfigurationReplicaClientFactory.class); + replicaLookUp = mock(ReplicaLookUp.class); + } + + @Test + public void appConfigurationRefreshBeanIsCreatedWhenDependenciesExist() { + // Arrange + when(bootstrapContext.getOrElse(AppConfigurationReplicaClientFactory.class, null)) + .thenReturn(clientFactory); + when(bootstrapContext.getOrElse(ReplicaLookUp.class, null)) + .thenReturn(replicaLookUp); + + // Act + AppConfigurationRefresh result = autoConfiguration.appConfigurationRefresh(properties, bootstrapContext); + + // Assert + assertNotNull(result, "AppConfigurationRefresh bean should be created when dependencies exist"); + } + + @Test + public void appConfigurationRefreshBeanIsNotCreatedWhenClientFactoryIsMissing() { + // Arrange + when(bootstrapContext.getOrElse(AppConfigurationReplicaClientFactory.class, null)) + .thenReturn(null); + when(bootstrapContext.getOrElse(ReplicaLookUp.class, null)) + .thenReturn(replicaLookUp); + + // Act + AppConfigurationRefresh result = autoConfiguration.appConfigurationRefresh(properties, bootstrapContext); + + // Assert + assertNull(result, "AppConfigurationRefresh bean should not be created when clientFactory is missing"); + } + + @Test + public void appConfigurationRefreshBeanIsNotCreatedWhenReplicaLookUpIsMissing() { + // Arrange + when(bootstrapContext.getOrElse(AppConfigurationReplicaClientFactory.class, null)) + .thenReturn(clientFactory); + when(bootstrapContext.getOrElse(ReplicaLookUp.class, null)) + .thenReturn(null); + + // Act + AppConfigurationRefresh result = autoConfiguration.appConfigurationRefresh(properties, bootstrapContext); + + // Assert + assertNull(result, "AppConfigurationRefresh bean should not be created when replicaLookUp is missing"); + } + + @Test + public void appConfigurationRefreshBeanIsNotCreatedWhenBothDependenciesAreMissing() { + // Arrange + when(bootstrapContext.getOrElse(AppConfigurationReplicaClientFactory.class, null)) + .thenReturn(null); + when(bootstrapContext.getOrElse(ReplicaLookUp.class, null)) + .thenReturn(null); + + // Act + AppConfigurationRefresh result = autoConfiguration.appConfigurationRefresh(properties, bootstrapContext); + + // Assert + assertNull(result, "AppConfigurationRefresh bean should not be created when both dependencies are missing"); + } +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java index af99492615e5..be1bd3534bab 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java @@ -19,10 +19,12 @@ import static com.azure.spring.cloud.appconfiguration.config.implementation.TestUtils.createItemFeatureFlag; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -30,6 +32,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; @@ -40,6 +43,7 @@ import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; +import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.PropertyNamingStrategies; @@ -113,7 +117,7 @@ public void init() { String[] labelFilter = { "\0" }; propertySource = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock, - keyVaultClientFactoryMock, KEY_FILTER, labelFilter); + keyVaultClientFactoryMock, KEY_FILTER, labelFilter, null); } @AfterEach @@ -192,4 +196,94 @@ public void jsonContentTypeWithInvalidJsonValueTest() { .isInstanceOf(InvalidConfigurationPropertyValueException.class) .hasMessageNotContaining(ITEM_INVALID_JSON.getValue()); } + + @Test + public void initPropertiesWithTagsFilterTest() throws IOException { + // Create a property source with tags filter + String[] labelFilter = { "\0" }; + List tagsFilter = Arrays.asList("env=prod", "team=backend"); + AppConfigurationApplicationSettingPropertySource taggedPropertySource + = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock, + keyVaultClientFactoryMock, KEY_FILTER, labelFilter, tagsFilter); + + when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(testItems); + + taggedPropertySource.initProperties(null, contextMock); + + // Capture the SettingSelector passed to listSettings + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + verify(clientMock).listSettings(selectorCaptor.capture(), Mockito.any(Context.class)); + + SettingSelector capturedSelector = selectorCaptor.getValue(); + assertThat(capturedSelector.getTagsFilter()).isNotNull(); + assertThat(capturedSelector.getTagsFilter()).hasSize(2); + assertThat(capturedSelector.getTagsFilter()).containsExactly("env=prod", "team=backend"); + } + + @Test + public void initPropertiesWithNullTagsFilterTest() throws IOException { + // Create a property source with null tags filter (default behavior) + String[] labelFilter = { "\0" }; + AppConfigurationApplicationSettingPropertySource untaggedPropertySource + = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock, + keyVaultClientFactoryMock, KEY_FILTER, labelFilter, null); + + when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(testItems); + + untaggedPropertySource.initProperties(null, contextMock); + + // Capture the SettingSelector passed to listSettings + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + verify(clientMock).listSettings(selectorCaptor.capture(), Mockito.any(Context.class)); + + SettingSelector capturedSelector = selectorCaptor.getValue(); + // Tags filter should not be set when null + assertThat(capturedSelector.getTagsFilter()).isNull(); + } + + @Test + public void initPropertiesWithEmptyTagsFilterTest() throws IOException { + // Create a property source with empty tags filter + String[] labelFilter = { "\0" }; + List tagsFilter = new ArrayList<>(); + AppConfigurationApplicationSettingPropertySource emptyTagPropertySource + = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock, + keyVaultClientFactoryMock, KEY_FILTER, labelFilter, tagsFilter); + + when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(testItems); + + emptyTagPropertySource.initProperties(null, contextMock); + + // Capture the SettingSelector passed to listSettings + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + verify(clientMock).listSettings(selectorCaptor.capture(), Mockito.any(Context.class)); + + SettingSelector capturedSelector = selectorCaptor.getValue(); + // Tags filter should not be set when empty + assertThat(capturedSelector.getTagsFilter()).isNull(); + } + + @Test + public void initPropertiesWithTagsFilterMultipleLabelsTest() throws IOException { + // Create a property source with tags filter and multiple labels + String[] labelFilter = { "dev", "prod" }; + List tagsFilter = Arrays.asList("env=staging"); + AppConfigurationApplicationSettingPropertySource multiLabelPropertySource + = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock, + keyVaultClientFactoryMock, KEY_FILTER, labelFilter, tagsFilter); + + when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(testItems); + + multiLabelPropertySource.initProperties(null, contextMock); + + // Capture all SettingSelector instances passed to listSettings (one per label) + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + verify(clientMock, Mockito.times(2)).listSettings(selectorCaptor.capture(), Mockito.any(Context.class)); + + // Both calls should have the tags filter set + for (SettingSelector capturedSelector : selectorCaptor.getAllValues()) { + assertThat(capturedSelector.getTagsFilter()).isNotNull(); + assertThat(capturedSelector.getTagsFilter()).containsExactly("env=staging"); + } + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java index 74d97b275702..e3b5ff4f8bd6 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java @@ -88,7 +88,7 @@ public void init() { String[] labelFilter = { "\0" }; propertySource = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, replicaClientMock, - keyVaultClientFactoryMock, KEY_FILTER, labelFilter); + keyVaultClientFactoryMock, KEY_FILTER, labelFilter, null); } @AfterEach diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java index 72bb068ecb71..5e3ba0d4cf61 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java @@ -22,14 +22,11 @@ import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationRefreshUtil.RefreshEventData; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; -import net.jcip.annotations.NotThreadSafe; - -@NotThreadSafe public class AppConfigurationPullRefreshTest { @Mock private ApplicationEventPublisher publisher; - + @Mock private ReplicaLookUp replicaLookUpMock; @@ -40,10 +37,13 @@ public class AppConfigurationPullRefreshTest { @Mock private AppConfigurationReplicaClientFactory clientFactoryMock; - + @Mock private AppConfigurationRefreshUtil refreshUtilMock; - + + @Mock + private StateHolder stateHolderMock; + private MockitoSession session; @BeforeEach @@ -60,24 +60,26 @@ public void cleanup() throws Exception { @Test public void refreshNoChange() throws InterruptedException, ExecutionException { - when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(eventDataMock); + when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(eventDataMock); AppConfigurationPullRefresh refresh = new AppConfigurationPullRefresh(clientFactoryMock, refreshInterval, - replicaLookUpMock, refreshUtilMock); + replicaLookUpMock, stateHolderMock, refreshUtilMock); assertFalse(refresh.refreshConfigurations().block()); - + } @Test public void refreshUpdate() throws InterruptedException, ExecutionException { when(eventDataMock.getMessage()).thenReturn("Updated"); when(eventDataMock.getDoRefresh()).thenReturn(true); - when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(eventDataMock); + when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(eventDataMock); AppConfigurationPullRefresh refresh = new AppConfigurationPullRefresh(clientFactoryMock, refreshInterval, - replicaLookUpMock, refreshUtilMock); + replicaLookUpMock, stateHolderMock, refreshUtilMock); refresh.setApplicationEventPublisher(publisher); assertTrue(refresh.refreshConfigurations().block()); - + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java index 36c6e0c2e381..02eb4b8dfc93 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -23,7 +24,6 @@ import org.junit.jupiter.api.TestInfo; import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.MockitoSession; @@ -35,8 +35,8 @@ import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationRefreshUtil.RefreshEventData; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.AccessToken; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification; @@ -73,6 +73,8 @@ public class AppConfigurationRefreshUtilTest { private String endpoint; + private AppConfigurationRefreshUtil refreshUtil; + private final List watchKeysFeatureFlags = generateFeatureFlagWatchKeys(); private final AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); @@ -96,6 +98,8 @@ public void setup() { monitoring.setEnabled(true); featureStore.setEnabled(true); + + refreshUtil = new AppConfigurationRefreshUtil(currentStateMock); } @AfterEach @@ -108,13 +112,10 @@ public void cleanup() throws Exception { public void refreshWithoutTimeWatchKeyConfigStoreNotLoaded(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); + when(currentStateMock.getLoadState(endpoint)).thenReturn(false); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(false); - - assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); - assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); - } + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock)); + assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } @Test @@ -128,13 +129,11 @@ public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNotReturned(TestInfo te // Config Store doesn't return a watch key change. when(clientMock.getWatchKey(Mockito.eq(KEY_FILTER), Mockito.eq(EMPTY_LABEL), Mockito.any(Context.class))) .thenReturn(watchKeys.get(0)); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); - stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState); + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(newState); - assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); - assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); - } + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock)); + assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } @Test @@ -143,18 +142,17 @@ public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNoChange(TestInfo testI when(clientMock.getEndpoint()).thenReturn(endpoint); FeatureFlagState newState = new FeatureFlagState( - List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), + List.of(new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store does return a watch key change. when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) .thenReturn(false); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); + when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(newState); - assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); - assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); - } + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock)); + assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } @Test @@ -164,11 +162,9 @@ public void refreshWithoutTimeFeatureFlagDisabled(TestInfo testInfo) { configStore.getFeatureFlags().setEnabled(false); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); - assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); - stateHolderMock.verify(() -> StateHolder.getLoadState(Mockito.anyString()), times(1)); - } + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock)); + assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); + verify(currentStateMock, times(1)).getLoadState(Mockito.anyString()); } @Test @@ -178,11 +174,9 @@ public void refreshWithoutTimeFeatureFlagNotLoaded(TestInfo testInfo) { configStore.getFeatureFlags().setEnabled(true); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); - assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); - stateHolderMock.verify(() -> StateHolder.getLoadState(Mockito.anyString()), times(1)); - } + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock)); + assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); + verify(currentStateMock, times(1)).getLoadState(Mockito.anyString()); } @Test @@ -191,19 +185,17 @@ public void refreshWithoutTimeFeatureFlagNoChange(TestInfo testInfo) { when(clientMock.getEndpoint()).thenReturn(endpoint); FeatureFlagState newState = new FeatureFlagState( - List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), + List.of(new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store doesn't return a watch key change. when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) .thenReturn(false); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); - - assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); - assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); - } + when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(newState); + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock)); + assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } @Test @@ -211,111 +203,111 @@ public void refreshWithoutTimeFeatureFlagEtagChanged(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - FeatureFlags featureFlags = new FeatureFlags(new SettingSelector(), watchKeysFeatureFlags); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(new SettingSelector(), + watchKeysFeatureFlags); FeatureFlagState newState = new FeatureFlagState(List.of(featureFlags), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store does return a watch key change. when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) .thenReturn(true); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); + when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(newState); - assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock)); - assertTrue(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); - } + assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock)); + assertTrue(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock)); } @Test public void refreshStoresCheckSettingsTestNotEnabled(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; - setupFeatureFlagLoad(); - State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); - // Config Store doesn't return a watch key change. - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(false); - stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState); - - // Monitor is disabled - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), - (long) 60, replicaLookUpMock); - assertFalse(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - } + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), + (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); } @Test public void refreshStoresCheckSettingsTestNotLoaded(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; - setupFeatureFlagLoad(); - State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(false); - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(false); - stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState); - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), (long) 60, replicaLookUpMock); - assertFalse(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - } + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); } @Test public void refreshStoresCheckSettingsTestNotRefreshTime(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; - setupFeatureFlagLoad(); - - State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); - stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState); + setupFeatureFlagLoadBasic(); + + // Set up state with WatchedConfigurationSettings and a future refresh time (not expired) + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + // Use a positive duration so the refresh time is in the future + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), - (long) 60, replicaLookUpMock); - assertFalse(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - } + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), + (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + // Verify that checkWatchKeys is NOT called because refresh time hasn't arrived + verify(clientOriginMock, times(0)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); + verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); } @Test - public void refreshStoresCheckSettingsTestFailedRequest(TestInfo testInfo) { + public void refreshStoresCheckSettingsTestNoChangeDetected(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; setupFeatureFlagLoad(); - State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); - stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState); - StateHolder updatedStateHolder = new StateHolder(); - stateHolderMock.when(() -> StateHolder.getCurrentState()).thenReturn(updatedStateHolder); - - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), - (long) 60, replicaLookUpMock); - assertFalse(eventData.getDoRefresh()); - ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - captorParam.capture()); - assertEquals(newState, StateHolder.getState(endpoint)); - Context testContext = captorParam.getValue(); - assertTrue((Boolean) testContext.getData("refresh").get()); - assertFalse((Boolean) testContext.getData("PushRefresh").get()); - } + // Set up state with WatchedConfigurationSettings + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), + (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + captorParam.capture()); + Context testContext = captorParam.getValue(); + assertTrue((Boolean) testContext.getData("refresh").get()); + assertFalse((Boolean) testContext.getData("PushRefresh").get()); } @Test @@ -323,27 +315,26 @@ public void refreshStoresCheckSettingsTestRefreshTimeNoChange(TestInfo testInfo) endpoint = testInfo.getDisplayName() + ".azconfig.io"; setupFeatureFlagLoad(); - when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.any(Context.class))) - .thenReturn(generateWatchKeys().get(0)); - - State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); - stateHolderMock.when(() -> StateHolder.getState(Mockito.any())).thenReturn(newState); - StateHolder updatedStateHolder = new StateHolder(); - stateHolderMock.when(() -> StateHolder.getCurrentState()).thenReturn(updatedStateHolder); - - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), (long) 60, replicaLookUpMock); - assertEquals(newState, StateHolder.getState(endpoint)); - assertFalse(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - } + // Set up state with WatchedConfigurationSettings + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); } - + @Test public void refreshStoresPushRefreshEnabledPrimary(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; @@ -356,31 +347,30 @@ public void refreshStoresPushRefreshEnabledPrimary(TestInfo testInfo) { monitoring.setPushNotification(pushNotificaiton); when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); - when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.any(Context.class))) - .thenReturn(generateWatchKeys().get(0)); - - State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); - stateHolderMock.when(() -> StateHolder.getState(Mockito.any())).thenReturn(newState); - StateHolder updatedStateHolder = new StateHolder(); - stateHolderMock.when(() -> StateHolder.getCurrentState()).thenReturn(updatedStateHolder); - - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), (long) 60, replicaLookUpMock); - assertEquals(newState, StateHolder.getState(endpoint)); - assertFalse(eventData.getDoRefresh()); - ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - captorParam.capture()); - Context testContext = captorParam.getValue(); - assertTrue((Boolean) testContext.getData("refresh").get()); - assertTrue((Boolean) testContext.getData("PushRefresh").get()); - } + // Set up state with WatchedConfigurationSettings + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + captorParam.capture()); + Context testContext = captorParam.getValue(); + assertTrue((Boolean) testContext.getData("refresh").get()); + assertTrue((Boolean) testContext.getData("PushRefresh").get()); } - + @Test public void refreshStoresPushRefreshEnabledSecondary(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; @@ -389,186 +379,180 @@ public void refreshStoresPushRefreshEnabledSecondary(TestInfo testInfo) { AccessToken p2 = new AccessToken(); p2.setName("fake name"); p2.setSecret("value"); - pushNotificaiton.setPrimaryToken(p2); + pushNotificaiton.setSecondaryToken(p2); monitoring.setPushNotification(pushNotificaiton); when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); - when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.any(Context.class))) - .thenReturn(generateWatchKeys().get(0)); - - State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); - stateHolderMock.when(() -> StateHolder.getState(Mockito.any())).thenReturn(newState); - StateHolder updatedStateHolder = new StateHolder(); - stateHolderMock.when(() -> StateHolder.getCurrentState()).thenReturn(updatedStateHolder); - - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), (long) 60, replicaLookUpMock); - assertEquals(newState, StateHolder.getState(endpoint)); - assertFalse(eventData.getDoRefresh()); - ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - captorParam.capture()); - Context testContext = captorParam.getValue(); - assertTrue((Boolean) testContext.getData("refresh").get()); - assertTrue((Boolean) testContext.getData("PushRefresh").get()); - } + // Set up state with WatchedConfigurationSettings + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class); + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + captorParam.capture()); + Context testContext = captorParam.getValue(); + assertTrue((Boolean) testContext.getData("refresh").get()); + assertTrue((Boolean) testContext.getData("PushRefresh").get()); } @Test public void refreshStoresCheckSettingsTestTriggerRefresh(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + FeatureFlagStore disabledFeatureStore = new FeatureFlagStore(); + disabledFeatureStore.setEnabled(false); + lenient().when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore); when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); // Refresh Time, trigger refresh - when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))).thenReturn(clientOriginMock); - ConfigurationSetting refreshKey = new ConfigurationSetting().setKey(KEY_FILTER).setLabel(EMPTY_LABEL) - .setETag("new"); - - when(clientOriginMock.getWatchKey(Mockito.anyString(), Mockito.anyString(), Mockito.any(Context.class))) - .thenReturn(refreshKey); - - State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); - - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); - stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState); - stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock); - - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), - (long) 60, replicaLookUpMock); - assertTrue(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - verify(currentStateMock, times(1)).updateStateRefresh(Mockito.any(), Mockito.any()); - } + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + // Set up state with WatchedConfigurationSettings + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(true); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), + (long) 60, replicaLookUpMock); + assertTrue(eventData.getDoRefresh()); + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); + verify(currentStateMock, times(1)).updateStateRefresh(Mockito.any(), Mockito.any()); } @Test public void refreshStoresCheckFeatureFlagTestNotLoaded(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; - setupFeatureFlagLoad(); - - FeatureFlagState newState = new FeatureFlagState(List.of(), - Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); - - // Config Store doesn't return a watch key change. - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); - - // Monitor is disabled - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), - (long) 60, replicaLookUpMock); - assertFalse(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - } + setupFeatureFlagLoadBasic(); + + // Feature flag state is not loaded (null) + when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(null); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), + (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + // Verify that checkWatchKeys is NOT called because feature flag state is not loaded + verify(clientOriginMock, times(0)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); + verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); } @Test public void refreshStoresCheckFeatureFlagTestNotRefreshTime(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; - setupFeatureFlagLoad(); - - FeatureFlagState newState = new FeatureFlagState(List.of(), + setupFeatureFlagLoadBasic(); + + // Set up feature flag state with a future refresh time (not expired) + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(FEATURE_FLAG_PREFIX).setLabelFilter(EMPTY_LABEL), + watchKeysFeatureFlags); + // Use a positive duration so the refresh time is in the future + FeatureFlagState ffState = new FeatureFlagState(List.of(featureFlags), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); - // Config Store doesn't return a watch key change. - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); - - // Monitor is disabled - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), - (long) 60, replicaLookUpMock); - assertFalse(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - } + when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(ffState); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), + (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + // Verify that checkWatchKeys is NOT called because refresh time hasn't arrived + verify(clientOriginMock, times(0)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); + verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); } @Test public void refreshStoresCheckFeatureFlagTestNoChange(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; - configStore.setEndpoint(endpoint); - configStore.setFeatureFlags(featureStore); - configStore.setMonitoring(monitoring); - setupFeatureFlagLoad(); - when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(false); - FeatureFlagState newState = new FeatureFlagState( - List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), + // Set up feature flag state so it can be checked + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(FEATURE_FLAG_PREFIX).setLabelFilter(EMPTY_LABEL), + watchKeysFeatureFlags); + FeatureFlagState ffState = new FeatureFlagState(List.of(featureFlags), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); - // Config Store doesn't return a watch key change. - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); - stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock); - - // Monitor is disabled - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), (long) 60, replicaLookUpMock); - assertFalse(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - verify(currentStateMock, times(1)).updateFeatureFlagStateRefresh(Mockito.any(), Mockito.any()); - - } + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(ffState); + when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(false); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + assertFalse(eventData.getDoRefresh()); + verify(currentStateMock, times(1)).updateFeatureFlagStateRefresh(Mockito.any(), Mockito.any()); } @Test public void refreshStoresCheckFeatureFlagTestTriggerRefresh(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; setupFeatureFlagLoad(); - when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(true); - FeatureFlags featureFlags = new FeatureFlags(new SettingSelector(), watchKeysFeatureFlags); - - FeatureFlagState newState = new FeatureFlagState(List.of(featureFlags), + // Set up feature flag state so it can be checked + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(FEATURE_FLAG_PREFIX).setLabelFilter(EMPTY_LABEL), + watchKeysFeatureFlags); + FeatureFlagState ffState = new FeatureFlagState(List.of(featureFlags), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); - // Config Store doesn't return a watch key change. - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState); - stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock); - - // Monitor is disabled - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(10), (long) 60, replicaLookUpMock); - assertTrue(eventData.getDoRefresh()); - verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); - verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), - Mockito.any(Context.class)); - } + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(ffState); + when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(true); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + assertTrue(eventData.getDoRefresh()); + verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); } @Test public void minRefreshPeriodTest() { - try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { - stateHolderMock.when(() -> StateHolder.getNextForcedRefresh()).thenReturn(Instant.now().minusSeconds(600)); - RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock, - Duration.ofMinutes(1), (long) 0, replicaLookUpMock); - assertTrue(eventData.getDoRefresh()); - assertEquals("Minimum refresh period reached. Refreshing configurations.", eventData.getMessage()); - } + when(currentStateMock.getNextForcedRefresh()).thenReturn(Instant.now().minusSeconds(600)); + RefreshEventData eventData = refreshUtil.refreshStoresCheck(clientFactoryMock, + Duration.ofMinutes(1), (long) 0, replicaLookUpMock); + assertTrue(eventData.getDoRefresh()); + assertEquals("Minimum refresh period reached. Refreshing configurations.", eventData.getMessage()); } private void setupFeatureFlagLoad() { + setupFeatureFlagLoadBasic(); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + } + + private void setupFeatureFlagLoadBasic() { when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore); when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); - when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))).thenReturn(clientOriginMock); } private List generateWatchKeys() { @@ -589,4 +573,139 @@ private List generateFeatureFlagWatchKeys() { watchKeys.add(currentWatchKey); return watchKeys; } + + @Test + public void refreshAllWithWatchedConfigurationSettingsTest(TestInfo testInfo) { + // Test that when refreshAll is enabled, watched configuration settings are used instead of watch keys + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + FeatureFlagStore disabledFeatureStore = new FeatureFlagStore(); + disabledFeatureStore.setEnabled(false); + lenient().when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + // Set up state with WatchedConfigurationSettings + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(true); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck( + clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + + assertTrue(eventData.getDoRefresh()); + // Verify checkWatchKeys is called (watched configuration settings path) + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); + // Verify getWatchKey is NOT called (traditional watch key path) + verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); + } + + @Test + public void refreshAllWithNullWatchKeysTest(TestInfo testInfo) { + // Test that when refreshAll is enabled with null watchKeys, watched configuration settings are still used + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + FeatureFlagStore disabledFeatureStore = new FeatureFlagStore(); + disabledFeatureStore.setEnabled(false); + when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + // Set up state with null watch keys but valid watched configuration settings + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck( + clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + + // No change detected, so should not refresh + assertFalse(eventData.getDoRefresh()); + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); + } + + @Test + public void watchedConfigurationSettingsNoChangeTest(TestInfo testInfo) { + // Test that watched configuration settings correctly detect no change + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + FeatureFlagStore disabledFeatureStore = new FeatureFlagStore(); + disabledFeatureStore.setEnabled(false); + when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + // Return false indicating no changes detected + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck( + clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + + assertFalse(eventData.getDoRefresh()); + verify(currentStateMock, times(1)).updateStateRefresh(Mockito.any(), Mockito.any()); + } + + @Test + public void watchedConfigurationSettingsWithChangeDetectedTest(TestInfo testInfo) { + // Test that watched configuration settings correctly detect changes + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + FeatureFlagStore disabledFeatureStore = new FeatureFlagStore(); + disabledFeatureStore.setEnabled(false); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + // Return true indicating changes detected + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(true); + + when(currentStateMock.getLoadState(endpoint)).thenReturn(true); + when(currentStateMock.getState(endpoint)).thenReturn(state); + + RefreshEventData eventData = refreshUtil.refreshStoresCheck( + clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + + assertTrue(eventData.getDoRefresh()); + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientBuilderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientBuilderTest.java index 10e10c33787b..3b95e77724f2 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientBuilderTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientBuilderTest.java @@ -298,4 +298,76 @@ public void buildClientConnectionStringInvalid3Test() { exception.getMessage()); } + @Test + public void buildClientsWithLoadBalancingEnabledTest() { + // Test that load balancing shuffles the order of clients + configStore = new ConfigStore(); + List endpoints = new ArrayList<>(); + + // Add multiple endpoints to shuffle + endpoints.add(TEST_ENDPOINT); + endpoints.add(TEST_ENDPOINT_GEO); + endpoints.add("https://third.test.config.io"); + endpoints.add("https://fourth.test.config.io"); + + configStore.setEndpoints(endpoints); + configStore.setLoadBalancingEnabled(true); + configStore.validateAndInit(); + + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); + AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); + + ConfigurationClientBuilder builder = new ConfigurationClientBuilder(); + when(builderMock.endpoint(Mockito.anyString())).thenReturn(builder); + when(builderMock.addPolicy(Mockito.any())).thenReturn(builderMock); + when(clientFactoryMock.build()).thenReturn(builderMock); + + // Build clients multiple times and verify that order changes (due to shuffle) + List clients1 = spy.buildClients(configStore); + List clients2 = spy.buildClients(configStore); + + assertEquals(4, clients1.size()); + assertEquals(4, clients2.size()); + + // The order should potentially differ due to random shuffling + // We can't guarantee they're different, but we can verify the same endpoints exist + List endpoints1 = clients1.stream().map(AppConfigurationReplicaClient::getEndpoint).toList(); + List endpoints2 = clients2.stream().map(AppConfigurationReplicaClient::getEndpoint).toList(); + + // All endpoints should be present in both lists (just potentially in different order) + assertTrue(endpoints1.containsAll(endpoints)); + assertTrue(endpoints2.containsAll(endpoints)); + } + + @Test + public void buildClientsWithLoadBalancingDisabledTest() { + // Test that without load balancing, order is preserved + configStore = new ConfigStore(); + List endpoints = new ArrayList<>(); + + endpoints.add(TEST_ENDPOINT); + endpoints.add(TEST_ENDPOINT_GEO); + endpoints.add("https://third.test.config.io"); + + configStore.setEndpoints(endpoints); + configStore.setLoadBalancingEnabled(false); + configStore.validateAndInit(); + + clientBuilder = new AppConfigurationReplicaClientsBuilder(clientFactoryMock, null, false, false); + AppConfigurationReplicaClientsBuilder spy = Mockito.spy(clientBuilder); + + ConfigurationClientBuilder builder = new ConfigurationClientBuilder(); + when(builderMock.endpoint(Mockito.anyString())).thenReturn(builder); + when(builderMock.addPolicy(Mockito.any())).thenReturn(builderMock); + when(clientFactoryMock.build()).thenReturn(builderMock); + + List clients = spy.buildClients(configStore); + + assertEquals(3, clients.size()); + // When load balancing is disabled, order should match input order + assertEquals(TEST_ENDPOINT, clients.get(0).getEndpoint()); + assertEquals(TEST_ENDPOINT_GEO, clients.get(1).getEndpoint()); + assertEquals("https://third.test.config.io", clients.get(2).getEndpoint()); + } + } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java index b6b48e7288e6..3e18eb2263ec 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java @@ -45,6 +45,7 @@ import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.data.appconfiguration.models.SnapshotComposition; import com.azure.identity.CredentialUnavailableException; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import reactor.core.publisher.Mono; @@ -70,7 +71,7 @@ public class AppConfigurationReplicaClientTest { @Mock private Response snapshotResponseMock; - + @Mock private Context contextMock; @@ -141,13 +142,16 @@ public void listSettingsTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); when(responseMock.getStatusCode()).thenReturn(429); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(408); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(500); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(499); assertThrows(HttpResponseException.class, () -> client.listSettings(new SettingSelector(), contextMock)); @@ -170,7 +174,8 @@ public void listFeatureFlagsTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenReturn(new PagedIterable<>(pagedFlux)); - assertEquals(configurations, client.listFeatureFlags(new SettingSelector(), contextMock).getFeatureFlags()); + assertEquals(configurations, + client.listFeatureFlags(new SettingSelector(), contextMock).getConfigurationSettings()); when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); @@ -196,7 +201,8 @@ public void listSettingsUnknownHostTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenThrow(new UncheckedIOException(new UnknownHostException())); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); } @Test @@ -206,15 +212,17 @@ public void listSettingsNoCredentialTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenThrow(new CredentialUnavailableException("No Credential")); - assertThrows(CredentialUnavailableException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(CredentialUnavailableException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); } @Test public void getWatchNoCredentialTest() { AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); - when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any())) - .thenThrow(new CredentialUnavailableException("No Credential")); + when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), + Mockito.any())) + .thenThrow(new CredentialUnavailableException("No Credential")); assertThrows(CredentialUnavailableException.class, () -> client.getWatchKey("key", "label", contextMock)); } @@ -265,20 +273,24 @@ public void listSettingSnapshotTest() { when(clientMock.listConfigurationSettingsForSnapshot(Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); when(responseMock.getStatusCode()).thenReturn(429); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(408); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(500); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(499); assertThrows(HttpResponseException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(clientMock.getSnapshotWithResponse(Mockito.any(), Mockito.any(), Mockito.any())) .thenThrow(new UncheckedIOException(new UnknownHostException())); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettingSnapshot("SnapshotName", contextMock)); } @Test @@ -340,7 +352,8 @@ public void checkWatchKeysTest() { when(supplierMock.get()).thenReturn(Mono.just(pagedResponse)); - when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenReturn(new PagedIterable<>(pagedFlux)); + when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) + .thenReturn(new PagedIterable<>(pagedFlux)); assertFalse(client.checkWatchKeys(new SettingSelector(), contextMock)); pagedResponse.close(); @@ -349,4 +362,67 @@ public void checkWatchKeysTest() { } } + @Test + public void watchedConfigurationSettingsTest() { + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); + + ConfigurationSetting setting1 = new ConfigurationSetting().setKey("key1").setLabel("label1"); + ConfigurationSetting setting2 = new ConfigurationSetting().setKey("key2").setLabel("label2"); + List configurations = List.of(setting1, setting2); + + PagedFlux pagedFlux = new PagedFlux<>(supplierMock); + HttpHeaders headers = new HttpHeaders().add(HttpHeaderName.ETAG, "test-etag-value"); + PagedResponse pagedResponse = new PagedResponseBase( + null, 200, headers, configurations, null, null); + + when(supplierMock.get()).thenReturn(Mono.just(pagedResponse)); + when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) + .thenReturn(new PagedIterable<>(pagedFlux)); + + SettingSelector selector = new SettingSelector().setKeyFilter("*"); + WatchedConfigurationSettings result = client.loadWatchedSettings(selector, contextMock); + + assertEquals(2, result.getConfigurationSettings().size()); + assertEquals("key1", result.getConfigurationSettings().get(0).getKey()); + assertEquals("key2", result.getConfigurationSettings().get(1).getKey()); + assertEquals(1, result.getSettingSelector().getMatchConditions().size()); + assertEquals("test-etag-value", result.getSettingSelector().getMatchConditions().get(0).getIfNoneMatch()); + assertEquals(0, client.getFailedAttempts()); + } + + @Test + public void watchedConfigurationSettingsErrorTest() { + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); + + when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock); + when(exceptionMock.getResponse()).thenReturn(responseMock); + when(responseMock.getStatusCode()).thenReturn(429); + + assertThrows(AppConfigurationStatusException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(408); + assertThrows(AppConfigurationStatusException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(500); + assertThrows(AppConfigurationStatusException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(499); + assertThrows(HttpResponseException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + } + + @Test + public void watchedConfigurationSettingsUncheckedIOExceptionTest() { + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); + + when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) + .thenThrow(new UncheckedIOException(new IOException("Network error"))); + + assertThrows(AppConfigurationStatusException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + } + } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java new file mode 100644 index 000000000000..84610b6a5769 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config.implementation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.MockitoSession; +import org.mockito.quality.Strictness; +import org.springframework.boot.ConfigurableBootstrapContext; +import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.context.config.ConfigDataLoaderContext; +import org.springframework.boot.context.config.Profiles; +import org.springframework.boot.logging.DeferredLog; +import org.springframework.boot.logging.DeferredLogFactory; + +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreTrigger; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore; + +public class AzureAppConfigDataLoaderTest { + + @Mock + private AppConfigurationReplicaClient clientMock; + + @Mock + private AppConfigurationReplicaClientFactory replicaClientFactoryMock; + + @Mock + private AppConfigurationKeyVaultClientFactory keyVaultClientFactoryMock; + + @Mock + private ConfigDataLoaderContext configDataLoaderContextMock; + + @Mock + private ConfigurableBootstrapContext bootstrapContextMock; + + @Mock + private DeferredLogFactory logFactoryMock; + + @Mock + private StateHolder stateHolderMock; + + private AzureAppConfigDataResource resource; + + private AzureAppConfigDataResource refreshResource; + + private ConfigStore configStore; + + private MockitoSession session; + + private static final String ENDPOINT = "https://test.azconfig.io"; + + private static final String KEY_FILTER = "/application/*"; + + private static final String LABEL_FILTER = "prod"; + + @BeforeEach + public void setup() { + session = Mockito.mockitoSession().initMocks(this).strictness(Strictness.STRICT_STUBS).startMocking(); + MockitoAnnotations.openMocks(this); + + configStore = new ConfigStore(); + configStore.setEndpoint(ENDPOINT); + configStore.setEnabled(true); + + // Setup feature flags + FeatureFlagStore featureFlagStore = new FeatureFlagStore(); + featureFlagStore.setEnabled(false); + configStore.setFeatureFlags(featureFlagStore); + + // Setup basic resource + Profiles profiles = Mockito.mock(Profiles.class); + lenient().when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER)); + + // Startup resource (isRefresh = false) + resource = new AzureAppConfigDataResource(true, configStore, profiles, true, Duration.ofMinutes(1), Duration.ofSeconds(30)); + // Refresh resource (isRefresh = true) + refreshResource = new AzureAppConfigDataResource(true, configStore, profiles, false, Duration.ofMinutes(1), Duration.ofSeconds(30)); + + // Setup common mocks for ConfigDataLoaderContext + lenient().when(configDataLoaderContextMock.getBootstrapContext()).thenReturn(bootstrapContextMock); + lenient().when(bootstrapContextMock.isRegistered(FeatureFlagClient.class)).thenReturn(false); + lenient().when(bootstrapContextMock.get(AppConfigurationReplicaClientFactory.class)) + .thenReturn(replicaClientFactoryMock); + lenient().when(bootstrapContextMock.get(AppConfigurationKeyVaultClientFactory.class)) + .thenReturn(keyVaultClientFactoryMock); + lenient().when(bootstrapContextMock.get(StateHolder.class)).thenReturn(stateHolderMock); + lenient().when(logFactoryMock.getLog(any(Class.class))).thenReturn(new DeferredLog()); + } + + @AfterEach + public void cleanup() throws Exception { + MockitoAnnotations.openMocks(this).close(); + session.finishMocking(); + } + + @Test + public void loadSucceedsWhenNoClientsAvailableTest() throws IOException { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mocks - no clients available + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null); + + // Test using public load() method + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, resource); + + // Verify - returns empty ConfigData when no clients available + assertNotNull(result); + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); + verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true)); + } + + @Test + public void refreshAllDisabledUsesWatchKeysTest() { + // Setup monitoring with refreshAll disabled (traditional watch keys) + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(true); + + // Add trigger for traditional watch key + AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); + trigger.setKey("sentinel"); + trigger.setLabel("prod"); + monitoring.setTriggers(List.of(trigger)); + + configStore.setMonitoring(monitoring); + + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Verify that when refreshAll is false, triggers are configured + assertEquals(1, monitoring.getTriggers().size()); + assertEquals("sentinel", monitoring.getTriggers().get(0).getKey()); + } + + // Startup Retry Tests + + @Test + public void startupSucceedsOnFirstAttemptTest() throws IOException { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mocks - no clients available (returns empty result) + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null); + + // Test using public load() method + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, resource); + + // Verify - success on first attempt, no retries needed + assertNotNull(result); + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); + verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true)); + } + + @Test + public void startupRetriesAfterClientFailureThenSucceedsTest() throws IOException { + // Use very short timeout so test runs quickly (minimal real sleeping) + Profiles profiles = Mockito.mock(Profiles.class); + when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER)); + + ConfigStore fastTimeoutStore = new ConfigStore(); + fastTimeoutStore.setEndpoint(ENDPOINT); + fastTimeoutStore.setEnabled(true); + FeatureFlagStore featureFlagStore = new FeatureFlagStore(); + featureFlagStore.setEnabled(false); + fastTimeoutStore.setFeatureFlags(featureFlagStore); + + // Short timeout - test will retry once then succeed, sleeping ~5 seconds total + AzureAppConfigDataResource fastResource = new AzureAppConfigDataResource( + true, fastTimeoutStore, profiles, true, Duration.ofMinutes(1), Duration.ofSeconds(10)); + + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + fastTimeoutStore.getSelects().add(selector); + + // Setup mocks: + // - First getNextActiveClient(true) returns clientMock which will throw + // - First getNextActiveClient(false) returns null (no more replicas in first attempt) + // - Second getNextActiveClient(true) returns null (simulating success path) + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))) + .thenReturn(clientMock) // First attempt - will fail + .thenReturn(null); // Second attempt - no clients, treated as success + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))) + .thenReturn(null); // No more replicas + when(clientMock.getEndpoint()).thenReturn(ENDPOINT); + when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure")); + + // Test using public load() method + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, fastResource); + + // Verify - retried after failure + assertNotNull(result); + verify(replicaClientFactoryMock, atLeast(2)).findActiveClients(ENDPOINT); + } + + @Test + public void startupFailsAfterAllRetriesExhaustedTest() { + // Setup with a short timeout + Profiles profiles = Mockito.mock(Profiles.class); + lenient().when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER)); + + ConfigStore shortTimeoutStore = new ConfigStore(); + shortTimeoutStore.setEndpoint(ENDPOINT); + shortTimeoutStore.setEnabled(true); + FeatureFlagStore featureFlagStore = new FeatureFlagStore(); + featureFlagStore.setEnabled(false); + shortTimeoutStore.setFeatureFlags(featureFlagStore); + + // Use 6-second timeout - enough for 1-2 retries (~5-10s total with backoff) + AzureAppConfigDataResource shortTimeoutResource = new AzureAppConfigDataResource( + true, shortTimeoutStore, profiles, true, Duration.ofMinutes(1), Duration.ofSeconds(6)); + + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + shortTimeoutStore.getSelects().add(selector); + + // Setup mocks - client always fails (keep returning clientMock so it keeps trying and failing) + lenient().when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock); + lenient().when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null); + lenient().when(clientMock.getEndpoint()).thenReturn(ENDPOINT); + lenient().when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure")); + + // Test using public load() method - should throw RuntimeException after retries exhausted + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + RuntimeException exception = assertThrows(RuntimeException.class, + () -> loader.load(configDataLoaderContextMock, shortTimeoutResource)); + + // Verify - failure after retries exhausted + assertTrue(exception.getMessage().contains("Failed to generate property sources")); + verify(replicaClientFactoryMock, atLeast(1)).findActiveClients(ENDPOINT); + } + + @Test + public void refreshOnlyAttemptsOnceOnFailureTest() throws IOException { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mocks - client fails + lenient().when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock); + lenient().when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null); + lenient().when(clientMock.getEndpoint()).thenReturn(ENDPOINT); + lenient().when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure")); + + // Test with refresh resource (isRefresh = true) - should NOT throw, just warn + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, refreshResource); + + // Verify - only one findActiveClients call (no retry loop for refresh) + assertNotNull(result); + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); + } + + @Test + public void refreshSucceedsOnFirstAttemptTest() throws IOException { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mocks - no clients available (returns null = no-op success) + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null); + + // Test with refresh resource + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, refreshResource); + + // Verify - success on first attempt + assertNotNull(result); + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); + verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true)); + } + + @Test + public void startupDoesNotRetryDuringRefreshTest() throws IOException { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mock - client throws exception + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock); + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null); + when(clientMock.getEndpoint()).thenReturn(ENDPOINT); + when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Test failure")); + + // Test with refresh resource - should NOT throw, just warn and continue + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, refreshResource); + + // Verify - failure on first attempt, no retry + assertNotNull(result); + // Only one findActiveClients call (would be multiple in startup retry loop) + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); + } +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolverTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolverTest.java index 04cc61b72850..a3f3655b20c7 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolverTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolverTest.java @@ -47,6 +47,12 @@ class AzureAppConfigDataLocationResolverTest { @Mock BindResult validResult; + @Mock + BindResult enabledTrueResult; + + @Mock + BindResult enabledFalseResult; + private static final String PREFIX = "azureAppConfiguration:"; private static final String INVALID_PREFIX = "someOtherPrefix:"; @@ -80,6 +86,9 @@ void testIsResolvableWithCorrectPrefix() { ConfigDataLocation location = ConfigDataLocation.of(PREFIX); when(mockContext.getBinder()).thenReturn(mockBinder); + when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class)) + .thenReturn(enabledTrueResult); + when(enabledTrueResult.orElse(true)).thenReturn(true); when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class)) .thenReturn(validResult); when(validResult.orElse("")).thenReturn("https://test.config.io"); @@ -95,6 +104,9 @@ void testIsResolvableWithValidConnectionStringConfiguration() { ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:"); when(mockContext.getBinder()).thenReturn(mockBinder); + when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class)) + .thenReturn(enabledTrueResult); + when(enabledTrueResult.orElse(true)).thenReturn(true); when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class)) .thenReturn(emptyResult); when(emptyResult.orElse("")).thenReturn(""); @@ -108,11 +120,28 @@ void testIsResolvableWithValidConnectionStringConfiguration() { assertTrue(result, "Resolver should accept locations with valid connection-string configuration"); } + @Test + void testIsResolvableWhenAppConfigurationIsDisabled() { + ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:"); + + when(mockContext.getBinder()).thenReturn(mockBinder); + when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class)) + .thenReturn(enabledFalseResult); + when(enabledFalseResult.orElse(true)).thenReturn(false); + + boolean result = resolver.isResolvable(mockContext, location); + + assertFalse(result, "Resolver should reject locations when App Configuration is disabled"); + } + @Test void testIsResolvableWithValidEndpointsConfiguration() { ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:"); when(mockContext.getBinder()).thenReturn(mockBinder); + when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class)) + .thenReturn(enabledTrueResult); + when(enabledTrueResult.orElse(true)).thenReturn(true); when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class)) .thenReturn(emptyResult); when(emptyResult.orElse("")).thenReturn(""); @@ -134,6 +163,9 @@ void testIsResolvableWithValidConnectionStringsConfiguration() { ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:"); when(mockContext.getBinder()).thenReturn(mockBinder); + when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class)) + .thenReturn(enabledTrueResult); + when(enabledTrueResult.orElse(true)).thenReturn(true); when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class)) .thenReturn(emptyResult); when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].connection-string", String.class)) @@ -156,6 +188,9 @@ void testIsResolvableWithNoValidConfiguration() { ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:"); when(mockContext.getBinder()).thenReturn(mockBinder); + when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class)) + .thenReturn(enabledTrueResult); + when(enabledTrueResult.orElse(true)).thenReturn(true); when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class)) .thenReturn(emptyResult); when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].connection-string", String.class)) diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java index 0de265607483..68d0686497fd 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java @@ -2,14 +2,15 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -32,6 +33,7 @@ class AzureAppConfigDataResourceTest { private static final String TEST_ENDPOINT = "https://test.azconfig.io"; private static final Duration TEST_REFRESH_INTERVAL = Duration.ofSeconds(30); + private static final Duration TEST_STARTUP_TIMEOUT = Duration.ofSeconds(100); private ConfigStore configStore; private AppConfigurationStoreMonitoring monitoring; @@ -57,7 +59,7 @@ void testConfigStoreEnabledState(boolean appConfigEnabled, boolean configStoreEn configStore.setEnabled(configStoreEnabled); AzureAppConfigDataResource resource = new AzureAppConfigDataResource( - appConfigEnabled, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL); + appConfigEnabled, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT); assertEquals(expectedEnabled, resource.isConfigStoreEnabled(), description); } @@ -71,7 +73,7 @@ void testEnabledStateWithRefreshScenarios(boolean isRefresh, String scenarioDesc configStore.setEnabled(true); AzureAppConfigDataResource resource = new AzureAppConfigDataResource( - true, configStore, mockProfiles, isRefresh, TEST_REFRESH_INTERVAL); + true, configStore, mockProfiles, isRefresh, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT); assertTrue(resource.isConfigStoreEnabled(), "Config store should be enabled in " + scenarioDescription + " when conditions are met"); @@ -91,14 +93,14 @@ void testAllPropertiesSetCorrectlyRegardlessOfEnabledState() { configStore.setEnabled(true); AzureAppConfigDataResource enabledResource = new AzureAppConfigDataResource( - true, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL); + true, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT); assertTrue(enabledResource.isConfigStoreEnabled()); assertAllPropertiesCorrect(enabledResource, trimKeyPrefixes, selects, featureFlagSelects, true); configStore.setEnabled(false); AzureAppConfigDataResource disabledResource = new AzureAppConfigDataResource( - true, configStore, mockProfiles, true, TEST_REFRESH_INTERVAL); + true, configStore, mockProfiles, true, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT); assertFalse(disabledResource.isConfigStoreEnabled()); assertAllPropertiesCorrect(disabledResource, trimKeyPrefixes, selects, featureFlagSelects, false); @@ -123,13 +125,13 @@ private void assertAllPropertiesCorrect(AzureAppConfigDataResource resource, void testNullRefreshIntervalHandling() { configStore.setEnabled(true); AzureAppConfigDataResource enabledResource = new AzureAppConfigDataResource( - true, configStore, mockProfiles, false, null); + true, configStore, mockProfiles, false, null, TEST_STARTUP_TIMEOUT); assertTrue(enabledResource.isConfigStoreEnabled()); assertNull(enabledResource.getRefreshInterval()); configStore.setEnabled(false); AzureAppConfigDataResource disabledResource = new AzureAppConfigDataResource( - false, configStore, mockProfiles, true, null); + false, configStore, mockProfiles, true, null, TEST_STARTUP_TIMEOUT); assertFalse(disabledResource.isConfigStoreEnabled()); assertNull(disabledResource.getRefreshInterval()); assertFalse(disabledResource.isRefresh()); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java index 787138a67a57..89eb2b75448f 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java @@ -2,27 +2,29 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; +import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.AfterEach; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Mockito; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import org.mockito.MockitoAnnotations; import org.mockito.MockitoSession; import org.mockito.quality.Strictness; import com.azure.spring.cloud.appconfiguration.config.AppConfigurationStoreHealth; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; @@ -101,8 +103,8 @@ public void backoffTest() { configStore = new ConfigStore(); List endpoints = new ArrayList<>(); - endpoints.add("https://fake.test.config.io"); - endpoints.add("https://fake.test.geo.config.io"); + endpoints.add("fake.test.endpoint.one"); + endpoints.add("fake.test.endpoint.two"); configStore.setEndpoints(endpoints); @@ -277,7 +279,7 @@ public void getNextActiveClientWithLoadBalancingTest() { throw new RuntimeException("Test verification failed", e); } - // activeClients list should now only have the second client + // activeClients list should now contain only the second client try { java.lang.reflect.Field activeClientsField = ConnectionManager.class.getDeclaredField("activeClients"); activeClientsField.setAccessible(true); @@ -419,7 +421,7 @@ public void getAvailableClientsWithAutoFailoverTest() { // Mock auto-failover endpoints List autoFailoverEndpoints = new ArrayList<>(); - String failoverEndpoint = "https://failover.test.config.io"; + String failoverEndpoint = "fake.test.failover.endpoint"; autoFailoverEndpoints.add(failoverEndpoint); when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); @@ -446,7 +448,7 @@ public void backoffAutoFailoverClientTest() { // Set up auto-failover scenario List autoFailoverEndpoints = new ArrayList<>(); - String failoverEndpoint = "https://failover.test.config.io"; + String failoverEndpoint = "fake.test.failover.endpoint"; autoFailoverEndpoints.add(failoverEndpoint); when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); @@ -543,7 +545,7 @@ public void getAvailableClientsWithLoadBalancingMixedBackoffTest() { // Mock auto-failover endpoints (but they should also be backed off) List autoFailoverEndpoints = new ArrayList<>(); - String failoverEndpoint = "https://failover.test.config.io"; + String failoverEndpoint = "fake.test.failover.endpoint"; autoFailoverEndpoints.add(failoverEndpoint); when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); @@ -579,5 +581,119 @@ public void getAvailableClientsSingleClientTest() { assertEquals(AppConfigurationStoreHealth.UP, manager.getHealth()); } + /** + * Tests getMillisUntilNextClientAvailable returns 0 when a client is available immediately. + */ + @Test + public void getMillisUntilNextClientAvailableReturnsZeroWhenClientAvailableTest() { + ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock); + + List clients = new ArrayList<>(); + clients.add(replicaClient1); + + // Client is not in backoff (available now) + when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().minusSeconds(60)); + when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients); + + // Initialize clients by calling getAvailableClients + manager.getAvailableClients(); + + long waitTime = manager.getMillisUntilNextClientAvailable(); + assertEquals(0, waitTime); + } + + /** + * Tests getMillisUntilNextClientAvailable returns wait time when all clients are in backoff. + */ + @Test + public void getMillisUntilNextClientAvailableReturnsWaitTimeWhenAllBackedOffTest() { + ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock); + + List clients = new ArrayList<>(); + clients.add(replicaClient1); + clients.add(replicaClient2); + + // Both clients are in backoff + Instant backoffEnd1 = Instant.now().plusSeconds(10); + Instant backoffEnd2 = Instant.now().plusSeconds(5); // This one expires sooner + when(replicaClient1.getBackoffEndTime()).thenReturn(backoffEnd1); + when(replicaClient2.getBackoffEndTime()).thenReturn(backoffEnd2); + when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients); + + // Initialize clients by calling getAvailableClients + manager.getAvailableClients(); + + long waitTime = manager.getMillisUntilNextClientAvailable(); + + // Should return approximately 5 seconds (the earlier backoff end time) + assertTrue(waitTime > 0); + assertTrue(waitTime <= 5000); + } + + /** + * Tests getMillisUntilNextClientAvailable considers auto-failover clients. + */ + @Test + public void getMillisUntilNextClientAvailableWithAutoFailoverClientTest() { + ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock); + + List clients = new ArrayList<>(); + clients.add(replicaClient1); + + // Regular client is in backoff for longer + when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(30)); + when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients); + + // Setup auto-failover client with shorter backoff + List autoFailoverEndpoints = new ArrayList<>(); + String failoverEndpoint = "fake.test.failover.endpoint"; + autoFailoverEndpoints.add(failoverEndpoint); + when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); + + // Auto-failover client expires sooner + when(autoFailoverClient.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(5)); + when(clientBuilderMock.buildClient(Mockito.eq(failoverEndpoint), Mockito.eq(configStore))).thenReturn(autoFailoverClient); + + // Initialize clients (will also add auto-failover client) + manager.getAvailableClients(); + + long waitTime = manager.getMillisUntilNextClientAvailable(); + + // Should return approximately 5 seconds (auto-failover client expires first) + assertTrue(waitTime > 0); + assertTrue(waitTime <= 5000); + } + + /** + * Tests getMillisUntilNextClientAvailable returns 0 when auto-failover client is available. + */ + @Test + public void getMillisUntilNextClientAvailableAutoFailoverAvailableTest() { + ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock); + + List clients = new ArrayList<>(); + clients.add(replicaClient1); + + // Regular client is in backoff + when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(30)); + when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients); + + // Setup auto-failover client that is available + List autoFailoverEndpoints = new ArrayList<>(); + String failoverEndpoint = "fake.test.failover.endpoint"; + autoFailoverEndpoints.add(failoverEndpoint); + when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); + + // Auto-failover client is available (not in backoff) + when(autoFailoverClient.getBackoffEndTime()).thenReturn(Instant.now().minusSeconds(60)); + when(clientBuilderMock.buildClient(Mockito.eq(failoverEndpoint), Mockito.eq(configStore))).thenReturn(autoFailoverClient); + + // Initialize clients (will also add auto-failover client) + manager.getAvailableClients(); + + long waitTime = manager.getMillisUntilNextClientAvailable(); + assertEquals(0, waitTime); + } + } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java index f2ad1eca8024..ea4b036fbae3 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java @@ -20,12 +20,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; @@ -36,7 +38,8 @@ import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagFilter; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.data.appconfiguration.models.SettingSelector; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Allocation; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Variant; @@ -51,15 +54,15 @@ public class FeatureFlagClientTest { private FeatureFlagClient featureFlagClient; - private String[] emptyLabelList = {"\0"}; + private String[] emptyLabelList = { "\0" }; private static final FeatureFlagConfigurationSetting TELEMETRY_FEATURE = createItemFeatureFlag( - ".appconfig.featureflag/", "Delta", - FEATURE_VALUE_TELEMETRY, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "Delta", + FEATURE_VALUE_TELEMETRY, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); private static final FeatureFlagConfigurationSetting ALL_FEATURE = createItemFeatureFlag( - ".appconfig.featureflag/", "Delta", - FEATURE_VALUE_ALL, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "Delta", + FEATURE_VALUE_ALL, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); private MockitoSession session; @@ -80,58 +83,65 @@ public void cleanup() throws Exception { @Test public void loadFeatureFlagsTestNoFeatureFlags() { List settings = List.of(new ConfigurationSetting().setKey("FakeKey")); - FeatureFlags featureFlags = new FeatureFlags(null, settings); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - contextMock); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, + emptyLabelList, null, + contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals("FakeKey", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); + assertEquals("FakeKey", featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); assertEquals(0, featureFlagClient.getFeatureFlags().size()); } @Test public void loadFeatureFlagsTestFeatureFlags() { List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false), - new FeatureFlagConfigurationSetting("Beta", true)); - FeatureFlags featureFlags = new FeatureFlags(null, settings); + new FeatureFlagConfigurationSetting("Beta", true)); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - contextMock); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, + emptyLabelList, null, + contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); - assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); + assertEquals(".appconfig.featureflag/Alpha", + featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); + assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getConfigurationSettings().get(1).getKey()); assertEquals(2, featureFlagClient.getFeatureFlags().size()); } @Test public void loadFeatureFlagsTestMultipleLoads() { List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false), - new FeatureFlagConfigurationSetting("Beta", true)); - FeatureFlags featureFlags = new FeatureFlags(null, settings); + new FeatureFlagConfigurationSetting("Beta", true)); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - contextMock); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, + emptyLabelList, null, + contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); - assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); + assertEquals(".appconfig.featureflag/Alpha", + featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); + assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getConfigurationSettings().get(1).getKey()); assertEquals(2, featureFlagClient.getFeatureFlags().size()); List settings2 = List.of(new FeatureFlagConfigurationSetting("Alpha", true), - new FeatureFlagConfigurationSetting("Gamma", false)); - featureFlags = new FeatureFlags(null, settings2); + new FeatureFlagConfigurationSetting("Gamma", false)); + featureFlags = new WatchedConfigurationSettings(null, settings2); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock); + featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, null, contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); - assertEquals(".appconfig.featureflag/Gamma", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); + assertEquals(".appconfig.featureflag/Alpha", + featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); + assertEquals(".appconfig.featureflag/Gamma", + featureFlagsList.get(0).getConfigurationSettings().get(1).getKey()); assertEquals(3, featureFlagClient.getFeatureFlags().size()); List features = featureFlagClient.getFeatureFlags(); assertTrue(features.get(0).isEnabled()); @@ -170,14 +180,16 @@ public void loadFeatureFlagsTestTargetingFilter() { targetingFilter.addParameter("Audience", parameters); targetingFlag.addClientFilter(targetingFilter); List settings = List.of(targetingFlag); - FeatureFlags featureFlags = new FeatureFlags(null, settings); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - contextMock); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, + emptyLabelList, null, + contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals(".appconfig.featureflag/TargetingTest", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); + assertEquals(".appconfig.featureflag/TargetingTest", + featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); assertEquals(1, featureFlagClient.getFeatureFlags().size()); } @@ -209,9 +221,9 @@ public void testAllocationIdInTelemetry() { @Test public void testAllocationIdWithDifferentSeed() { FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", - "{\"allocation\":{\"seed\":\"newSeed\"},\"telemetry\":{\"enabled\":true}}", FEATURE_LABEL, - FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", + "{\"allocation\":{\"seed\":\"newSeed\"},\"telemetry\":{\"enabled\":true}}", FEATURE_LABEL, + FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); assertEquals("RkxUK5CoaOaNWBjc55Mi", feature.getTelemetry().getMetadata().get("AllocationId")); @@ -221,7 +233,7 @@ public void testAllocationIdWithDifferentSeed() { public void testAllocationIdWithVariants() { String flagValue = "{\"allocation\": { \"percentile\": [{\"variant\": \"Off\", \"from\": 0, \"to\": 50}, {\"variant\": \"On\", \"from\": 50, \"to\": 100}], \"default_when_enabled\": \"Off2\", \"default_when_disabled\": \"Off\" }, \"telemetry\": {\"enabled\": true}}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); assertEquals("wGzzPy4qGy92SHnMtSvY", feature.getTelemetry().getMetadata().get("AllocationId")); @@ -230,9 +242,9 @@ public void testAllocationIdWithVariants() { @Test public void testAllocationIdWithEmptyAllocation() { FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", - "{\"allocation\":{},\"telemetry\":{\"enabled\":true}}}", FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, - TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", + "{\"allocation\":{},\"telemetry\":{\"enabled\":true}}}", FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, + TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); assertNull(feature.getTelemetry().getMetadata().get("AllocationId")); @@ -241,12 +253,12 @@ public void testAllocationIdWithEmptyAllocation() { @Test public void testVariantsParsing() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true," - + "\"variants\":[" - + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\",\"status_override\":\"Enabled\"}," - + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\",\"status_override\":\"None\"}" - + "]}"; + + "\"variants\":[" + + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\",\"status_override\":\"Enabled\"}," + + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\",\"status_override\":\"None\"}" + + "]}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -267,16 +279,16 @@ public void testVariantsParsing() { @Test public void testAllocationParsing() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true," - + "\"allocation\":{" - + "\"default_when_enabled\":\"Red\"," - + "\"default_when_disabled\":\"Off\"," - + "\"seed\":\"testSeed\"," - + "\"user\":[{\"variant\":\"Green\",\"users\":[\"user1\",\"user2\"]}]," - + "\"group\":[{\"variant\":\"Blue\",\"groups\":[\"group1\"]}]," - + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]" - + "}}"; + + "\"allocation\":{" + + "\"default_when_enabled\":\"Red\"," + + "\"default_when_disabled\":\"Off\"," + + "\"seed\":\"testSeed\"," + + "\"user\":[{\"variant\":\"Green\",\"users\":[\"user1\",\"user2\"]}]," + + "\"group\":[{\"variant\":\"Blue\",\"groups\":[\"group1\"]}]," + + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]" + + "}}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -309,16 +321,16 @@ public void testAllocationParsing() { @Test public void testVariantsAndAllocationTogether() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true," - + "\"variants\":[" - + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\"}," - + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\"}" - + "]," - + "\"allocation\":{" - + "\"default_when_enabled\":\"Red\"," - + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]" - + "}}"; + + "\"variants\":[" + + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\"}," + + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\"}" + + "]," + + "\"allocation\":{" + + "\"default_when_enabled\":\"Red\"," + + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]" + + "}}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -336,14 +348,14 @@ public void testVariantsAndAllocationTogether() { @Test public void testVariantsWithComplexConfigurationValue() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true," - + "\"variants\":[" - + "{\"name\":\"SimpleString\",\"configuration_value\":\"hello\"}," - + "{\"name\":\"Number\",\"configuration_value\":42}," - + "{\"name\":\"Boolean\",\"configuration_value\":true}," - + "{\"name\":\"Object\",\"configuration_value\":{\"key\":\"value\",\"nested\":{\"prop\":123}}}" - + "]}"; + + "\"variants\":[" + + "{\"name\":\"SimpleString\",\"configuration_value\":\"hello\"}," + + "{\"name\":\"Number\",\"configuration_value\":42}," + + "{\"name\":\"Boolean\",\"configuration_value\":true}," + + "{\"name\":\"Object\",\"configuration_value\":{\"key\":\"value\",\"nested\":{\"prop\":123}}}" + + "]}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -361,7 +373,7 @@ public void testVariantsWithComplexConfigurationValue() { public void testFeatureFlagWithoutVariantsOrAllocation() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -370,4 +382,80 @@ public void testFeatureFlagWithoutVariantsOrAllocation() { assertEquals("TestFeature", feature.getId()); assertTrue(feature.isEnabled()); } + + @Test + public void loadFeatureFlagsWithTagsFilterTest() { + List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false)); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); + + List tagsFilter = Arrays.asList("env=prod", "team=backend"); + featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, tagsFilter, contextMock); + + // Capture the SettingSelector passed to listFeatureFlags + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + Mockito.verify(clientMock).listFeatureFlags(selectorCaptor.capture(), Mockito.any(Context.class)); + + SettingSelector capturedSelector = selectorCaptor.getValue(); + assertEquals(2, capturedSelector.getTagsFilter().size()); + assertEquals("env=prod", capturedSelector.getTagsFilter().get(0)); + assertEquals("team=backend", capturedSelector.getTagsFilter().get(1)); + } + + @Test + public void loadFeatureFlagsWithNullTagsFilterTest() { + List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false)); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); + + featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, null, contextMock); + + // Capture the SettingSelector passed to listFeatureFlags + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + Mockito.verify(clientMock).listFeatureFlags(selectorCaptor.capture(), Mockito.any(Context.class)); + + SettingSelector capturedSelector = selectorCaptor.getValue(); + // Tags filter should not be set when null + assertNull(capturedSelector.getTagsFilter()); + } + + @Test + public void loadFeatureFlagsWithEmptyTagsFilterTest() { + List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false)); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); + + List emptyTags = List.of(); + featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, emptyTags, contextMock); + + // Capture the SettingSelector passed to listFeatureFlags + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + Mockito.verify(clientMock).listFeatureFlags(selectorCaptor.capture(), Mockito.any(Context.class)); + + SettingSelector capturedSelector = selectorCaptor.getValue(); + // Tags filter should not be set when empty + assertNull(capturedSelector.getTagsFilter()); + } + + @Test + public void loadFeatureFlagsWithTagsFilterMultipleLabelsTest() { + List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false)); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); + when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); + + String[] multiLabelList = { "dev", "prod" }; + List tagsFilter = Arrays.asList("env=staging"); + featureFlagClient.loadFeatureFlags(clientMock, null, multiLabelList, tagsFilter, contextMock); + + // Capture all SettingSelector instances passed to listFeatureFlags (one per label) + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + Mockito.verify(clientMock, Mockito.times(2)).listFeatureFlags(selectorCaptor.capture(), + Mockito.any(Context.class)); + + // Both calls should have the tags filter set + for (SettingSelector capturedSelector : selectorCaptor.getAllValues()) { + assertEquals(1, capturedSelector.getTagsFilter().size()); + assertEquals("env=staging", capturedSelector.getTagsFilter().get(0)); + } + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParserTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParserTest.java index 51cc010bb88a..52641e43abc7 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParserTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParserTest.java @@ -25,17 +25,77 @@ public class JsonConfigurationParserTest { @Test public void isJsonContentType() { + // Basic valid JSON content types assertTrue(JsonConfigurationParser.isJsonContentType("application/json")); assertTrue(JsonConfigurationParser.isJsonContentType("application/api+json")); - assertTrue(JsonConfigurationParser.isJsonContentType("application/json+activity")); assertTrue(JsonConfigurationParser.isJsonContentType("application/vnd.xxxx+json")); assertTrue(JsonConfigurationParser.isJsonContentType("application/vnd.microsoft.appconfig.document+json")); + + // Invalid content types assertFalse(JsonConfigurationParser.isJsonContentType("application")); assertFalse(JsonConfigurationParser.isJsonContentType("app/json")); assertFalse(JsonConfigurationParser.isJsonContentType("app/config")); assertFalse(JsonConfigurationParser.isJsonContentType("application/config")); assertFalse(JsonConfigurationParser.isJsonContentType("")); assertFalse(JsonConfigurationParser.isJsonContentType(null)); + assertFalse(JsonConfigurationParser.isJsonContentType("application/json+activity")); // activity is the suffix, not json + } + + @Test + public void isJsonContentTypeWithParameters() { + // Content types with charset and other parameters + assertTrue(JsonConfigurationParser.isJsonContentType("application/json; charset=utf-8")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/json;charset=utf-8")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/json ; charset=utf-8")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/vnd.api+json; charset=utf-8")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/json; charset=ISO-8859-1")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/json;boundary=something")); + } + + @Test + public void isJsonContentTypeWithWhitespace() { + // Content types with various whitespace at the boundaries + assertTrue(JsonConfigurationParser.isJsonContentType(" application/json")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/json ")); + assertTrue(JsonConfigurationParser.isJsonContentType(" application/json ")); + + // Internal whitespace around '/' or '+' is not allowed by RFC 7231/6838 token rules + assertFalse(JsonConfigurationParser.isJsonContentType("application / json")); + assertFalse(JsonConfigurationParser.isJsonContentType("application/vnd.api + json")); + } + + @Test + public void isJsonContentTypeCaseInsensitive() { + // Case variations + assertTrue(JsonConfigurationParser.isJsonContentType("Application/Json")); + assertTrue(JsonConfigurationParser.isJsonContentType("APPLICATION/JSON")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/JSON")); + assertTrue(JsonConfigurationParser.isJsonContentType("Application/vnd.api+JSON")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/API+JSON")); + } + + @Test + public void isJsonContentTypeEdgeCases() { + // Edge cases and boundary conditions + assertFalse(JsonConfigurationParser.isJsonContentType("application/")); // Empty subtype + assertFalse(JsonConfigurationParser.isJsonContentType("/json")); // Empty main type + assertFalse(JsonConfigurationParser.isJsonContentType("/")); // Both empty + assertFalse(JsonConfigurationParser.isJsonContentType("application/xml")); + assertFalse(JsonConfigurationParser.isJsonContentType("text/json")); // Wrong main type + assertFalse(JsonConfigurationParser.isJsonContentType("application/json+xml")); // json not as suffix + assertFalse(JsonConfigurationParser.isJsonContentType("application/xml+html")); // No json at all + assertFalse(JsonConfigurationParser.isJsonContentType(" ")); // Only whitespace + } + + @Test + public void isJsonContentTypeComplexStructuredSyntax() { + // Complex structured syntax suffixes (RFC 6839) + assertTrue(JsonConfigurationParser.isJsonContentType("application/problem+json")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/merge-patch+json")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/json-patch+json")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/ld+json")); // JSON-LD + assertTrue(JsonConfigurationParser.isJsonContentType("application/hal+json")); + assertTrue(JsonConfigurationParser.isJsonContentType("application/vnd.geo+json")); } @Test diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java index 683de01f730b..0b288e0aabf8 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java @@ -3,7 +3,10 @@ package com.azure.spring.cloud.appconfiguration.config.implementation; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.Mockito.times; @@ -13,176 +16,145 @@ import java.util.ArrayList; import java.util.List; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; import org.mockito.MockedStatic; import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; -import org.mockito.MockitoSession; -import org.mockito.quality.Strictness; import com.azure.data.appconfiguration.models.ConfigurationSetting; public class StateHolderTest { - private final List watchKeys = new ArrayList<>(); - - private MockitoSession session; + private List watchKeys; + private StateHolder stateHolder; + private static final String TEST_ENDPOINT = "test.azconfig.io"; @BeforeEach public void setup() { - session = Mockito.mockitoSession().initMocks(this).strictness(Strictness.STRICT_STUBS).startMocking(); - MockitoAnnotations.openMocks(this); + watchKeys = new ArrayList<>(); ConfigurationSetting watchKey = new ConfigurationSetting().setKey("sentinel").setValue("0").setETag("current"); - watchKeys.add(watchKey); - } - - @AfterEach - public void cleanup() throws Exception { - MockitoAnnotations.openMocks(this).close(); - session.finishMocking(); + stateHolder = new StateHolder(); } - /** - * Because of static code these need to run all at once. - * @param testInfo - */ @Test - public void stateHolderTest(TestInfo testInfo) { - // Expire State Tests - stateNotExpiredTest(testInfo); - stateExpiredTest(testInfo); - - // Update Next Refresh Time Tests - updateNextRefreshTimeNoRefreshTest(testInfo); - updateNextRefreshTimeRefreshTest(testInfo); - updateNextRefreshBackoffCalcTest(testInfo); - - // Load State Tests - loadStateTest(testInfo); + public void stateNotExpiredTest() { + stateHolder.setNextForcedRefresh(Duration.ofMinutes(10)); + stateHolder.setState(TEST_ENDPOINT, watchKeys, Duration.ofSeconds(30)); + + State originalState = stateHolder.getState(TEST_ENDPOINT); + assertNotNull(originalState); + Instant originalRefreshCheck = originalState.getNextRefreshCheck(); + + stateHolder.expireState(TEST_ENDPOINT); + State newState = stateHolder.getState(TEST_ENDPOINT); + + // expireState should update the refresh check time with jitter (0-15 seconds from now) + assertNotEquals(originalRefreshCheck, newState.getNextRefreshCheck()); + // The new refresh check should be sooner than the original (since jitter is added from now) + assertTrue(newState.getNextRefreshCheck().isBefore(originalRefreshCheck)); } - private void stateNotExpiredTest(TestInfo testInfo) { - // State isn't expired Test - String endpoint = testInfo.getDisplayName() + "expire" + ".azconfig.io"; - - StateHolder expireStateHolder = new StateHolder(); - expireStateHolder.setNextForcedRefresh(Duration.ofMinutes(10)); - expireStateHolder.setState(endpoint, watchKeys, Duration.ofSeconds(30)); - - StateHolder.updateState(expireStateHolder); - - State originalExpireState = StateHolder.getState(endpoint); - expireStateHolder.expireState(endpoint); - StateHolder.updateState(expireStateHolder); - assertNotEquals(originalExpireState, StateHolder.getState(endpoint)); - } - - private void stateExpiredTest(TestInfo testInfo) { - // State is expired Test - String endpoint = testInfo.getDisplayName() + "expireNegativeDuration" + ".azconfig.io"; - - StateHolder expiredNegativeDurationStateHolder = new StateHolder(); - expiredNegativeDurationStateHolder.setNextForcedRefresh(Duration.ofMinutes(10)); - expiredNegativeDurationStateHolder.setState(endpoint, watchKeys, Duration.ofHours(-30)); - - StateHolder.updateState(expiredNegativeDurationStateHolder); - - State originalExpireNegativeState = StateHolder.getState(endpoint); - expiredNegativeDurationStateHolder.expireState(endpoint); - StateHolder.updateState(expiredNegativeDurationStateHolder); - assertEquals(originalExpireNegativeState, StateHolder.getState(endpoint)); + @Test + public void stateExpiredTest() { + // State with negative duration is already expired + stateHolder.setNextForcedRefresh(Duration.ofMinutes(10)); + stateHolder.setState(TEST_ENDPOINT, watchKeys, Duration.ofHours(-30)); + + State originalState = stateHolder.getState(TEST_ENDPOINT); + Instant originalRefreshCheck = originalState.getNextRefreshCheck(); + + stateHolder.expireState(TEST_ENDPOINT); + State newState = stateHolder.getState(TEST_ENDPOINT); + + // When state is already expired, expireState won't update if jitter would make it later + // The check is: if wait < timeLeft, update. Since timeLeft is negative, wait is always >= timeLeft + assertEquals(originalRefreshCheck, newState.getNextRefreshCheck()); } - private void updateNextRefreshTimeNoRefreshTest(TestInfo testInfo) { - String endpoint = testInfo.getDisplayName() + "updateRefreshTime" + ".azconfig.io"; - - StateHolder stateHolder = new StateHolder(); - - stateHolder.setState(endpoint, watchKeys, Duration.ofMinutes((long) 10)); - - StateHolder.updateState(stateHolder); + @Test + public void updateNextRefreshTimeNoRefreshTest() { + stateHolder.setState(TEST_ENDPOINT, watchKeys, Duration.ofMinutes(10)); - State originalState = StateHolder.getState(endpoint); + State originalState = stateHolder.getState(TEST_ENDPOINT); - stateHolder.updateNextRefreshTime(null, (long) 0); - StateHolder.updateState(stateHolder); - State newState = StateHolder.getState(endpoint); + stateHolder.updateNextRefreshTime(null, 0L); + State newState = stateHolder.getState(TEST_ENDPOINT); + assertEquals(originalState.getNextRefreshCheck(), newState.getNextRefreshCheck()); } - private void updateNextRefreshTimeRefreshTest(TestInfo testInfo) { - String endpoint = testInfo.getDisplayName() + "updateRefreshTimeRefresh" + ".azconfig.io"; - - StateHolder stateHolder = new StateHolder(); - - stateHolder.setState(endpoint, watchKeys, Duration.ofMinutes((long) 0)); - - StateHolder.updateState(stateHolder); + @Test + public void updateNextRefreshTimeRefreshTest() { + // Duration 0 means refresh immediately + stateHolder.setState(TEST_ENDPOINT, watchKeys, Duration.ofMinutes(0)); - State originalState = StateHolder.getState(endpoint); + State originalState = stateHolder.getState(TEST_ENDPOINT); - // Duration is less than the minBackOff try { Thread.sleep(1000); } catch (InterruptedException e) { fail("Sleep failed"); } - stateHolder.updateNextRefreshTime(null, (long) 0); - State newState = StateHolder.getState(endpoint); + + stateHolder.updateNextRefreshTime(null, 0L); + State newState = stateHolder.getState(TEST_ENDPOINT); + assertNotEquals(originalState.getNextRefreshCheck(), newState.getNextRefreshCheck()); assertTrue(originalState.getNextRefreshCheck().isBefore(newState.getNextRefreshCheck())); } - private void updateNextRefreshBackoffCalcTest(TestInfo testInfo) { - String endpoint = testInfo.getDisplayName() + "updateRefreshTimeBackoffCalc" + ".azconfig.io"; - - StateHolder stateHolder = new StateHolder(); - stateHolder.setState(endpoint, watchKeys, Duration.ofMinutes((long) -1)); - StateHolder.updateState(stateHolder); - State originalState = StateHolder.getState(endpoint); + @Test + public void updateNextRefreshBackoffCalcTest() { + stateHolder.setState(TEST_ENDPOINT, watchKeys, Duration.ofMinutes(-1)); + State originalState = stateHolder.getState(TEST_ENDPOINT); - // Duration is less than the minBackOff try (MockedStatic backoffTimeCalculatorMock = Mockito .mockStatic(BackoffTimeCalculator.class)) { Long ns = Long.valueOf("300000000000"); backoffTimeCalculatorMock.when(() -> BackoffTimeCalculator.calculateBackoff(Mockito.anyInt())) .thenReturn(ns); - stateHolder.updateNextRefreshTime(null, (long) -120); - State newState = StateHolder.getState(endpoint); + stateHolder.updateNextRefreshTime(null, -120L); + State newState = stateHolder.getState(TEST_ENDPOINT); assertTrue(originalState.getNextRefreshCheck().isBefore(newState.getNextRefreshCheck())); backoffTimeCalculatorMock.verify(() -> BackoffTimeCalculator.calculateBackoff(Mockito.anyInt()), times(1)); } + } - stateHolder = new StateHolder(); - Duration duration = Duration.ofMinutes((long) -1); - + @Test + public void updateNextForcedRefreshTest() { + Duration duration = Duration.ofMinutes(-1); stateHolder.setNextForcedRefresh(duration); + stateHolder.setState(TEST_ENDPOINT, watchKeys, duration); - stateHolder.setState(endpoint, watchKeys, duration); + Instant originalForcedRefresh = stateHolder.getNextForcedRefresh(); - StateHolder.updateState(stateHolder); + stateHolder.updateNextRefreshTime(Duration.ofMinutes(11), 0L); - Instant originalForcedRefresh = StateHolder.getNextForcedRefresh(); - - stateHolder.updateNextRefreshTime(Duration.ofMinutes((long) 11), (long) 0); - - Instant newForcedRefresh = StateHolder.getNextForcedRefresh(); + Instant newForcedRefresh = stateHolder.getNextForcedRefresh(); assertNotEquals(originalForcedRefresh, newForcedRefresh); } - private void loadStateTest(TestInfo testInfo) { - String endpoint = testInfo.getDisplayName() + "updateRefreshTimeBackoffCalc" + ".azconfig.io"; - StateHolder testStateHolder = new StateHolder(); - testStateHolder.setLoadState(endpoint, true); - StateHolder.updateState(testStateHolder); - assertEquals(testStateHolder, StateHolder.getCurrentState()); + @Test + public void loadStateTest() { + assertFalse(stateHolder.getLoadState(TEST_ENDPOINT)); + + stateHolder.setLoadState(TEST_ENDPOINT, true); + + assertTrue(stateHolder.getLoadState(TEST_ENDPOINT)); + } + + @Test + public void getStateReturnsNullForUnknownEndpoint() { + assertNotNull(stateHolder); + assertNull(stateHolder.getState("unknown.azconfig.io")); } + @Test + public void getStateFeatureFlagReturnsNullForUnknownEndpoint() { + assertNull(stateHolder.getStateFeatureFlag("unknown.azconfig.io")); + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtilTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtilTest.java new file mode 100644 index 000000000000..7f7109690b04 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtilTest.java @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config.implementation; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +public class ValidationUtilTest { + + @Test + public void testValidateTagsFilterNullList() { + assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(null)); + } + + @Test + public void testValidateTagsFilterEmptyList() { + List tagsFilter = Collections.emptyList(); + assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter)); + } + + @Test + public void testValidateTagsFilterValidSingleTag() { + List tagsFilter = Collections.singletonList("env=prod"); + assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter)); + } + + @Test + public void testValidateTagsFilterValidMultipleTags() { + List tagsFilter = Arrays.asList("env=prod", "team=backend", "region=us-east"); + assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter)); + } + + @Test + public void testValidateTagsFilterValidTagWithSpecialCharactersInValue() { + List tagsFilter = Collections.singletonList("version=1.0.0-beta"); + assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter)); + } + + @Test + public void testValidateTagsFilterValidTagWithEqualsInValue() { + List tagsFilter = Collections.singletonList("formula=x=y+z"); + assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter)); + } + + @Test + public void testValidateTagsFilterNullEntry() { + List tagsFilter = Arrays.asList("env=prod", null); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag filter entries must not be null or empty", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterEmptyEntry() { + List tagsFilter = Arrays.asList("env=prod", ""); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag filter entries must not be null or empty", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterWhitespaceOnlyEntry() { + List tagsFilter = Arrays.asList("env=prod", " "); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag filter entries must not be null or empty", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterMissingEqualsSeparator() { + List tagsFilter = Collections.singletonList("envprod"); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag filter entries must be in tagName=tagValue format", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterEmptyTagName() { + List tagsFilter = Collections.singletonList("=prod"); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag name must not be empty in tag filter: =prod", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterWhitespaceOnlyTagName() { + List tagsFilter = Collections.singletonList(" =prod"); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag name must not be empty in tag filter: =prod", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterEmptyTagValue() { + List tagsFilter = Collections.singletonList("env="); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag value must not be empty in tag filter: env=", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterWhitespaceOnlyTagValue() { + List tagsFilter = Collections.singletonList("env= "); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag value must not be empty in tag filter: env= ", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterMultipleInvalidEntriesFailsOnFirst() { + List tagsFilter = Arrays.asList("=invalid", "alsoInvalid", "env=valid"); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag name must not be empty in tag filter: =invalid", exception.getMessage()); + } + + @Test + public void testValidateTagsFilterMixedValidAndInvalidEntries() { + List tagsFilter = Arrays.asList("env=prod", "team=backend", "invalid"); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> ValidationUtil.validateTagsFilter(tagsFilter)); + assertEquals("Tag filter entries must be in tagName=tagValue format", exception.getMessage()); + } +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelectorTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelectorTest.java index 87f0a24060fa..e02e103546d3 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelectorTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelectorTest.java @@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; @@ -256,4 +258,59 @@ public void profilesListModificationSafetyTest() { // Original list should remain unchanged assertEquals(profilesCopy, originalProfiles); } + + @Test + public void getTagsFilterDefaultTest() { + // When no tags filter is set, should return null + assertNull(selector.getTagsFilter()); + } + + @Test + public void getTagsFilterCustomTest() { + // When custom tags filter is set + List tags = Arrays.asList("env=prod", "team=backend"); + selector.setTagsFilter(tags); + List result = selector.getTagsFilter(); + assertEquals(2, result.size()); + assertEquals("env=prod", result.get(0)); + assertEquals("team=backend", result.get(1)); + } + + @Test + public void setTagsFilterReturnsSelectorTest() { + // Test fluent API returns the selector + AppConfigurationKeyValueSelector returned = selector.setTagsFilter(Arrays.asList("env=dev")); + assertSame(selector, returned); + } + + @Test + public void validateAndInitSnapshotWithTagsFilterTest() { + // Test snapshot with tags filter (should fail) + selector.setTagsFilter(Arrays.asList("env=prod")); + selector.setSnapshotName("test-snapshot"); + + assertThrows(IllegalArgumentException.class, () -> { + selector.validateAndInit(); + }); + } + + @Test + public void validateAndInitValidWithTagsFilterTest() { + // Test valid configuration with tags filter + selector.setKeyFilter("/valid/"); + selector.setTagsFilter(Arrays.asList("env=prod", "team=backend")); + + // Should not throw any exception + selector.validateAndInit(); + } + + @Test + public void validateAndInitEmptyTagsFilterWithSnapshotTest() { + // Test snapshot with empty tags filter list (should not fail) + selector.setTagsFilter(new ArrayList<>()); + selector.setSnapshotName("test-snapshot"); + + // Should not throw any exception + selector.validateAndInit(); + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationPropertiesTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationPropertiesTest.java index 6641e1ee7108..326f859245a2 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationPropertiesTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationPropertiesTest.java @@ -131,4 +131,51 @@ public void multipleEndpointsTest() { IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> properties.validateAndInit()); assertEquals("Duplicate store name exists.", e.getMessage()); } + + @Test + public void disabledStoreIsSkippedDuringValidation() { + AppConfigurationProperties properties = new AppConfigurationProperties(); + List stores = new ArrayList<>(); + + // Create a disabled store with no connection string (would normally fail validation) + ConfigStore disabledStore = new ConfigStore(); + disabledStore.setEnabled(false); + stores.add(disabledStore); + + // Create an enabled store with valid connection string + ConfigStore enabledStore = new ConfigStore(); + enabledStore.setConnectionString(TEST_CONN_STRING); + stores.add(enabledStore); + + properties.setStores(stores); + + // Should not throw exception even though disabled store has no connection string + properties.validateAndInit(); + + assertEquals(2, properties.getStores().size()); + } + + @Test + public void disabledStoreWithDuplicateEndpointIsAllowed() { + AppConfigurationProperties properties = new AppConfigurationProperties(); + List stores = new ArrayList<>(); + + // Create an enabled store with endpoint + ConfigStore enabledStore = new ConfigStore(); + enabledStore.setConnectionString(TEST_CONN_STRING); + stores.add(enabledStore); + + // Create a disabled store with same endpoint (would normally fail duplicate check) + ConfigStore disabledStore = new ConfigStore(); + disabledStore.setEnabled(false); + disabledStore.setConnectionString(TEST_CONN_STRING); + stores.add(disabledStore); + + properties.setStores(stores); + + // Should not throw exception about duplicate endpoint because second store is disabled + properties.validateAndInit(); + + assertEquals(2, properties.getStores().size()); + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java index ab4f2e6c0656..27ff6e4cdb88 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java @@ -2,13 +2,12 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation.properties; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - import java.time.Duration; import java.util.ArrayList; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; public class AppConfigurationStoreMonitoringTest { @@ -19,15 +18,9 @@ public void validateAndInitTest() { AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); monitoring.validateAndInit(); - // Enabled throw error if no triggers - monitoring.setEnabled(true); - assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit()); - List triggers = new ArrayList<>(); monitoring.setTriggers(triggers); - assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit()); - AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); trigger.setKey("sentinal"); @@ -51,4 +44,45 @@ public void validateAndInitTest() { monitoring.validateAndInit(); } + @Test + public void refreshAllEnabledWithoutTriggersTest() { + // When refreshAll is enabled, triggers are not required + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(true); + + // Should not throw an exception even with no triggers + monitoring.validateAndInit(); + + // Verify refresh interval validation still applies + monitoring.setRefreshInterval(Duration.ofSeconds(0)); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit()); + assertEquals("Minimum refresh interval time is 1 Second.", e.getMessage()); + } + + @Test + public void refreshAllWithTriggersTest() { + // Even when refreshAll is enabled, having triggers should still be valid + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(true); + + List triggers = new ArrayList<>(); + AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); + trigger.setKey("sentinel"); + triggers.add(trigger); + monitoring.setTriggers(triggers); + + // Should not throw an exception + monitoring.validateAndInit(); + } + + @Test + public void monitoringDisabledWithRefreshAllTest() { + // When monitoring is disabled, refreshAll setting should not matter + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(false); + + // Should not throw an exception even with no triggers + monitoring.validateAndInit(); + } + } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelectorTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelectorTest.java index b8259ec5b0cc..5a3cf81281fb 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelectorTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelectorTest.java @@ -4,10 +4,13 @@ import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Test; @@ -72,4 +75,48 @@ public void getLabelFilterTest() { assertEquals("test1", labels[1]); } + @Test + public void getTagsFilterDefaultTest() { + FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector(); + // When no tags filter is set, should return null + assertNull(selector.getTagsFilter()); + } + + @Test + public void getTagsFilterCustomTest() { + FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector(); + List tags = Arrays.asList("env=prod", "team=backend"); + selector.setTagsFilter(tags); + + List result = selector.getTagsFilter(); + assertEquals(2, result.size()); + assertEquals("env=prod", result.get(0)); + assertEquals("team=backend", result.get(1)); + } + + @Test + public void setTagsFilterReturnsSelectorTest() { + FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector(); + FeatureFlagKeyValueSelector returned = selector.setTagsFilter(Arrays.asList("env=dev")); + assertSame(selector, returned); + } + + @Test + public void setTagsFilterEmptyListTest() { + FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector(); + selector.setTagsFilter(new ArrayList<>()); + List result = selector.getTagsFilter(); + assertTrue(result.isEmpty()); + } + + @Test + public void setTagsFilterSingleTagTest() { + FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector(); + selector.setTagsFilter(Arrays.asList("environment=production")); + + List result = selector.getTagsFilter(); + assertEquals(1, result.size()); + assertEquals("environment=production", result.get(0)); + } + } diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrar.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrar.java index 2a94664e3db6..10521f66f533 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrar.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrar.java @@ -137,7 +137,7 @@ private KeyStore initilizeKeyVaultKeyStore(String storeName, configureJcaKeyStoreSystemProperties(jcaVaultProperties, keyStoreProperties, resourceLoader); if (providerConfigured.compareAndSet(false, true)) { Security.removeProvider(KeyVaultJcaProvider.PROVIDER_NAME); - Security.insertProviderAt(new KeyVaultJcaProvider(), 1); + Security.addProvider(new KeyVaultJcaProvider()); } KeyStore azureKeyVaultKeyStore; try { diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrarTests.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrarTests.java index ba2539ab7c9c..2d1a99583046 100644 --- a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrarTests.java +++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrarTests.java @@ -3,8 +3,10 @@ package com.azure.spring.cloud.autoconfigure.implementation.keyvault.jca; +import com.azure.security.keyvault.jca.KeyVaultJcaProvider; import com.azure.spring.cloud.autoconfigure.implementation.keyvault.jca.properties.AzureKeyVaultJcaProperties; import com.azure.spring.cloud.autoconfigure.implementation.keyvault.jca.properties.AzureKeyVaultSslBundleProperties; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.Isolated; @@ -19,6 +21,7 @@ import org.springframework.util.ClassUtils; import java.security.KeyStore; +import java.security.Security; import java.util.Arrays; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -33,6 +36,11 @@ @ExtendWith({OutputCaptureExtension.class}) class AzureKeyVaultSslBundleRegistrarTests { + @AfterEach + void cleanupProvider() { + Security.removeProvider(KeyVaultJcaProvider.PROVIDER_NAME); + } + @Test void noJcaProviderOnClassPath(CapturedOutput capturedOutput) { AzureKeyVaultSslBundleRegistrar registrar = new AzureKeyVaultSslBundleRegistrar(new AzureKeyVaultJcaProperties(), new AzureKeyVaultSslBundleProperties()); @@ -200,4 +208,42 @@ void registerMultipleSslBundles(CapturedOutput capturedOutput) { }); } } + + @Test + void keyVaultProviderNotInsertedAtHighestPriority() { + AzureKeyVaultJcaProperties jcaProperties = new AzureKeyVaultJcaProperties(); + AzureKeyVaultSslBundleProperties sslBundleProperties = new AzureKeyVaultSslBundleProperties(); + AzureKeyVaultSslBundleRegistrar registrar = new AzureKeyVaultSslBundleRegistrar(jcaProperties, sslBundleProperties); + registrar.setResourceLoader(new DefaultResourceLoader()); + SslBundleRegistry registry = Mockito.mock(SslBundleRegistry.class); + + try (MockedStatic keyStoreMockedStatic = mockStatic(KeyStore.class)) { + KeyStore keyStore = Mockito.mock(KeyStore.class); + keyStoreMockedStatic.when(() -> KeyStore.getInstance(KeyVaultJcaProvider.PROVIDER_NAME)).thenReturn(keyStore); + + String keyvaultName = "keyvault1"; + AzureKeyVaultJcaProperties.JcaVaultProperties jcaVaultProperties = new AzureKeyVaultJcaProperties.JcaVaultProperties(); + jcaVaultProperties.setEndpoint("https://test.vault.azure.net/"); + jcaProperties.getVaults().put(keyvaultName, jcaVaultProperties); + AzureKeyVaultSslBundleProperties.KeyVaultSslBundleProperties bundleProperties = new AzureKeyVaultSslBundleProperties.KeyVaultSslBundleProperties(); + bundleProperties.getTruststore().setKeyvaultRef(keyvaultName); + sslBundleProperties.getKeyvault().put("testBundle", bundleProperties); + + registrar.registerBundles(registry); + + // Verify the KeyVault JCA provider is NOT inserted at position 1 (highest priority), + // to avoid interfering with standard SSL/TLS operations and breaking mTLS. + java.security.Provider[] providers = Security.getProviders(); + int providerPosition = -1; + for (int i = 0; i < providers.length; i++) { + if (KeyVaultJcaProvider.PROVIDER_NAME.equals(providers[i].getName())) { + providerPosition = i + 1; // 1-indexed position + break; + } + } + assertTrue(providerPosition > 1, "KeyVaultJcaProvider must not be inserted at position 1 " + + "to avoid overriding standard JCA services like KeyManagerFactory.SunX509 and Signature algorithms, " + + "which would break mTLS with standard keystores."); + } + } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java index a49837a0fe46..daabaca43938 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java @@ -6,6 +6,8 @@ import java.time.DayOfWeek; import java.time.Duration; import java.time.ZonedDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.List; import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; @@ -96,10 +98,30 @@ private static OccurrenceInfo getWeeklyPreviousOccurrence(TimeWindowFilterSettin final ZonedDateTime firstDayOfFirstWeek = start.minusDays( TimeWindowUtils.getPassedWeekDays(start.getDayOfWeek(), pattern.getFirstDayOfWeek())); - final long numberOfInterval = Duration.between(firstDayOfFirstWeek, now).toSeconds() - / Duration.ofDays((long) interval * RecurrenceConstants.DAYS_PER_WEEK).toSeconds(); - final ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays( + // Use calendar-based week calculation to avoid DST-related undercounting + // Convert both dates to the same zone for accurate week counting + final ZonedDateTime alignedNow = now.withZoneSameInstant(firstDayOfFirstWeek.getZone()); + final long numberOfInterval = ChronoUnit.WEEKS.between(firstDayOfFirstWeek, alignedNow) / interval; + ZonedDateTime firstDayOfMostRecentOccurringWeek = firstDayOfFirstWeek.plusDays( numberOfInterval * (interval * RecurrenceConstants.DAYS_PER_WEEK)); + + // Handle DST transitions: If the calculated week start has a fixed offset (ZoneOffset) + // and 'now' has a region zone, check if they represent the same geographic location. + // We do this by checking if the fixed offset matches what the region zone's offset + // was at the *original start time*. If it matches, they're in the same timezone, + // and we should convert to the region zone for DST-aware comparisons. + if (firstDayOfMostRecentOccurringWeek.getZone() instanceof ZoneOffset + && !(now.getZone() instanceof ZoneOffset)) { + // Check if the fixed offset matches the region zone's offset at the *start* instant + // (not at firstDayOfMostRecentOccurringWeek's instant, which might have crossed DST) + ZoneOffset offsetAtStart = now.getZone().getRules().getOffset(start.toInstant()); + if (start.getOffset().equals(offsetAtStart)) { + // Same geographic location, convert to region zone for DST-aware comparisons + firstDayOfMostRecentOccurringWeek = firstDayOfMostRecentOccurringWeek + .withZoneSameLocal(now.getZone()); + } + } + final List sortedDaysOfWeek = TimeWindowUtils.sortDaysOfWeek(pattern.getDaysOfWeek(), pattern.getFirstDayOfWeek()); final int maxDayOffset = TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(sortedDaysOfWeek.size() - 1), pattern.getFirstDayOfWeek()); final int minDayOffset = TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(0), pattern.getFirstDayOfWeek()); diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java index 366e36394da5..2f94f03c9021 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceValidator.java @@ -9,6 +9,7 @@ import java.time.DayOfWeek; import java.time.Duration; +import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.List; @@ -22,8 +23,19 @@ public class RecurrenceValidator { public static void validateSettings(TimeWindowFilterSettings settings) { + validateSettings(settings, ZonedDateTime.now(ZoneOffset.UTC)); + } + + /** + * Validates recurrence settings with a specified reference time. + * Public for testing purposes to allow deterministic validation. + * + * @param settings The time window filter settings to validate + * @param referenceTime The reference time to use for "today" in validation calculations + */ + public static void validateSettings(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) { validateRecurrenceRequiredParameter(settings); - validateRecurrencePattern(settings); + validateRecurrencePattern(settings, referenceTime); validateRecurrenceRange(settings); } @@ -53,13 +65,13 @@ private static void validateRecurrenceRequiredParameter(TimeWindowFilterSettings } } - private static void validateRecurrencePattern(TimeWindowFilterSettings settings) { + private static void validateRecurrencePattern(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) { final RecurrencePatternType patternType = settings.getRecurrence().getPattern().getType(); if (patternType == RecurrencePatternType.DAILY) { validateDailyRecurrencePattern(settings); } else { - validateWeeklyRecurrencePattern(settings); + validateWeeklyRecurrencePattern(settings, referenceTime); } } @@ -76,7 +88,7 @@ private static void validateDailyRecurrencePattern(TimeWindowFilterSettings sett validateTimeWindowDuration(settings); } - private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings settings) { + private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) { validateDaysOfWeek(settings); // Check whether "Start" is a valid first occurrence @@ -90,7 +102,7 @@ private static void validateWeeklyRecurrencePattern(TimeWindowFilterSettings set validateTimeWindowDuration(settings); // Check whether the time window duration is shorter than the minimum gap between days of week - if (!isDurationCompliantWithDaysOfWeek(settings)) { + if (!isDurationCompliantWithDaysOfWeek(settings, referenceTime)) { throw new IllegalArgumentException(String.format(RecurrenceConstants.TIME_WINDOW_DURATION_OUT_OF_RANGE, "Recurrence.Pattern.DaysOfWeek")); } } @@ -103,7 +115,10 @@ private static void validateTimeWindowDuration(TimeWindowFilterSettings settings final Duration intervalDuration = RecurrencePatternType.DAILY.equals(pattern.getType()) ? Duration.ofDays(pattern.getInterval()) : Duration.ofDays((long) pattern.getInterval() * RecurrenceConstants.DAYS_PER_WEEK); - final Duration timeWindowDuration = Duration.between(settings.getStart(), settings.getEnd()); + // Convert to UTC to ensure consistent duration calculation across DST transitions + final Duration timeWindowDuration = Duration.between( + settings.getStart().withZoneSameInstant(ZoneOffset.UTC), + settings.getEnd().withZoneSameInstant(ZoneOffset.UTC)); if (timeWindowDuration.compareTo(intervalDuration) > 0) { throw new IllegalArgumentException(String.format(RecurrenceConstants.TIME_WINDOW_DURATION_OUT_OF_RANGE, "Recurrence.Pattern.Interval")); } @@ -123,35 +138,39 @@ private static void validateEndDate(TimeWindowFilterSettings settings) { } /** - * Check whether the duration is shorter than the minimum gap between recurrence of days of week. - * - * @param settings time window filter settings + /** + * Validate if time window duration is shorter than the minimum gap between days of week + * @param settings The settings to validate + * @param referenceTime The reference time to use for "today" in gap calculations * @return True if the duration is compliant with days of week, false otherwise. */ - private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSettings settings) { + private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSettings settings, ZonedDateTime referenceTime) { final List daysOfWeek = settings.getRecurrence().getPattern().getDaysOfWeek(); if (daysOfWeek.size() == 1) { return true; } - // Get the date of first day of the week - final ZonedDateTime today = ZonedDateTime.now(); + // Get the date of first day of the week using provided reference time + final ZonedDateTime today = referenceTime; final DayOfWeek firstDayOfWeek = settings.getRecurrence().getPattern().getFirstDayOfWeek(); final int offset = TimeWindowUtils.getPassedWeekDays(today.getDayOfWeek(), firstDayOfWeek); final ZonedDateTime firstDateOfWeek = today.minusDays(offset).truncatedTo(ChronoUnit.DAYS); final List sortedDaysOfWeek = TimeWindowUtils.sortDaysOfWeek(daysOfWeek, firstDayOfWeek); // Loop the whole week to get the min gap between the two consecutive recurrences + // Use calendar-based day counting to avoid DST-related issues (23/25 hour elapsed time) ZonedDateTime date; ZonedDateTime prevOccurrence = null; - Duration minGap = Duration.ofDays(RecurrenceConstants.DAYS_PER_WEEK); + long minGapDays = RecurrenceConstants.DAYS_PER_WEEK; for (DayOfWeek day: sortedDaysOfWeek) { date = firstDateOfWeek.plusDays(TimeWindowUtils.getPassedWeekDays(day, firstDayOfWeek)); if (prevOccurrence != null) { - final Duration currentGap = Duration.between(prevOccurrence, date); - if (currentGap.compareTo(minGap) < 0) { - minGap = currentGap; + // Use ChronoUnit.DAYS to count calendar days, not elapsed time + // This ensures Sunday-Monday is always 1 day (24 hours) regardless of DST + final long currentGapDays = ChronoUnit.DAYS.between(prevOccurrence, date); + if (currentGapDays < minGapDays) { + minGapDays = currentGapDays; } } prevOccurrence = date; @@ -161,13 +180,19 @@ private static boolean isDurationCompliantWithDaysOfWeek(TimeWindowFilterSetting // It may across weeks. Check the adjacent week date = firstDateOfWeek.plusDays(RecurrenceConstants.DAYS_PER_WEEK) .plusDays(TimeWindowUtils.getPassedWeekDays(sortedDaysOfWeek.get(0), firstDayOfWeek)); - final Duration currentGap = Duration.between(prevOccurrence, date); - if (currentGap.compareTo(minGap) < 0) { - minGap = currentGap; + // Use ChronoUnit.DAYS to count calendar days, not elapsed time + final long currentGapDays = ChronoUnit.DAYS.between(prevOccurrence, date); + if (currentGapDays < minGapDays) { + minGapDays = currentGapDays; } } - final Duration timeWindowDuration = Duration.between(settings.getStart(), settings.getEnd()); + final Duration minGap = Duration.ofDays(minGapDays); + + // Convert to UTC to ensure consistent duration calculation across DST transitions + final Duration timeWindowDuration = Duration.between( + settings.getStart().withZoneSameInstant(ZoneOffset.UTC), + settings.getEnd().withZoneSameInstant(ZoneOffset.UTC)); return minGap.compareTo(timeWindowDuration) >= 0; } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorDSTTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorDSTTest.java new file mode 100644 index 000000000000..acaa08dab1ce --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceEvaluatorDSTTest.java @@ -0,0 +1,436 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.spring.cloud.feature.management.filters.recurrence; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; +import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; +import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceEvaluator; + +/** + * Tests for Daylight Saving Time (DST) handling in RecurrenceEvaluator. + * These tests verify that recurrence evaluation correctly handles timezone transitions + * when comparing fixed offset zones with region-based zones. + */ +public class RecurrenceEvaluatorDSTTest { + + /** + * Test weekly recurrence across DST transition (Spring forward). + * Verifies that when a start time is defined with a fixed offset (e.g., +01:00) + * and the current time uses a region zone (e.g., Europe/Paris), + * the evaluator correctly identifies they represent the same geographic location + * and handles the DST transition properly. + */ + @Test + public void weeklyRecurrenceSpringForwardDSTTransition() { + // Setup: Weekly recurrence starting before DST transition + // In Europe/Paris, DST transition happens last Sunday of March (2:00 AM -> 3:00 AM) + // Start: 2024-03-15 10:00 +01:00 (before DST, standard time) + // Now: 2024-04-05 10:00 +02:00 (after DST, daylight time) + + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start time with fixed offset (before DST) + settings.setStart("2024-03-15T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-15T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Current time with region zone (after DST) + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, during time window + + // Should match because Friday is a valid recurrence day + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match weekly recurrence on Friday after DST transition"); + } + + /** + * Test weekly recurrence across DST transition (Fall back). + * Verifies handling when clocks fall back (e.g., from +02:00 to +01:00). + */ + @Test + public void weeklyRecurrenceFallBackDSTTransition() { + // Setup: Weekly recurrence starting before DST fall back + // In Europe/Paris, DST fall back happens last Sunday of October (3:00 AM -> 2:00 AM) + // Start: 2024-10-18 10:00 +02:00 (before fall back, daylight time) + // Now: 2024-11-01 10:00 +01:00 (after fall back, standard time) + + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start time with fixed offset (before fall back) + settings.setStart("2024-10-18T10:00:00+02:00"); // Friday + settings.setEnd("2024-10-18T12:00:00+02:00"); + settings.setRecurrence(recurrence); + + // Current time with region zone (after fall back) + final ZonedDateTime now = ZonedDateTime.of(2024, 11, 1, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, during time window + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match weekly recurrence on Friday after DST fall back"); + } + + /** + * Test that recurrence evaluation works correctly when both start and now + * use fixed offsets (no DST conversion needed). + */ + @Test + public void weeklyRecurrenceBothFixedOffsets() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Both use fixed offsets + settings.setStart("2024-03-01T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-01T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 8, 10, 30, 0, 0, + ZoneOffset.ofHours(1)); // Friday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match when both use fixed offsets"); + } + + /** + * Test that recurrence evaluation works correctly when both start and now + * use region zones (DST-aware zones). + */ + @Test + public void weeklyRecurrenceBothRegionZones() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Both use region zones + ZonedDateTime startTime = ZonedDateTime.of(2024, 3, 1, 10, 0, 0, 0, + ZoneId.of("Europe/Paris")); // Friday + ZonedDateTime endTime = ZonedDateTime.of(2024, 3, 1, 12, 0, 0, 0, + ZoneId.of("Europe/Paris")); + + settings.setStart(startTime.toString()); + settings.setEnd(endTime.toString()); + settings.setRecurrence(recurrence); + + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 8, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match when both use region zones"); + } + + /** + * Test weekly recurrence with different geographic locations. + * When fixed offset doesn't match the region zone's offset at start time, + * they represent different geographic locations and should not be converted. + */ + @Test + public void weeklyRecurrenceDifferentGeographicLocations() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start with offset that doesn't match Europe/Paris + settings.setStart("2024-03-01T10:00:00+05:00"); // Friday, Asia timezone + settings.setEnd("2024-03-01T12:00:00+05:00"); + settings.setRecurrence(recurrence); + + // Now in Europe/Paris (offset +01:00 at this time) + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 8, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, 10:30 local time + + // When offsets don't match (+05:00 vs +01:00), they represent different geographic locations + // No conversion should happen. The evaluator will compare times as-is. + // Start: 2024-03-01 10:00 +05:00 = 2024-03-01 05:00 UTC + // Now: 2024-03-08 10:30 +01:00 = 2024-03-08 09:30 UTC + // The recurrence day (Friday) matches, and time window should match + assertEquals(false, RecurrenceEvaluator.isMatch(settings, now), + "Should not match due to different geographic locations with different time windows"); + } + + /** + * Test weekly recurrence on the exact day of DST transition. + * This tests the edge case where 'now' is during the DST transition day. + */ + @Test + public void weeklyRecurrenceOnDSTTransitionDay() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Wednesday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST + settings.setStart("2024-03-20T10:00:00+01:00"); // Wednesday + settings.setEnd("2024-03-20T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Now on DST transition day (last Sunday of March 2024) + // In Europe/Paris, 2024-03-31 is the DST transition day + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 31, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Sunday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match on DST transition day"); + } + + /** + * Test weekly recurrence with multiple intervals across DST. + * Verifies that multi-week intervals work correctly across DST boundaries. + */ + @Test + public void weeklyRecurrenceMultiIntervalAcrossDST() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(2); // Every 2 weeks + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start in February (before DST) + settings.setStart("2024-02-16T10:00:00+01:00"); // Friday + settings.setEnd("2024-02-16T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Now in April (after DST), on a recurrence week + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 12, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, 8 weeks later (4th occurrence) + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match multi-interval recurrence across DST"); + } + + /** + * Test weekly recurrence with numbered range across DST. + * Ensures that occurrence counting works correctly across DST transitions. + */ + @Test + public void weeklyRecurrenceNumberedRangeAcrossDST() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + range.setType("Numbered"); + range.setNumberOfOccurrences(15); // 15 occurrences + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST - 2024-03-08 is a Friday + // Occurrences: 1:Fri 3/8, 2:Mon 3/11, 3:Fri 3/15, 4:Mon 3/18, 5:Fri 3/22, 6:Mon 3/25, 7:Fri 3/29, + // 8:Mon 4/1 (after DST on 3/31), 9:Fri 4/5, 10:Mon 4/8, 11:Fri 4/12, 12:Mon 4/15... + settings.setStart("2024-03-08T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-08T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Now after DST - 2024-04-05 is Friday, should be 9th occurrence (within range) + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match numbered occurrence across DST"); + + // Test after the numbered range ends - 2024-04-22 is Monday, would be 16th occurrence (beyond range) + final ZonedDateTime afterRange = ZonedDateTime.of(2024, 4, 22, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Monday + + assertEquals(false, RecurrenceEvaluator.isMatch(settings, afterRange), + "Should not match after numbered range ends"); + } + + /** + * Test weekly recurrence with end date across DST. + * Verifies that end date comparisons work correctly across DST transitions. + */ + @Test + public void weeklyRecurrenceEndDateAcrossDST() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + range.setType("EndDate"); + range.setEndDate("2024-04-10T23:59:59+02:00"); // After DST + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before DST + settings.setStart("2024-03-15T10:00:00+01:00"); // Friday + settings.setEnd("2024-03-15T12:00:00+01:00"); + settings.setRecurrence(recurrence); + + // Now after DST, before end date + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match before end date across DST"); + + // Test after end date + final ZonedDateTime afterEnd = ZonedDateTime.of(2024, 4, 12, 10, 30, 0, 0, + ZoneId.of("Europe/Paris")); // Friday, after end date + + assertEquals(false, RecurrenceEvaluator.isMatch(settings, afterEnd), + "Should not match after end date"); + } + + /** + * Test with US timezone (different DST transition dates than Europe). + * US DST: Second Sunday of March (2:00 AM -> 3:00 AM) + */ + @Test + public void weeklyRecurrenceUSDSTTransition() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start before US DST (PST: -08:00) + settings.setStart("2024-03-01T10:00:00-08:00"); // Friday + settings.setEnd("2024-03-01T12:00:00-08:00"); + settings.setRecurrence(recurrence); + + // Now after US DST (PDT: -07:00) + final ZonedDateTime now = ZonedDateTime.of(2024, 3, 15, 10, 30, 0, 0, + ZoneId.of("America/Los_Angeles")); // Friday, after DST + + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match weekly recurrence across US DST transition"); + } + + /** + * Test that the DST conversion only happens when start uses fixed offset + * and now uses region zone, not the other way around. + */ + @Test + public void weeklyRecurrenceNoConversionWhenStartIsRegionZone() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // Start with region zone + ZonedDateTime startTime = ZonedDateTime.of(2024, 3, 1, 10, 0, 0, 0, + ZoneId.of("Europe/Paris")); + ZonedDateTime endTime = ZonedDateTime.of(2024, 3, 1, 12, 0, 0, 0, + ZoneId.of("Europe/Paris")); + + settings.setStart(startTime.toString()); + settings.setEnd(endTime.toString()); + settings.setRecurrence(recurrence); + + // Now with fixed offset + final ZonedDateTime now = ZonedDateTime.of(2024, 4, 5, 10, 30, 0, 0, + ZoneOffset.ofHours(2)); // Friday + + // The DST conversion logic only applies when start is fixed offset and now is region zone. + // In this reverse case (start is region zone, now is fixed offset), no conversion happens. + // Start: 2024-03-01 10:00 Europe/Paris (+01:00) = Friday + // Now: 2024-04-05 10:30 +02:00 = Friday (matches recurring day) + // Time window: 10:00-12:00, Now: 10:30 (within window) + assertTrue(RecurrenceEvaluator.isMatch(settings, now), + "Should match - region zone start with fixed offset now should evaluate correctly"); + } +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorDSTTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorDSTTest.java new file mode 100644 index 000000000000..0e76a6da5895 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/recurrence/RecurrenceValidatorDSTTest.java @@ -0,0 +1,396 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.spring.cloud.feature.management.filters.recurrence; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.azure.spring.cloud.feature.management.implementation.models.Recurrence; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrencePattern; +import com.azure.spring.cloud.feature.management.implementation.models.RecurrenceRange; +import com.azure.spring.cloud.feature.management.implementation.timewindow.TimeWindowFilterSettings; +import com.azure.spring.cloud.feature.management.implementation.timewindow.recurrence.RecurrenceValidator; + +/** + * Tests for Daylight Saving Time (DST) handling in RecurrenceValidator. + * These tests verify that validation uses UTC time to avoid DST-related issues + * when calculating time window durations and validating recurrence patterns. + * + * Tests use time windows close to 23-24 hours (the minimum gap between daily occurrences) + * to ensure that DST transitions don't cause incorrect validation failures. + */ +public class RecurrenceValidatorDSTTest { + + /** + * Test that a 23-hour window between Monday and Wednesday is valid when using calendar-based gap calculation. + * Without calendar-based calculation, the validator might incorrectly calculate gaps based on elapsed time + * which could be 23 or 25 hours depending on DST transitions. + * + * This test uses a reference time on the DST transition day (Sunday, March 31, 2024) + * to ensure the validator correctly calculates gaps using calendar days (always 24 hours per day). + */ + @Test + public void validate23HourWindowDuringDSTSpringForward() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Wednesday")); // 48-hour gap minimum (2 calendar days) + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window in UTC (unambiguous duration) + settings.setStart("2024-03-25T10:00:00Z"); // Monday + settings.setEnd("2024-03-26T09:00:00Z"); // Tuesday, 23 hours later + settings.setRecurrence(recurrence); + + // Reference time during DST transition week (Sunday, March 31, 2024 in UTC) + // This forces the validator to calculate gaps during a DST week + final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Should pass because 23 hours < 48 hours (Monday to Wednesday is 2 calendar days = 48 hours) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), + "23-hour window should be valid with 48-hour gap using calendar-based calculation"); + } + + /** + * Test that a 23-hour window is valid with consecutive day recurrence (Sunday, Monday). + * The reference time is set to the DST transition week, so when the validator calculates + * the Sunday-Monday gap, it crosses the DST spring forward boundary (March 31 2AM -> April 1). + * With calendar-based calculation, Sunday to Monday is always 1 day = 24 hours. + * A 23-hour window should pass validation (23 < 24). + */ + @Test + public void validate23HourWindowWithConsecutiveDays() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Monday")); // 24-hour gap minimum (1 calendar day) + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window (1 hour less than 24-hour gap) + settings.setStart("2024-03-24T10:00:00Z"); // Sunday 10:00 UTC + settings.setEnd("2024-03-25T09:00:00Z"); // Monday 09:00 UTC (23 hours later) + settings.setRecurrence(recurrence); + + // Reference time: Wednesday March 27, 2024 in Europe/Paris + // This week spans Monday Mar 25 - Sunday Mar 31 (DST transition on Mar 31) + // The validator will check the gap from Sunday Mar 31 to Monday Apr 1 which crosses DST + // With calendar-based calculation: 1 day = 24 hours + final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 27, 12, 0, 0, 0, + ZoneId.of("Europe/Paris")); + + // Should pass because 23 hours < 24 hours (1 calendar day) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), + "23-hour window should be valid with 24-hour gap when using calendar-based calculation"); + } + + /** + * Test that a window GREATER than the gap fails validation with consecutive day recurrence. + * The window must not exceed the gap between occurrences. + * With calendar-based calculation, the gap is always 24 hours (1 day) regardless of DST. + */ + @Test + public void validate25HourWindowWithConsecutiveDaysShouldFail() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Tuesday")); // 24-hour gap minimum + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 25-hour window (greater than 24-hour gap, should fail) + settings.setStart("2024-03-25T10:00:00Z"); // Monday 10:00 UTC + settings.setEnd("2024-03-26T11:00:00Z"); // Tuesday 11:00 UTC, 25 hours later + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Should fail because window duration (25h) > gap (24h calendar days) + assertThrows(IllegalArgumentException.class, + () -> RecurrenceValidator.validateSettings(settings, reference), + "25-hour window should fail validation with 24-hour gap"); + } + + /** + * Test validation across DST fall back transition. + * A 23-hour window on Sunday-Tuesday should be valid. + * With calendar-based calculation, Sunday-Tuesday is always 2 days = 48 hours. + */ + @Test + public void validate23HourWindowDuringDSTFallBack() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Tuesday")); // 48-hour gap minimum + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window in UTC + settings.setStart("2024-10-27T10:00:00Z"); // Sunday + settings.setEnd("2024-10-28T09:00:00Z"); // Monday, 23 hours later + settings.setRecurrence(recurrence); + + // Reference time during DST fall back week + final ZonedDateTime referenceDuringFallBack = ZonedDateTime.of(2024, 10, 27, 12, 0, 0, 0, + ZoneOffset.UTC); + + // Should pass because 23 hours < 48 hours (Sunday to Tuesday gap with calendar days) + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringFallBack), + "23-hour window should be valid during DST fall back when using calendar-based calculation"); + } + + /** + * Test that a time window spanning the DST spring forward transition hour + * is correctly validated using UTC duration calculation. + */ + @Test + public void validateTimeWindowSpanningDSTSpringForwardHour() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday")); // Single day, no gap check + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // DST spring forward in Europe/Paris: 2024-03-31 at 2:00 AM -> 3:00 AM + // Time window from 1:30 to 3:30 - appears to be 2 hours locally but is actually 1 hour in UTC + settings.setStart("2024-03-31T01:30:00+01:00"); // Sunday, 00:30 UTC + settings.setEnd("2024-03-31T03:30:00+02:00"); // Sunday, 01:30 UTC (1 hour duration) + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Should pass - window is only 1 hour in UTC despite appearing as 2 hours locally + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "Window spanning DST transition should be validated using UTC duration"); + } + + /** + * Test with daily recurrence and 23-hour window. + * Daily recurrence has a 24-hour minimum gap, so 23-hour window should be valid. + */ + @Test + public void validateDailyRecurrence23HourWindow() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Daily"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window + settings.setStart("2024-03-30T10:00:00+01:00"); + settings.setEnd("2024-03-31T09:00:00+01:00"); // 23 hours later + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid for daily recurrence (< 24-hour gap)"); + } + + /** + * Test that a window GREATER than the interval fails with daily recurrence. + * Daily recurrence with interval=1 has a 24-hour interval. + * A 25-hour window should fail because it's greater than the interval. + */ + @Test + public void validateDailyRecurrence25HourWindowShouldFail() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Daily"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 25-hour window (in UTC): + // Start: 2024-03-30 10:00 +01:00 = 09:00 UTC + // End: 2024-03-31 12:00 +02:00 = 10:00 UTC (25 hours later in UTC) + settings.setStart("2024-03-30T10:00:00+01:00"); + settings.setEnd("2024-03-31T12:00:00+02:00"); // 25 hours later in UTC + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + assertThrows(IllegalArgumentException.class, + () -> RecurrenceValidator.validateSettings(settings, reference), + "25-hour window should fail for daily recurrence (greater than 24-hour interval)"); + } + + /** + * Test weekly recurrence with Monday, Friday, and Sunday and a 23.5-hour window. + * The minimum gap between enabled days is Friday-Sunday (48 hours), so this should pass. + */ + @Test + public void validateMultipleDaysOfWeekWith23HourWindow() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday", "Sunday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23.5-hour window + settings.setStart("2024-03-25T10:00:00+01:00"); // Monday + settings.setEnd("2024-03-26T09:30:00+01:00"); // Tuesday, 23.5 hours later + settings.setRecurrence(recurrence); + + // Reference on DST transition day + final ZonedDateTime referenceDuringDST = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Minimum gap is Friday->Sunday (48 hours), so 23.5 hours should pass + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, referenceDuringDST), + "23.5-hour window should be valid with Friday-Sunday 48-hour minimum gap"); + } + + /** + * Test with consecutive days spanning DST transition. + * Saturday-Sunday-Monday where Sunday is the DST transition day. + */ + @Test + public void validateConsecutiveDaysSpanningDSTTransition() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Saturday", "Sunday", "Monday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window on Saturday + settings.setStart("2024-03-30T10:00:00+01:00"); // Saturday + settings.setEnd("2024-03-31T09:00:00+01:00"); // Sunday, 23 hours later + settings.setRecurrence(recurrence); + + // Reference on the DST transition day itself, using a valid local time after the DST gap + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 3, 30, 0, 0, + ZoneId.of("Europe/Paris")); + + // Minimum gap is between consecutive calendar days (Saturday-Sunday or Sunday-Monday). + // On this DST transition day the elapsed UTC time is 23 hours, but recurrence validation + // is based on calendar days, so a 23-hour window is still less than one day and should pass. + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid spanning DST transition with UTC calculation"); + } + + /** + * Test validation with US timezone DST transition. + * US DST spring forward is second Sunday of March (different from Europe). + */ + @Test + public void validate23HourWindowDuringUSDSTTransition() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Sunday", "Tuesday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(1); + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window on Sunday (US DST transition day: March 10, 2024) + settings.setStart("2024-03-10T10:00:00-08:00"); // Sunday, PST + settings.setEnd("2024-03-11T09:00:00-07:00"); // Monday, PDT (23 hours later in UTC) + settings.setRecurrence(recurrence); + + // Reference during US DST transition + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 10, 18, 0, 0, 0, ZoneOffset.UTC); + + // Sunday-Tuesday gap is 48 hours, 23-hour window should pass + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid during US DST transition with UTC calculation"); + } + + /** + * Test with multi-week interval and 23-hour window. + * Even with larger gaps, the window must still be validated correctly. + */ + @Test + public void validateMultiWeekIntervalWith23HourWindow() { + final TimeWindowFilterSettings settings = new TimeWindowFilterSettings(); + final RecurrencePattern pattern = new RecurrencePattern(); + final RecurrenceRange range = new RecurrenceRange(); + final Recurrence recurrence = new Recurrence(); + + pattern.setType("Weekly"); + pattern.setDaysOfWeek(List.of("Monday", "Friday")); + pattern.setFirstDayOfWeek("Monday"); + pattern.setInterval(2); // Every 2 weeks + + recurrence.setRange(range); + recurrence.setPattern(pattern); + + // 23-hour window + settings.setStart("2024-03-04T10:00:00+01:00"); // Monday + settings.setEnd("2024-03-05T09:00:00+01:00"); // Tuesday, 23 hours later + settings.setRecurrence(recurrence); + + final ZonedDateTime reference = ZonedDateTime.of(2024, 3, 31, 12, 0, 0, 0, ZoneOffset.UTC); + + // Minimum gap is Monday-Friday (96 hours), so 23 hours should pass + assertDoesNotThrow(() -> RecurrenceValidator.validateSettings(settings, reference), + "23-hour window should be valid with multi-week interval"); + } +} diff --git a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md index 4208aff37153..247279e82870 100644 --- a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md +++ b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md @@ -54,15 +54,16 @@ spring.config.import | The Spring property that triggers the loading of Azure Ap Name | Description | Required | Default ---|---|---|--- -spring.cloud.azure.appconfiguration.stores | List of configuration stores from which to load configuration properties | Yes | true -spring.cloud.azure.appconfiguration.enabled | Whether enable spring-cloud-azure-appconfiguration-config or not | No | true -spring.cloud.azure.appconfiguration.refresh-interval | Amount of time, of type Duration, configurations are stored before a check can occur. | No | null +spring.cloud.azure.appconfiguration.stores | List of configuration stores from which to load configuration properties | Yes | Empty List +spring.cloud.azure.appconfiguration.enabled | Whether to enable spring-cloud-azure-appconfiguration-config or not | No | true +spring.cloud.azure.appconfiguration.refresh-interval | Amount of time, of type Duration, configurations are stored before a check can occur. | No | null +spring.cloud.azure.appconfiguration.startup-timeout | Maximum time to retry loading configuration during application startup when transient failures occur. | No | 100s `spring.cloud.azure.appconfiguration.stores` is a list of stores, where each store follows the following format: Name | Description | Required | Default ---|---|---|--- -spring.cloud.azure.appconfiguration.stores[0].enabled | Whether the store will be loaded. | No | true +spring.cloud.azure.appconfiguration.stores[0].enabled | Whether the store will be loaded. Requires either `spring.config.import= optional:azureAppConfiguration` or another config store to be loaded. | No | true spring.cloud.azure.appconfiguration.stores[0].fail-fast | Whether to throw a `RuntimeException` or not when failing to read from App Configuration during application start-up. If an exception does occur during startup when set to false the store is skipped. | No | true spring.cloud.azure.appconfiguration.stores[0].selects[0].key-filter | The key pattern used to indicate which configuration(s) will be loaded. | No | /application/* spring.cloud.azure.appconfiguration.stores[0].selects[0].label-filter | The label used to indicate which configuration(s) will be loaded. | No | `${spring.profiles.active}` or if null `\0` diff --git a/sdk/spring/spring-messaging-azure-servicebus/src/main/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactory.java b/sdk/spring/spring-messaging-azure-servicebus/src/main/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactory.java index b59b5b90c960..096aa3570fbe 100644 --- a/sdk/spring/spring-messaging-azure-servicebus/src/main/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactory.java +++ b/sdk/spring/spring-messaging-azure-servicebus/src/main/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactory.java @@ -162,6 +162,30 @@ private ServiceBusProcessorClient doCreateProcessor(String name, @Nullable ProcessorProperties properties) { ConsumerIdentifier key = new ConsumerIdentifier(name, subscription); + // If a cached processor is no longer running (e.g., closed due to a non-transient error, + // or stopped), evict it atomically so a fresh one is created below. The atomic + // remove(key, value) ensures that if another thread already replaced the stale entry, + // we do not accidentally remove the new processor. + ServiceBusProcessorClient stale = processorMap.get(key); + if (stale != null && !stale.isRunning()) { + if (processorMap.remove(key, stale)) { + String processorName = buildProcessorName(key); + LOGGER.debug("Removing stale (non-running) processor for '{}'.", processorName); + try { + listeners.forEach(l -> l.processorRemoved(processorName, stale)); + } catch (Exception ex) { + LOGGER.warn("Listener notification failed while removing stale processor for '{}'.", + processorName, ex); + } + try { + stale.close(); + } catch (Exception ex) { + LOGGER.warn("Failed to close stale Service Bus processor client for '{}'.", + processorName, ex); + } + } + } + return processorMap.computeIfAbsent(key, k -> { ProcessorPropertiesParentMerger propertiesMerger = new ProcessorPropertiesParentMerger(); ProcessorProperties processorProperties = propertiesMerger.merge(properties, this.namespaceProperties); diff --git a/sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java b/sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java index 1b349f2aa5f3..236e11a8ab2f 100644 --- a/sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java +++ b/sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java @@ -6,15 +6,24 @@ import com.azure.messaging.servicebus.ServiceBusProcessorClient; import com.azure.spring.cloud.service.servicebus.consumer.ServiceBusErrorHandler; import com.azure.spring.cloud.service.servicebus.consumer.ServiceBusRecordMessageListener; +import com.azure.spring.messaging.ConsumerIdentifier; import com.azure.spring.messaging.servicebus.core.properties.NamespaceProperties; import com.azure.spring.messaging.servicebus.core.properties.ServiceBusContainerProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class DefaultServiceBusNamespaceProcessorFactoryTests { private ServiceBusProcessorFactory processorFactory; @@ -70,10 +79,79 @@ void testCreateServiceBusProcessorClientQueueTwice() { @Test void testCreateServiceBusProcessorClientTopicTwice() { + // The first processor is created but never started (isRunning() == false). + // The factory treats a non-running cached processor as stale: it evicts it and + // creates a fresh one on the next createProcessor call for the same key. ServiceBusProcessorClient client = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler); assertNotNull(client); processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler); + assertEquals(2, topicProcessorAddedTimes); + } + + @Test + void testStaleProcessorReplacedAfterClose() { + // End-to-end test with a real processor: create, close, re-create. + // Note: the processor is never started, so isRunning() is already false before close(). + // See testProcessorEvictedAfterTransitionToNotRunning for a mock-based test that + // simulates the running → not-running transition. + ServiceBusProcessorClient first = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler); + assertNotNull(first); + assertEquals(1, topicProcessorAddedTimes); + + first.close(); + + ServiceBusProcessorClient second = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler); + assertNotNull(second); + assertNotSame(first, second); + assertEquals(2, topicProcessorAddedTimes); + } + + @Test + void testStaleQueueProcessorReplacedAfterClose() { + // End-to-end test with a real queue processor: create, close, re-create. + // See testProcessorEvictedAfterTransitionToNotRunning for a mock-based test that + // simulates the running → not-running transition. + ServiceBusProcessorClient first = processorFactory.createProcessor(entityName, this.listener, errorHandler); + assertNotNull(first); + assertEquals(1, queueProcessorAddedTimes); + + first.close(); + + ServiceBusProcessorClient second = processorFactory.createProcessor(entityName, this.listener, errorHandler); + assertNotNull(second); + assertNotSame(first, second); + assertEquals(2, queueProcessorAddedTimes); + } + + @Test + void testRunningProcessorReturnedFromCache() throws Exception { + // A processor that reports isRunning() == true must NOT be evicted from the cache. + ServiceBusProcessorClient runningMock = mock(ServiceBusProcessorClient.class); + when(runningMock.isRunning()).thenReturn(true); + + ConsumerIdentifier key = new ConsumerIdentifier(entityName, subscription); + getProcessorMap().put(key, runningMock); + + ServiceBusProcessorClient result = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler); + assertSame(runningMock, result); + verify(runningMock, never()).close(); + } + + @Test + void testProcessorEvictedAfterTransitionToNotRunning() throws Exception { + // Simulate a processor that was running and then transitioned to not-running + // (e.g., closed by the SDK due to a non-transient error, or stopped). + ServiceBusProcessorClient staleMock = mock(ServiceBusProcessorClient.class); + when(staleMock.isRunning()).thenReturn(false); + + ConsumerIdentifier key = new ConsumerIdentifier(entityName, subscription); + getProcessorMap().put(key, staleMock); + + ServiceBusProcessorClient result = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler); + assertNotSame(staleMock, result); + assertNotNull(result); + verify(staleMock).close(); assertEquals(1, topicProcessorAddedTimes); } @@ -170,4 +248,11 @@ void dedicatedCustomizerShouldBeCalledOnlyWhenMatchingClientsCreated() { assertEquals(1, sessionClientCalledTimes.get()); } + @SuppressWarnings("unchecked") + private Map getProcessorMap() throws Exception { + Field field = DefaultServiceBusNamespaceProcessorFactory.class.getDeclaredField("processorMap"); + field.setAccessible(true); + return (Map) field.get(processorFactory); + } + }