Skip to main content

rigg_client/
arm_resources.rs

1//! Generic ARM CRUD for Foundry control-plane resource kinds
2//! (model deployments, project connections, RAI policies / guardrails)
3//! under `Microsoft.CognitiveServices/accounts`, api-version 2026-05-01.
4
5use std::time::Duration;
6
7use reqwest::{Client, Method, StatusCode};
8use serde_json::Value;
9use tracing::debug;
10
11use rigg_core::registry::{self, Domain};
12use rigg_core::resources::ResourceKind;
13
14use crate::arm::ArmClient;
15use crate::error::ClientError;
16
17const ARM_BASE_URL: &str = "https://management.azure.com";
18
19/// Where a Foundry account lives in ARM.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ArmScope {
22    pub subscription_id: String,
23    pub resource_group: String,
24    pub account: String,
25}
26
27/// Build the ARM resource URL for a Foundry control-plane kind.
28///
29/// - Deployment → `accounts/{account}/deployments/{name}`
30/// - Guardrail  → `accounts/{account}/raiPolicies/{name}`
31/// - Connection → `accounts/{account}/projects/{project}/connections/{name}`
32///
33/// `name: None` yields the collection URL.
34pub fn arm_url(
35    scope: &ArmScope,
36    kind: ResourceKind,
37    project: Option<&str>,
38    name: Option<&str>,
39) -> Result<String, ClientError> {
40    let meta = registry::meta(kind);
41    if meta.domain != Domain::FoundryArm {
42        return Err(ClientError::Api {
43            status: 400,
44            message: format!("{kind:?} is not an ARM-managed kind"),
45        });
46    }
47    let mut path = format!(
48        "{}/subscriptions/{}/resourceGroups/{}/providers/Microsoft.CognitiveServices/accounts/{}",
49        ARM_BASE_URL, scope.subscription_id, scope.resource_group, scope.account
50    );
51    if kind == ResourceKind::Connection {
52        let project = project.ok_or_else(|| ClientError::Api {
53            status: 400,
54            message: "connections require a Foundry project".to_string(),
55        })?;
56        path.push_str(&format!("/projects/{}", urlencoding::encode(project)));
57    }
58    path.push_str(&format!("/{}", meta.collection_path));
59    if let Some(name) = name {
60        path.push_str(&format!("/{}", urlencoding::encode(name)));
61    }
62    path.push_str(&format!(
63        "?api-version={}",
64        registry::ARM_COGNITIVE_API_VERSION
65    ));
66    Ok(path)
67}
68
69/// Generic ARM client for Foundry control-plane kinds.
70pub struct ArmResourceClient {
71    http: Client,
72    token: String,
73    scope: ArmScope,
74    project: String,
75}
76
77impl ArmResourceClient {
78    /// Resolve the ARM scope for a Foundry account by name and construct the client.
79    pub async fn for_account(account: &str, project: &str) -> Result<Self, ClientError> {
80        let arm = ArmClient::new()?;
81        let scope = resolve_account_scope(&arm, account).await?;
82        Ok(Self {
83            http: Client::builder().timeout(Duration::from_secs(30)).build()?,
84            token: arm.token().to_string(),
85            scope,
86            project: project.to_string(),
87        })
88    }
89
90    /// Test constructor with explicit scope/token/base handled by arm_url.
91    pub fn with_token(
92        scope: ArmScope,
93        project: String,
94        token: String,
95    ) -> Result<Self, ClientError> {
96        Ok(Self {
97            http: Client::builder().timeout(Duration::from_secs(30)).build()?,
98            token,
99            scope,
100            project: project.to_string(),
101        })
102    }
103
104    pub fn scope(&self) -> &ArmScope {
105        &self.scope
106    }
107
108    fn project_for(&self, kind: ResourceKind) -> Option<&str> {
109        (kind == ResourceKind::Connection).then_some(self.project.as_str())
110    }
111
112    async fn request(
113        &self,
114        method: Method,
115        url: &str,
116        body: Option<&Value>,
117    ) -> Result<(StatusCode, Option<Value>), ClientError> {
118        let mut req = self
119            .http
120            .request(method.clone(), url)
121            .header("Authorization", format!("Bearer {}", self.token))
122            .header("Content-Type", "application/json");
123        if let Some(json) = body {
124            req = req.json(json);
125        }
126        debug!("ARM request: {} {}", method, url);
127        let response = req.send().await?;
128        let status = response.status();
129        let text = response.text().await?;
130        if status.is_success() {
131            let value = if text.is_empty() {
132                None
133            } else {
134                Some(serde_json::from_str(&text)?)
135            };
136            Ok((status, value))
137        } else if status == StatusCode::NOT_FOUND {
138            Err(ClientError::NotFound {
139                kind: "arm-resource".to_string(),
140                name: url.to_string(),
141            })
142        } else {
143            Err(ClientError::from_response_with_url(
144                status.as_u16(),
145                &text,
146                Some(url),
147            ))
148        }
149    }
150
151    pub async fn list(&self, kind: ResourceKind) -> Result<Vec<Value>, ClientError> {
152        let url = arm_url(&self.scope, kind, self.project_for(kind), None)?;
153        let (_, body) = self.request(Method::GET, &url, None).await?;
154        Ok(body
155            .and_then(|v| v.get("value").and_then(|a| a.as_array()).cloned())
156            .unwrap_or_default())
157    }
158
159    pub async fn get(&self, kind: ResourceKind, name: &str) -> Result<Option<Value>, ClientError> {
160        let url = arm_url(&self.scope, kind, self.project_for(kind), Some(name))?;
161        match self.request(Method::GET, &url, None).await {
162            Ok((_, body)) => Ok(body),
163            Err(ClientError::NotFound { .. }) => Ok(None),
164            Err(e) => Err(e),
165        }
166    }
167
168    /// PUT and poll until the resource reaches a terminal provisioning state.
169    pub async fn put(
170        &self,
171        kind: ResourceKind,
172        name: &str,
173        body: &Value,
174    ) -> Result<Value, ClientError> {
175        let url = arm_url(&self.scope, kind, self.project_for(kind), Some(name))?;
176        let (status, response) = self.request(Method::PUT, &url, Some(body)).await?;
177
178        // 200/201 with terminal state → done. 201/202 in-progress → poll GET.
179        if is_terminal(response.as_ref()) && status != StatusCode::ACCEPTED {
180            return response.ok_or_else(|| ClientError::Api {
181                status: 500,
182                message: "empty ARM PUT response".to_string(),
183            });
184        }
185        self.poll_until_terminal(kind, name).await
186    }
187
188    pub async fn delete(&self, kind: ResourceKind, name: &str) -> Result<(), ClientError> {
189        let url = arm_url(&self.scope, kind, self.project_for(kind), Some(name))?;
190        match self.request(Method::DELETE, &url, None).await {
191            Ok(_) => Ok(()),
192            Err(ClientError::NotFound { .. }) => Ok(()),
193            Err(e) => Err(e),
194        }
195    }
196
197    async fn poll_until_terminal(
198        &self,
199        kind: ResourceKind,
200        name: &str,
201    ) -> Result<Value, ClientError> {
202        const POLL_INTERVAL: Duration = Duration::from_secs(3);
203        const MAX_POLLS: u32 = 100; // 5 minutes
204        for _ in 0..MAX_POLLS {
205            tokio::time::sleep(POLL_INTERVAL).await;
206            if let Some(current) = self.get(kind, name).await? {
207                if is_terminal(Some(&current)) {
208                    let state = provisioning_state(&current).unwrap_or_default();
209                    if state.eq_ignore_ascii_case("succeeded") || state.is_empty() {
210                        return Ok(current);
211                    }
212                    return Err(ClientError::Api {
213                        status: 500,
214                        message: format!("{kind} '{name}' ended in state '{state}'"),
215                    });
216                }
217            }
218        }
219        Err(ClientError::Api {
220            status: 504,
221            message: format!("{kind} '{name}' did not reach a terminal state in time"),
222        })
223    }
224}
225
226fn provisioning_state(v: &Value) -> Option<String> {
227    v.get("properties")
228        .and_then(|p| p.get("provisioningState"))
229        .and_then(|s| s.as_str())
230        .map(str::to_string)
231}
232
233fn is_terminal(v: Option<&Value>) -> bool {
234    match v.and_then(provisioning_state) {
235        None => true, // no provisioningState → treat as terminal
236        Some(state) => !matches!(
237            state.to_ascii_lowercase().as_str(),
238            "creating" | "updating" | "deleting" | "accepted" | "running" | "moving"
239        ),
240    }
241}
242
243/// Find the subscription + resource group for a CognitiveServices account name.
244pub async fn resolve_account_scope(
245    arm: &ArmClient,
246    account: &str,
247) -> Result<ArmScope, ClientError> {
248    for sub in arm.list_subscriptions().await? {
249        let accounts = arm.list_ai_services_accounts(&sub.subscription_id).await?;
250        for acct in accounts {
251            if acct.name.eq_ignore_ascii_case(account) {
252                // id: /subscriptions/{s}/resourceGroups/{rg}/providers/...
253                let rg = acct
254                    .id
255                    .split('/')
256                    .skip_while(|s| !s.eq_ignore_ascii_case("resourceGroups"))
257                    .nth(1)
258                    .unwrap_or_default()
259                    .to_string();
260                if rg.is_empty() {
261                    continue;
262                }
263                return Ok(ArmScope {
264                    subscription_id: sub.subscription_id.clone(),
265                    resource_group: rg,
266                    account: acct.name.clone(),
267                });
268            }
269        }
270    }
271    Err(ClientError::NotFound {
272        kind: "Foundry account".to_string(),
273        name: account.to_string(),
274    })
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn scope() -> ArmScope {
282        ArmScope {
283            subscription_id: "sub-1".into(),
284            resource_group: "rg-1".into(),
285            account: "mklabaifndr".into(),
286        }
287    }
288
289    #[test]
290    fn deployment_url() {
291        let url = arm_url(&scope(), ResourceKind::Deployment, None, Some("gpt-5-mini")).unwrap();
292        assert_eq!(
293            url,
294            "https://management.azure.com/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.CognitiveServices/accounts/mklabaifndr/deployments/gpt-5-mini?api-version=2026-05-01"
295        );
296    }
297
298    #[test]
299    fn guardrail_url() {
300        let url = arm_url(&scope(), ResourceKind::Guardrail, None, None).unwrap();
301        assert!(url.ends_with("accounts/mklabaifndr/raiPolicies?api-version=2026-05-01"));
302    }
303
304    #[test]
305    fn connection_url_requires_project() {
306        let err = arm_url(&scope(), ResourceKind::Connection, None, Some("c")).unwrap_err();
307        assert!(err.to_string().contains("project"));
308        let url = arm_url(
309            &scope(),
310            ResourceKind::Connection,
311            Some("proj-default"),
312            Some("c"),
313        )
314        .unwrap();
315        assert!(url.contains("/projects/proj-default/connections/c?"));
316    }
317
318    #[test]
319    fn non_arm_kind_rejected() {
320        let err = arm_url(&scope(), ResourceKind::Index, None, None).unwrap_err();
321        assert!(err.to_string().contains("not an ARM-managed kind"));
322    }
323
324    #[test]
325    fn terminal_state_detection() {
326        use serde_json::json;
327        assert!(is_terminal(Some(
328            &json!({"properties": {"provisioningState": "Succeeded"}})
329        )));
330        assert!(is_terminal(Some(
331            &json!({"properties": {"provisioningState": "Failed"}})
332        )));
333        assert!(!is_terminal(Some(
334            &json!({"properties": {"provisioningState": "Creating"}})
335        )));
336        assert!(is_terminal(Some(&json!({"name": "no-state"}))));
337        assert!(is_terminal(None));
338    }
339}