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);
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);
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,
},
],
}
);
}
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:
- Public IP address (e.g.,
clone42-my-vm-ip
) - Network adapter (e.g.,
clone42-my-network
) - 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,
}
);
}
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;
}
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`
);
}
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!');
}
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"
);
}
More on these states can be found in various online documentations. In short the state diagram for a VM looks as follows:
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")
);
}
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");
}
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.
Top comments (0)