Automating Azure VM Ubuntu install without fancy tools

Motivation

In a 2022 post I showed how to bring up a disposable CBL-Mariner VM using cloud-init and (mostly) the dnf package manager. As I explained in that post, it takes some fiddling around to find sources for various packages and also to mix installation methods. To achieve a more concise installation approach I tried mixing CBL Mariner with Nix package manager in a 2023 post.

Since then I have been using NixOS on my tinkering computers (x86 & ARM64) at home because I liked this one-file-format-declarative-definition of machines. With some new cloud technology evaluations ahead, for which I usually bring up dedicated disposable VMs, I wanted to transfer some of my NixOS learnings and create a disposable NixOS Azure VM. As I did want to create a (lame, everbody does that) custom image I was focusing a while on some infection methods (use any installed system and then “infect” with NixOS) nixos-anywhere and nixos-infect. I did only succeed to a certain point but had to stop because time was running out. One thing I learned in the past 3 decades: pull back in time before you get stuck in a rabbit hole, contain your frustration, swallow your professional pride and move on. Maybe someone reading this already has figured out how to bring NixOS on an Azure VM in this or another way.

Ambition

Moving on, I decided to go for the simplest solution in my eyes: Ubuntu. Why? When experimenting in my experience most on-the-spot tool installations are documented and work usually with Ubuntu or rather Debian. To still make it interesting I set this “bar” for me:

  • use basic tools like cloud-init and scripts – no Ansible, Chef, Puppet – to start VM installation quickly without too many dependencies
  • not to use persisted SSH keys – rather read with CLI from 1Password
  • make my regular working environment like NeoVim, TMUX, ZSH available on the VM
  • pre-install Node.js, Python, Rust, Docker with the scripts and methods which bring exactly the desired versions for my dev workloads

This all might not seem very exiting, however, I still had to explore and learn many things (coming from a more or less homogenous NixOS & Home Manager ecosystem) which I want to share here.

Structure

I will share 4 files I use to drive the installation and then pick out and comment on interesting sections within those files:

  • create.sh – driving the installation process
  • cloud-init.txt – basic installation of VM
  • check-creation.sh – connect to VM and check whether cloud-init installation step concluded
  • install-stages.sh – install all requirements in several stages which build up on each other

Have fun extracting whatever is interesting or useful for you!

In the scripts I will share names of primitives – username, SSH keys, repositories. Those are already renamed or obfuscated – so it makes no sense to spend energy in finding those objects out there in the wild.

create.sh

In general this script

  • reads SSH public key to be authorized on VM (file authorized_keys) from 1Password
  • creates a Resource Group and a Storage Account for Boot Diagnostics (which I used to debug NixOS infection progress and which I wanted to keep)
  • create a VM, 1TB OS disk, cloud-init.txt for initialization
  • sets Auto Shutdown to 22:00 UTC
  • removes probably existing SSH entries from known_hosts on my local machine and removes empty lines
#! /bin/sh set -e VMNAME=${1:-thevm}
USERNAME=${2:-theuser}
PUBKEYNAME=${3:-theVmSshKey}
LOCATION=${4:-uksouth}
STORAGENAME=`echo $VMNAME$RANDOM | tr -cd '[a-z0-9]'` op account get --account my
if [ $? -ne 0 ]; then eval $(op signin --account my)
fi PUBKEY=`op read "op://Private/$PUBKEYNAME/public key"` az group create -n $VMNAME -l $LOCATION az storage account create -n $STORAGENAME -g $VMNAME \ --sku Standard_LRS \ --kind StorageV2 \ --allow-blob-public-access false az vm create -n $VMNAME -g $VMNAME \ --image "Canonical:ubuntu-24_04-lts:server:latest" \ --public-ip-sku Standard \ --public-ip-address-dns-name $VMNAME \ --ssh-key-values "$PUBKEY" \ --admin-username $USERNAME \ --os-disk-size-gb 1024 \ --boot-diagnostics-storage $STORAGENAME \ --size Standard_DS2_v2 \ --custom-data "$(cat ./cloud-init.txt)" az vm auto-shutdown -n $VMNAME -g $VMNAME \ --time "22:00" sed -i '' "s/$VMNAME.*//" ~/.ssh/known_hosts
sed -i '' '/^$/d' ~/.ssh/known_hosts
Enter fullscreen mode
Exit fullscreen mode

Azure Storage Account Name

Reduce Storage Account name, derived from VM name to alphanumeric characters as other characters like - are not allowed. Add a random number to somewhat ensure that the Storage Account name is unique.

STORAGENAME=`echo $VMNAME$RANDOM | tr -cd '[a-z0-9]'`
Enter fullscreen mode
Exit fullscreen mode

Get SSH public key from 1Password

This section tests whether 1Password CLI is already signed in, if not does the signin and then reads the public key portion from the secret. myis the account (could be more than one) and Private is the vault’s name.

op account get --account my
if [ $? -ne 0 ]; then eval $(op signin --account my)
fi PUBKEY=`op read "op://Private/$PUBKEYNAME/public key"`
Enter fullscreen mode
Exit fullscreen mode

Clean up known_hosts

In case that VM name had been used before and was signed in to with SSH, these statements remove the previous entries and potential resulting empty lines.

The variance of sed works on MacOS, probably with BSD and might need to be adjusted for Linux.

sed -i '' "s/$VMNAME.*//" ~/.ssh/known_hosts
sed -i '' '/^$/d' ~/.ssh/known_hosts
Enter fullscreen mode
Exit fullscreen mode

cloud-init.txt

This files defines

  • installation of a basic set of standard apt packages
  • installation scripts in various stages which are copied to user’s home folder for later installation
#cloud-config
package_upgrade: true
packages:
- apt-transport-https
- ca-certificates
- curl
- wget
- less
- lsb-release
- gnupg
- build-essential
- python3
- zsh
- tmux
- jq
- xclip
- dos2unix
- fzf
- ripgrep
write_files: - path: /tmp/install-stage1.sh content: | #!/usr/bin/env bash # Azure CLI curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash # Rust curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y # NVM / Node part 1 curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # TMUX TPM part 1 git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm # Python sudo update-alternatives --install /usr/bin/python python /usr/bin/python3 10 # ZSH oh-my-sh part 1 sudo chsh -s $(which zsh) $USER sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" permissions: '0755' - path: /tmp/install-stage2.sh content: | #!/usr/bin/env bash ssh -T [email protected] # clone script folders if ! [ -d ~/scripts ]; then git clone [email protected]:theuser/bash-scripts.git ~/scripts; fi if ! [ -d ~/.dotfiles.git ]; then git clone [email protected]:theuser/dotfiles.git ~/.dotfiles.git; fi # configurations [ -e ~/.zshrc ] && rm ~/.zshrc [ -d ~/.dotfiles.git ] && ln -s ~/.dotfiles.git/.zshrc ~/.zshrc [ -e ~/.tmux.conf ] && rm ~/.tmux.conf [ -d ~/.dotfiles.git ] && ln -s ~/.dotfiles.git/.tmux.conf ~/.tmux.conf [ -e ~/.configgit ] && rm ~/.configgit [ -d ~/.dotfiles.git ] && ln -s ~/.dotfiles.git/.configgit ~/.configgit ([ ! -L ~/.config ] && [ -d ~/.dotfiles.git ]) && ln -s ~/.dotfiles.git/.config ~/.config # TMUX TPM part 2 .tmux/plugins/tpm/scripts/install_plugins.sh # NeoVim [ -e ~/scripts/install-neovim.sh ] && ./scripts/install-neovim.sh # NVM / Node part 2 source .nvm/nvm.sh nvm install --lts permissions: '0755' - path: /tmp/install-stage3.sh content: | #!/usr/bin/env bash ZSH=$HOME/.oh-my-zsh [ ! -d $ZSH/custom/plugins/zsh-autocomplete ] && git clone --depth 1 -- https://github.com/marlonrichert/zsh-autocomplete.git $ZSH/custom/plugins/zsh-autocomplete permissions: '0755'
runcmd:
- export USER=$(awk -v uid=1000 -F":" '{ if($3==uid){print $1} }' /etc/passwd) - curl -fsSL https://test.docker.com -o test-docker.sh
- sh test-docker.sh
- rm test-docker.sh
- usermod -aG docker $USER - mv /tmp/install-stage* /home/$USER/
Enter fullscreen mode
Exit fullscreen mode

Determine user name

This line extracts non-root user name with user id 1000 from passwd to reference later in variable $USER.

- export USER=$(awk -v uid=1000 -F":" '{ if($3==uid){print $1} }' /etc/passwd)
Enter fullscreen mode
Exit fullscreen mode

Install Docker Beta version

- curl -fsSL https://test.docker.com -o test-docker.sh
- sh test-docker.sh
- rm test-docker.sh
- usermod -aG docker $USER
Enter fullscreen mode
Exit fullscreen mode

Map configuration files to dotfiles folder

On my disposable VMs I only map selective files and folder from .dotfiles.git folder to user’s home:

# configurations
[ -e ~/.zshrc ] && rm ~/.zshrc
[ -d ~/.dotfiles.git ] && ln -s ~/.dotfiles.git/.zshrc ~/.zshrc [ -e ~/.tmux.conf ] && rm ~/.tmux.conf
[ -d ~/.dotfiles.git ] && ln -s ~/.dotfiles.git/.tmux.conf ~/.tmux.conf [ -e ~/.configgit ] && rm ~/.configgit
[ -d ~/.dotfiles.git ] && ln -s ~/.dotfiles.git/.configgit ~/.configgit ([ ! -L ~/.config ] && [ -d ~/.dotfiles.git ]) && ln -s ~/.dotfiles.git/.config ~/.config
Enter fullscreen mode
Exit fullscreen mode

check-creation.sh

This is used to SSH into the newly created VM and wait for the cloud init process to finish (or fail):

#!/bin/sh VMNAME=${1:-thevm}
USERNAME=${2:-theuser}
GITHUBSSHKEYNAME=${3:-theGitHubSshKey}
FQDN=`az vm show --show-details -n $VMNAME -g $VMNAME --query fqdns -o tsv | cut -d "," -f 1`
ssh $USERNAME@$FQDN sudo tail -f /var/log/cloud-init-output.log
Enter fullscreen mode
Exit fullscreen mode

Determine VM’s FQDN

Azure CLI has an option --show-details which returns (among also the provisioning/running state) the VM’s FQDNs as a comma separated list.

FQDN=`az vm show --show-details -n $VMNAME -g $VMNAME --query fqdns -o tsv | cut -d "," -f 1`
Enter fullscreen mode
Exit fullscreen mode

install-stages.sh

This script is called after create.sh and check-creation.sh which prepares SSH keys for GitHub and then runs the installation stages scripts:

#!/bin/sh VMNAME=${1:-thevm}
USERNAME=${2:-theuser}
GITHUBSSHKEYNAME=${3:-theGitHubSshKey}
FQDN=`az vm show --show-details -n $VMNAME -g $VMNAME --query fqdns -o tsv | cut -d "," -f 1` op account get --account my
if [ $? -ne 0 ]; then eval $(op signin --account my)
fi op read "op://Private/$GITHUBSSHKEYNAME/private key?ssh-format=openssh" | ssh $USERNAME@$FQDN -T "cat > /home/$USERNAME/.ssh/github"
op read "op://Private/$GITHUBSSHKEYNAME/public key" | ssh $USERNAME@$FQDN -T "cat > /home/$USERNAME/.ssh/github.pub" ssh $USERNAME@$FQDN bash -c "'
chmod 700 ~/.ssh
chmod 644 ~/.ssh/authorized_keys
chmod 644 ~/.ssh/*pub
chmod 600 ~/.ssh/github dos2unix ~/.ssh/github cat << EOF > ~/.ssh/config
Host github.com HostName github.com User git IdentityFile ~/.ssh/github EOF chmod 644 ~/.ssh/config '" echo "SSH config finished ... press key"
read -s -n 1 ssh -t $USERNAME@$FQDN ./install-stage1.sh echo "Stage 1 finished ... press key"
read -s -n 1 ssh -t $USERNAME@$FQDN ./install-stage2.sh echo "Stage 2 finished ... press key"
read -s -n 1 ssh -t $USERNAME@$FQDN ./install-stage3.sh
Enter fullscreen mode
Exit fullscreen mode

Retrieve and set GitHub SSH keys

These 2 statements extract private and public SSH keys and transfers those with SSH to the VM:

op read "op://Private/$GITHUBSSHKEYNAME/private key?ssh-format=openssh" | ssh $USERNAME@$FQDN -T "cat > /home/$USERNAME/.ssh/github"
op read "op://Private/$GITHUBSSHKEYNAME/public key" | ssh $USERNAME@$FQDN -T "cat > /home/$USERNAME/.ssh/github.pub"
Enter fullscreen mode
Exit fullscreen mode

On the VM line endings need to be converted from CR/LF to LF:

dos2unix ~/.ssh/github
Enter fullscreen mode
Exit fullscreen mode

Multiple line SSH commands

This one I had to figure out first and comes in handy when multiple lines have to be send over SSH to a remote machine:

ssh $USERNAME@$FQDN bash -c "'
...
cat << EOF > ~/.ssh/config
Host github.com HostName github.com User git IdentityFile ~/.ssh/github EOF
... '"
Enter fullscreen mode
Exit fullscreen mode

Use SSH with terminal allocated

I ran into a problem …

Host key verification failed.
Enter fullscreen mode
Exit fullscreen mode

… with this section when just SSHing with ssh -t $USERNAME@$FQDN ./install-stage2.sh

ssh -T [email protected] # clone script folders
if ! [ -d ~/scripts ]; then git clone [email protected]:theuser/bash-scripts.git ~/scripts; fi
if ! [ -d ~/.dotfiles.git ]; then git clone [email protected]:theuser/dotfiles.git ~/.dotfiles.git; fi
Enter fullscreen mode
Exit fullscreen mode

I had to change to -t option:

ssh -t $USERNAME@$FQDN ./install-stage2.sh
Enter fullscreen mode
Exit fullscreen mode

install-neovim.sh

I noticed that when installing NeoVim with the various package managers (DNF, apt, AUR) different configuration postures are put on the systems. Hence I always install with this script to end up with a reproducable configuration.

#!/bin/bash set -e case $1 in nightly) # Ok tag=tags/nightly ;; *) tag=latest ;;
esac latest_nv_linux=$(curl -sL https://api.github.com/repos/neovim/neovim/releases/$tag | jq -r ".assets[].browser_download_url" | grep -E 'nvim-linux64.tar.gz$')
wget $latest_nv_linux -O ~/nvim-linux64.tar.gz
sudo tar xvf ~/nvim-linux64.tar.gz -C /usr/local/bin/
rm ~/nvim-linux64.tar.gz
mkdir -p ~/.local/bin if [ ! -e ~/.local/bin/nvim ]; then sudo ln -s /usr/local/bin/nvim-linux64/bin/nvim ~/.local/bin/nvim
fi
Enter fullscreen mode
Exit fullscreen mode

Discover more from Coursity

Subscribe to get the latest posts sent to your email.

Leave a Comment

Your email address will not be published. Required fields are marked *

Discover more from Coursity

Subscribe now to keep reading and get access to the full archive.

Continue reading

Scroll to Top