DEV Community

Cover image for Cloning a VM in Azure from Node.js
Florian Rappl
Florian Rappl

Posted on

Cloning a VM in Azure from Node.js

Photo by Phil Shaw on Unsplash

Sometimes you need to duplicate a certain virtual machine (VM). This may be necessary to get not only the same base data (operating system, installed programs, user accounts and settings), but also the same VM configuration such as the used number of cores, memory, and network settings.

One area where this might be helpful is if you want to create a test setup, where each test should start at the same kind of VM. Another interesting area is to provide a standardized VM to each employee or customer. The approach of cloning helps in that regard, as a "template" (or clone master, i.e., the source VM for cloning) can be updated and taken care of - having direct impact on the cloning without needing to update the code.

Basic Setup

In order to deal with it efficiently, I've decided to create a small Node.js powered service that does all the orchestration for me. The most important packages to utilize have been:

  • @azure/arm-compute (brings the actual VM orchestration)
  • @azure/arm-network (ability to control the network plane, i.e., create a new virtual ethernet adapter)
  • @azure/identity (for the actual authorization of other Azure management packages)

Our orchestration needs require elevated rights. This can be done with an Azure service principal.

What you'll need:

  • Your tenant
  • The subscription ID
  • The clientId of a created service principal
  • The associated clientSecret of the created service principal
  • The resourceGroup where the reference VM (and the cloned VM) will reside
  • The name of the reference VM (referenceVmName) to use as a template for cloning

The service principal can be created via the Azure CLI as explained on the documentation.

What we need now is the following imports and creation of the credentials:

const { ClientSecretCredential } = require("@azure/identity");
const { ComputeManagementClient } = require("@azure/arm-compute");
const { NetworkManagementClient } = require("@azure/arm-network");

const credential = new ClientSecretCredential(tenant, clientId, clientSecret);
Enter fullscreen mode Exit fullscreen mode

At some later point in time we can create the actual management clients and do something useful with them. As an example, we could just list all the available VMs in the provided resource group or try to locate the reference VM in them. If the reference VM is not there we might want to error out.

const computeClient = new ComputeManagementClient(credential, subscription);
const networkClient = new NetworkManagementClient(credential, subscription);
const machines = await computeClient.virtualMachines.list(resourceGroup);
const referenceVm = machines.find((m) => m.name === referenceVmName);
Enter fullscreen mode Exit fullscreen mode

With those prerequisites in mind we can have a look at the actual cloning process.

Cloning Process

Cloning is the process of making an exact copy. Unfortunately, the copy cannot be 100% exact. For instance, since its a different machine we require a dedicated network adapter that also exposes, for instance, a different IP address. Also, some system internals such as the processor ID will definitely be different.

Before we can actually clone the VM we need to create the other (required) resources:

  • A network adapter
  • An (OS) disk

While cloning of the disk works by taking the template and copying it, the other resources are just created via the API. We will still copy some properties from the template VM, however, many interesting parts (e.g., the public IP allocation method of the network adapter) are directly specified.

Without further ado, here's the code for creating the network adapter.

async function createNetwork(networkClient, vm, prefix) {
  const [nic] = vm.networkProfile.networkInterfaces;
  const networks = await networkClient.networkInterfaces.list(resourceGroup);
  const network = networks.find((m) => m.id === nic.id);
  const [config] = network.ipConfigurations;
  const publicIpInfo = await networkClient.publicIPAddresses.createOrUpdate(
    resourceGroup,
    `${prefix}-${vm.name}-ip`,
    {
      location: network.location,
      publicIPAllocationMethod: 'Static',
      publicIPAddressVersion: 'IPv4',
    }
  );
  return await networkClient.networkInterfaces.createOrUpdate(
    resourceGroup,
    `${prefix}-${network.name}`,
    {
      location: network.location,
      ipConfigurations: [
        {
          name: `${prefix}-${config.name}`,
          privateIPAllocationMethod: "Dynamic",
          subnet: config.subnet,
          publicIPAddress: publicIpInfo,
        },
      ],
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

We always assume that the prefix is something like a clone ID, while the template has a primary name. As an example, let's say the template VM is called my-vm with the network adapter my-network and the prefix is clone42 then we'd end up with clone42-my-network for the network interface. The public IP address will be called clone42-my-vm-ip.

In total we have:

  1. Public IP address (e.g., clone42-my-vm-ip)
  2. Network adapter (e.g., clone42-my-network)
  3. IP configuration, which attaches the IP address to the network adapter (e.g., clone42-my-network-config)

Similar, for the disk. Here, we choose the osDisk of the template VM as clone source. Important is the createOption, which can be set to Copy.

async function createDisk(computeClient, vm, prefix) {
  const disk = vm.storageProfile.osDisk;
  return await computeClient.disks.createOrUpdate(
    resourceGroup,
    `${prefix}-${disk.name}`,
    {
      location: vm.location,
      creationData: {
        createOption: "Copy",
        sourceUri: disk.managedDisk.id,
      },
      sku: {
        name: disk.managedDisk.storageAccountType,
      },
      diskSizeGB: disk.diskSizeGB,
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

With these in mind we can actually write the cloning function. In short, it waits for the sub-resources to be created and then issues a new VM creation using the Azure REST API:

async function cloneVirtualMachine(computeClient, networkClient, vm, prefix) {
  const cloneName = `${prefix}-${vm.name}`;

  const [disk, nic] = await Promise.all([
    createDisk(computeClient, vm, suffix),
    createNetwork(networkClient, vm, suffix),
  ]);
  const result = await computeClient.virtualMachines.createOrUpdate(
    resourceGroup,
    cloneName,
    {
      location: vm.location,
      plan: vm.plan,
      hardwareProfile: {
        vmSize: vm.hardwareProfile.vmSize,
      },
      networkProfile: {
        networkInterfaces: [
          {
            id: nic.id,
            primary: true,
          },
        ],
      },
      storageProfile: {
        osDisk: {
          createOption: "Attach",
          osType: vm.storageProfile.osDisk.osType,
          managedDisk: {
            id: disk.id,
          },
        },
      },
    }
  );

  return result;
}
Enter fullscreen mode Exit fullscreen mode

Since we created the OS disk separately we only need to Attach the previously created resource. The great thing with the shown approach is that is really just takes the parameters from the template VM. So, if we want to change the VM plan or size we can do that on the template VM and then have all clones done correctly.

So much for the actual VM cloning, however, this is not all that we might need. Let's have a look at some little helpers that might come in handy.

Little Helpers

Obviously, if we create we might want also to destroy. Having multiple clones sitting around doing nothing might not be ideal, which is why a delete functionality would be great.

Luckily, this is rather straight forward - the only thing to keep in mind is that the used sub-resources cannot be removed before the VM is removed. As a rule of thumb - resources can only be removed once no other resource has a dependency to it.

We therefore start by removing the actual VM followed by the disk and finally the network.

async function deleteVirtualMachine(computeClient, networkClient, vm) {
  const [nic] = vm.networkProfile.networkInterfaces;
  const networks = await networkClient.networkInterfaces.list(resourceGroup);
  const network = networks.find((m) => m.id === nic.id);

  await computeClient.virtualMachines.deleteMethod(resourceGroup, vm.name);
  await computeClient.disks.deleteMethod(
    resourceGroup,
    vm.storageProfile.osDisk.name
  );
  await networkClient.networkInterfaces.deleteMethod(
    resourceGroup,
    network.name
  );
  await networkClient.publicIPAddresses.deleteMethod(
    resourceGroup,
    `${vm.name}-ip`
  );
}
Enter fullscreen mode Exit fullscreen mode

This is great - and helps us to clean up properly.

Next, we require a function to actually turn a VM off or on. This is especially handy when we want to save money on the template VM. We would have it turned off all the time (except for updates / maintenance, of course) - only turning it on briefly for the cloning process.

Remark Turning off means deallocating. In Azure you can either turn off a VM (essentially still keeps the resources allocated / billing active) or deallocate it. The latter has to be done for saving money. The downside is that it definitely will take longer to restart it from this state.

async function togglePower(computeClient, vm) {
  const running = await isRunning(computeClient, vm);

  if (running) {
    console.log('VM is running! Shutting down ...');
    await computeClient.virtualMachines.deallocate(resourceGroup, vm.name);
  } else {
    console.log('VM is shut down! Starting up ...');
    await computeClient.virtualMachines.start(resourceGroup, vm.name);
  }

  console.log('All done!');
}
Enter fullscreen mode Exit fullscreen mode

Keep in mind that we use deallocate here. Alternatively, you could use powerOff to just suspend the VM (remember that you'd still be billed in that case).

In order to choose the right action (deallocate or start) we need a simple way of determining if the VM is running. The following snippet is helpful.

async function isRunning(computeClient, vm) {
  const details = await computeClient.virtualMachines.get(
    resourceGroup,
    vm.name,
    {
      expand: "instanceView",
    }
  );

  return details.instanceView.statuses.some(
    (m) => m.code === "PowerState/running"
  );
}
Enter fullscreen mode Exit fullscreen mode

More on these states can be found in various online documentations. In short the state diagram for a VM looks as follows:

State diagram by Stefanos Evangelou

Finally, in our use case a Windows VM has been created. The OS disk had an additional user account in there, which should receive a randomized password.

We can use the runCommand functionality to actually achieve this. The following snippet can reset the password of a local Windows user on the VM OS disk given a username user and a new password newPassword.

async function changePassword(computeClient, vm, user, newPassword) {
  const res = await computeClient.virtualMachines.runCommand(
    resourceGroup,
    vm.name,
    {
      commandId: "RunPowerShellScript",
      script: [
        `Set-LocalUser -Name "${user}" -Password (ConvertTo-SecureString "${newPassword}" -AsPlainText -Force)`,
      ],
    }
  );
  const output = res.properties.output.value;
  return (
    output.some((m) => m.code === "ComponentStatus/StdOut/succeeded") &&
    output.some((m) => m.code === "ComponentStatus/StdErr/succeeded")
  );
}
Enter fullscreen mode Exit fullscreen mode

Another thing that you might want to consider is a simple function to generate an RDP file. RDP is the remote desktop protocol and makes it possible to connect to a (Windows) VM from another computer. There is an integrated RDP client in Windows - on Mac OS the Microsoft Remote Desktop Client exists. Linux has some fantastic options, too.

async function getRdpConnectionFile(networkClient, vm, user) {
  const network = await networkClient.publicIPAddresses.get(
    resourceGroup,
    `${vm.name}-ip`
  );
  return [
    `full address:s:${network.ipAddress}:3389`,
    `username:s:${user}`,
    `prompt for credentials:i:0`,
    `administrative session:i:0`,
  ].join("\n");
}
Enter fullscreen mode Exit fullscreen mode

This generates a new file that automatically connects to the VM's public IP address using the given username.

Conclusion

In this article I've showed you how you can leverage Node.js to clone a VM in Azure programmatically. This can be very in handy in many situations and allows you to tailor the process exactly to your needs.

The Azure REST API provides a very stable and intuitive interface to control all functionality around VMs. This makes it easy to write reliable scripts like the one above. In my own tests I've never encountered issues of any kind, even though the code above would still require retries and state management for edge case scenarios.

Latest comments (0)