Skip to main content

azure_lite_rs/api/
compute.rs

1//! Azure Compute API client.
2//!
3//! Thin wrapper over generated ops. All URL construction and HTTP methods
4//! are in `ops::compute::ComputeOps`. This layer adds:
5//! - Ergonomic method signatures (auto-injects subscription_id from client)
6
7use crate::{
8    AzureHttpClient, Result,
9    ops::compute::ComputeOps,
10    types::compute::{
11        AccessUri, Disk, DiskCreateRequest, DiskListResult, DiskSku, DiskUpdateRequest,
12        GrantAccessData, VirtualMachine, VirtualMachineCreateRequest,
13        VirtualMachineInstanceViewResult, VirtualMachineListResult, VirtualMachineScaleSet,
14        VirtualMachineScaleSetCreateRequest, VirtualMachineScaleSetListResult,
15        VirtualMachineScaleSetVMInstanceIDs, VirtualMachineScaleSetVMListResult,
16    },
17};
18
19/// Client for the Azure Compute API.
20///
21/// Wraps the raw [`ComputeOps`] with ergonomic signatures that
22/// auto-inject `subscription_id` from the parent [`AzureHttpClient`].
23pub struct ComputeClient<'a> {
24    ops: ComputeOps<'a>,
25    client: &'a AzureHttpClient,
26}
27
28impl<'a> ComputeClient<'a> {
29    /// Create a new Azure Compute API client.
30    pub(crate) fn new(client: &'a AzureHttpClient) -> Self {
31        Self {
32            ops: ComputeOps::new(client),
33            client,
34        }
35    }
36
37    /// List virtual machines in a resource group.
38    pub async fn list_vms(&self, resource_group_name: &str) -> Result<VirtualMachineListResult> {
39        self.ops
40            .list_vms(self.client.subscription_id(), resource_group_name)
41            .await
42    }
43
44    /// Get a virtual machine.
45    pub async fn get_vm(&self, resource_group_name: &str, vm_name: &str) -> Result<VirtualMachine> {
46        self.ops
47            .get_vm(
48                self.client.subscription_id(),
49                resource_group_name,
50                vm_name,
51                "",
52            )
53            .await
54    }
55
56    /// Get a virtual machine with expanded properties.
57    pub async fn get_vm_expanded(
58        &self,
59        resource_group_name: &str,
60        vm_name: &str,
61        expand: &str,
62    ) -> Result<VirtualMachine> {
63        self.ops
64            .get_vm(
65                self.client.subscription_id(),
66                resource_group_name,
67                vm_name,
68                expand,
69            )
70            .await
71    }
72
73    /// Create or update a virtual machine.
74    pub async fn create_vm(
75        &self,
76        resource_group_name: &str,
77        vm_name: &str,
78        body: &VirtualMachineCreateRequest,
79    ) -> Result<VirtualMachine> {
80        self.ops
81            .create_vm(
82                self.client.subscription_id(),
83                resource_group_name,
84                vm_name,
85                body,
86            )
87            .await
88    }
89
90    /// Delete a virtual machine.
91    pub async fn delete_vm(&self, resource_group_name: &str, vm_name: &str) -> Result<()> {
92        self.ops
93            .delete_vm(self.client.subscription_id(), resource_group_name, vm_name)
94            .await
95    }
96
97    /// Start a virtual machine.
98    pub async fn start_vm(&self, resource_group_name: &str, vm_name: &str) -> Result<()> {
99        self.ops
100            .start_vm(self.client.subscription_id(), resource_group_name, vm_name)
101            .await
102    }
103
104    /// Power off (stop) a virtual machine. The VM continues to be billed.
105    pub async fn stop_vm(&self, resource_group_name: &str, vm_name: &str) -> Result<()> {
106        self.ops
107            .stop_vm(self.client.subscription_id(), resource_group_name, vm_name)
108            .await
109    }
110
111    /// Deallocate a virtual machine. Stops billing.
112    pub async fn deallocate_vm(&self, resource_group_name: &str, vm_name: &str) -> Result<()> {
113        self.ops
114            .deallocate_vm(self.client.subscription_id(), resource_group_name, vm_name)
115            .await
116    }
117
118    /// Restart a virtual machine.
119    pub async fn restart_vm(&self, resource_group_name: &str, vm_name: &str) -> Result<()> {
120        self.ops
121            .restart_vm(self.client.subscription_id(), resource_group_name, vm_name)
122            .await
123    }
124
125    /// Get the instance view of a virtual machine.
126    pub async fn get_instance_view(
127        &self,
128        resource_group_name: &str,
129        vm_name: &str,
130    ) -> Result<VirtualMachineInstanceViewResult> {
131        self.ops
132            .get_instance_view(self.client.subscription_id(), resource_group_name, vm_name)
133            .await
134    }
135
136    // --- VMSS operations ---
137
138    /// List virtual machine scale sets in a resource group.
139    pub async fn list_vmss(
140        &self,
141        resource_group_name: &str,
142    ) -> Result<VirtualMachineScaleSetListResult> {
143        self.ops
144            .list_vmss(self.client.subscription_id(), resource_group_name)
145            .await
146    }
147
148    /// Get a virtual machine scale set.
149    pub async fn get_vmss(
150        &self,
151        resource_group_name: &str,
152        vmss_name: &str,
153    ) -> Result<VirtualMachineScaleSet> {
154        self.ops
155            .get_vmss(
156                self.client.subscription_id(),
157                resource_group_name,
158                vmss_name,
159            )
160            .await
161    }
162
163    /// Create or update a virtual machine scale set.
164    pub async fn create_vmss(
165        &self,
166        resource_group_name: &str,
167        vmss_name: &str,
168        body: &VirtualMachineScaleSetCreateRequest,
169    ) -> Result<VirtualMachineScaleSet> {
170        self.ops
171            .create_vmss(
172                self.client.subscription_id(),
173                resource_group_name,
174                vmss_name,
175                body,
176            )
177            .await
178    }
179
180    /// Delete a virtual machine scale set.
181    pub async fn delete_vmss(&self, resource_group_name: &str, vmss_name: &str) -> Result<()> {
182        self.ops
183            .delete_vmss(
184                self.client.subscription_id(),
185                resource_group_name,
186                vmss_name,
187            )
188            .await
189    }
190
191    /// List virtual machines in a VM scale set.
192    pub async fn list_vmss_instances(
193        &self,
194        resource_group_name: &str,
195        vmss_name: &str,
196    ) -> Result<VirtualMachineScaleSetVMListResult> {
197        self.ops
198            .list_vmss_instances(
199                self.client.subscription_id(),
200                resource_group_name,
201                vmss_name,
202            )
203            .await
204    }
205
206    /// Start one or more virtual machines in a VM scale set.
207    pub async fn start_vmss_instances(
208        &self,
209        resource_group_name: &str,
210        vmss_name: &str,
211        instance_ids: &VirtualMachineScaleSetVMInstanceIDs,
212    ) -> Result<()> {
213        self.ops
214            .start_vmss_instances(
215                self.client.subscription_id(),
216                resource_group_name,
217                vmss_name,
218                instance_ids,
219            )
220            .await
221    }
222
223    /// Power off one or more virtual machines in a VM scale set.
224    pub async fn stop_vmss_instances(
225        &self,
226        resource_group_name: &str,
227        vmss_name: &str,
228        instance_ids: &VirtualMachineScaleSetVMInstanceIDs,
229    ) -> Result<()> {
230        self.ops
231            .stop_vmss_instances(
232                self.client.subscription_id(),
233                resource_group_name,
234                vmss_name,
235                instance_ids,
236            )
237            .await
238    }
239
240    // --- Managed Disk operations ---
241
242    /// List all managed disks in a resource group.
243    pub async fn list_disks(&self, resource_group_name: &str) -> Result<DiskListResult> {
244        self.ops
245            .list_disks(self.client.subscription_id(), resource_group_name)
246            .await
247    }
248
249    /// List all managed disks in the subscription.
250    pub async fn list_disks_in_subscription(&self) -> Result<DiskListResult> {
251        self.ops
252            .list_disks_in_subscription(self.client.subscription_id())
253            .await
254    }
255
256    /// Get information about a managed disk.
257    pub async fn get_disk(&self, resource_group_name: &str, disk_name: &str) -> Result<Disk> {
258        self.ops
259            .get_disk(
260                self.client.subscription_id(),
261                resource_group_name,
262                disk_name,
263            )
264            .await
265    }
266
267    /// Create or update a managed disk.
268    pub async fn create_disk(
269        &self,
270        resource_group_name: &str,
271        disk_name: &str,
272        body: &DiskCreateRequest,
273    ) -> Result<Disk> {
274        self.ops
275            .create_disk(
276                self.client.subscription_id(),
277                resource_group_name,
278                disk_name,
279                body,
280            )
281            .await
282    }
283
284    /// Delete a managed disk.
285    pub async fn delete_disk(&self, resource_group_name: &str, disk_name: &str) -> Result<()> {
286        self.ops
287            .delete_disk(
288                self.client.subscription_id(),
289                resource_group_name,
290                disk_name,
291            )
292            .await
293    }
294
295    /// Change the SKU (performance tier) of a managed disk.
296    ///
297    /// The disk must be unattached OR the attached VM must be fully deallocated
298    /// before this call will succeed. Returns an error if the VM is still running.
299    ///
300    /// # Arguments
301    /// * `resource_group_name` - Resource group containing the disk
302    /// * `disk_name` - Name of the disk to update
303    /// * `sku_name` - Target SKU name: "StandardSSD_LRS", "StandardSSD_ZRS",
304    ///   "Standard_LRS", "Premium_LRS", "Premium_ZRS"
305    pub async fn update_disk_sku(
306        &self,
307        resource_group_name: &str,
308        disk_name: &str,
309        sku_name: &str,
310    ) -> Result<Disk> {
311        let body = DiskUpdateRequest {
312            sku: Some(DiskSku {
313                name: Some(sku_name.to_string()),
314                ..Default::default()
315            }),
316            ..Default::default()
317        };
318        self.ops
319            .update_disk(
320                self.client.subscription_id(),
321                resource_group_name,
322                disk_name,
323                &body,
324            )
325            .await
326    }
327
328    /// Delete a managed disk snapshot.
329    pub async fn delete_snapshot(
330        &self,
331        subscription_id: &str,
332        resource_group_name: &str,
333        snapshot_name: &str,
334    ) -> Result<()> {
335        self.ops
336            .delete_snapshot(subscription_id, resource_group_name, snapshot_name)
337            .await
338    }
339
340    /// Grant SAS access to a managed disk.
341    ///
342    /// The Azure `beginGetAccess` endpoint returns HTTP 202 with a `Location` header
343    /// pointing to an async operation URL. This method polls that URL until the
344    /// operation completes and returns the SAS URI.
345    pub async fn grant_access(
346        &self,
347        resource_group_name: &str,
348        disk_name: &str,
349        body: &GrantAccessData,
350    ) -> Result<AccessUri> {
351        let sub = self.client.subscription_id();
352        let url = format!(
353            "https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/disks/{disk}/beginGetAccess?api-version=2024-07-01",
354            rg = urlencoding::encode(resource_group_name),
355            disk = urlencoding::encode(disk_name),
356        );
357
358        let body_bytes =
359            serde_json::to_vec(body).map_err(|e| crate::AzureError::InvalidResponse {
360                message: format!("Failed to serialize grant_access request: {e}"),
361                body: None,
362            })?;
363
364        let response = self.client.post(&url, &body_bytes).await?;
365        let location = response.location();
366        let response_bytes = response.error_for_status().await?.bytes().await?;
367
368        // If the response body is non-empty, parse it directly (rare synchronous response)
369        if !response_bytes.is_empty() {
370            return serde_json::from_slice(&response_bytes).map_err(|e| {
371                crate::AzureError::InvalidResponse {
372                    message: format!("Failed to parse grant_access response: {e}"),
373                    body: Some(String::from_utf8_lossy(&response_bytes).to_string()),
374                }
375            });
376        }
377
378        // 202 Accepted: poll the Location URL until the operation completes
379        let poll_url = location.ok_or_else(|| crate::AzureError::InvalidResponse {
380            message: "grant_access returned 202 with no Location header".into(),
381            body: None,
382        })?;
383
384        for _ in 0..30 {
385            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
386            let poll_response = self.client.get(&poll_url).await?;
387            let poll_bytes = poll_response.error_for_status().await?.bytes().await?;
388            if !poll_bytes.is_empty() {
389                return serde_json::from_slice(&poll_bytes).map_err(|e| {
390                    crate::AzureError::InvalidResponse {
391                        message: format!("Failed to parse grant_access poll response: {e}"),
392                        body: Some(String::from_utf8_lossy(&poll_bytes).to_string()),
393                    }
394                });
395            }
396        }
397
398        Err(crate::AzureError::InvalidResponse {
399            message: "grant_access polling timed out after 30 attempts".into(),
400            body: None,
401        })
402    }
403
404    /// Revoke SAS access to a managed disk.
405    pub async fn revoke_access(&self, resource_group_name: &str, disk_name: &str) -> Result<()> {
406        self.ops
407            .revoke_access(
408                self.client.subscription_id(),
409                resource_group_name,
410                disk_name,
411            )
412            .await
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::types::compute::VirtualMachineProperties;
420
421    #[tokio::test]
422    async fn list_vms_returns_empty_list() {
423        let mut mock = crate::MockClient::new();
424        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines")
425            .returning_json(serde_json::json!({
426                "value": []
427            }));
428
429        let client = AzureHttpClient::from_mock(mock);
430        let compute = client.compute();
431        let result = compute.list_vms("my-rg").await.expect("list_vms failed");
432        assert!(result.value.is_empty());
433        assert!(result.next_link.is_none());
434    }
435
436    #[tokio::test]
437    async fn list_vms_returns_vm_with_fields() {
438        let mut mock = crate::MockClient::new();
439        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines")
440            .returning_json(serde_json::json!({
441                "value": [{
442                    "id": "/subscriptions/sub/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachines/vm1",
443                    "name": "vm1",
444                    "type": "Microsoft.Compute/virtualMachines",
445                    "location": "eastus",
446                    "properties": {
447                        "vmId": "abc-123",
448                        "provisioningState": "Succeeded",
449                        "hardwareProfile": { "vmSize": "Standard_B1s" }
450                    }
451                }]
452            }));
453
454        let client = AzureHttpClient::from_mock(mock);
455        let compute = client.compute();
456        let result = compute.list_vms("my-rg").await.expect("list_vms failed");
457        assert_eq!(result.value.len(), 1);
458        let vm = &result.value[0];
459        assert_eq!(vm.name.as_deref(), Some("vm1"));
460        assert_eq!(vm.location.as_deref(), Some("eastus"));
461        assert_eq!(
462            vm.properties.as_ref().and_then(|p| p.vm_id.as_deref()),
463            Some("abc-123"),
464        );
465        assert_eq!(
466            vm.properties
467                .as_ref()
468                .and_then(|p| p.provisioning_state.as_deref()),
469            Some("Succeeded"),
470        );
471    }
472
473    #[tokio::test]
474    async fn get_vm_injects_subscription_id() {
475        let mut mock = crate::MockClient::new();
476        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/my-vm")
477            .returning_json(serde_json::json!({
478                "id": "/subscriptions/sub/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/my-vm",
479                "name": "my-vm",
480                "type": "Microsoft.Compute/virtualMachines",
481                "location": "westus2",
482                "properties": {
483                    "vmId": "vm-uuid",
484                    "provisioningState": "Succeeded"
485                }
486            }));
487
488        let client = AzureHttpClient::from_mock(mock);
489        let compute = client.compute();
490        let vm = compute.get_vm("rg1", "my-vm").await.expect("get_vm failed");
491        assert_eq!(vm.name.as_deref(), Some("my-vm"));
492        assert_eq!(vm.location.as_deref(), Some("westus2"));
493        assert_eq!(
494            vm.properties.as_ref().and_then(|p| p.vm_id.as_deref()),
495            Some("vm-uuid"),
496        );
497    }
498
499    #[tokio::test]
500    async fn create_vm_sends_put_and_returns_vm() {
501        let mut mock = crate::MockClient::new();
502        mock.expect_put("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/new-vm")
503            .returning_json(serde_json::json!({
504                "name": "new-vm",
505                "location": "eastus",
506                "properties": {
507                    "provisioningState": "Creating",
508                    "hardwareProfile": { "vmSize": "Standard_B1s" }
509                }
510            }));
511
512        let client = AzureHttpClient::from_mock(mock);
513        let compute = client.compute();
514        let request = VirtualMachineCreateRequest {
515            location: "eastus".into(),
516            properties: Some(VirtualMachineProperties {
517                hardware_profile: Some(crate::types::compute::HardwareProfile {
518                    vm_size: Some("Standard_B1s".into()),
519                }),
520                ..Default::default()
521            }),
522            ..Default::default()
523        };
524        let vm = compute
525            .create_vm("rg1", "new-vm", &request)
526            .await
527            .expect("create_vm failed");
528        assert_eq!(vm.name.as_deref(), Some("new-vm"));
529        assert_eq!(
530            vm.properties
531                .as_ref()
532                .and_then(|p| p.provisioning_state.as_deref()),
533            Some("Creating"),
534        );
535    }
536
537    #[tokio::test]
538    async fn delete_vm_sends_delete() {
539        let mut mock = crate::MockClient::new();
540        mock.expect_delete("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/dead-vm")
541            .returning_json(serde_json::json!(null));
542
543        let client = AzureHttpClient::from_mock(mock);
544        let compute = client.compute();
545        compute
546            .delete_vm("rg1", "dead-vm")
547            .await
548            .expect("delete_vm failed");
549    }
550
551    #[tokio::test]
552    async fn deallocate_vm_sends_post() {
553        let mut mock = crate::MockClient::new();
554        mock.expect_post("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/my-vm/deallocate")
555            .returning_json(serde_json::json!(null));
556
557        let client = AzureHttpClient::from_mock(mock);
558        let compute = client.compute();
559        compute
560            .deallocate_vm("rg1", "my-vm")
561            .await
562            .expect("deallocate_vm failed");
563    }
564
565    #[tokio::test]
566    async fn get_instance_view_returns_statuses() {
567        let mut mock = crate::MockClient::new();
568        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/my-vm/instanceView")
569            .returning_json(serde_json::json!({
570                "statuses": [
571                    {
572                        "code": "ProvisioningState/succeeded",
573                        "displayStatus": "Provisioning succeeded",
574                        "level": "Info"
575                    },
576                    {
577                        "code": "PowerState/running",
578                        "displayStatus": "VM running",
579                        "level": "Info"
580                    }
581                ]
582            }));
583
584        let client = AzureHttpClient::from_mock(mock);
585        let compute = client.compute();
586        let iv = compute
587            .get_instance_view("rg1", "my-vm")
588            .await
589            .expect("get_instance_view failed");
590        assert_eq!(iv.statuses.len(), 2);
591        assert_eq!(
592            iv.statuses[0].code.as_deref(),
593            Some("ProvisioningState/succeeded")
594        );
595        assert_eq!(
596            iv.statuses[0].display_status.as_deref(),
597            Some("Provisioning succeeded")
598        );
599        assert_eq!(iv.statuses[1].code.as_deref(), Some("PowerState/running"));
600    }
601
602    // --- VMSS unit tests ---
603
604    #[tokio::test]
605    async fn list_vmss_returns_empty_list() {
606        let mut mock = crate::MockClient::new();
607        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachineScaleSets")
608            .returning_json(serde_json::json!({ "value": [] }));
609
610        let client = AzureHttpClient::from_mock(mock);
611        let result = client
612            .compute()
613            .list_vmss("my-rg")
614            .await
615            .expect("list_vmss failed");
616        assert!(result.value.is_empty());
617        assert!(result.next_link.is_none());
618    }
619
620    #[tokio::test]
621    async fn list_vmss_returns_vmss_with_fields() {
622        let mut mock = crate::MockClient::new();
623        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachineScaleSets")
624            .returning_json(serde_json::json!({
625                "value": [{
626                    "id": "/subscriptions/sub/resourceGroups/my-rg/providers/Microsoft.Compute/virtualMachineScaleSets/my-vmss",
627                    "name": "my-vmss",
628                    "type": "Microsoft.Compute/virtualMachineScaleSets",
629                    "location": "eastus",
630                    "sku": { "name": "Standard_B1s", "tier": "Standard", "capacity": 1 },
631                    "properties": {
632                        "provisioningState": "Succeeded",
633                        "uniqueId": "abc-123"
634                    }
635                }]
636            }));
637
638        let client = AzureHttpClient::from_mock(mock);
639        let result = client
640            .compute()
641            .list_vmss("my-rg")
642            .await
643            .expect("list_vmss failed");
644        assert_eq!(result.value.len(), 1);
645        let vmss = &result.value[0];
646        assert_eq!(vmss.name.as_deref(), Some("my-vmss"));
647        assert_eq!(vmss.location.as_deref(), Some("eastus"));
648        assert_eq!(
649            vmss.sku.as_ref().and_then(|s| s.name.as_deref()),
650            Some("Standard_B1s")
651        );
652        assert_eq!(vmss.sku.as_ref().and_then(|s| s.capacity), Some(1));
653        assert_eq!(
654            vmss.properties
655                .as_ref()
656                .and_then(|p| p.provisioning_state.as_deref()),
657            Some("Succeeded"),
658        );
659        assert_eq!(
660            vmss.properties
661                .as_ref()
662                .and_then(|p| p.unique_id.as_deref()),
663            Some("abc-123"),
664        );
665    }
666
667    #[tokio::test]
668    async fn get_vmss_injects_subscription_id() {
669        let mut mock = crate::MockClient::new();
670        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachineScaleSets/my-vmss")
671            .returning_json(serde_json::json!({
672                "name": "my-vmss",
673                "type": "Microsoft.Compute/virtualMachineScaleSets",
674                "location": "eastus",
675                "sku": { "name": "Standard_B1s", "tier": "Standard", "capacity": 2 },
676                "properties": {
677                    "provisioningState": "Succeeded",
678                    "uniqueId": "vmss-uuid"
679                }
680            }));
681
682        let client = AzureHttpClient::from_mock(mock);
683        let vmss = client
684            .compute()
685            .get_vmss("rg1", "my-vmss")
686            .await
687            .expect("get_vmss failed");
688        assert_eq!(vmss.name.as_deref(), Some("my-vmss"));
689        assert_eq!(vmss.sku.as_ref().and_then(|s| s.capacity), Some(2));
690        assert_eq!(
691            vmss.properties
692                .as_ref()
693                .and_then(|p| p.unique_id.as_deref()),
694            Some("vmss-uuid"),
695        );
696    }
697
698    #[tokio::test]
699    async fn create_vmss_sends_put_and_returns_vmss() {
700        let mut mock = crate::MockClient::new();
701        mock.expect_put("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachineScaleSets/new-vmss")
702            .returning_json(serde_json::json!({
703                "name": "new-vmss",
704                "location": "eastus",
705                "sku": { "name": "Standard_B1s", "tier": "Standard", "capacity": 1 },
706                "properties": {
707                    "provisioningState": "Creating"
708                }
709            }));
710
711        let client = AzureHttpClient::from_mock(mock);
712        let request = VirtualMachineScaleSetCreateRequest {
713            location: "eastus".into(),
714            sku: Some(crate::types::compute::Sku {
715                name: Some("Standard_B1s".into()),
716                capacity: Some(1),
717                ..Default::default()
718            }),
719            ..Default::default()
720        };
721        let vmss = client
722            .compute()
723            .create_vmss("rg1", "new-vmss", &request)
724            .await
725            .expect("create_vmss failed");
726        assert_eq!(vmss.name.as_deref(), Some("new-vmss"));
727        assert_eq!(
728            vmss.properties
729                .as_ref()
730                .and_then(|p| p.provisioning_state.as_deref()),
731            Some("Creating"),
732        );
733    }
734
735    #[tokio::test]
736    async fn delete_vmss_sends_delete() {
737        let mut mock = crate::MockClient::new();
738        mock.expect_delete("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachineScaleSets/dead-vmss")
739            .returning_json(serde_json::json!(null));
740
741        let client = AzureHttpClient::from_mock(mock);
742        client
743            .compute()
744            .delete_vmss("rg1", "dead-vmss")
745            .await
746            .expect("delete_vmss failed");
747    }
748
749    #[tokio::test]
750    async fn list_vmss_instances_returns_instances() {
751        let mut mock = crate::MockClient::new();
752        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachineScaleSets/my-vmss/virtualMachines")
753            .returning_json(serde_json::json!({
754                "value": [{
755                    "name": "my-vmss_0",
756                    "instanceId": "0",
757                    "location": "eastus",
758                    "properties": {
759                        "latestModelApplied": true,
760                        "provisioningState": "Succeeded",
761                        "vmId": "instance-uuid"
762                    }
763                }]
764            }));
765
766        let client = AzureHttpClient::from_mock(mock);
767        let result = client
768            .compute()
769            .list_vmss_instances("rg1", "my-vmss")
770            .await
771            .expect("list_vmss_instances failed");
772        assert_eq!(result.value.len(), 1);
773        let inst = &result.value[0];
774        assert_eq!(inst.name.as_deref(), Some("my-vmss_0"));
775        assert_eq!(inst.instance_id.as_deref(), Some("0"));
776        assert_eq!(
777            inst.properties
778                .as_ref()
779                .and_then(|p| p.provisioning_state.as_deref()),
780            Some("Succeeded"),
781        );
782        assert_eq!(
783            inst.properties
784                .as_ref()
785                .and_then(|p| p.latest_model_applied),
786            Some(true),
787        );
788    }
789
790    #[tokio::test]
791    async fn stop_vmss_instances_sends_post() {
792        let mut mock = crate::MockClient::new();
793        mock.expect_post("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachineScaleSets/my-vmss/poweroff")
794            .returning_json(serde_json::json!(null));
795
796        let client = AzureHttpClient::from_mock(mock);
797        let ids = VirtualMachineScaleSetVMInstanceIDs {
798            instance_ids: vec!["0".into()],
799        };
800        client
801            .compute()
802            .stop_vmss_instances("rg1", "my-vmss", &ids)
803            .await
804            .expect("stop_vmss_instances failed");
805    }
806
807    #[tokio::test]
808    async fn start_vmss_instances_sends_post() {
809        let mut mock = crate::MockClient::new();
810        mock.expect_post("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachineScaleSets/my-vmss/start")
811            .returning_json(serde_json::json!(null));
812
813        let client = AzureHttpClient::from_mock(mock);
814        let ids = VirtualMachineScaleSetVMInstanceIDs {
815            instance_ids: vec!["0".into(), "1".into()],
816        };
817        client
818            .compute()
819            .start_vmss_instances("rg1", "my-vmss", &ids)
820            .await
821            .expect("start_vmss_instances failed");
822    }
823
824    // --- Managed Disk unit tests ---
825
826    #[tokio::test]
827    async fn list_disks_returns_empty_list() {
828        let mut mock = crate::MockClient::new();
829        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Compute/disks")
830            .returning_json(serde_json::json!({ "value": [] }));
831
832        let client = AzureHttpClient::from_mock(mock);
833        let result = client
834            .compute()
835            .list_disks("my-rg")
836            .await
837            .expect("list_disks failed");
838        assert!(result.value.is_empty());
839        assert!(result.next_link.is_none());
840    }
841
842    #[tokio::test]
843    async fn list_disks_returns_disk_with_fields() {
844        let mut mock = crate::MockClient::new();
845        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Compute/disks")
846            .returning_json(serde_json::json!({
847                "value": [{
848                    "id": "/subscriptions/sub/resourceGroups/my-rg/providers/Microsoft.Compute/disks/my-disk",
849                    "name": "my-disk",
850                    "type": "Microsoft.Compute/disks",
851                    "location": "eastus",
852                    "sku": { "name": "Standard_LRS" },
853                    "properties": {
854                        "diskSizeGB": 32,
855                        "diskState": "Unattached",
856                        "provisioningState": "Succeeded"
857                    }
858                }]
859            }));
860
861        let client = AzureHttpClient::from_mock(mock);
862        let result = client
863            .compute()
864            .list_disks("my-rg")
865            .await
866            .expect("list_disks failed");
867        assert_eq!(result.value.len(), 1);
868        let disk = &result.value[0];
869        assert_eq!(disk.name.as_deref(), Some("my-disk"));
870        assert_eq!(disk.location.as_str(), "eastus");
871        assert_eq!(
872            disk.sku.as_ref().and_then(|s| s.name.as_deref()),
873            Some("Standard_LRS")
874        );
875        assert_eq!(
876            disk.properties.as_ref().and_then(|p| p.disk_size_gb),
877            Some(32)
878        );
879        assert_eq!(
880            disk.properties
881                .as_ref()
882                .and_then(|p| p.disk_state.as_deref()),
883            Some("Unattached"),
884        );
885    }
886
887    #[tokio::test]
888    async fn list_disks_in_subscription_returns_list() {
889        let mut mock = crate::MockClient::new();
890        mock.expect_get("/subscriptions/test-subscription-id/providers/Microsoft.Compute/disks")
891            .returning_json(serde_json::json!({
892                "value": [{
893                    "name": "disk-a",
894                    "location": "eastus",
895                    "properties": {
896                        "diskSizeGB": 64,
897                        "diskState": "Unattached"
898                    }
899                }]
900            }));
901
902        let client = AzureHttpClient::from_mock(mock);
903        let result = client
904            .compute()
905            .list_disks_in_subscription()
906            .await
907            .expect("list_disks_in_subscription failed");
908        assert_eq!(result.value.len(), 1);
909        assert_eq!(result.value[0].name.as_deref(), Some("disk-a"));
910    }
911
912    #[tokio::test]
913    async fn get_disk_returns_disk_fields() {
914        let mut mock = crate::MockClient::new();
915        mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/disks/my-disk")
916            .returning_json(serde_json::json!({
917                "id": "/subscriptions/sub/resourceGroups/rg1/providers/Microsoft.Compute/disks/my-disk",
918                "name": "my-disk",
919                "location": "westus2",
920                "sku": { "name": "Premium_LRS", "tier": "Premium" },
921                "properties": {
922                    "diskSizeGB": 128,
923                    "diskState": "Unattached",
924                    "provisioningState": "Succeeded",
925                    "uniqueId": "disk-uuid-abc"
926                }
927            }));
928
929        let client = AzureHttpClient::from_mock(mock);
930        let disk = client
931            .compute()
932            .get_disk("rg1", "my-disk")
933            .await
934            .expect("get_disk failed");
935        assert_eq!(disk.name.as_deref(), Some("my-disk"));
936        assert_eq!(disk.location.as_str(), "westus2");
937        assert_eq!(
938            disk.sku.as_ref().and_then(|s| s.name.as_deref()),
939            Some("Premium_LRS")
940        );
941        assert_eq!(
942            disk.properties.as_ref().and_then(|p| p.disk_size_gb),
943            Some(128)
944        );
945        assert_eq!(
946            disk.properties
947                .as_ref()
948                .and_then(|p| p.unique_id.as_deref()),
949            Some("disk-uuid-abc"),
950        );
951    }
952
953    #[tokio::test]
954    async fn create_disk_sends_put_and_returns_disk() {
955        use crate::types::compute::{DiskCreateRequest, DiskCreationData, DiskProperties, DiskSku};
956        let mut mock = crate::MockClient::new();
957        mock.expect_put("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/disks/new-disk")
958            .returning_json(serde_json::json!({
959                "name": "new-disk",
960                "location": "eastus",
961                "sku": { "name": "Standard_LRS" },
962                "properties": {
963                    "diskSizeGB": 4,
964                    "diskState": "Unattached",
965                    "provisioningState": "Updating"
966                }
967            }));
968
969        let client = AzureHttpClient::from_mock(mock);
970        let request = DiskCreateRequest {
971            location: "eastus".into(),
972            sku: Some(DiskSku {
973                name: Some("Standard_LRS".into()),
974                ..Default::default()
975            }),
976            properties: Some(DiskProperties {
977                disk_size_gb: Some(4),
978                creation_data: Some(DiskCreationData {
979                    create_option: "Empty".into(),
980                    ..Default::default()
981                }),
982                ..Default::default()
983            }),
984            ..Default::default()
985        };
986        let disk = client
987            .compute()
988            .create_disk("rg1", "new-disk", &request)
989            .await
990            .expect("create_disk failed");
991        assert_eq!(disk.name.as_deref(), Some("new-disk"));
992        assert_eq!(
993            disk.properties.as_ref().and_then(|p| p.disk_size_gb),
994            Some(4)
995        );
996        assert_eq!(
997            disk.properties
998                .as_ref()
999                .and_then(|p| p.provisioning_state.as_deref()),
1000            Some("Updating"),
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn delete_disk_sends_delete() {
1006        let mut mock = crate::MockClient::new();
1007        mock.expect_delete("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/disks/old-disk")
1008            .returning_json(serde_json::json!(null));
1009
1010        let client = AzureHttpClient::from_mock(mock);
1011        client
1012            .compute()
1013            .delete_disk("rg1", "old-disk")
1014            .await
1015            .expect("delete_disk failed");
1016    }
1017
1018    #[tokio::test]
1019    async fn update_disk_sku_sends_patch_and_returns_disk() {
1020        let mut mock = crate::MockClient::new();
1021        mock.expect_patch("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/disks/my-disk")
1022            .returning_json(serde_json::json!({
1023                "name": "my-disk",
1024                "location": "eastus",
1025                "sku": { "name": "StandardSSD_LRS", "tier": "StandardSSD" },
1026                "properties": {
1027                    "diskSizeGB": 128,
1028                    "diskState": "Unattached",
1029                    "provisioningState": "Succeeded"
1030                }
1031            }));
1032
1033        let client = AzureHttpClient::from_mock(mock);
1034        let disk = client
1035            .compute()
1036            .update_disk_sku("rg1", "my-disk", "StandardSSD_LRS")
1037            .await
1038            .expect("update_disk_sku failed");
1039        assert_eq!(disk.name.as_deref(), Some("my-disk"));
1040        assert_eq!(
1041            disk.sku.as_ref().and_then(|s| s.name.as_deref()),
1042            Some("StandardSSD_LRS")
1043        );
1044        assert_eq!(
1045            disk.properties.as_ref().and_then(|p| p.disk_size_gb),
1046            Some(128)
1047        );
1048        assert_eq!(
1049            disk.properties
1050                .as_ref()
1051                .and_then(|p| p.disk_state.as_deref()),
1052            Some("Unattached"),
1053        );
1054        assert_eq!(
1055            disk.properties
1056                .as_ref()
1057                .and_then(|p| p.provisioning_state.as_deref()),
1058            Some("Succeeded"),
1059        );
1060    }
1061
1062    #[tokio::test]
1063    async fn revoke_access_sends_post() {
1064        let mut mock = crate::MockClient::new();
1065        mock.expect_post("/subscriptions/test-subscription-id/resourceGroups/rg1/providers/Microsoft.Compute/disks/my-disk/endGetAccess")
1066            .returning_json(serde_json::json!(null));
1067
1068        let client = AzureHttpClient::from_mock(mock);
1069        client
1070            .compute()
1071            .revoke_access("rg1", "my-disk")
1072            .await
1073            .expect("revoke_access failed");
1074    }
1075}