1use reqwest::Client;
4use serde::Deserialize;
5use serde_json::Value;
6use tracing::debug;
7
8use crate::auth::AzCliAuth;
9use crate::error::ClientError;
10
11const ARM_BASE_URL: &str = "https://management.azure.com";
12
13pub struct ArmClient {
15 http: Client,
16 token: String,
17}
18
19#[derive(Debug, Clone, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct Subscription {
23 pub subscription_id: String,
24 pub display_name: String,
25 pub state: String,
26}
27
28impl std::fmt::Display for Subscription {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 write!(f, "{} ({})", self.display_name, self.subscription_id)
31 }
32}
33
34#[derive(Debug, Clone, Deserialize)]
36pub struct SearchService {
37 pub name: String,
38 pub location: String,
39 pub sku: SearchServiceSku,
40 #[serde(default)]
41 pub id: String,
42}
43
44#[derive(Debug, Clone, Deserialize)]
45pub struct SearchServiceSku {
46 pub name: String,
47}
48
49impl std::fmt::Display for SearchService {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(
52 f,
53 "{} ({}, {})",
54 self.name,
55 self.location,
56 self.sku.name.to_uppercase()
57 )
58 }
59}
60
61#[derive(Debug, Clone)]
63pub struct DiscoveredService {
64 pub name: String,
65 pub subscription_id: String,
66 pub location: String,
67}
68
69#[derive(Debug, Clone, Deserialize)]
71pub struct AiServicesAccount {
72 pub name: String,
73 pub location: String,
74 #[serde(default)]
75 pub kind: String,
76 #[serde(default)]
77 pub id: String,
78 #[serde(default)]
79 pub properties: AiServicesAccountProperties,
80}
81
82#[derive(Debug, Clone, Default, Deserialize)]
83pub struct AiServicesAccountProperties {
84 #[serde(default)]
86 pub endpoint: Option<String>,
87}
88
89impl AiServicesAccount {
90 pub fn agents_endpoint(&self) -> String {
96 if let Some(ref endpoint) = self.properties.endpoint {
97 if let Some(subdomain) = extract_subdomain(endpoint) {
98 return format!("https://{}.services.ai.azure.com", subdomain);
99 }
100 }
101 format!("https://{}.services.ai.azure.com", self.name)
102 }
103}
104
105fn extract_subdomain(endpoint: &str) -> Option<&str> {
109 let host = endpoint.strip_prefix("https://")?.split('/').next()?;
110 host.split('.').next()
111}
112
113impl std::fmt::Display for AiServicesAccount {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 write!(f, "{} ({})", self.name, self.location)
116 }
117}
118
119#[derive(Debug, Clone, Deserialize)]
121pub struct FoundryProject {
122 #[serde(default)]
124 name: String,
125 pub location: String,
126 #[serde(default)]
127 pub id: String,
128 #[serde(default)]
129 pub properties: FoundryProjectProperties,
130}
131
132#[derive(Debug, Clone, Default, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct FoundryProjectProperties {
135 #[serde(default)]
136 pub display_name: String,
137}
138
139impl FoundryProject {
140 pub fn display_name(&self) -> &str {
142 if !self.properties.display_name.is_empty() {
143 &self.properties.display_name
144 } else {
145 self.name.rsplit('/').next().unwrap_or(&self.name)
147 }
148 }
149}
150
151impl std::fmt::Display for FoundryProject {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 write!(f, "{} ({})", self.display_name(), self.location)
154 }
155}
156
157#[derive(Debug, Clone, Deserialize)]
159pub struct StorageAccount {
160 pub name: String,
161 pub location: String,
162 #[serde(default)]
163 pub id: String,
164}
165
166impl std::fmt::Display for StorageAccount {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 write!(f, "{} ({})", self.name, self.location)
169 }
170}
171
172#[derive(Debug, Clone, Deserialize)]
174struct StorageKey {
175 value: String,
176}
177
178#[derive(Debug, Deserialize)]
180struct StorageKeyList {
181 keys: Vec<StorageKey>,
182}
183
184#[derive(Debug, Clone, Deserialize)]
186pub struct ModelDeployment {
187 pub name: String,
188 #[serde(default)]
189 pub properties: ModelDeploymentProperties,
190 #[serde(default)]
191 pub sku: ModelDeploymentSku,
192}
193
194#[derive(Debug, Clone, Default, Deserialize)]
195pub struct ModelDeploymentProperties {
196 #[serde(default)]
197 pub model: ModelDeploymentModel,
198}
199
200#[derive(Debug, Clone, Default, Deserialize)]
201pub struct ModelDeploymentModel {
202 #[serde(default)]
203 pub name: String,
204 #[serde(default)]
205 pub version: String,
206}
207
208#[derive(Debug, Clone, Default, Deserialize)]
209pub struct ModelDeploymentSku {
210 #[serde(default)]
211 pub name: String,
212 #[serde(default)]
213 pub capacity: u32,
214}
215
216impl std::fmt::Display for ModelDeployment {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 write!(
219 f,
220 "{} ({}, {})",
221 self.name, self.properties.model.name, self.sku.name
222 )
223 }
224}
225
226#[derive(Debug, Clone)]
228pub struct ResourceIdentity {
229 pub kind: String,
231 pub principal_id: Option<String>,
233 pub user_assigned: Vec<(String, String)>,
235}
236
237impl ResourceIdentity {
238 pub fn principal_ids(&self) -> Vec<&str> {
240 let mut ids: Vec<&str> = self.principal_id.iter().map(String::as_str).collect();
241 ids.extend(self.user_assigned.iter().map(|(_, p)| p.as_str()));
242 ids
243 }
244}
245
246fn deterministic_uuid(input: &str) -> String {
248 let mut h1: u64 = 0xcbf29ce484222325;
249 let mut h2: u64 = 0x9e3779b97f4a7c15;
250 for b in input.as_bytes() {
251 h1 ^= u64::from(*b);
252 h1 = h1.wrapping_mul(0x100000001b3);
253 h2 = h2.rotate_left(7) ^ u64::from(*b);
254 h2 = h2.wrapping_mul(0x2545f4914f6cdd1d);
255 }
256 let bytes = [h1.to_be_bytes(), h2.to_be_bytes()].concat();
257 format!(
258 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
259 bytes[0],
260 bytes[1],
261 bytes[2],
262 bytes[3],
263 bytes[4],
264 bytes[5],
265 bytes[6],
266 bytes[7],
267 bytes[8],
268 bytes[9],
269 bytes[10],
270 bytes[11],
271 bytes[12],
272 bytes[13],
273 bytes[14],
274 bytes[15]
275 )
276}
277
278#[derive(Debug, Deserialize)]
280struct ArmListResponse<T> {
281 value: Vec<T>,
282}
283
284impl ArmClient {
285 pub fn new() -> Result<Self, ClientError> {
287 let token = AzCliAuth::get_arm_token()?;
288 let http = Client::builder()
289 .timeout(std::time::Duration::from_secs(30))
290 .build()?;
291
292 Ok(Self { http, token })
293 }
294
295 pub async fn get_resource_identity(
297 &self,
298 resource_id: &str,
299 api_version: &str,
300 ) -> Result<Option<ResourceIdentity>, ClientError> {
301 let url = format!("{ARM_BASE_URL}{resource_id}?api-version={api_version}");
302 let response = self
303 .http
304 .get(&url)
305 .header("Authorization", format!("Bearer {}", self.token))
306 .send()
307 .await?;
308 let status = response.status();
309 if !status.is_success() {
310 let body = response.text().await?;
311 return Err(ClientError::from_response(status.as_u16(), &body));
312 }
313 let value: serde_json::Value = response.json().await?;
314 let Some(identity) = value.get("identity") else {
315 return Ok(None);
316 };
317 let kind = identity
318 .get("type")
319 .and_then(|t| t.as_str())
320 .unwrap_or("None")
321 .to_string();
322 let principal_id = identity
323 .get("principalId")
324 .and_then(|p| p.as_str())
325 .map(str::to_string);
326 let user_assigned = identity
327 .get("userAssignedIdentities")
328 .and_then(|u| u.as_object())
329 .map(|map| {
330 map.iter()
331 .filter_map(|(id, v)| {
332 v.get("principalId")
333 .and_then(|p| p.as_str())
334 .map(|p| (id.clone(), p.to_string()))
335 })
336 .collect()
337 })
338 .unwrap_or_default();
339 Ok(Some(ResourceIdentity {
340 kind,
341 principal_id,
342 user_assigned,
343 }))
344 }
345
346 pub async fn list_role_assignments(
348 &self,
349 scope: &str,
350 principal_id: &str,
351 ) -> Result<Vec<String>, ClientError> {
352 let url = format!(
353 "{ARM_BASE_URL}{scope}/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01&$filter=principalId%20eq%20'{principal_id}'"
354 );
355 let response = self
356 .http
357 .get(&url)
358 .header("Authorization", format!("Bearer {}", self.token))
359 .send()
360 .await?;
361 let status = response.status();
362 if !status.is_success() {
363 let body = response.text().await?;
364 return Err(ClientError::from_response(status.as_u16(), &body));
365 }
366 let value: serde_json::Value = response.json().await?;
367 Ok(value
368 .get("value")
369 .and_then(|v| v.as_array())
370 .map(|arr| {
371 arr.iter()
372 .filter_map(|a| {
373 a.get("properties")
374 .and_then(|p| p.get("roleDefinitionId"))
375 .and_then(|r| r.as_str())
376 .map(str::to_string)
377 })
378 .collect()
379 })
380 .unwrap_or_default())
381 }
382
383 pub async fn create_role_assignment(
385 &self,
386 scope: &str,
387 principal_id: &str,
388 role_definition_guid: &str,
389 ) -> Result<(), ClientError> {
390 let assignment_name =
391 deterministic_uuid(&format!("{scope}|{principal_id}|{role_definition_guid}"));
392 let url = format!(
393 "{ARM_BASE_URL}{scope}/providers/Microsoft.Authorization/roleAssignments/{assignment_name}?api-version=2022-04-01"
394 );
395 let sub = scope.split('/').nth(2).unwrap_or_default();
396 let body = serde_json::json!({
397 "properties": {
398 "roleDefinitionId": format!(
399 "/subscriptions/{sub}/providers/Microsoft.Authorization/roleDefinitions/{role_definition_guid}"
400 ),
401 "principalId": principal_id,
402 "principalType": "ServicePrincipal"
403 }
404 });
405 let response = self
406 .http
407 .put(&url)
408 .header("Authorization", format!("Bearer {}", self.token))
409 .json(&body)
410 .send()
411 .await?;
412 let status = response.status();
413 if status.is_success() || status.as_u16() == 409 {
415 return Ok(());
416 }
417 let text = response.text().await?;
418 Err(ClientError::from_response(status.as_u16(), &text))
419 }
420
421 pub async fn enable_system_identity(
423 &self,
424 resource_id: &str,
425 api_version: &str,
426 ) -> Result<(), ClientError> {
427 let url = format!("{ARM_BASE_URL}{resource_id}?api-version={api_version}");
428 let response = self
429 .http
430 .patch(&url)
431 .header("Authorization", format!("Bearer {}", self.token))
432 .json(&serde_json::json!({"identity": {"type": "SystemAssigned"}}))
433 .send()
434 .await?;
435 let status = response.status();
436 if status.is_success() {
437 return Ok(());
438 }
439 let text = response.text().await?;
440 Err(ClientError::from_response(status.as_u16(), &text))
441 }
442
443 pub async fn find_search_service_id(&self, name: &str) -> Result<String, ClientError> {
445 for sub in self.list_subscriptions().await? {
446 for svc in self.list_search_services(&sub.subscription_id).await? {
447 if svc.name.eq_ignore_ascii_case(name) && !svc.id.is_empty() {
448 return Ok(svc.id);
449 }
450 }
451 }
452 Err(ClientError::NotFound {
453 kind: "search service".to_string(),
454 name: name.to_string(),
455 })
456 }
457
458 pub fn token(&self) -> &str {
460 &self.token
461 }
462
463 pub async fn list_subscriptions(&self) -> Result<Vec<Subscription>, ClientError> {
465 let url = format!("{}/subscriptions?api-version=2022-12-01", ARM_BASE_URL);
466 debug!("Listing subscriptions: {}", url);
467
468 let response = self
469 .http
470 .get(&url)
471 .header("Authorization", format!("Bearer {}", self.token))
472 .send()
473 .await?;
474
475 let status = response.status();
476 if !status.is_success() {
477 let body = response.text().await?;
478 return Err(ClientError::from_response(status.as_u16(), &body));
479 }
480
481 let result: ArmListResponse<Subscription> = response.json().await?;
482 Ok(result
484 .value
485 .into_iter()
486 .filter(|s| s.state == "Enabled")
487 .collect())
488 }
489
490 pub async fn list_search_services(
492 &self,
493 subscription_id: &str,
494 ) -> Result<Vec<SearchService>, ClientError> {
495 let url = format!(
496 "{}/subscriptions/{}/providers/Microsoft.Search/searchServices?api-version=2023-11-01",
497 ARM_BASE_URL, subscription_id
498 );
499 debug!("Listing search services: {}", url);
500
501 let response = self
502 .http
503 .get(&url)
504 .header("Authorization", format!("Bearer {}", self.token))
505 .send()
506 .await?;
507
508 let status = response.status();
509 if !status.is_success() {
510 let body = response.text().await?;
511 return Err(ClientError::from_response(status.as_u16(), &body));
512 }
513
514 let result: ArmListResponse<SearchService> = response.json().await?;
515 Ok(result.value)
516 }
517
518 pub async fn find_resource_group(
522 &self,
523 subscription_id: &str,
524 service_name: &str,
525 ) -> Result<String, ClientError> {
526 let services = self.list_search_services(subscription_id).await?;
527
528 for svc in &services {
529 if svc.name.eq_ignore_ascii_case(service_name) {
530 return parse_resource_group(&svc.id).ok_or_else(|| ClientError::Api {
533 status: 0,
534 message: format!("Could not parse resource group from ARM ID: {}", svc.id),
535 });
536 }
537 }
538
539 Err(ClientError::NotFound {
540 kind: "Search service".to_string(),
541 name: service_name.to_string(),
542 })
543 }
544
545 pub async fn list_ai_services_accounts(
547 &self,
548 subscription_id: &str,
549 ) -> Result<Vec<AiServicesAccount>, ClientError> {
550 let url = format!(
551 "{}/subscriptions/{}/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01",
552 ARM_BASE_URL, subscription_id
553 );
554 debug!("Listing AI Services accounts: {}", url);
555
556 let response = self
557 .http
558 .get(&url)
559 .header("Authorization", format!("Bearer {}", self.token))
560 .send()
561 .await?;
562
563 let status = response.status();
564 if !status.is_success() {
565 let body = response.text().await?;
566 return Err(ClientError::from_response(status.as_u16(), &body));
567 }
568
569 let result: ArmListResponse<AiServicesAccount> = response.json().await?;
570 Ok(result
571 .value
572 .into_iter()
573 .filter(|a| a.kind.eq_ignore_ascii_case("AIServices"))
574 .collect())
575 }
576
577 pub async fn find_cognitive_account_id(&self, name: &str) -> Result<String, ClientError> {
583 self.find_cognitive_account(name).await.map(|a| a.id)
584 }
585
586 pub async fn find_cognitive_account(
590 &self,
591 name: &str,
592 ) -> Result<AiServicesAccount, ClientError> {
593 for sub in self.list_subscriptions().await? {
594 for acct in self.list_cognitive_accounts(&sub.subscription_id).await? {
595 if acct.name.eq_ignore_ascii_case(name) && !acct.id.is_empty() {
596 return Ok(acct);
597 }
598 }
599 }
600 Err(ClientError::NotFound {
601 kind: "Cognitive Services account".to_string(),
602 name: name.to_string(),
603 })
604 }
605
606 pub async fn list_cognitive_accounts(
609 &self,
610 subscription_id: &str,
611 ) -> Result<Vec<AiServicesAccount>, ClientError> {
612 let url = format!(
613 "{}/subscriptions/{}/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01",
614 ARM_BASE_URL, subscription_id
615 );
616 let response = self
617 .http
618 .get(&url)
619 .header("Authorization", format!("Bearer {}", self.token))
620 .send()
621 .await?;
622 if !response.status().is_success() {
623 return Ok(Vec::new());
624 }
625 let result: ArmListResponse<AiServicesAccount> = response.json().await?;
626 Ok(result.value)
627 }
628
629 pub async fn all_foundry_accounts(&self) -> Result<Vec<AiServicesAccount>, ClientError> {
632 let mut out = Vec::new();
633 for sub in self.list_subscriptions().await? {
634 out.extend(
635 self.list_cognitive_accounts(&sub.subscription_id)
636 .await?
637 .into_iter()
638 .filter(|a| a.kind.eq_ignore_ascii_case("AIServices")),
639 );
640 }
641 Ok(out)
642 }
643
644 pub async fn find_web_site_id(&self, name: &str) -> Result<String, ClientError> {
647 for sub in self.list_subscriptions().await? {
648 let url = format!(
649 "{}/subscriptions/{}/providers/Microsoft.Web/sites?api-version=2023-12-01",
650 ARM_BASE_URL, sub.subscription_id
651 );
652 let response = self
653 .http
654 .get(&url)
655 .header("Authorization", format!("Bearer {}", self.token))
656 .send()
657 .await?;
658 if !response.status().is_success() {
659 continue;
660 }
661 #[derive(Deserialize)]
662 struct Site {
663 name: String,
664 #[serde(default)]
665 id: String,
666 }
667 let result: ArmListResponse<Site> = response.json().await?;
668 for site in result.value {
669 if site.name.eq_ignore_ascii_case(name) && !site.id.is_empty() {
670 return Ok(site.id);
671 }
672 }
673 }
674 Err(ClientError::NotFound {
675 kind: "function app".to_string(),
676 name: name.to_string(),
677 })
678 }
679
680 pub async fn function_key(
684 &self,
685 site_id: &str,
686 function_name: &str,
687 ) -> Result<String, ClientError> {
688 let url = format!(
689 "{ARM_BASE_URL}{site_id}/functions/{function_name}/listkeys?api-version=2023-12-01"
690 );
691 let response = self
692 .http
693 .post(&url)
694 .header("Authorization", format!("Bearer {}", self.token))
695 .header("Content-Length", "0")
696 .send()
697 .await?;
698 if response.status().is_success() {
699 let keys: serde_json::Value = response.json().await?;
700 if let Some(k) = keys.get("default").and_then(Value::as_str).or_else(|| {
701 keys.as_object()
702 .and_then(|o| o.values().find_map(Value::as_str))
703 }) {
704 return Ok(k.to_string());
705 }
706 }
707 let url = format!("{ARM_BASE_URL}{site_id}/host/default/listkeys?api-version=2023-12-01");
709 let response = self
710 .http
711 .post(&url)
712 .header("Authorization", format!("Bearer {}", self.token))
713 .header("Content-Length", "0")
714 .send()
715 .await?;
716 let status = response.status();
717 if !status.is_success() {
718 let body = response.text().await?;
719 return Err(ClientError::from_response(status.as_u16(), &body));
720 }
721 let keys: serde_json::Value = response.json().await?;
722 keys.pointer("/functionKeys/default")
723 .and_then(Value::as_str)
724 .or_else(|| keys.get("masterKey").and_then(Value::as_str))
725 .map(str::to_string)
726 .ok_or_else(|| ClientError::NotFound {
727 kind: "function key".to_string(),
728 name: function_name.to_string(),
729 })
730 }
731
732 pub async fn site_auth_settings(&self, site_id: &str) -> Result<Value, ClientError> {
734 let url =
735 format!("{ARM_BASE_URL}{site_id}/config/authsettingsV2/list?api-version=2023-12-01");
736 let response = self
737 .http
738 .get(&url)
739 .header("Authorization", format!("Bearer {}", self.token))
740 .send()
741 .await?;
742 let status = response.status();
743 if !status.is_success() {
744 let body = response.text().await?;
745 return Err(ClientError::from_response(status.as_u16(), &body));
746 }
747 Ok(response.json().await?)
748 }
749
750 pub async fn list_foundry_projects(
758 &self,
759 account: &AiServicesAccount,
760 subscription_id: &str,
761 ) -> Result<Vec<FoundryProject>, ClientError> {
762 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
763 status: 0,
764 message: format!("Could not parse resource group from ARM ID: {}", account.id),
765 })?;
766
767 let url = format!(
768 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/projects?api-version=2025-06-01",
769 ARM_BASE_URL, subscription_id, resource_group, account.name
770 );
771 debug!("Listing Foundry projects: {}", url);
772
773 let response = self
774 .http
775 .get(&url)
776 .header("Authorization", format!("Bearer {}", self.token))
777 .send()
778 .await?;
779
780 let status = response.status();
781 if !status.is_success() {
782 let body = response.text().await?;
783 return Err(ClientError::from_response(status.as_u16(), &body));
784 }
785
786 let result: ArmListResponse<FoundryProject> = response.json().await?;
787 Ok(result.value)
788 }
789
790 pub async fn list_storage_accounts(
792 &self,
793 subscription_id: &str,
794 resource_group: &str,
795 ) -> Result<Vec<StorageAccount>, ClientError> {
796 let url = format!(
797 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01",
798 ARM_BASE_URL, subscription_id, resource_group
799 );
800 debug!("Listing storage accounts: {}", url);
801
802 let response = self
803 .http
804 .get(&url)
805 .header("Authorization", format!("Bearer {}", self.token))
806 .send()
807 .await?;
808
809 let status = response.status();
810 if !status.is_success() {
811 let body = response.text().await?;
812 return Err(ClientError::from_response(status.as_u16(), &body));
813 }
814
815 let result: ArmListResponse<StorageAccount> = response.json().await?;
816 Ok(result.value)
817 }
818
819 pub async fn list_storage_accounts_subscription(
821 &self,
822 subscription_id: &str,
823 ) -> Result<Vec<StorageAccount>, ClientError> {
824 let url = format!(
825 "{}/subscriptions/{}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01",
826 ARM_BASE_URL, subscription_id
827 );
828 debug!("Listing storage accounts (subscription-wide): {}", url);
829
830 let response = self
831 .http
832 .get(&url)
833 .header("Authorization", format!("Bearer {}", self.token))
834 .send()
835 .await?;
836
837 let status = response.status();
838 if !status.is_success() {
839 let body = response.text().await?;
840 return Err(ClientError::from_response(status.as_u16(), &body));
841 }
842
843 let result: ArmListResponse<StorageAccount> = response.json().await?;
844 Ok(result.value)
845 }
846
847 pub async fn storage_account_has_container(
851 &self,
852 account_id: &str,
853 container: &str,
854 ) -> Result<bool, ClientError> {
855 let url = format!(
856 "{ARM_BASE_URL}{account_id}/blobServices/default/containers/{container}?api-version=2023-05-01"
857 );
858 debug!("Checking container: {}", url);
859
860 let response = self
861 .http
862 .get(&url)
863 .header("Authorization", format!("Bearer {}", self.token))
864 .send()
865 .await?;
866
867 match response.status().as_u16() {
868 200 => Ok(true),
869 404 => Ok(false),
870 status => {
871 let body = response.text().await?;
872 Err(ClientError::from_response(status, &body))
873 }
874 }
875 }
876
877 pub async fn find_storage_accounts_with_container(
884 &self,
885 container: &str,
886 ) -> Result<Vec<StorageAccount>, ClientError> {
887 let mut matches = Vec::new();
888 for sub in self.list_subscriptions().await? {
889 let accounts = match self
890 .list_storage_accounts_subscription(&sub.subscription_id)
891 .await
892 {
893 Ok(a) => a,
894 Err(e) => {
895 debug!(
896 "skipping subscription {} ({}): {e}",
897 sub.display_name, sub.subscription_id
898 );
899 continue;
900 }
901 };
902 for account in accounts {
903 if account.id.is_empty() {
904 continue;
905 }
906 match self
907 .storage_account_has_container(&account.id, container)
908 .await
909 {
910 Ok(true) => matches.push(account),
911 Ok(false) => {}
912 Err(e) => debug!("skipping account {} ({e})", account.name),
913 }
914 }
915 }
916 Ok(matches)
917 }
918
919 pub async fn get_storage_account_key(
921 &self,
922 subscription_id: &str,
923 resource_group: &str,
924 account_name: &str,
925 ) -> Result<String, ClientError> {
926 let url = format!(
927 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts/{}/listKeys?api-version=2023-05-01",
928 ARM_BASE_URL, subscription_id, resource_group, account_name
929 );
930 debug!("Getting storage account keys: {}", url);
931
932 let response = self
933 .http
934 .post(&url)
935 .header("Authorization", format!("Bearer {}", self.token))
936 .header("Content-Length", "0")
937 .send()
938 .await?;
939
940 let status = response.status();
941 if !status.is_success() {
942 let body = response.text().await?;
943 return Err(ClientError::from_response(status.as_u16(), &body));
944 }
945
946 let key_list: StorageKeyList = response.json().await?;
947 key_list
948 .keys
949 .into_iter()
950 .next()
951 .map(|k| k.value)
952 .ok_or_else(|| ClientError::Api {
953 status: 0,
954 message: "No keys found for storage account".to_string(),
955 })
956 }
957
958 pub async fn list_model_deployments(
960 &self,
961 account: &AiServicesAccount,
962 subscription_id: &str,
963 ) -> Result<Vec<ModelDeployment>, ClientError> {
964 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
965 status: 0,
966 message: format!("Could not parse resource group from ARM ID: {}", account.id),
967 })?;
968
969 let url = format!(
970 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments?api-version=2024-10-01",
971 ARM_BASE_URL, subscription_id, resource_group, account.name
972 );
973 debug!("Listing model deployments: {}", url);
974
975 let response = self
976 .http
977 .get(&url)
978 .header("Authorization", format!("Bearer {}", self.token))
979 .send()
980 .await?;
981
982 let status = response.status();
983 if !status.is_success() {
984 let body = response.text().await?;
985 return Err(ClientError::from_response(status.as_u16(), &body));
986 }
987
988 let result: ArmListResponse<ModelDeployment> = response.json().await?;
989 Ok(result.value)
990 }
991
992 pub async fn create_model_deployment(
994 &self,
995 account: &AiServicesAccount,
996 subscription_id: &str,
997 deployment_name: &str,
998 model_name: &str,
999 model_version: &str,
1000 ) -> Result<(), ClientError> {
1001 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
1002 status: 0,
1003 message: format!("Could not parse resource group from ARM ID: {}", account.id),
1004 })?;
1005
1006 let url = format!(
1007 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments/{}?api-version=2024-10-01",
1008 ARM_BASE_URL, subscription_id, resource_group, account.name, deployment_name
1009 );
1010 debug!("Creating model deployment: {}", url);
1011
1012 let body = serde_json::json!({
1013 "sku": {
1014 "name": "GlobalStandard",
1015 "capacity": 1
1016 },
1017 "properties": {
1018 "model": {
1019 "format": "OpenAI",
1020 "name": model_name,
1021 "version": model_version
1022 }
1023 }
1024 });
1025
1026 let response = self
1027 .http
1028 .put(&url)
1029 .header("Authorization", format!("Bearer {}", self.token))
1030 .json(&body)
1031 .send()
1032 .await?;
1033
1034 let status = response.status();
1035 if !status.is_success() {
1036 let body = response.text().await?;
1037 return Err(ClientError::from_response(status.as_u16(), &body));
1038 }
1039
1040 Ok(())
1041 }
1042
1043 pub async fn get_storage_connection_string(
1045 &self,
1046 subscription_id: &str,
1047 resource_group: &str,
1048 account_name: &str,
1049 ) -> Result<String, ClientError> {
1050 let key = self
1051 .get_storage_account_key(subscription_id, resource_group, account_name)
1052 .await?;
1053
1054 Ok(format!(
1055 "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
1056 account_name, key
1057 ))
1058 }
1059}
1060
1061fn parse_resource_group(arm_id: &str) -> Option<String> {
1065 let parts: Vec<&str> = arm_id.split('/').collect();
1066 for (i, part) in parts.iter().enumerate() {
1067 if part.eq_ignore_ascii_case("resourceGroups")
1068 || part.eq_ignore_ascii_case("resourcegroups")
1069 {
1070 return parts.get(i + 1).map(|s| s.to_string());
1071 }
1072 }
1073 None
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079
1080 #[test]
1081 fn test_parse_resource_group() {
1082 let id = "/subscriptions/abc-123/resourceGroups/my-rg/providers/Microsoft.Search/searchServices/my-svc";
1083 assert_eq!(parse_resource_group(id), Some("my-rg".to_string()));
1084 }
1085
1086 #[test]
1087 fn test_parse_resource_group_case_insensitive() {
1088 let id = "/subscriptions/abc/resourcegroups/MyRG/providers/Something";
1089 assert_eq!(parse_resource_group(id), Some("MyRG".to_string()));
1090 }
1091
1092 #[test]
1093 fn test_parse_resource_group_missing() {
1094 let id = "/subscriptions/abc/providers/Something";
1095 assert_eq!(parse_resource_group(id), None);
1096 }
1097
1098 #[test]
1099 fn test_ai_services_account_display() {
1100 let account = AiServicesAccount {
1101 name: "my-ai-service".to_string(),
1102 location: "eastus".to_string(),
1103 kind: "AIServices".to_string(),
1104 id: String::new(),
1105 properties: AiServicesAccountProperties::default(),
1106 };
1107 assert_eq!(format!("{}", account), "my-ai-service (eastus)");
1108 }
1109
1110 #[test]
1111 fn test_agents_endpoint_from_arm_endpoint() {
1112 let account = AiServicesAccount {
1113 name: "irma-prod-foundry".to_string(),
1114 location: "swedencentral".to_string(),
1115 kind: "AIServices".to_string(),
1116 id: String::new(),
1117 properties: AiServicesAccountProperties {
1118 endpoint: Some("https://custom-subdomain.cognitiveservices.azure.com/".to_string()),
1119 },
1120 };
1121 assert_eq!(
1122 account.agents_endpoint(),
1123 "https://custom-subdomain.services.ai.azure.com"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_agents_endpoint_fallback_to_name() {
1129 let account = AiServicesAccount {
1130 name: "irma-prod-foundry".to_string(),
1131 location: "swedencentral".to_string(),
1132 kind: "AIServices".to_string(),
1133 id: String::new(),
1134 properties: AiServicesAccountProperties::default(),
1135 };
1136 assert_eq!(
1137 account.agents_endpoint(),
1138 "https://irma-prod-foundry.services.ai.azure.com"
1139 );
1140 }
1141
1142 #[test]
1143 fn test_extract_subdomain() {
1144 assert_eq!(
1145 extract_subdomain("https://my-svc.cognitiveservices.azure.com/"),
1146 Some("my-svc")
1147 );
1148 assert_eq!(
1149 extract_subdomain("https://custom.services.ai.azure.com"),
1150 Some("custom")
1151 );
1152 assert_eq!(extract_subdomain("not-a-url"), None);
1153 }
1154
1155 #[test]
1156 fn test_foundry_project_display_with_display_name() {
1157 let project = FoundryProject {
1158 name: "my-account/my-project".to_string(),
1159 location: "westus2".to_string(),
1160 id: String::new(),
1161 properties: FoundryProjectProperties {
1162 display_name: "my-project".to_string(),
1163 },
1164 };
1165 assert_eq!(format!("{}", project), "my-project (westus2)");
1166 assert_eq!(project.display_name(), "my-project");
1167 }
1168
1169 #[test]
1170 fn test_model_deployment_display() {
1171 let deployment = ModelDeployment {
1172 name: "gpt-4o-mini".to_string(),
1173 properties: ModelDeploymentProperties {
1174 model: ModelDeploymentModel {
1175 name: "gpt-4o-mini".to_string(),
1176 version: "2024-07-18".to_string(),
1177 },
1178 },
1179 sku: ModelDeploymentSku {
1180 name: "GlobalStandard".to_string(),
1181 capacity: 1,
1182 },
1183 };
1184 assert_eq!(
1185 format!("{}", deployment),
1186 "gpt-4o-mini (gpt-4o-mini, GlobalStandard)"
1187 );
1188 }
1189
1190 #[test]
1191 fn test_foundry_project_display_name_fallback() {
1192 let project = FoundryProject {
1193 name: "my-account/proj-default".to_string(),
1194 location: "swedencentral".to_string(),
1195 id: String::new(),
1196 properties: FoundryProjectProperties::default(),
1197 };
1198 assert_eq!(project.display_name(), "proj-default");
1199 assert_eq!(format!("{}", project), "proj-default (swedencentral)");
1200 }
1201}
1202
1203#[cfg(test)]
1204mod identity_tests {
1205 use super::*;
1206
1207 #[test]
1208 fn deterministic_uuid_stable_and_shaped() {
1209 let a = deterministic_uuid("scope|principal|role");
1210 let b = deterministic_uuid("scope|principal|role");
1211 let c = deterministic_uuid("scope|principal|other-role");
1212 assert_eq!(a, b);
1213 assert_ne!(a, c);
1214 assert_eq!(a.len(), 36);
1215 assert_eq!(a.chars().filter(|ch| *ch == '-').count(), 4);
1216 }
1217
1218 #[test]
1219 fn resource_identity_principal_ids() {
1220 let id = ResourceIdentity {
1221 kind: "SystemAssigned, UserAssigned".into(),
1222 principal_id: Some("sys".into()),
1223 user_assigned: vec![("id1".into(), "ua1".into())],
1224 };
1225 assert_eq!(id.principal_ids(), vec!["sys", "ua1"]);
1226 }
1227}