Skip to content

Commit 5127760

Browse files
authored
Live ACI and IMDS managed identity tests (#2775)
1 parent e132848 commit 5127760

File tree

9 files changed

+397
-14
lines changed

9 files changed

+397
-14
lines changed

.vscode/cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"ignorePaths": [
66
"**/test-resources.bicep",
77
"**/test-resources.json",
8+
"**/test-resources-post.ps1",
89
"**/assets.json",
910
".config",
1011
".devcontainer/devcontainer.json",

sdk/identity/.dict.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ clientcertificate
66
clientsecret
77
cloudshell
88
imds
9+
LINUXPOOL
10+
LINUXVMIMAGE
911
managedidentity
1012
msal
1113
replacen
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"include": [
3+
{
4+
"Agent": {
5+
"msi_image": {
6+
"ArmTemplateParameters": "@{deployResources = $true}",
7+
"OSVmImage": "env:LINUXVMIMAGE",
8+
"Pool": "env:LINUXPOOL"
9+
}
10+
},
11+
"IDENTITY_IMDS_AVAILABLE": "1",
12+
"RustToolchainName": [
13+
"stable"
14+
]
15+
}
16+
]
17+
}

sdk/identity/azure_identity/src/managed_identity_credential.rs

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,42 @@ mod tests {
153153
use crate::env::Env;
154154
use crate::tests::{LIVE_TEST_RESOURCE, LIVE_TEST_SCOPES};
155155
use azure_core::http::headers::Headers;
156-
use azure_core::http::{Method, RawResponse, Request, StatusCode};
156+
use azure_core::http::{Method, RawResponse, Request, StatusCode, Url};
157+
use azure_core::time::OffsetDateTime;
157158
use azure_core::Bytes;
158-
use azure_core_test::http::MockHttpClient;
159+
use azure_core_test::{http::MockHttpClient, recorded};
159160
use futures::FutureExt;
161+
use std::env;
160162
use std::sync::atomic::{AtomicUsize, Ordering};
161163
use std::time::{SystemTime, UNIX_EPOCH};
162164

163165
const EXPIRES_ON: &str = "EXPIRES_ON";
164166

167+
async fn run_deployed_test(
168+
authority: &str,
169+
storage_name: &str,
170+
id: Option<UserAssignedId>,
171+
) -> azure_core::Result<()> {
172+
let id_param = id.map_or("".to_string(), |id| match id {
173+
UserAssignedId::ClientId(id) => format!("client-id={id}&"),
174+
UserAssignedId::ObjectId(id) => format!("object-id={id}&"),
175+
UserAssignedId::ResourceId(id) => format!("resource-id={id}&"),
176+
});
177+
let url = format!(
178+
"http://{authority}/api?test=managed-identity&{id_param}storage-name={storage_name}"
179+
);
180+
let u = Url::parse(&url).expect("invalid URL");
181+
let client = azure_core::http::new_http_client();
182+
let req = Request::new(u, Method::Get);
183+
184+
let res = client.execute_request(&req).await.expect("request failed");
185+
let status = res.status();
186+
let body = res.into_body().collect_string().await?;
187+
assert_eq!(StatusCode::Ok, status, "Test app responded with '{body}'");
188+
189+
Ok(())
190+
}
191+
165192
async fn run_supported_source_test(
166193
env: Env,
167194
options: Option<ManagedIdentityCredentialOptions>,
@@ -256,6 +283,27 @@ mod tests {
256283
);
257284
}
258285

286+
#[recorded::test(live)]
287+
async fn aci_user_assigned_live() -> azure_core::Result<()> {
288+
if env::var("CI_HAS_DEPLOYED_RESOURCES").is_err() {
289+
println!("Skipped: ACI live tests require deployed resources");
290+
return Ok(());
291+
}
292+
let ip = env::var("IDENTITY_ACI_IP_USER_ASSIGNED").expect("IDENTITY_ACI_IP_USER_ASSIGNED");
293+
let storage_name = env::var("IDENTITY_STORAGE_NAME_USER_ASSIGNED")
294+
.expect("IDENTITY_STORAGE_NAME_USER_ASSIGNED");
295+
let client_id = env::var("IDENTITY_USER_ASSIGNED_IDENTITY_CLIENT_ID")
296+
.expect("IDENTITY_USER_ASSIGNED_IDENTITY_CLIENT_ID");
297+
run_deployed_test(
298+
&format!("{}:8080", ip),
299+
&storage_name,
300+
Some(UserAssignedId::ClientId(client_id)),
301+
)
302+
.await?;
303+
304+
Ok(())
305+
}
306+
259307
async fn run_app_service_test(options: Option<ManagedIdentityCredentialOptions>) {
260308
let endpoint = "http://localhost/metadata/identity/oauth2/token";
261309
let x_id_header = "x-id-header";
@@ -369,6 +417,27 @@ mod tests {
369417
);
370418
}
371419

420+
async fn run_imds_live_test(id: Option<UserAssignedId>) -> azure_core::Result<()> {
421+
if std::env::var("IDENTITY_IMDS_AVAILABLE").is_err() {
422+
println!("Skipped: IMDS isn't available");
423+
return Ok(());
424+
}
425+
426+
let credential = ManagedIdentityCredential::new(Some(ManagedIdentityCredentialOptions {
427+
user_assigned_id: id,
428+
..Default::default()
429+
}))
430+
.expect("valid credential");
431+
432+
let token = credential.get_token(LIVE_TEST_SCOPES, None).await?;
433+
434+
assert!(!token.token.secret().is_empty());
435+
assert_eq!(time::UtcOffset::UTC, token.expires_on.offset());
436+
assert!(token.expires_on.unix_timestamp() > OffsetDateTime::now_utc().unix_timestamp());
437+
438+
Ok(())
439+
}
440+
372441
async fn run_imds_test(options: Option<ManagedIdentityCredentialOptions>) {
373442
let mut model = Request::new(
374443
"http://169.254.169.254/metadata/identity/oauth2/token"
@@ -408,11 +477,6 @@ mod tests {
408477
).await;
409478
}
410479

411-
#[tokio::test]
412-
async fn imds() {
413-
run_imds_test(None).await;
414-
}
415-
416480
#[tokio::test]
417481
async fn imds_client_id() {
418482
run_imds_test(Some(ManagedIdentityCredentialOptions {
@@ -442,6 +506,16 @@ mod tests {
442506
.await;
443507
}
444508

509+
#[tokio::test]
510+
async fn imds_system_assigned() {
511+
run_imds_test(None).await;
512+
}
513+
514+
#[recorded::test(live)]
515+
async fn imds_system_assigned_live() -> azure_core::Result<()> {
516+
run_imds_live_test(None).await
517+
}
518+
445519
#[tokio::test]
446520
async fn requires_one_scope() {
447521
let credential = ManagedIdentityCredential::new(None).expect("valid credential");
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "deployed_live_test"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[workspace]
8+
9+
[dependencies]
10+
azure_identity = { path = "../../../" }
11+
azure_storage_blob = { path = "../../../../../storage/azure_storage_blob" }
12+
azure_core = { path = "../../../../../core/azure_core" }
13+
openssl = { version = "0.10", features = ["vendored"]}
14+
tokio = { version = "1.0", features = ["full"] }
15+
url = "2.5"
16+
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "query"] }
17+
serde = { version = "1.0", features = ["derive"] }
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
use axum::{
5+
extract::Query,
6+
http::StatusCode,
7+
response::{IntoResponse, Response},
8+
routing::get,
9+
Router,
10+
};
11+
use azure_core::credentials::TokenCredential;
12+
use azure_identity::{ManagedIdentityCredential, ManagedIdentityCredentialOptions, UserAssignedId};
13+
use azure_storage_blob::BlobServiceClient;
14+
use serde::Deserialize;
15+
use std::sync::Arc;
16+
17+
#[derive(Deserialize)]
18+
#[serde(rename_all = "kebab-case")]
19+
struct Params {
20+
client_id: Option<String>,
21+
object_id: Option<String>,
22+
resource_id: Option<String>,
23+
storage_name: String,
24+
test: String,
25+
}
26+
27+
#[tokio::main]
28+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
29+
// cspell:ignore CUSTOMHANDLER
30+
let port = std::env::var("FUNCTIONS_CUSTOMHANDLER_PORT").unwrap_or_else(|_| "8080".to_string());
31+
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
32+
println!("Listening on http://{}", listener.local_addr()?);
33+
34+
let router = Router::new().route("/api", get(handle_request));
35+
axum::serve(listener, router)
36+
.with_graceful_shutdown(async {
37+
tokio::signal::ctrl_c()
38+
.await
39+
.expect("failed to handle ctrl-c");
40+
})
41+
.await?;
42+
43+
Ok(())
44+
}
45+
46+
async fn handle_request(Query(params): Query<Params>) -> Response {
47+
let credential = match params.test.as_str() {
48+
"managed-identity" => {
49+
let user_assigned_id = match (
50+
params.client_id.as_ref(),
51+
params.object_id.as_ref(),
52+
params.resource_id.as_ref(),
53+
) {
54+
(Some(id), None, None) => Some(UserAssignedId::ClientId(id.clone())),
55+
(None, Some(id), None) => Some(UserAssignedId::ObjectId(id.clone())),
56+
(None, None, Some(id)) => Some(UserAssignedId::ResourceId(id.clone())),
57+
(None, None, None) => None,
58+
_ => {
59+
return (
60+
StatusCode::BAD_REQUEST,
61+
"Multiple user-assigned identity parameters",
62+
)
63+
.into_response()
64+
}
65+
};
66+
let options = ManagedIdentityCredentialOptions {
67+
user_assigned_id,
68+
..Default::default()
69+
};
70+
match ManagedIdentityCredential::new(Some(options)) {
71+
Ok(cred) => cred,
72+
Err(e) => {
73+
return (
74+
StatusCode::INTERNAL_SERVER_ERROR,
75+
format!("ManagedIdentityCredential::new returned '{e}'"),
76+
)
77+
.into_response()
78+
}
79+
}
80+
}
81+
test => return (StatusCode::BAD_REQUEST, format!("Unknown test '{test}'")).into_response(),
82+
};
83+
84+
match try_storage(credential, &params.storage_name).await {
85+
Ok(_) => StatusCode::OK.into_response(),
86+
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
87+
}
88+
}
89+
90+
async fn try_storage(
91+
credential: Arc<dyn TokenCredential>,
92+
storage_name: &str,
93+
) -> Result<(), Box<dyn std::error::Error>> {
94+
let endpoint = format!("https://{}.blob.core.windows.net", storage_name);
95+
BlobServiceClient::new(endpoint.as_str(), credential, None)?
96+
.get_properties(None)
97+
.await
98+
.map_err(|e| format!("BlobServiceClient::get_properties failed: {:?}", e).into())
99+
.map(|_| ())
100+
}

sdk/identity/ci.yml

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
# NOTE: Please refer to https://aka.ms/azsdk/engsys/ci-yaml before editing this file.
2-
32
trigger:
43
branches:
54
include:
6-
- main
7-
- hotfix/*
8-
- release/*
5+
- main
6+
- hotfix/*
7+
- release/*
98
paths:
109
include:
11-
- sdk/identity/
10+
- sdk/identity/
1211

1312
extends:
1413
template: /eng/pipelines/templates/stages/archetype-sdk-client.yml
1514
parameters:
1615
ServiceDirectory: identity
1716
Artifacts:
18-
- name: azure_identity
19-
safeName: AzureIdentity
17+
- name: azure_identity
18+
safeName: AzureIdentity
19+
20+
${{ if endsWith(variables['Build.DefinitionName'], 'weekly') }}:
21+
RunLiveTests: true
22+
PersistOidcToken: true
23+
MatrixConfigs:
24+
- Name: managed_identity_matrix
25+
GenerateVMJobs: true
26+
Path: sdk/identity/azure_identity/managed-identity-matrix.json
27+
Selection: sparse
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
# IMPORTANT: Do not invoke this file directly. Please instead run eng/common/TestResources/New-TestResources.ps1 from the repository root.
5+
6+
param (
7+
[hashtable] $AdditionalParameters = @{},
8+
[hashtable] $DeploymentOutputs,
9+
10+
[Parameter(Mandatory = $true)]
11+
[ValidateNotNullOrEmpty()]
12+
[string] $SubscriptionId,
13+
14+
[Parameter(ParameterSetName = 'Provisioner', Mandatory = $true)]
15+
[ValidateNotNullOrEmpty()]
16+
[string] $TenantId,
17+
18+
[Parameter()]
19+
[ValidatePattern('^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$')]
20+
[string] $TestApplicationId,
21+
22+
[Parameter(Mandatory = $true)]
23+
[ValidateNotNullOrEmpty()]
24+
[string] $Environment,
25+
26+
# Captures any arguments from eng/New-TestResources.ps1 not declared here (no parameter errors).
27+
[Parameter(ValueFromRemainingArguments = $true)]
28+
$RemainingArguments
29+
)
30+
31+
$ErrorActionPreference = 'Stop'
32+
$PSNativeCommandUseErrorActionPreference = $true
33+
34+
if ($CI) {
35+
if (!$AdditionalParameters['deployResources']) {
36+
Write-Host "Skipping post-provisioning script because resources weren't deployed"
37+
return
38+
}
39+
az cloud set -n $Environment
40+
az login --federated-token $env:ARM_OIDC_TOKEN --service-principal -t $TenantId -u $TestApplicationId
41+
az account set --subscription $SubscriptionId
42+
}
43+
44+
Set-Location "$(git rev-parse --show-toplevel)/sdk/identity/azure_identity/tests/tools/deployed_live_test"
45+
46+
Write-Host "##[group]Building test app"
47+
cargo install --path . --root target
48+
Write-Host "##[endgroup]"
49+
50+
Write-Host "##[group]Building container image"
51+
az acr login -n $DeploymentOutputs['IDENTITY_ACR_NAME']
52+
$image = "$($DeploymentOutputs['IDENTITY_ACR_LOGIN_SERVER'])/live-test"
53+
Set-Content -Path Dockerfile -Value @"
54+
FROM mcr.microsoft.com/mirror/docker/library/ubuntu:24.04
55+
RUN apt update && apt install ca-certificates --no-install-recommends -y
56+
COPY target/bin/deployed_live_test .
57+
CMD ["./deployed_live_test"]
58+
"@
59+
docker build -t $image .
60+
docker push $image
61+
Write-Host "##[endgroup]"
62+
63+
$rg = $DeploymentOutputs['IDENTITY_RESOURCE_GROUP']
64+
65+
Write-Host "##[group]Deploying Azure Container Instance with user-assigned identity"
66+
$aciName = "azure-identity-test-user-assigned"
67+
az container create -g $rg -n $aciName --image $image `
68+
--acr-identity $($DeploymentOutputs['IDENTITY_USER_ASSIGNED_IDENTITY']) `
69+
--assign-identity $($DeploymentOutputs['IDENTITY_USER_ASSIGNED_IDENTITY']) `
70+
--cpu 1 `
71+
--ip-address Public `
72+
--memory 1.0 `
73+
--os-type Linux `
74+
--ports 8080
75+
$aciIP = az container show -g $rg -n $aciName --query ipAddress.ip -o tsv
76+
Write-Host "##vso[task.setvariable variable=IDENTITY_ACI_IP_USER_ASSIGNED;]$aciIP"
77+
Write-Host "##[endgroup]"

0 commit comments

Comments
 (0)