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 list_web_sites(&self) -> Result<Vec<String>, ClientError> {
647 let mut out: Vec<String> = Vec::new();
648 for sub in self.list_subscriptions().await? {
649 let url = format!(
650 "{}/subscriptions/{}/providers/Microsoft.Web/sites?api-version=2023-12-01",
651 ARM_BASE_URL, sub.subscription_id
652 );
653 let response = self
654 .http
655 .get(&url)
656 .header("Authorization", format!("Bearer {}", self.token))
657 .send()
658 .await?;
659 if !response.status().is_success() {
660 continue;
661 }
662 #[derive(Deserialize)]
663 struct Site {
664 name: String,
665 }
666 let result: ArmListResponse<Site> = response.json().await?;
667 out.extend(result.value.into_iter().map(|s| s.name));
668 }
669 out.sort();
670 out.dedup();
671 Ok(out)
672 }
673
674 pub async fn find_web_site_id(&self, name: &str) -> Result<String, ClientError> {
677 for sub in self.list_subscriptions().await? {
678 let url = format!(
679 "{}/subscriptions/{}/providers/Microsoft.Web/sites?api-version=2023-12-01",
680 ARM_BASE_URL, sub.subscription_id
681 );
682 let response = self
683 .http
684 .get(&url)
685 .header("Authorization", format!("Bearer {}", self.token))
686 .send()
687 .await?;
688 if !response.status().is_success() {
689 continue;
690 }
691 #[derive(Deserialize)]
692 struct Site {
693 name: String,
694 #[serde(default)]
695 id: String,
696 }
697 let result: ArmListResponse<Site> = response.json().await?;
698 for site in result.value {
699 if site.name.eq_ignore_ascii_case(name) && !site.id.is_empty() {
700 return Ok(site.id);
701 }
702 }
703 }
704 Err(ClientError::NotFound {
705 kind: "function app".to_string(),
706 name: name.to_string(),
707 })
708 }
709
710 pub async fn function_key(
714 &self,
715 site_id: &str,
716 function_name: &str,
717 ) -> Result<String, ClientError> {
718 let url = format!(
719 "{ARM_BASE_URL}{site_id}/functions/{function_name}/listkeys?api-version=2023-12-01"
720 );
721 let response = self
722 .http
723 .post(&url)
724 .header("Authorization", format!("Bearer {}", self.token))
725 .header("Content-Length", "0")
726 .send()
727 .await?;
728 if response.status().is_success() {
729 let keys: serde_json::Value = response.json().await?;
730 if let Some(k) = keys.get("default").and_then(Value::as_str).or_else(|| {
731 keys.as_object()
732 .and_then(|o| o.values().find_map(Value::as_str))
733 }) {
734 return Ok(k.to_string());
735 }
736 }
737 let url = format!("{ARM_BASE_URL}{site_id}/host/default/listkeys?api-version=2023-12-01");
739 let response = self
740 .http
741 .post(&url)
742 .header("Authorization", format!("Bearer {}", self.token))
743 .header("Content-Length", "0")
744 .send()
745 .await?;
746 let status = response.status();
747 if !status.is_success() {
748 let body = response.text().await?;
749 return Err(ClientError::from_response(status.as_u16(), &body));
750 }
751 let keys: serde_json::Value = response.json().await?;
752 keys.pointer("/functionKeys/default")
753 .and_then(Value::as_str)
754 .or_else(|| keys.get("masterKey").and_then(Value::as_str))
755 .map(str::to_string)
756 .ok_or_else(|| ClientError::NotFound {
757 kind: "function key".to_string(),
758 name: function_name.to_string(),
759 })
760 }
761
762 pub async fn site_auth_settings(&self, site_id: &str) -> Result<Value, ClientError> {
764 let url =
765 format!("{ARM_BASE_URL}{site_id}/config/authsettingsV2/list?api-version=2023-12-01");
766 let response = self
767 .http
768 .get(&url)
769 .header("Authorization", format!("Bearer {}", self.token))
770 .send()
771 .await?;
772 let status = response.status();
773 if !status.is_success() {
774 let body = response.text().await?;
775 return Err(ClientError::from_response(status.as_u16(), &body));
776 }
777 Ok(response.json().await?)
778 }
779
780 pub async fn list_foundry_projects(
788 &self,
789 account: &AiServicesAccount,
790 subscription_id: &str,
791 ) -> Result<Vec<FoundryProject>, ClientError> {
792 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
793 status: 0,
794 message: format!("Could not parse resource group from ARM ID: {}", account.id),
795 })?;
796
797 let url = format!(
798 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/projects?api-version=2025-06-01",
799 ARM_BASE_URL, subscription_id, resource_group, account.name
800 );
801 debug!("Listing Foundry projects: {}", url);
802
803 let response = self
804 .http
805 .get(&url)
806 .header("Authorization", format!("Bearer {}", self.token))
807 .send()
808 .await?;
809
810 let status = response.status();
811 if !status.is_success() {
812 let body = response.text().await?;
813 return Err(ClientError::from_response(status.as_u16(), &body));
814 }
815
816 let result: ArmListResponse<FoundryProject> = response.json().await?;
817 Ok(result.value)
818 }
819
820 pub async fn list_storage_accounts(
822 &self,
823 subscription_id: &str,
824 resource_group: &str,
825 ) -> Result<Vec<StorageAccount>, ClientError> {
826 let url = format!(
827 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01",
828 ARM_BASE_URL, subscription_id, resource_group
829 );
830 debug!("Listing storage accounts: {}", url);
831
832 let response = self
833 .http
834 .get(&url)
835 .header("Authorization", format!("Bearer {}", self.token))
836 .send()
837 .await?;
838
839 let status = response.status();
840 if !status.is_success() {
841 let body = response.text().await?;
842 return Err(ClientError::from_response(status.as_u16(), &body));
843 }
844
845 let result: ArmListResponse<StorageAccount> = response.json().await?;
846 Ok(result.value)
847 }
848
849 pub async fn list_storage_accounts_subscription(
851 &self,
852 subscription_id: &str,
853 ) -> Result<Vec<StorageAccount>, ClientError> {
854 let url = format!(
855 "{}/subscriptions/{}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01",
856 ARM_BASE_URL, subscription_id
857 );
858 debug!("Listing storage accounts (subscription-wide): {}", url);
859
860 let response = self
861 .http
862 .get(&url)
863 .header("Authorization", format!("Bearer {}", self.token))
864 .send()
865 .await?;
866
867 let status = response.status();
868 if !status.is_success() {
869 let body = response.text().await?;
870 return Err(ClientError::from_response(status.as_u16(), &body));
871 }
872
873 let result: ArmListResponse<StorageAccount> = response.json().await?;
874 Ok(result.value)
875 }
876
877 pub async fn storage_account_has_container(
881 &self,
882 account_id: &str,
883 container: &str,
884 ) -> Result<bool, ClientError> {
885 let url = format!(
886 "{ARM_BASE_URL}{account_id}/blobServices/default/containers/{container}?api-version=2023-05-01"
887 );
888 debug!("Checking container: {}", url);
889
890 let response = self
891 .http
892 .get(&url)
893 .header("Authorization", format!("Bearer {}", self.token))
894 .send()
895 .await?;
896
897 match response.status().as_u16() {
898 200 => Ok(true),
899 404 => Ok(false),
900 status => {
901 let body = response.text().await?;
902 Err(ClientError::from_response(status, &body))
903 }
904 }
905 }
906
907 pub async fn find_storage_accounts_with_container(
914 &self,
915 container: &str,
916 ) -> Result<Vec<StorageAccount>, ClientError> {
917 let mut matches = Vec::new();
918 for sub in self.list_subscriptions().await? {
919 let accounts = match self
920 .list_storage_accounts_subscription(&sub.subscription_id)
921 .await
922 {
923 Ok(a) => a,
924 Err(e) => {
925 debug!(
926 "skipping subscription {} ({}): {e}",
927 sub.display_name, sub.subscription_id
928 );
929 continue;
930 }
931 };
932 for account in accounts {
933 if account.id.is_empty() {
934 continue;
935 }
936 match self
937 .storage_account_has_container(&account.id, container)
938 .await
939 {
940 Ok(true) => matches.push(account),
941 Ok(false) => {}
942 Err(e) => debug!("skipping account {} ({e})", account.name),
943 }
944 }
945 }
946 Ok(matches)
947 }
948
949 pub async fn get_storage_account_key(
951 &self,
952 subscription_id: &str,
953 resource_group: &str,
954 account_name: &str,
955 ) -> Result<String, ClientError> {
956 let url = format!(
957 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts/{}/listKeys?api-version=2023-05-01",
958 ARM_BASE_URL, subscription_id, resource_group, account_name
959 );
960 debug!("Getting storage account keys: {}", url);
961
962 let response = self
963 .http
964 .post(&url)
965 .header("Authorization", format!("Bearer {}", self.token))
966 .header("Content-Length", "0")
967 .send()
968 .await?;
969
970 let status = response.status();
971 if !status.is_success() {
972 let body = response.text().await?;
973 return Err(ClientError::from_response(status.as_u16(), &body));
974 }
975
976 let key_list: StorageKeyList = response.json().await?;
977 key_list
978 .keys
979 .into_iter()
980 .next()
981 .map(|k| k.value)
982 .ok_or_else(|| ClientError::Api {
983 status: 0,
984 message: "No keys found for storage account".to_string(),
985 })
986 }
987
988 pub async fn list_model_deployments(
990 &self,
991 account: &AiServicesAccount,
992 subscription_id: &str,
993 ) -> Result<Vec<ModelDeployment>, ClientError> {
994 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
995 status: 0,
996 message: format!("Could not parse resource group from ARM ID: {}", account.id),
997 })?;
998
999 let url = format!(
1000 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments?api-version=2024-10-01",
1001 ARM_BASE_URL, subscription_id, resource_group, account.name
1002 );
1003 debug!("Listing model deployments: {}", url);
1004
1005 let response = self
1006 .http
1007 .get(&url)
1008 .header("Authorization", format!("Bearer {}", self.token))
1009 .send()
1010 .await?;
1011
1012 let status = response.status();
1013 if !status.is_success() {
1014 let body = response.text().await?;
1015 return Err(ClientError::from_response(status.as_u16(), &body));
1016 }
1017
1018 let result: ArmListResponse<ModelDeployment> = response.json().await?;
1019 Ok(result.value)
1020 }
1021
1022 pub async fn create_model_deployment(
1024 &self,
1025 account: &AiServicesAccount,
1026 subscription_id: &str,
1027 deployment_name: &str,
1028 model_name: &str,
1029 model_version: &str,
1030 ) -> Result<(), ClientError> {
1031 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
1032 status: 0,
1033 message: format!("Could not parse resource group from ARM ID: {}", account.id),
1034 })?;
1035
1036 let url = format!(
1037 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/deployments/{}?api-version=2024-10-01",
1038 ARM_BASE_URL, subscription_id, resource_group, account.name, deployment_name
1039 );
1040 debug!("Creating model deployment: {}", url);
1041
1042 let body = serde_json::json!({
1043 "sku": {
1044 "name": "GlobalStandard",
1045 "capacity": 1
1046 },
1047 "properties": {
1048 "model": {
1049 "format": "OpenAI",
1050 "name": model_name,
1051 "version": model_version
1052 }
1053 }
1054 });
1055
1056 let response = self
1057 .http
1058 .put(&url)
1059 .header("Authorization", format!("Bearer {}", self.token))
1060 .json(&body)
1061 .send()
1062 .await?;
1063
1064 let status = response.status();
1065 if !status.is_success() {
1066 let body = response.text().await?;
1067 return Err(ClientError::from_response(status.as_u16(), &body));
1068 }
1069
1070 Ok(())
1071 }
1072
1073 pub async fn get_storage_connection_string(
1075 &self,
1076 subscription_id: &str,
1077 resource_group: &str,
1078 account_name: &str,
1079 ) -> Result<String, ClientError> {
1080 let key = self
1081 .get_storage_account_key(subscription_id, resource_group, account_name)
1082 .await?;
1083
1084 Ok(format!(
1085 "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
1086 account_name, key
1087 ))
1088 }
1089}
1090
1091fn parse_resource_group(arm_id: &str) -> Option<String> {
1095 let parts: Vec<&str> = arm_id.split('/').collect();
1096 for (i, part) in parts.iter().enumerate() {
1097 if part.eq_ignore_ascii_case("resourceGroups")
1098 || part.eq_ignore_ascii_case("resourcegroups")
1099 {
1100 return parts.get(i + 1).map(|s| s.to_string());
1101 }
1102 }
1103 None
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108 use super::*;
1109
1110 #[test]
1111 fn test_parse_resource_group() {
1112 let id = "/subscriptions/abc-123/resourceGroups/my-rg/providers/Microsoft.Search/searchServices/my-svc";
1113 assert_eq!(parse_resource_group(id), Some("my-rg".to_string()));
1114 }
1115
1116 #[test]
1117 fn test_parse_resource_group_case_insensitive() {
1118 let id = "/subscriptions/abc/resourcegroups/MyRG/providers/Something";
1119 assert_eq!(parse_resource_group(id), Some("MyRG".to_string()));
1120 }
1121
1122 #[test]
1123 fn test_parse_resource_group_missing() {
1124 let id = "/subscriptions/abc/providers/Something";
1125 assert_eq!(parse_resource_group(id), None);
1126 }
1127
1128 #[test]
1129 fn test_ai_services_account_display() {
1130 let account = AiServicesAccount {
1131 name: "my-ai-service".to_string(),
1132 location: "eastus".to_string(),
1133 kind: "AIServices".to_string(),
1134 id: String::new(),
1135 properties: AiServicesAccountProperties::default(),
1136 };
1137 assert_eq!(format!("{}", account), "my-ai-service (eastus)");
1138 }
1139
1140 #[test]
1141 fn test_agents_endpoint_from_arm_endpoint() {
1142 let account = AiServicesAccount {
1143 name: "irma-prod-foundry".to_string(),
1144 location: "swedencentral".to_string(),
1145 kind: "AIServices".to_string(),
1146 id: String::new(),
1147 properties: AiServicesAccountProperties {
1148 endpoint: Some("https://custom-subdomain.cognitiveservices.azure.com/".to_string()),
1149 },
1150 };
1151 assert_eq!(
1152 account.agents_endpoint(),
1153 "https://custom-subdomain.services.ai.azure.com"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_agents_endpoint_fallback_to_name() {
1159 let account = AiServicesAccount {
1160 name: "irma-prod-foundry".to_string(),
1161 location: "swedencentral".to_string(),
1162 kind: "AIServices".to_string(),
1163 id: String::new(),
1164 properties: AiServicesAccountProperties::default(),
1165 };
1166 assert_eq!(
1167 account.agents_endpoint(),
1168 "https://irma-prod-foundry.services.ai.azure.com"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_extract_subdomain() {
1174 assert_eq!(
1175 extract_subdomain("https://my-svc.cognitiveservices.azure.com/"),
1176 Some("my-svc")
1177 );
1178 assert_eq!(
1179 extract_subdomain("https://custom.services.ai.azure.com"),
1180 Some("custom")
1181 );
1182 assert_eq!(extract_subdomain("not-a-url"), None);
1183 }
1184
1185 #[test]
1186 fn test_foundry_project_display_with_display_name() {
1187 let project = FoundryProject {
1188 name: "my-account/my-project".to_string(),
1189 location: "westus2".to_string(),
1190 id: String::new(),
1191 properties: FoundryProjectProperties {
1192 display_name: "my-project".to_string(),
1193 },
1194 };
1195 assert_eq!(format!("{}", project), "my-project (westus2)");
1196 assert_eq!(project.display_name(), "my-project");
1197 }
1198
1199 #[test]
1200 fn test_model_deployment_display() {
1201 let deployment = ModelDeployment {
1202 name: "gpt-4o-mini".to_string(),
1203 properties: ModelDeploymentProperties {
1204 model: ModelDeploymentModel {
1205 name: "gpt-4o-mini".to_string(),
1206 version: "2024-07-18".to_string(),
1207 },
1208 },
1209 sku: ModelDeploymentSku {
1210 name: "GlobalStandard".to_string(),
1211 capacity: 1,
1212 },
1213 };
1214 assert_eq!(
1215 format!("{}", deployment),
1216 "gpt-4o-mini (gpt-4o-mini, GlobalStandard)"
1217 );
1218 }
1219
1220 #[test]
1221 fn test_foundry_project_display_name_fallback() {
1222 let project = FoundryProject {
1223 name: "my-account/proj-default".to_string(),
1224 location: "swedencentral".to_string(),
1225 id: String::new(),
1226 properties: FoundryProjectProperties::default(),
1227 };
1228 assert_eq!(project.display_name(), "proj-default");
1229 assert_eq!(format!("{}", project), "proj-default (swedencentral)");
1230 }
1231}
1232
1233#[cfg(test)]
1234mod identity_tests {
1235 use super::*;
1236
1237 #[test]
1238 fn deterministic_uuid_stable_and_shaped() {
1239 let a = deterministic_uuid("scope|principal|role");
1240 let b = deterministic_uuid("scope|principal|role");
1241 let c = deterministic_uuid("scope|principal|other-role");
1242 assert_eq!(a, b);
1243 assert_ne!(a, c);
1244 assert_eq!(a.len(), 36);
1245 assert_eq!(a.chars().filter(|ch| *ch == '-').count(), 4);
1246 }
1247
1248 #[test]
1249 fn resource_identity_principal_ids() {
1250 let id = ResourceIdentity {
1251 kind: "SystemAssigned, UserAssigned".into(),
1252 principal_id: Some("sys".into()),
1253 user_assigned: vec![("id1".into(), "ua1".into())],
1254 };
1255 assert_eq!(id.principal_ids(), vec!["sys", "ua1"]);
1256 }
1257}