Skip to content

Live workload identity (AKS) test #2805

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,10 @@ mod tests {
tests::*,
};
use azure_core::{
http::{headers::Headers, RawResponse, StatusCode},
http::{headers::Headers, Method, RawResponse, Request, StatusCode, Url},
Bytes,
};
use azure_core_test::recorded;
use std::{
env,
fs::File,
Expand Down Expand Up @@ -279,6 +280,35 @@ mod tests {
.expect_err("invalid tenant ID");
}

#[recorded::test(live)]
async fn live() -> azure_core::Result<()> {
if env::var("CI_HAS_DEPLOYED_RESOURCES").is_err() {
println!("Skipped: workload identity live tests require deployed resources");
return Ok(());
}
let ip = env::var("IDENTITY_AKS_IP").expect("IDENTITY_AKS_IP");
let storage_name = env::var("IDENTITY_STORAGE_NAME_USER_ASSIGNED")
.expect("IDENTITY_STORAGE_NAME_USER_ASSIGNED");

let url =
format!("http://{ip}:8080/api?test=workload-identity&storage-name={storage_name}");
let u = Url::parse(&url).expect("valid URL");
let client = azure_core::http::new_http_client();
let req = Request::new(u, Method::Get);

let res = client.execute_request(&req).await.expect("response");
let status = res.status();
let body = res
.into_body()
.collect_string()
.await
.expect("body content");

assert_eq!(StatusCode::Ok, status, "Test app responded with '{body}'");

Ok(())
}

#[test]
fn missing_config() {
WorkloadIdentityCredential::new(None).expect_err("missing config");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use axum::{
Router,
};
use azure_core::credentials::TokenCredential;
use azure_identity::{ManagedIdentityCredential, ManagedIdentityCredentialOptions, UserAssignedId};
use azure_identity::{ManagedIdentityCredential, ManagedIdentityCredentialOptions, UserAssignedId, WorkloadIdentityCredential};
use azure_storage_blob::BlobServiceClient;
use serde::Deserialize;
use std::sync::Arc;
Expand Down Expand Up @@ -44,7 +44,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}

async fn handle_request(Query(params): Query<Params>) -> Response {
let credential = match params.test.as_str() {
let credential: Arc<dyn TokenCredential> = match params.test.as_str() {
"managed-identity" => {
let user_assigned_id = match (
params.client_id.as_ref(),
Expand Down Expand Up @@ -78,6 +78,18 @@ async fn handle_request(Query(params): Query<Params>) -> Response {
}
}
}
"workload-identity" => {
match WorkloadIdentityCredential::new(None) {
Ok(cred) => cred,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("WorkloadIdentityCredential::new returned '{e}'"),
)
.into_response()
}
}
}
test => return (StatusCode::BAD_REQUEST, format!("Unknown test '{test}'")).into_response(),
};

Expand Down
2 changes: 2 additions & 0 deletions sdk/identity/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# NOTE: Please refer to https://aka.ms/azsdk/engsys/ci-yaml before editing this file.
# cSpell:disable
trigger:
branches:
include:
Expand All @@ -18,6 +19,7 @@ extends:
safeName: AzureIdentity

${{ if endsWith(variables['Build.DefinitionName'], 'weekly') }}:
Location: uksouth
RunLiveTests: true
PersistOidcToken: true
MatrixConfigs:
Expand Down
76 changes: 73 additions & 3 deletions sdk/identity/test-resources-post.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,85 @@ Write-Host "##[endgroup]"
$rg = $DeploymentOutputs['IDENTITY_RESOURCE_GROUP']

Write-Host "##[group]Deploying Azure Container Instance with user-assigned identity"
$aciName = "azure-identity-test-user-assigned"
az container create -g $rg -n $aciName --image $image `
$containerName = "azure-identity-test-user-assigned"
az container create -g $rg -n $containerName --image $image `
--acr-identity $($DeploymentOutputs['IDENTITY_USER_ASSIGNED_IDENTITY']) `
--assign-identity $($DeploymentOutputs['IDENTITY_USER_ASSIGNED_IDENTITY']) `
--cpu 1 `
--ip-address Public `
--memory 1.0 `
--os-type Linux `
--ports 8080
$aciIP = az container show -g $rg -n $aciName --query ipAddress.ip -o tsv
$aciIP = az container show -g $rg -n $containerName --query ipAddress.ip -o tsv
Write-Host "##vso[task.setvariable variable=IDENTITY_ACI_IP_USER_ASSIGNED;]$aciIP"
Write-Host "##[endgroup]"

$aksName = $DeploymentOutputs['IDENTITY_AKS_NAME']
$serviceAccountName = "workload-identity-sa"

Write-Host "##[group]Creating federated identity"
$idName = $DeploymentOutputs['IDENTITY_USER_ASSIGNED_IDENTITY_NAME']
$issuer = az aks show -g $rg -n $aksName --query "oidcIssuerProfile.issuerUrl" -otsv
az identity federated-credential create -g $rg --identity-name $idName --issuer $issuer --name $idName --subject system:serviceaccount:default:$serviceAccountName --audiences api://AzureADTokenExchange
Write-Host "##[endgroup]"

Write-Host "##[group]Deploying to AKS"
az aks get-credentials -g $rg -n $aksName
az aks update --attach-acr $DeploymentOutputs['IDENTITY_ACR_NAME'] -g $rg -n $aksName
Set-Content -Path "$PSScriptRoot/k8s.yaml" -Value @"
apiVersion: v1
kind: ServiceAccount
metadata:
annotations:
azure.workload.identity/client-id: $($DeploymentOutputs['IDENTITY_USER_ASSIGNED_IDENTITY_CLIENT_ID'])
name: $serviceAccountName
namespace: default
---
apiVersion: v1
kind: Pod
metadata:
name: $containerName
namespace: default
labels:
app: $containerName
azure.workload.identity/use: "true"
spec:
serviceAccountName: $serviceAccountName
containers:
- name: $containerName
image: $image
ports:
- containerPort: 8080
nodeSelector:
kubernetes.io/os: linux
---
apiVersion: v1
kind: Service
metadata:
name: $containerName-service
namespace: default
spec:
selector:
app: $containerName
ports:
- protocol: TCP
port: 8080
targetPort: 8080
type: LoadBalancer
"@
kubectl apply -f "$PSScriptRoot/k8s.yaml" --wait=true

$timeout = [TimeSpan]::FromMinutes(2)
$interval = 20
$startTime = Get-Date
do {
$serviceIP = kubectl get service "$($containerName)-service" -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
if ($serviceIP) { break }
Start-Sleep -Seconds $interval
} while ((Get-Date) - $startTime -lt $timeout)
if (-not $serviceIP) {
Write-Error "Timed out waiting for AKS test pod's external IP"
exit 1
}
Write-Host "##vso[task.setvariable variable=IDENTITY_AKS_IP;]$serviceIP"
Write-Host "##[endgroup]"
20 changes: 20 additions & 0 deletions sdk/identity/test-resources-pre.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

# IMPORTANT: Do not invoke this file directly. Please instead run eng/common/TestResources/New-TestResources.ps1 from the repository root.

# cSpell:disable

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param (
[hashtable] $AdditionalParameters = @{},

# Captures any arguments from eng/New-TestResources.ps1 not declared here (no parameter errors).
[Parameter(ValueFromRemainingArguments = $true)]
$RemainingArguments
)

if (-not (Test-Path "$PSScriptRoot/sshkey.pub")) {
ssh-keygen -t rsa -b 4096 -f "$PSScriptRoot/sshkey" -N '' -C ''
}
$templateFileParameters['sshPubKey'] = Get-Content "$PSScriptRoot/sshkey.pub"
51 changes: 51 additions & 0 deletions sdk/identity/test-resources.bicep
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

@description('Kubernetes cluster admin user name.')
param adminUser string = 'azureuser'

@minLength(6)
@maxLength(23)
@description('The base resource name.')
Expand All @@ -12,6 +15,8 @@ param deployResources bool = false
@description('The location of the resource. By default, this is the same as the resource group.')
param location string = resourceGroup().location

param sshPubKey string = ''

// https://learn.microsoft.com/azure/role-based-access-control/built-in-roles
var acrPull = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')
var storageAccountContributor = subscriptionResourceId(
Expand Down Expand Up @@ -76,8 +81,54 @@ resource acrPullContainerInstance 'Microsoft.Authorization/roleAssignments@2022-
scope: containerRegistry
}

resource aks 'Microsoft.ContainerService/managedClusters@2023-06-01' = if (deployResources) {
name: baseName
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
agentPoolProfiles: [
{
count: 1
enableAutoScaling: false
kubeletDiskType: 'OS'
mode: 'System'
name: 'agentpool'
osDiskSizeGB: 128
osDiskType: 'Managed'
osSKU: 'Ubuntu'
osType: 'Linux'
type: 'VirtualMachineScaleSets'
vmSize: 'Standard_D2s_v3'
}
]
dnsPrefix: 'identitytest'
enableRBAC: true
linuxProfile: {
adminUsername: adminUser
ssh: {
publicKeys: [
{
keyData: sshPubKey
}
]
}
}
oidcIssuerProfile: {
enabled: true
}
securityProfile: {
workloadIdentity: {
enabled: true
}
}
}
}

output IDENTITY_ACR_LOGIN_SERVER string = deployResources ? containerRegistry.properties.loginServer : ''
output IDENTITY_ACR_NAME string = deployResources ? containerRegistry.name : ''
output IDENTITY_AKS_NAME string = deployResources ? aks.name : ''
output IDENTITY_STORAGE_ID string = deployResources ? saSystemAssigned.id : ''
output IDENTITY_STORAGE_NAME_SYSTEM_ASSIGNED string = deployResources ? saSystemAssigned.name : ''
output IDENTITY_STORAGE_NAME_USER_ASSIGNED string = deployResources ? saUserAssigned.name : ''
Expand Down