1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ArmScope {
22 pub subscription_id: String,
23 pub resource_group: String,
24 pub account: String,
25}
26
27pub 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
69pub struct ArmResourceClient {
71 http: Client,
72 token: String,
73 scope: ArmScope,
74 project: String,
75}
76
77impl ArmResourceClient {
78 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 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 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 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; 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(¤t)) {
208 let state = provisioning_state(¤t).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, Some(state) => !matches!(
237 state.to_ascii_lowercase().as_str(),
238 "creating" | "updating" | "deleting" | "accepted" | "running" | "moving"
239 ),
240 }
241}
242
243pub 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 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}