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