Skip to main content

rigg_client/
arm.rs

1//! Azure Resource Manager client for discovering Search and Foundry services
2
3use 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
13/// Azure Resource Manager client for subscription/service discovery
14pub struct ArmClient {
15    http: Client,
16    token: String,
17}
18
19/// Azure subscription
20#[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/// Azure AI Search service
35#[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/// Result of the discovery flow
62#[derive(Debug, Clone)]
63pub struct DiscoveredService {
64    pub name: String,
65    pub subscription_id: String,
66    pub location: String,
67}
68
69/// Azure AI Services account (kind=AIServices)
70#[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    /// Primary endpoint (e.g., "https://name.cognitiveservices.azure.com/")
85    #[serde(default)]
86    pub endpoint: Option<String>,
87}
88
89impl AiServicesAccount {
90    /// Derive the `.services.ai.azure.com` endpoint for the agents API.
91    ///
92    /// Extracts the custom subdomain from the ARM `properties.endpoint`
93    /// (which may differ from the resource name), then constructs the
94    /// AI services endpoint. Falls back to the resource name.
95    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
105/// Extract the subdomain from an Azure endpoint URL.
106///
107/// `"https://my-svc.cognitiveservices.azure.com/"` → `"my-svc"`
108fn 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/// Microsoft Foundry project (sub-resource of AI Services account)
120#[derive(Debug, Clone, Deserialize)]
121pub struct FoundryProject {
122    /// ARM name — may be "accountName/projectName" for sub-resources
123    #[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    /// The project display name (human-friendly, e.g. "proj-default")
141    pub fn display_name(&self) -> &str {
142        if !self.properties.display_name.is_empty() {
143            &self.properties.display_name
144        } else {
145            // Fallback: parse from "account/project" ARM name
146            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/// Azure Storage account
158#[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/// Storage account key
173#[derive(Debug, Clone, Deserialize)]
174struct StorageKey {
175    value: String,
176}
177
178/// Storage account key list response
179#[derive(Debug, Deserialize)]
180struct StorageKeyList {
181    keys: Vec<StorageKey>,
182}
183
184/// Azure OpenAI model deployment
185#[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/// A resource's managed identity block.
227#[derive(Debug, Clone)]
228pub struct ResourceIdentity {
229    /// `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`, or `None`.
230    pub kind: String,
231    /// System-assigned principal id, when enabled.
232    pub principal_id: Option<String>,
233    /// (resource id, principal id) of attached user-assigned identities.
234    pub user_assigned: Vec<(String, String)>,
235}
236
237impl ResourceIdentity {
238    /// All principal ids this resource can act as.
239    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
246/// Deterministic UUID-shaped name from a string (stable role-assignment names).
247fn 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/// ARM list response envelope
279#[derive(Debug, Deserialize)]
280struct ArmListResponse<T> {
281    value: Vec<T>,
282}
283
284impl ArmClient {
285    /// Create a new ARM client using Azure CLI credentials
286    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    /// Read a resource's managed identity block: `GET {id}?api-version=...`.
296    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    /// Role definition IDs assigned to `principal_id` at (or inherited by) `scope`.
347    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    /// Create a role assignment for a principal at a scope.
384    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        // 409 = already exists → fine
414        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    /// Enable a system-assigned managed identity on a resource (PATCH).
422    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    /// Find the full ARM resource id of a search service by name.
444    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    /// The ARM bearer token this client authenticated with.
459    pub fn token(&self) -> &str {
460        &self.token
461    }
462
463    /// List subscriptions the user has access to
464    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        // Only return enabled subscriptions
483        Ok(result
484            .value
485            .into_iter()
486            .filter(|s| s.state == "Enabled")
487            .collect())
488    }
489
490    /// List Azure AI Search services in a subscription
491    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    /// Find the resource group of a search service by scanning the subscription.
519    ///
520    /// Returns the resource group name extracted from the service's ARM resource ID.
521    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                // Parse resource group from ARM ID:
531                // /subscriptions/{sub}/resourceGroups/{rg}/providers/...
532                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    /// List Azure AI Services accounts in a subscription (filtered to kind=AIServices)
546    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    /// Find the ARM resource id of ANY Microsoft.CognitiveServices account
578    /// by name — regardless of kind (AIServices, CognitiveServices, OpenAI,
579    /// ...). Unlike [`Self::list_ai_services_accounts`] (which serves
580    /// Foundry discovery and filters to kind AIServices), this covers e.g.
581    /// the plain CognitiveServices accounts skillsets use for enrichment.
582    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    /// Find ANY Microsoft.CognitiveServices account by name (full document,
587    /// including its `kind` — needed to tell Foundry/AIServices resources
588    /// apart from legacy CognitiveServices accounts).
589    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    /// List every Microsoft.CognitiveServices account in a subscription
607    /// (any kind). Subscriptions the caller cannot read yield an empty list.
608    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    /// Every kind=AIServices (Foundry) account visible to the caller,
630    /// across all subscriptions.
631    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    /// All Microsoft.Web sites (function apps / web apps) visible to this
645    /// login, by name, across every subscription. Sorted, de-duplicated.
646    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    /// Find a Microsoft.Web site (function app / web app) by name across
675    /// all visible subscriptions; returns its ARM resource id.
676    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    /// Fetch a usable key for one function of a function app: the
711    /// function-scoped `default` key when present, else any function key,
712    /// else the host's `default` function key.
713    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        // Fallback: host-level function keys.
738        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    /// The site's Easy Auth (authSettingsV2) configuration.
763    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    /// List Microsoft Foundry projects under a specific AI Services account.
781    ///
782    /// Projects are sub-resources at:
783    /// `Microsoft.CognitiveServices/accounts/{accountName}/projects`
784    ///
785    /// The `account_id` should be the full ARM resource ID of the account,
786    /// from which we extract the resource group.
787    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    /// List storage accounts in a resource group.
821    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    /// List storage accounts across a whole subscription.
850    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    /// Whether a storage account contains a blob container with this name
878    /// (checked via ARM with the caller's CLI token — no data-plane access
879    /// or account keys involved).
880    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    /// Find every storage account (across all visible subscriptions) that
908    /// holds a blob container named `container`. Used to auto-construct
909    /// identity-based `ResourceId=` data-source connections — the user is
910    /// already logged in via Azure CLI, so rigg discovers instead of asking.
911    /// Accounts that fail the container check (e.g. insufficient RBAC on an
912    /// unrelated subscription) are skipped, not fatal.
913    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    /// Get the primary access key for a storage account.
950    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    /// List model deployments for an AI Services account.
989    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    /// Create a model deployment on an AI Services account.
1023    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    /// Build a full connection string for a storage account.
1074    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
1091/// Parse resource group from an ARM resource ID.
1092///
1093/// ARM IDs look like: `/subscriptions/{sub}/resourceGroups/{rg}/providers/...`
1094fn 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}