1use reqwest::Client;
4use serde::Deserialize;
5use tracing::debug;
6
7use crate::auth::AzCliAuth;
8use crate::error::ClientError;
9
10const ARM_BASE_URL: &str = "https://management.azure.com";
11
12pub struct ArmClient {
14 http: Client,
15 token: String,
16}
17
18#[derive(Debug, Clone, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct Subscription {
22 pub subscription_id: String,
23 pub display_name: String,
24 pub state: String,
25}
26
27impl std::fmt::Display for Subscription {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 write!(f, "{} ({})", self.display_name, self.subscription_id)
30 }
31}
32
33#[derive(Debug, Clone, Deserialize)]
35pub struct SearchService {
36 pub name: String,
37 pub location: String,
38 pub sku: SearchServiceSku,
39 #[serde(default)]
40 pub id: String,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44pub struct SearchServiceSku {
45 pub name: String,
46}
47
48impl std::fmt::Display for SearchService {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 write!(
51 f,
52 "{} ({}, {})",
53 self.name,
54 self.location,
55 self.sku.name.to_uppercase()
56 )
57 }
58}
59
60#[derive(Debug, Clone)]
62pub struct DiscoveredService {
63 pub name: String,
64 pub subscription_id: String,
65 pub location: String,
66}
67
68#[derive(Debug, Clone, Deserialize)]
70pub struct AiServicesAccount {
71 pub name: String,
72 pub location: String,
73 #[serde(default)]
74 pub kind: String,
75 #[serde(default)]
76 pub id: String,
77}
78
79impl std::fmt::Display for AiServicesAccount {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 write!(f, "{} ({})", self.name, self.location)
82 }
83}
84
85#[derive(Debug, Clone, Deserialize)]
87pub struct FoundryProject {
88 #[serde(default)]
90 name: String,
91 pub location: String,
92 #[serde(default)]
93 pub id: String,
94 #[serde(default)]
95 pub properties: FoundryProjectProperties,
96}
97
98#[derive(Debug, Clone, Default, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct FoundryProjectProperties {
101 #[serde(default)]
102 pub display_name: String,
103}
104
105impl FoundryProject {
106 pub fn display_name(&self) -> &str {
108 if !self.properties.display_name.is_empty() {
109 &self.properties.display_name
110 } else {
111 self.name.rsplit('/').next().unwrap_or(&self.name)
113 }
114 }
115}
116
117impl std::fmt::Display for FoundryProject {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 write!(f, "{} ({})", self.display_name(), self.location)
120 }
121}
122
123#[derive(Debug, Clone, Deserialize)]
125pub struct StorageAccount {
126 pub name: String,
127 pub location: String,
128 #[serde(default)]
129 pub id: String,
130}
131
132impl std::fmt::Display for StorageAccount {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 write!(f, "{} ({})", self.name, self.location)
135 }
136}
137
138#[derive(Debug, Clone, Deserialize)]
140struct StorageKey {
141 value: String,
142}
143
144#[derive(Debug, Deserialize)]
146struct StorageKeyList {
147 keys: Vec<StorageKey>,
148}
149
150#[derive(Debug, Deserialize)]
152struct ArmListResponse<T> {
153 value: Vec<T>,
154}
155
156impl ArmClient {
157 pub fn new() -> Result<Self, ClientError> {
159 let token = AzCliAuth::get_arm_token()?;
160 let http = Client::builder()
161 .timeout(std::time::Duration::from_secs(30))
162 .build()?;
163
164 Ok(Self { http, token })
165 }
166
167 pub async fn list_subscriptions(&self) -> Result<Vec<Subscription>, ClientError> {
169 let url = format!("{}/subscriptions?api-version=2022-12-01", ARM_BASE_URL);
170 debug!("Listing subscriptions: {}", url);
171
172 let response = self
173 .http
174 .get(&url)
175 .header("Authorization", format!("Bearer {}", self.token))
176 .send()
177 .await?;
178
179 let status = response.status();
180 if !status.is_success() {
181 let body = response.text().await?;
182 return Err(ClientError::from_response(status.as_u16(), &body));
183 }
184
185 let result: ArmListResponse<Subscription> = response.json().await?;
186 Ok(result
188 .value
189 .into_iter()
190 .filter(|s| s.state == "Enabled")
191 .collect())
192 }
193
194 pub async fn list_search_services(
196 &self,
197 subscription_id: &str,
198 ) -> Result<Vec<SearchService>, ClientError> {
199 let url = format!(
200 "{}/subscriptions/{}/providers/Microsoft.Search/searchServices?api-version=2023-11-01",
201 ARM_BASE_URL, subscription_id
202 );
203 debug!("Listing search services: {}", url);
204
205 let response = self
206 .http
207 .get(&url)
208 .header("Authorization", format!("Bearer {}", self.token))
209 .send()
210 .await?;
211
212 let status = response.status();
213 if !status.is_success() {
214 let body = response.text().await?;
215 return Err(ClientError::from_response(status.as_u16(), &body));
216 }
217
218 let result: ArmListResponse<SearchService> = response.json().await?;
219 Ok(result.value)
220 }
221
222 pub async fn find_resource_group(
226 &self,
227 subscription_id: &str,
228 service_name: &str,
229 ) -> Result<String, ClientError> {
230 let services = self.list_search_services(subscription_id).await?;
231
232 for svc in &services {
233 if svc.name.eq_ignore_ascii_case(service_name) {
234 return parse_resource_group(&svc.id).ok_or_else(|| ClientError::Api {
237 status: 0,
238 message: format!("Could not parse resource group from ARM ID: {}", svc.id),
239 });
240 }
241 }
242
243 Err(ClientError::NotFound {
244 kind: "Search service".to_string(),
245 name: service_name.to_string(),
246 })
247 }
248
249 pub async fn list_ai_services_accounts(
251 &self,
252 subscription_id: &str,
253 ) -> Result<Vec<AiServicesAccount>, ClientError> {
254 let url = format!(
255 "{}/subscriptions/{}/providers/Microsoft.CognitiveServices/accounts?api-version=2024-10-01",
256 ARM_BASE_URL, subscription_id
257 );
258 debug!("Listing AI Services accounts: {}", url);
259
260 let response = self
261 .http
262 .get(&url)
263 .header("Authorization", format!("Bearer {}", self.token))
264 .send()
265 .await?;
266
267 let status = response.status();
268 if !status.is_success() {
269 let body = response.text().await?;
270 return Err(ClientError::from_response(status.as_u16(), &body));
271 }
272
273 let result: ArmListResponse<AiServicesAccount> = response.json().await?;
274 Ok(result
275 .value
276 .into_iter()
277 .filter(|a| a.kind.eq_ignore_ascii_case("AIServices"))
278 .collect())
279 }
280
281 pub async fn list_foundry_projects(
289 &self,
290 account: &AiServicesAccount,
291 subscription_id: &str,
292 ) -> Result<Vec<FoundryProject>, ClientError> {
293 let resource_group = parse_resource_group(&account.id).ok_or_else(|| ClientError::Api {
294 status: 0,
295 message: format!("Could not parse resource group from ARM ID: {}", account.id),
296 })?;
297
298 let url = format!(
299 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}/projects?api-version=2025-06-01",
300 ARM_BASE_URL, subscription_id, resource_group, account.name
301 );
302 debug!("Listing Foundry projects: {}", url);
303
304 let response = self
305 .http
306 .get(&url)
307 .header("Authorization", format!("Bearer {}", self.token))
308 .send()
309 .await?;
310
311 let status = response.status();
312 if !status.is_success() {
313 let body = response.text().await?;
314 return Err(ClientError::from_response(status.as_u16(), &body));
315 }
316
317 let result: ArmListResponse<FoundryProject> = response.json().await?;
318 Ok(result.value)
319 }
320
321 pub async fn list_storage_accounts(
323 &self,
324 subscription_id: &str,
325 resource_group: &str,
326 ) -> Result<Vec<StorageAccount>, ClientError> {
327 let url = format!(
328 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01",
329 ARM_BASE_URL, subscription_id, resource_group
330 );
331 debug!("Listing storage accounts: {}", url);
332
333 let response = self
334 .http
335 .get(&url)
336 .header("Authorization", format!("Bearer {}", self.token))
337 .send()
338 .await?;
339
340 let status = response.status();
341 if !status.is_success() {
342 let body = response.text().await?;
343 return Err(ClientError::from_response(status.as_u16(), &body));
344 }
345
346 let result: ArmListResponse<StorageAccount> = response.json().await?;
347 Ok(result.value)
348 }
349
350 pub async fn get_storage_account_key(
352 &self,
353 subscription_id: &str,
354 resource_group: &str,
355 account_name: &str,
356 ) -> Result<String, ClientError> {
357 let url = format!(
358 "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.Storage/storageAccounts/{}/listKeys?api-version=2023-05-01",
359 ARM_BASE_URL, subscription_id, resource_group, account_name
360 );
361 debug!("Getting storage account keys: {}", url);
362
363 let response = self
364 .http
365 .post(&url)
366 .header("Authorization", format!("Bearer {}", self.token))
367 .header("Content-Length", "0")
368 .send()
369 .await?;
370
371 let status = response.status();
372 if !status.is_success() {
373 let body = response.text().await?;
374 return Err(ClientError::from_response(status.as_u16(), &body));
375 }
376
377 let key_list: StorageKeyList = response.json().await?;
378 key_list
379 .keys
380 .into_iter()
381 .next()
382 .map(|k| k.value)
383 .ok_or_else(|| ClientError::Api {
384 status: 0,
385 message: "No keys found for storage account".to_string(),
386 })
387 }
388
389 pub async fn get_storage_connection_string(
391 &self,
392 subscription_id: &str,
393 resource_group: &str,
394 account_name: &str,
395 ) -> Result<String, ClientError> {
396 let key = self
397 .get_storage_account_key(subscription_id, resource_group, account_name)
398 .await?;
399
400 Ok(format!(
401 "DefaultEndpointsProtocol=https;AccountName={};AccountKey={};EndpointSuffix=core.windows.net",
402 account_name, key
403 ))
404 }
405}
406
407fn parse_resource_group(arm_id: &str) -> Option<String> {
411 let parts: Vec<&str> = arm_id.split('/').collect();
412 for (i, part) in parts.iter().enumerate() {
413 if part.eq_ignore_ascii_case("resourceGroups")
414 || part.eq_ignore_ascii_case("resourcegroups")
415 {
416 return parts.get(i + 1).map(|s| s.to_string());
417 }
418 }
419 None
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn test_parse_resource_group() {
428 let id = "/subscriptions/abc-123/resourceGroups/my-rg/providers/Microsoft.Search/searchServices/my-svc";
429 assert_eq!(parse_resource_group(id), Some("my-rg".to_string()));
430 }
431
432 #[test]
433 fn test_parse_resource_group_case_insensitive() {
434 let id = "/subscriptions/abc/resourcegroups/MyRG/providers/Something";
435 assert_eq!(parse_resource_group(id), Some("MyRG".to_string()));
436 }
437
438 #[test]
439 fn test_parse_resource_group_missing() {
440 let id = "/subscriptions/abc/providers/Something";
441 assert_eq!(parse_resource_group(id), None);
442 }
443
444 #[test]
445 fn test_ai_services_account_display() {
446 let account = AiServicesAccount {
447 name: "my-ai-service".to_string(),
448 location: "eastus".to_string(),
449 kind: "AIServices".to_string(),
450 id: String::new(),
451 };
452 assert_eq!(format!("{}", account), "my-ai-service (eastus)");
453 }
454
455 #[test]
456 fn test_foundry_project_display_with_display_name() {
457 let project = FoundryProject {
458 name: "my-account/my-project".to_string(),
459 location: "westus2".to_string(),
460 id: String::new(),
461 properties: FoundryProjectProperties {
462 display_name: "my-project".to_string(),
463 },
464 };
465 assert_eq!(format!("{}", project), "my-project (westus2)");
466 assert_eq!(project.display_name(), "my-project");
467 }
468
469 #[test]
470 fn test_foundry_project_display_name_fallback() {
471 let project = FoundryProject {
472 name: "my-account/proj-default".to_string(),
473 location: "swedencentral".to_string(),
474 id: String::new(),
475 properties: FoundryProjectProperties::default(),
476 };
477 assert_eq!(project.display_name(), "proj-default");
478 assert_eq!(format!("{}", project), "proj-default (swedencentral)");
479 }
480}