Skip to main content

hoist_client/
arm.rs

1//! Azure Resource Manager client for discovering Search and Foundry services
2
3use 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
12/// Azure Resource Manager client for subscription/service discovery
13pub struct ArmClient {
14    http: Client,
15    token: String,
16}
17
18/// Azure subscription
19#[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/// Azure AI Search service
34#[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/// Result of the discovery flow
61#[derive(Debug, Clone)]
62pub struct DiscoveredService {
63    pub name: String,
64    pub subscription_id: String,
65    pub location: String,
66}
67
68/// Azure AI Services account (kind=AIServices)
69#[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/// Microsoft Foundry project (sub-resource of AI Services account)
86#[derive(Debug, Clone, Deserialize)]
87pub struct FoundryProject {
88    /// ARM name — may be "accountName/projectName" for sub-resources
89    #[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    /// The project display name (human-friendly, e.g. "proj-default")
107    pub fn display_name(&self) -> &str {
108        if !self.properties.display_name.is_empty() {
109            &self.properties.display_name
110        } else {
111            // Fallback: parse from "account/project" ARM name
112            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/// Azure Storage account
124#[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/// Storage account key
139#[derive(Debug, Clone, Deserialize)]
140struct StorageKey {
141    value: String,
142}
143
144/// Storage account key list response
145#[derive(Debug, Deserialize)]
146struct StorageKeyList {
147    keys: Vec<StorageKey>,
148}
149
150/// ARM list response envelope
151#[derive(Debug, Deserialize)]
152struct ArmListResponse<T> {
153    value: Vec<T>,
154}
155
156impl ArmClient {
157    /// Create a new ARM client using Azure CLI credentials
158    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    /// List subscriptions the user has access to
168    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        // Only return enabled subscriptions
187        Ok(result
188            .value
189            .into_iter()
190            .filter(|s| s.state == "Enabled")
191            .collect())
192    }
193
194    /// List Azure AI Search services in a subscription
195    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    /// Find the resource group of a search service by scanning the subscription.
223    ///
224    /// Returns the resource group name extracted from the service's ARM resource ID.
225    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                // Parse resource group from ARM ID:
235                // /subscriptions/{sub}/resourceGroups/{rg}/providers/...
236                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    /// List Azure AI Services accounts in a subscription (filtered to kind=AIServices)
250    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    /// List Microsoft Foundry projects under a specific AI Services account.
282    ///
283    /// Projects are sub-resources at:
284    /// `Microsoft.CognitiveServices/accounts/{accountName}/projects`
285    ///
286    /// The `account_id` should be the full ARM resource ID of the account,
287    /// from which we extract the resource group.
288    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    /// List storage accounts in a resource group.
322    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    /// Get the primary access key for a storage account.
351    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    /// Build a full connection string for a storage account.
390    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
407/// Parse resource group from an ARM resource ID.
408///
409/// ARM IDs look like: `/subscriptions/{sub}/resourceGroups/{rg}/providers/...`
410fn 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}