You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1142 lines
36 KiB
1142 lines
36 KiB
#!/usr/bin/env bash |
|
set -euo pipefail |
|
|
|
# Set program name variable - basename without subshell |
|
prog=${0##*/} |
|
|
|
function usage() { |
|
cat << EOF |
|
NAME |
|
kvm-install-vm - Install virtual guests using cloud-init on a local KVM |
|
hypervisor. |
|
|
|
SYNOPSIS |
|
$prog COMMAND [OPTIONS] |
|
|
|
DESCRIPTION |
|
A bash wrapper around virt-install to build virtual machines on a local KVM |
|
hypervisor. You can run it as a normal user which will use qemu:///session |
|
to connect locally to your KVM domains. |
|
|
|
COMMANDS |
|
help - show this help or help for a subcommand |
|
attach-disk - create and attach a disk device to guest domain |
|
create - create a new guest domain |
|
detach-disk - detach a disk device from a guest domain |
|
list - list all domains, running and stopped |
|
remove - delete a guest domain |
|
|
|
EOF |
|
exit 0 |
|
} |
|
|
|
function usage_subcommand() { |
|
case "$1" in |
|
create) |
|
printf "NAME\n" |
|
printf " %s create [COMMANDS] [OPTIONS] VMNAME\n" "$prog" |
|
printf "\n" |
|
printf "DESCRIPTION\n" |
|
printf " Create a new guest domain.\n" |
|
printf "\n" |
|
printf "COMMANDS\n" |
|
printf " help - show this help\n" |
|
printf "\n" |
|
printf "OPTIONS\n" |
|
printf " -a Autostart (default: false)\n" |
|
printf " -b Bridge (default: virbr0)\n" |
|
printf " -c Number of vCPUs (default: 1)\n" |
|
printf " -d Disk Size (GB) (default: 10)\n" |
|
printf " -D DNS Domain (default: example.local)\n" |
|
printf " -f CPU Model / Feature (default: host)\n" |
|
printf " -g Graphics type (default: vnc)\n" |
|
printf " -h Display help\n" |
|
printf " -i Custom image (local path or HTTPS URL)\n" |
|
printf " -k SSH Public Key (default: %s/.ssh/id_rsa.pub)\n" "$HOME" |
|
printf " -l Location of Images (default: %s/virt/images)\n" "$HOME" |
|
printf " -L Location of VMs (default: %s/virt/vms)\n" "$HOME" |
|
printf " -m Memory Size (MB) (default: 1024)\n" |
|
printf " -M Mac address (default: auto-assigned)\n" |
|
printf " -p Console port (default: auto)\n" |
|
printf " -s Custom shell script\n" |
|
printf " -S Enable UEFI secureboot\n" |
|
printf " -t Linux Distribution (default: centos8)\n" |
|
printf " -T Timezone (default: US/Eastern)\n" |
|
printf " -u Custom user (default: %s)\n" "$USER" |
|
printf " -y Assume yes to prompts (default: false)\n" |
|
printf " -n Assume no to prompts (default: false)\n" |
|
printf " -v Verbose\n" |
|
printf " -V Virt type (kvm, xen, qemu)\n (default: kvm)\n" |
|
printf "\n" |
|
printf "DISTRIBUTIONS\n" |
|
list_available_vms |
|
printf "\n" |
|
printf "EXAMPLES\n" |
|
printf " %s create foo\n" "$prog" |
|
printf " Create VM with the default parameters: CentOS 8, 1 vCPU, 1GB RAM, 10GB\n" |
|
printf " disk capacity.\n" |
|
printf "\n" |
|
printf " %s create -c 2 -m 2048 -d 20 foo\n" "$prog" |
|
printf " Create VM with custom parameters: 2 vCPUs, 2GB RAM, and 20GB disk\n" |
|
printf " capacity.\n" |
|
printf "\n" |
|
printf " %s create -t debian9 foo\n" "$prog" |
|
printf " Create a Debian 9 VM with the default parameters.\n" |
|
printf "\n" |
|
printf " %s create -T UTC foo\n" "$prog" |
|
printf " Create a default VM with UTC timezone.\n" |
|
printf "\n" |
|
printf " %s create -i /path/to/custom-image.qcow2 foo\n" "$prog" |
|
printf " Create a VM from a local custom image file.\n" |
|
printf "\n" |
|
printf " %s create -i https://example.com/image.qcow2 foo\n" "$prog" |
|
printf " Create a VM from a remote image (HTTPS download).\n" |
|
printf "\n" |
|
;; |
|
remove) |
|
printf "NAME\n" |
|
printf " %s remove [COMMANDS] VMNAME\n" "prog" |
|
printf "\n" |
|
printf "DESCRIPTION\n" |
|
printf " Destroys (stops) and undefines a guest domain. This also remove the\n" |
|
printf " associated storage pool.\n" |
|
printf "\n" |
|
printf "COMMANDS\n" |
|
printf " help - show this help\n" |
|
printf "\n" |
|
printf "OPTIONS\n" |
|
printf " -l Location of Images (default: %s/virt/images)\n" "$HOME" |
|
printf " -L Location of VMs (default: %s/virt/vms)\n" "$HOME" |
|
printf " -v Be verbose\n" |
|
printf "\n" |
|
printf "EXAMPLE\n" |
|
printf " %s remove foo\n" "$prog" |
|
printf " Remove (destroy and undefine) a guest domain. WARNING: This will\n" |
|
printf " delete the guest domain and any changes made inside it!\n" |
|
;; |
|
attach-disk) |
|
printf "NAME\n" |
|
printf " %s attach-disk [OPTIONS] [COMMANDS] VMNAME\n" "$prog" |
|
printf "\n" |
|
printf "DESCRIPTION\n" |
|
printf " Attaches a new disk to a guest domain.\n" |
|
printf "\n" |
|
printf "COMMANDS\n" |
|
printf " help - show this help\n" |
|
printf "\n" |
|
printf "OPTIONS\n" |
|
printf " -d SIZE Disk size (GB)\n" |
|
printf " -f FORMAT Disk image format (default: qcow2)\n" |
|
printf " -t TARGET Disk device target\n" |
|
printf "\n" |
|
printf "EXAMPLE\n" |
|
printf " %s attach-disk -d 10 -t vdb myvm\n" "$prog" |
|
printf " Attach a 10GB disk device to the myvm guest domain.\n" |
|
;; |
|
list) |
|
printf "NAME\n" |
|
printf " %s list\n" "$prog" |
|
printf "\n" |
|
printf "DESCRIPTION\n" |
|
printf " Lists all running and stopped guest domains.\n" |
|
;; |
|
*) |
|
printf "'%s' is not a valid subcommand.\n" "$subcommand" |
|
exit 1 |
|
;; |
|
esac |
|
exit 0 |
|
} |
|
|
|
# Console output colors |
|
red() { echo -e "\e[31m$@\e[0m"; } |
|
green() { echo -e "\e[32m$@\e[0m"; } |
|
yellow() { echo -e "\e[33m$@\e[0m"; } |
|
|
|
die() { |
|
red "ERR: $@" >&2 |
|
exit 2 |
|
} |
|
silent() { "$@" > /dev/null 2>&1; } |
|
output() { echo -e "- $@"; } |
|
outputn() { echo -en "- $@ ... "; } |
|
ok() { green "${@:-OK}"; } |
|
|
|
pushd() { command pushd "$@" > /dev/null; } |
|
popd() { command popd "$@" > /dev/null; } |
|
|
|
# Join zero or more strings into a delimited string. |
|
function join() { |
|
local sep="$1" |
|
if [ $# -eq 0 ]; then |
|
return |
|
fi |
|
shift |
|
while [ $# -gt 1 ]; do |
|
printf "%s%s" "$1" "$sep" |
|
shift |
|
done |
|
printf "%s\n" "$1" |
|
} |
|
|
|
# Print an optional name=value[,value,..] parameter. |
|
# Prints nothing if no values are given. |
|
function param() { |
|
if [ $# -lt 2 ]; then |
|
return # skip empty value |
|
fi |
|
local name="$1" |
|
shift |
|
local values="$(join ',' "$@")" |
|
printf "%s=%s\n" $name $values |
|
} |
|
|
|
# Output a command, one argument per line. |
|
function output_command() { |
|
local line_cont=$' \\ \n ' |
|
local command_lines=$(join "$line_cont" "$@") |
|
printf " %s\n" "$command_lines" |
|
} |
|
|
|
# Command wrapper to output the command to be run in verbose |
|
# mode and redirect stdout and stderr to the vm log file. |
|
function run() { |
|
local msg="$1" |
|
shift |
|
if [ "${VERBOSE}" -eq 1 ]; then |
|
output "$msg with the following command" |
|
output_command "$@" |
|
else |
|
outputn "$msg" |
|
fi |
|
("$@" &>> ${VMNAME}.log && ok) |
|
} |
|
|
|
# Detect OS and set wget parameters |
|
function set_wget() { |
|
if [ -f /etc/fedora-release ]; then |
|
WGET="wget --quiet --show-progress" |
|
else |
|
WGET="wget" |
|
fi |
|
} |
|
|
|
function check_vmname_set() { |
|
[ -n "${VMNAME}" ] || die "VMNAME not set." |
|
} |
|
|
|
function delete_vm() { |
|
# Check if domain exists and set DOMAIN_EXISTS variable. |
|
domain_exists "${VMNAME}" |
|
|
|
# Check if storage pool exists and set STORPOOL_EXISTS variable. |
|
storpool_exists "${VMNAME}" |
|
|
|
check_vmname_set |
|
|
|
if [ "${DOMAIN_EXISTS}" -eq 1 ]; then |
|
release_dhcp_lease "${VMNAME}" "${BRIDGE}" |
|
outputn "Destroying ${VMNAME} domain" |
|
virsh destroy --graceful ${VMNAME} > /dev/null 2>&1 && |
|
ok || |
|
yellow "(Domain is not running.)" |
|
|
|
outputn "Undefining ${VMNAME} domain" |
|
virsh undefine --managed-save --snapshots-metadata --nvram ${VMNAME} > /dev/null 2>&1 && |
|
ok || |
|
die "Could not undefine domain." |
|
else |
|
output "Domain ${VMNAME} does not exist" |
|
fi |
|
|
|
[[ -d ${VMDIR}/${VMNAME} ]] && DISKDIR=${VMDIR}/${VMNAME} || DISKDIR=${IMAGEDIR}/${VMNAME} |
|
[ -d $DISKDIR ] && |
|
outputn "Deleting ${VMNAME} files" && |
|
rm -rf $DISKDIR && |
|
ok |
|
|
|
if [ "${STORPOOL_EXISTS}" -eq 1 ]; then |
|
outputn "Destroying ${VMNAME} storage pool" |
|
virsh pool-destroy ${VMNAME} > /dev/null 2>&1 && ok |
|
else |
|
output "Storage pool ${VMNAME} does not exist" |
|
fi |
|
} |
|
|
|
function download_image() { |
|
local url="$1" |
|
local target_dir="$2" |
|
local filename="$3" |
|
|
|
# Create target directory if it doesn't exist |
|
mkdir -p "${target_dir}" |
|
|
|
local target_path="${target_dir}/${filename}" |
|
local temp_path="${target_path}.part" |
|
|
|
# Skip download if file already exists |
|
if [ -f "${target_path}" ]; then |
|
output "Image already exists: ${target_path}" |
|
return 0 |
|
fi |
|
|
|
set_wget |
|
local continue_flag="" |
|
|
|
if [ -f "${temp_path}" ]; then |
|
continue_flag="--continue" |
|
output "Partial image found. Resuming download" |
|
else |
|
output "Downloading image from ${url}" |
|
fi |
|
|
|
${WGET} \ |
|
${continue_flag} \ |
|
--directory-prefix="${target_dir}" \ |
|
--output-document="${temp_path}" \ |
|
"${url}" || |
|
die "Could not download image from ${url}." |
|
|
|
mv "${temp_path}" "${target_path}" |
|
output "Downloaded: ${target_path}" |
|
} |
|
|
|
function fetch_images() { |
|
# Create image directory if it doesn't already exist |
|
mkdir -p ${IMAGEDIR} |
|
|
|
local found="false" |
|
local vm_url="" |
|
|
|
for vm in "${BUILTIN_VMS[@]}"; do |
|
IFS=\| read -r vm_distro vm_desc vm_arch vm_url vm_login_user <<< "$vm" |
|
if [[ "$vm_distro" == "$DISTRO" && "$vm_arch" == "$ARCH" ]]; then |
|
QCOW="${vm_url##*/}" # Grab just the file from the URL to pass into virt-install |
|
OS_INFO="$vm_distro" # Distro name should come from osinfo |
|
IMAGE_URL="${vm_url%/*}" # Grab everything but the filename and the slash |
|
DISK_FORMAT="qcow2" |
|
LOGIN_USER="$vm_login_user" |
|
found="true" |
|
break |
|
fi |
|
done |
|
|
|
if [ "$found" = "false" ]; then |
|
die "No matching image found for ${DISTRO} (${ARCH})" |
|
fi |
|
|
|
IMAGE=${IMAGEDIR}/${QCOW} |
|
|
|
if [ ! -f ${IMAGEDIR}/${QCOW} ]; then |
|
download_image "${vm_url}" "${IMAGEDIR}" "${QCOW}" |
|
fi |
|
|
|
} |
|
|
|
function check_dhcp_release() { |
|
if ! command -v dhcp_release > /dev/null 2>&1; then |
|
return 1 |
|
fi |
|
return 0 |
|
} |
|
|
|
function suggest_dhcp_release_install() { |
|
yellow "dhcp_release not found. DHCP lease cleanup will be skipped." |
|
yellow "To enable automatic lease cleanup, install dnsmasq-utils:" |
|
|
|
# Detect distribution and suggest appropriate command |
|
if command -v lsb_release > /dev/null 2>&1; then |
|
local distro=$(lsb_release -si 2>/dev/null) |
|
elif [ -f /etc/os-release ]; then |
|
local distro=$(grep '^ID=' /etc/os-release | cut -d'=' -f2 | tr -d '"') |
|
else |
|
local distro="Unknown" |
|
fi |
|
|
|
case "${distro,,}" in |
|
ubuntu|debian) |
|
yellow " sudo apt-get install dnsmasq-utils" |
|
;; |
|
fedora|rhel|centos|rocky|almalinux) |
|
yellow " sudo dnf install dnsmasq-utils" |
|
;; |
|
*) |
|
yellow " Install dnsmasq-utils package for your distribution" |
|
;; |
|
esac |
|
} |
|
|
|
function release_dhcp_lease() { |
|
local vmname="$1" |
|
local bridge="${2:-${BRIDGE}}" |
|
|
|
# Skip if dhcp_release not available |
|
if ! check_dhcp_release; then |
|
suggest_dhcp_release_install |
|
return 0 |
|
fi |
|
|
|
# Get MAC address from domain XML (if domain still exists) |
|
local mac="" |
|
if domain_exists "${vmname}"; then |
|
mac=$(virsh dumpxml "${vmname}" 2>/dev/null | awk -F\' '/mac address/ {print $2; exit}') |
|
fi |
|
|
|
# If we can't get MAC from domain, try to find it in DHCP lease file |
|
if [[ -z "$mac" ]]; then |
|
local status_file="/var/lib/libvirt/dnsmasq/${bridge}.status" |
|
if [[ -f "$status_file" ]]; then |
|
# Search for any lease with matching hostname |
|
mac=$(grep -B2 -A2 "\"hostname\": \"${vmname}\"" "$status_file" 2>/dev/null | awk -F'"' '/mac-address/ {print $4; exit}' || true) |
|
fi |
|
fi |
|
|
|
if [[ -n "$mac" ]]; then |
|
# Get IP address from lease file |
|
local status_file="/var/lib/libvirt/dnsmasq/${bridge}.status" |
|
local ip="" |
|
|
|
if [[ -f "$status_file" ]]; then |
|
ip=$(grep -B1 -A1 "\"mac-address\": \"$mac\"" "$status_file" 2>/dev/null | awk -F'"' '/ip-address/ {print $4; exit}' || true) |
|
fi |
|
|
|
if [[ -n "$ip" ]]; then |
|
outputn "Releasing DHCP lease for ${mac} (${ip})" |
|
if dhcp_release "${bridge}" "${ip}" "${mac}" > /dev/null 2>&1; then |
|
ok |
|
else |
|
yellow "(Could not release DHCP lease - may require root privileges)" |
|
fi |
|
else |
|
output "No IP address found for MAC ${mac}, skipping DHCP release" |
|
fi |
|
else |
|
output "No MAC address found for ${vmname}, skipping DHCP release" |
|
fi |
|
} |
|
|
|
function check_ssh_key() { |
|
local key |
|
if [ -z "${PUBKEY}" ]; then |
|
# Try to find a suitable key file. |
|
for key in ~/.ssh/id_{rsa,dsa,ed25519}.pub; do |
|
if [ -f "$key" ]; then |
|
PUBKEY="$key" |
|
break |
|
fi |
|
done |
|
fi |
|
|
|
if [ ! -f "${PUBKEY}" ]; then |
|
# Check for existence of a pubkey, or else exit with message |
|
die "Please generate an SSH keypair using 'ssh-keygen -t rsa' or \ |
|
specify one with the "-k" flag." |
|
else |
|
# Place contents of $PUBKEY into $KEY |
|
KEY=$(< ${PUBKEY}) |
|
fi |
|
} |
|
|
|
function check_os_variant() { |
|
if [[ ${OS_INFO} != auto ]]; then |
|
osinfo-query os short-id=${OS_INFO} > /dev/null || |
|
die "Unknown OS variant '${OS_INFO}'. Please update your osinfo-db. " \ |
|
"See https://libosinfo.org/download for more information." |
|
fi |
|
} |
|
|
|
function domain_exists() { |
|
virsh dominfo "${1}" > /dev/null 2>&1 && |
|
DOMAIN_EXISTS=1 || |
|
DOMAIN_EXISTS=0 |
|
} |
|
|
|
function storpool_exists() { |
|
virsh pool-info "${1}" > /dev/null 2>&1 && |
|
STORPOOL_EXISTS=1 || |
|
STORPOOL_EXISTS=0 |
|
} |
|
|
|
function set_sudo_group() { |
|
case "${DISTRO}" in |
|
almalinux* | centos* | fedora* | rocky* | *-atomic | amazon* | opensuse*) |
|
SUDOGROUP="wheel" |
|
;; |
|
ubuntu* | debian*) |
|
SUDOGROUP="sudo" |
|
;; |
|
*) |
|
die "OS not supported." |
|
;; |
|
esac |
|
} |
|
|
|
function check_delete_known_host() { |
|
output "Checking for ${IP} in known_hosts file" |
|
grep -q ${IP} ${HOME}/.ssh/known_hosts && |
|
outputn "Found entry for ${IP}. Removing" && |
|
(sed --in-place "/^${IP}/d" ~/.ssh/known_hosts && ok) || |
|
output "No entries found for ${IP}" |
|
} |
|
|
|
function detect_disk_format() { |
|
local image_path="$1" |
|
local extension="${image_path##*.}" |
|
|
|
case "${extension,,}" in |
|
qcow2) |
|
echo "qcow2" |
|
;; |
|
raw) |
|
echo "raw" |
|
;; |
|
vhd) |
|
echo "vpc" |
|
;; |
|
*) |
|
echo "qcow" |
|
;; |
|
esac |
|
} |
|
|
|
function is_url() { |
|
local input="$1" |
|
case "$input" in |
|
https://*) |
|
return 0 |
|
;; |
|
*) |
|
return 1 |
|
;; |
|
esac |
|
} |
|
|
|
function set_boot_flag() { |
|
local share_dir="" |
|
|
|
if command -v rpm > /dev/null 2>&1 && rpm -q edk2-ovmf > /dev/null 2>&1; then |
|
share_dir="/usr/share/edk2/ovmf" |
|
elif command -v dpkg > /dev/null 2>&1 && dpkg -s edk2-ovmf > /dev/null 2>&1; then |
|
share_dir="/usr/share/OVMF" |
|
else |
|
BOOTFLAG="" |
|
return |
|
fi |
|
|
|
local machine |
|
case "$ARCH" in |
|
x86_64) machine="--machine q35" ;; |
|
aarch64) machine="--machine virt" ;; |
|
*) machine="" ;; |
|
esac |
|
|
|
local suffix="" |
|
if ((SECUREBOOT)); then |
|
suffix=".secboot" |
|
fi |
|
local code_fd="${share_dir}/OVMF_CODE${suffix}.fd" |
|
local vars_fd="${share_dir}/OVMF_VARS${suffix}.fd" |
|
|
|
BOOTFLAG="--boot uefi,loader=${code_fd},loader.readonly=yes,loader.type=pflash,nvram_template=$vars_fd,nvram=/var/tmp/$(basename "$vars_fd"),loader.secure=$( ((SECUREBOOT)) && echo yes || echo no) --features smm=on $machine" |
|
} |
|
|
|
function create_vm() { |
|
# Create image directory if it doesn't already exist |
|
mkdir -p ${VMDIR} |
|
|
|
check_vmname_set |
|
|
|
# Start clean |
|
[ -d "${VMDIR}/${VMNAME}" ] && rm -rf ${VMDIR}/${VMNAME} |
|
mkdir -p ${VMDIR}/${VMNAME} |
|
|
|
pushd ${VMDIR}/${VMNAME} |
|
|
|
# Create log file |
|
touch ${VMNAME}.log |
|
|
|
# cloud-init config: set hostname, remove cloud-init package, |
|
# and add ssh-key |
|
cat > $USER_DATA << _EOF_ |
|
Content-Type: multipart/mixed; boundary="==BOUNDARY==" |
|
MIME-Version: 1.0 |
|
--==BOUNDARY== |
|
Content-Type: text/cloud-config; charset="us-ascii" |
|
|
|
#cloud-config |
|
|
|
# Hostname management |
|
preserve_hostname: False |
|
hostname: ${VMNAME} |
|
fqdn: ${VMNAME}.${DNSDOMAIN} |
|
|
|
# Users |
|
users: |
|
- default |
|
- name: ${ADDITIONAL_USER} |
|
groups: ['${SUDOGROUP}'] |
|
shell: /bin/bash |
|
sudo: ALL=(ALL) NOPASSWD:ALL |
|
ssh_authorized_keys: |
|
- ${KEY} |
|
|
|
# configure interaction with ssh server |
|
ssh_genkeytypes: ['ed25519', 'rsa'] |
|
|
|
# Install my public ssh key to the first user-defined user configured |
|
# in cloud.cfg in the template |
|
ssh_authorized_keys: |
|
- ${KEY} |
|
|
|
timezone: ${TIMEZONE} |
|
|
|
# Disk and filesystem growth |
|
growpart: |
|
mode: auto |
|
devices: ['/'] |
|
ignore_growroot_disabled: false |
|
|
|
resize_rootfs: true |
|
_EOF_ |
|
|
|
if [ ! -z "${SCRIPTNAME+x}" ]; then |
|
SCRIPT=$(< $SCRIPTNAME) |
|
cat >> $USER_DATA << _EOF_ |
|
|
|
--==BOUNDARY== |
|
Content-Type: text/x-shellscript; charset="us-ascii" |
|
${SCRIPT} |
|
|
|
--==BOUNDARY==-- |
|
_EOF_ |
|
else |
|
cat >> $USER_DATA << _EOF_ |
|
|
|
--==BOUNDARY==-- |
|
_EOF_ |
|
fi |
|
|
|
{ |
|
echo "instance-id: ${VMNAME}" |
|
echo "local-hostname: ${VMNAME}" |
|
} > $META_DATA |
|
|
|
outputn "Copying cloud image ($(basename ${IMAGE}))" |
|
DISK=${VMNAME}.qcow2 |
|
|
|
if [ "${DISK_FORMAT}" = "qcow2" ]; then |
|
qemu-img create -q -f qcow2 -F qcow2 -b $IMAGE $DISK && ok |
|
else |
|
qemu-img create -q -f qcow2 -F "${DISK_FORMAT}" -b $IMAGE $DISK && ok |
|
fi |
|
|
|
if $RESIZE_DISK; then |
|
outputn "Resizing the disk to $DISK_SIZE" |
|
# Cloud-init will automatically grow the partition and filesystem on first boot |
|
# via the growpart and resize_rootfs modules configured in user-data |
|
qemu-img resize $DISK $DISK_SIZE &>> ${VMNAME}.log && |
|
ok || |
|
die "Could not resize disk." |
|
fi |
|
|
|
# Create new storage pool for new VM |
|
run "Creating storage pool" \ |
|
virsh pool-create-as \ |
|
--name=${VMNAME} \ |
|
--type=dir \ |
|
--target=${VMDIR}/${VMNAME} || |
|
die "Could not create storage pool. VM may already exist. Try removing first." |
|
|
|
# Add custom MAC Address if specified |
|
NETWORK_PARAMS="$(join ',' \ |
|
$(param bridge ${BRIDGE}) \ |
|
$(param model ${NETWORK_MODEL}) \ |
|
$(param mac ${MACADDRESS}) \ |
|
${NETWORK_EXTRA})" |
|
|
|
# Assemble disk parameters. |
|
DISK_PARAMS="$(join ',' \ |
|
${DISK} \ |
|
$(param format ${DISK_FORMAT}) \ |
|
$(param bus ${DISK_BUS}) \ |
|
${DISK_EXTRA})" |
|
|
|
# Omit the --graphics option to auto-detect. |
|
if [ "${GRAPHICS}" = 'auto' ]; then |
|
GRAPHICS_PARAMS="" |
|
else |
|
GRAPHICS_PARAMS="$(join ',' \ |
|
${GRAPHICS} \ |
|
$(param port ${PORT}) \ |
|
$(param listen ${GRAPHICS_LISTEN}) \ |
|
${GRAPHICS_EXTRA})" |
|
fi |
|
|
|
# Assemble virt-install options. |
|
NETWORK_OPTION="$(param --network ${NETWORK_PARAMS})" |
|
DISK_OPTION="$(param --disk ${DISK_PARAMS})" |
|
GRAPHICS_OPTION="$(param --graphics ${GRAPHICS_PARAMS})" |
|
CLOUD_INIT_OPTION="$(param --cloud-init user-data=${USER_DATA},meta-data=${META_DATA},disable=on)" |
|
|
|
# Call virt-install to import the cloud image and create a new VM |
|
if ! run "Installing the domain" \ |
|
virt-install --import \ |
|
--name=${VMNAME} \ |
|
--memory=${MEMORY} \ |
|
--virt-type=${VIRTTYPE} \ |
|
--vcpus=${CPUS} \ |
|
--cpu=${FEATURE} \ |
|
${DISK_OPTION} \ |
|
${CLOUD_INIT_OPTION} \ |
|
${NETWORK_OPTION} \ |
|
--osinfo=${OS_INFO} \ |
|
--noautoconsole \ |
|
${GRAPHICS_OPTION} \ |
|
${BOOTFLAG} \ |
|
${VIRT_INSTALL_EXTRA}; then |
|
|
|
# Cleanup on failure |
|
red "VM creation failed. Cleaning up..." |
|
|
|
if [ "${VERBOSE}" -eq 1 ] && [ -f "${VMNAME}.log" ]; then |
|
yellow "Log contents:" |
|
cat "${VMNAME}.log" |
|
else |
|
yellow "Run with -v flag to see detailed logs" |
|
fi |
|
|
|
popd |
|
delete_vm |
|
die "Could not create domain with virt-install." |
|
fi |
|
|
|
virsh dominfo ${VMNAME} &>> ${VMNAME}.log |
|
|
|
# Enable autostart if true |
|
if $AUTOSTART; then |
|
outputn "Enabling autostart" |
|
virsh autostart \ |
|
--domain ${VMNAME} > /dev/null 2>&1 && |
|
ok || |
|
die "Could not enable autostart." |
|
fi |
|
|
|
# Remove the unnecessary cloud init files |
|
outputn "Cleaning up cloud-init files" |
|
rm -f $USER_DATA $META_DATA && ok |
|
|
|
MAC=$(virsh dumpxml ${VMNAME} | awk -F\' '/mac address/ {print $2}') |
|
output "MAC address: ${MAC}" |
|
|
|
status_file="/var/lib/libvirt/dnsmasq/${BRIDGE}.status" |
|
IP="" |
|
timeout=60 # seconds |
|
|
|
if [[ -f "$status_file" ]]; then |
|
outputn "Waiting for domain to get an IP address " |
|
|
|
for ((i = 0; i < timeout; i++)); do |
|
IP=$( |
|
{ grep -B1 -m1 "\"mac-address\": \"$MAC\"" "$status_file" || true; } | |
|
awk -F'"' '/ip-address/ {print $4; exit}' |
|
) |
|
|
|
if [[ -n "$IP" ]]; then |
|
ok |
|
break |
|
fi |
|
sleep 1 |
|
done |
|
|
|
[[ -n "$IP" ]] || die "Timed out waiting for DHCP lease" |
|
|
|
printf '\n' |
|
check_delete_known_host |
|
else |
|
outputn "Bridge looks like a layer‑2 bridge; get the domain’s IP from your DHCP server" |
|
IP="<IP address>" |
|
fi |
|
|
|
printf "\n" |
|
output "SSH to ${VMNAME}:" |
|
output " ssh ${IP}" |
|
output " ssh ${LOGIN_USER}@${IP}" |
|
output " ssh ${LOGIN_USER}@${VMNAME}" |
|
CONSOLE=$(virsh domdisplay ${VMNAME}) |
|
# Workaround because VNC port number shown by virsh domdisplay is offset from 5900 |
|
if [ "${GRAPHICS}" = 'vnc' ]; then |
|
CONSOLE_NO_PORT=$(echo $CONSOLE | cut -d ':' -f 1,2 -) |
|
CONSOLE_PORT=$(expr 5900 + $(echo $CONSOLE | cut -d ':' -f 3 -)) |
|
output "Console at ${CONSOLE_NO_PORT}:${CONSOLE_PORT}" |
|
else |
|
output "Console at ${CONSOLE}" |
|
fi |
|
output "DONE" |
|
|
|
popd |
|
} |
|
|
|
# Delete VM |
|
function remove() { |
|
# Parse command line arguments |
|
while getopts ":l:L:hv" opt; do |
|
case "$opt" in |
|
l) IMAGEDIR="${OPTARG}" ;; |
|
L) VMDIR="${OPTARG}" ;; |
|
v) VERBOSE=1 ;; |
|
h) usage ;; |
|
*) die "Unsupported option. Run 'kvm-install-vm help remove'." ;; |
|
esac |
|
done |
|
|
|
shift $((OPTIND - 1)) |
|
|
|
if [ "$#" != 1 ]; then |
|
printf "Please specify a single host to remove.\n" |
|
printf "Run 'kvm-install-vm help remove' for usage.\n" |
|
exit 1 |
|
else |
|
VMNAME=$1 |
|
fi |
|
|
|
delete_vm |
|
} |
|
|
|
function set_defaults() { |
|
# Defaults are set here. Override using command line arguments. |
|
AUTOSTART=false # Automatically start VM at boot time |
|
ARCH=$(uname -m) # Architecture (autodetected) |
|
CPUS=1 # Number of virtual CPUs |
|
FEATURE=host-model # Use host cpu features to the guest |
|
MEMORY=1536 # Amount of RAM in MB |
|
DISK_SIZE="" # Disk Size in GB |
|
DNSDOMAIN=example.local # DNS domain |
|
GRAPHICS=vnc # Graphics type (spice or vnc) |
|
RESIZE_DISK=false # Resize disk (boolean) |
|
IMAGEDIR=${HOME}/virt/images # Directory to store images |
|
VMDIR=${HOME}/virt/vms # Directory to store virtual machines |
|
BRIDGE=virbr0 # Hypervisor bridge |
|
PUBKEY="" # SSH public key |
|
DISTRO=rocky9 # Distribution |
|
MACADDRESS="" # MAC Address |
|
PORT=-1 # Console port |
|
TIMEZONE=US/Eastern # Timezone |
|
ADDITIONAL_USER=${USER} # User |
|
ASSUME_YES=0 # Assume yes to prompts |
|
ASSUME_NO=0 # Assume no to prompts |
|
VERBOSE=0 # Verbosity |
|
VIRTTYPE=kvm # Virt type (kvm, xen, qemu) |
|
SECUREBOOT=0 # Enable UEFI with SecureBoot |
|
|
|
# Reset OPTIND |
|
OPTIND=1 |
|
|
|
# Advanced hypervisor options. Override in ~/.kivrc if needed. |
|
NETWORK_MODEL=virtio |
|
NETWORK_EXTRA="" |
|
DISK_BUS=virtio |
|
DISK_EXTRA="" |
|
CI_ISO_EXTRA="" |
|
GRAPHICS_LISTEN=localhost |
|
GRAPHICS_EXTRA="" |
|
VIRT_INSTALL_EXTRA="" |
|
|
|
# Format: "name|description|architecture|image download url|login user" |
|
# *Note*: The name should match what is in the osinfo list |
|
DEFAULT_BUILTIN_VMS=( |
|
"ubuntu24.04|Ubuntu 24.04 LTS (Noble Numbat)|x86_64|https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img|ubuntu" |
|
"ubuntu24.04|Ubuntu 24.04 LTS (Noble Numbat)|aarch64|https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img|ubuntu" |
|
"ubuntu22.04|Ubuntu 22.04 LTS (Noble Numbat)|x86_64|https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img|ubuntu" |
|
"ubuntu22.04|Ubuntu 22.04 LTS (Noble Numbat)|aarch64|https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img|ubuntu" |
|
"debian12|Debian 12 Stable (Bookworm)|x86_64|https://cdimage.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2|debian" |
|
"debian12|Debian 12 Stable (Bookworm)|aarch64|https://cdimage.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2|debian" |
|
"rocky8|Rocky Linux 8 (Green Obsidian)|x86_64|https://dl.rockylinux.org/pub/rocky/8/images/x86_64/Rocky-8-GenericCloud.latest.x86_64.qcow2|rocky" |
|
"rocky8|Rocky Linux 8 (Green Obsidian)|aarch64|https://dl.rockylinux.org/pub/rocky/8/images/aarch64/Rocky-8-GenericCloud.latest.aarch64.qcow2|rocky" |
|
"rocky9|Rocky Linux 9 (Blue Onyx)|x86_64|https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2|rocky" |
|
"rocky9|Rocky Linux 9 (Blue Onyx)|aarch64|https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud.latest.aarch64.qcow2|rocky" |
|
) |
|
|
|
BUILTIN_VMS=("${DEFAULT_BUILTIN_VMS[@]}") |
|
} |
|
|
|
function set_custom_defaults() { |
|
# Source custom defaults: first local .kivrc, then fallback to ~/.kivrc |
|
if [ -f "./.kivrc" ]; then |
|
source "./.kivrc" |
|
elif [ -f "${HOME}/.kivrc" ]; then |
|
source "${HOME}/.kivrc" |
|
fi |
|
|
|
declare -gA BUILTIN_VM_DESCRIPTIONS=() |
|
declare -gA BUILTIN_VM_SOURCES=() |
|
declare -gA BUILTIN_VM_ARCHS=() |
|
|
|
local IFS="|" |
|
|
|
for entry in "${BUILTIN_VMS[@]}"; do |
|
read -r name description arch url <<< "$entry" |
|
key="${name}_${arch}" |
|
BUILTIN_VM_DESCRIPTIONS["$key"]=$description |
|
BUILTIN_VM_ARCHS["$key"]=$arch |
|
BUILTIN_VM_SOURCES["$key"]=$url |
|
done |
|
} |
|
|
|
function list_available_vms() { |
|
for key in "${!BUILTIN_VM_DESCRIPTIONS[@]}"; do |
|
local arch="${BUILTIN_VM_ARCHS[$key]}" |
|
local description="${BUILTIN_VM_DESCRIPTIONS[$key]}" |
|
|
|
# Extract the base name (before the architecture part) |
|
# Since arch can be x86_64 or aarch64, we need to be more specific |
|
local name |
|
if [[ "$key" == *_x86_64 ]]; then |
|
name="${key%_x86_64}" |
|
elif [[ "$key" == *_aarch64 ]]; then |
|
name="${key%_aarch64}" |
|
else |
|
name="$key" |
|
fi |
|
|
|
if [[ ${#arch} -eq 6 ]]; then |
|
printf " %-13s (%s) - %s\n" "$name" "$arch" "$description" |
|
else |
|
printf " %-12s (%s) - %s\n" "$name" "$arch" "$description" |
|
fi |
|
done | sort |
|
} |
|
|
|
function create() { |
|
# Parse command line arguments |
|
while getopts ":b:c:d:D:f:g:i:k:l:L:m:M:p:s:t:T:u:V:ahynSv" opt; do |
|
case "$opt" in |
|
a) AUTOSTART="${OPTARG}" ;; |
|
b) BRIDGE="${OPTARG}" ;; |
|
c) CPUS="${OPTARG}" ;; |
|
d) DISK_SIZE="${OPTARG}" ;; |
|
D) DNSDOMAIN="${OPTARG}" ;; |
|
f) FEATURE="${OPTARG}" ;; |
|
g) GRAPHICS="${OPTARG}" ;; |
|
i) IMAGE="${OPTARG}" ;; |
|
k) PUBKEY="${OPTARG}" ;; |
|
l) IMAGEDIR="${OPTARG}" ;; |
|
L) VMDIR="${OPTARG}" ;; |
|
m) MEMORY="${OPTARG}" ;; |
|
M) MACADDRESS="${OPTARG}" ;; |
|
p) PORT="${OPTARG}" ;; |
|
s) SCRIPTNAME="${OPTARG}" ;; |
|
t) DISTRO="${OPTARG}" ;; |
|
T) TIMEZONE="${OPTARG}" ;; |
|
u) ADDITIONAL_USER="${OPTARG}" ;; |
|
V) VIRTTYPE="${OPTARG}" ;; |
|
y) ASSUME_YES=1 ;; |
|
n) ASSUME_NO=1 ;; |
|
S) SECUREBOOT=1 ;; |
|
v) VERBOSE=1 ;; |
|
h) usage ;; |
|
*) die "Unsupported option. Run 'kvm-install-vm help create'." ;; |
|
esac |
|
done |
|
|
|
shift $((OPTIND - 1)) |
|
|
|
# Resize disk if you specify a disk size either via cmdline option or .kivrc |
|
if [ -n "${DISK_SIZE}" ]; then |
|
RESIZE_DISK=true |
|
DISK_SIZE="${DISK_SIZE}G" # Append 'G' for Gigabyte |
|
fi |
|
|
|
# Yes (-y) and No (-n) are mutually exclusive. |
|
if [[ "${ASSUME_YES}" -eq 1 ]] && [[ "${ASSUME_NO}" -eq 1 ]]; then |
|
printf "Please specify only one of -y or -n flags.\n" |
|
exit 1 |
|
fi |
|
|
|
# After all options are processed, make sure only one variable is left (vmname) |
|
if [ "$#" != 1 ]; then |
|
printf "Please specify a single host to create.\n" |
|
printf "Run 'kvm-install-vm create help' for usage.\n" |
|
exit 1 |
|
else |
|
VMNAME=$1 |
|
fi |
|
|
|
# Set cloud-init variables after VMNAME is assigned |
|
USER_DATA=user-data |
|
META_DATA=meta-data |
|
|
|
# Check for ssh key |
|
check_ssh_key |
|
|
|
if [ ! -z "${IMAGE+x}" ]; then |
|
if is_url "${IMAGE}"; then |
|
# Handle remote URL |
|
QCOW="${IMAGE##*/}" # Extract filename from URL |
|
DISK_FORMAT=$(detect_disk_format "${QCOW}") |
|
|
|
# Validate that it's a supported image format |
|
case "${DISK_FORMAT}" in |
|
qcow2|raw|vpc) |
|
output "Using remote image: ${IMAGE} (format: ${DISK_FORMAT})" |
|
;; |
|
*) |
|
die "Unsupported remote image format. Only .qcow2, .raw, and .vhd files are supported." |
|
;; |
|
esac |
|
|
|
# Download the image |
|
download_image "${IMAGE}" "${IMAGEDIR}" "${QCOW}" |
|
IMAGE="${IMAGEDIR}/${QCOW}" |
|
OS_INFO="linux2024" |
|
LOGIN_USER="<use the default account in your custom image>" |
|
else |
|
# Handle local file path |
|
if [[ "${IMAGE}" != /* ]]; then |
|
# Convert relative path to absolute path |
|
IMAGE=$(realpath "${IMAGE}") |
|
fi |
|
|
|
# Verify file exists |
|
if [ ! -f "${IMAGE}" ]; then |
|
die "Custom image file not found: ${IMAGE}" |
|
fi |
|
|
|
DISK_FORMAT=$(detect_disk_format "${IMAGE}") |
|
output "Using custom image: ${IMAGE} (format: ${DISK_FORMAT})" |
|
OS_INFO="linux2024" |
|
LOGIN_USER="<use the default account in your custom image>" |
|
fi |
|
else |
|
fetch_images |
|
fi |
|
|
|
# Verify the osinfo-db is up to date. |
|
check_os_variant |
|
|
|
# Check if domain already exists |
|
domain_exists "${VMNAME}" |
|
|
|
if [ "${DOMAIN_EXISTS}" -eq 1 ]; then |
|
echo -n "[WARNING] ${VMNAME} already exists. Do you want to overwrite ${VMNAME} [y/N]? " |
|
if [ "${ASSUME_YES}" -eq 1 ]; then |
|
REPLY="y" |
|
echo $REPLY |
|
elif [ "${ASSUME_NO}" -eq 1 ]; then |
|
REPLY="n" |
|
echo $REPLY |
|
else |
|
read -r |
|
fi |
|
if [[ $REPLY =~ ^[Yy]$ ]]; then |
|
delete_vm |
|
else |
|
echo -e "\nNot overwriting ${VMNAME}. Exiting..." |
|
exit 1 |
|
fi |
|
fi |
|
|
|
# Set package manager |
|
set_sudo_group |
|
|
|
# Set flag for BIOS/UEFI/SecureBoot mode |
|
set_boot_flag |
|
|
|
# Finally, create requested VM |
|
create_vm |
|
} |
|
|
|
function attach-disk() { |
|
# Set default variables |
|
local FORMAT=qcow2 |
|
local TARGET="" |
|
local DISKSIZE="" |
|
|
|
# Parse command line arguments |
|
while getopts ":d:f:t:h" opt; do |
|
case "$opt" in |
|
d) DISKSIZE="${OPTARG}G" ;; |
|
f) FORMAT="${OPTARG}" ;; |
|
t) TARGET="${OPTARG}" ;; |
|
h) usage ;; |
|
*) die "Unsupported option. Run 'kvm-install-vm help attach-disk'." ;; |
|
esac |
|
done |
|
|
|
shift $((OPTIND - 1)) |
|
|
|
[ ! -z ${TARGET} ] || die "You must specify a target device, for e.g. '-t vdb'" |
|
[ ! -z ${DISKSIZE} ] || die "You must specify a size (in GB) for the new device, for e.g. '-d 5'" |
|
|
|
if [ "$#" != 1 ]; then |
|
printf "Please specify a single host to attach a disk to.\n" |
|
printf "Run 'kvm-install-vm help attach-disk' for usage.\n" |
|
exit 1 |
|
else |
|
# Set variables |
|
VMNAME=$1 |
|
# Directory to create attached disk (Checks both images an vms directories for backward compatibility!) |
|
[[ -d ${VMDIR}/${VMNAME} ]] && DISKDIR=${VMDIR}/${VMNAME} || DISKDIR=${IMAGEDIR}/${VMNAME} |
|
DISKNAME=${VMNAME}-${TARGET}-${DISKSIZE}.${FORMAT} |
|
|
|
if [ ! -f "${DISKDIR}/${DISKNAME}" ]; then |
|
outputn "Creating new '${TARGET}' disk image for domain ${VMNAME}" |
|
(qemu-img create -f ${FORMAT} -o size=$DISKSIZE,preallocation=metadata \ |
|
${DISKDIR}/${DISKNAME} &>> ${DISKDIR}/${VMNAME}.log && ok) && |
|
outputn "Attaching ${DISKNAME} to domain ${VMNAME}" |
|
(virsh attach-disk ${VMNAME} \ |
|
--source $DISKDIR/${DISKNAME} \ |
|
--target ${TARGET} \ |
|
--subdriver ${FORMAT} \ |
|
--cache none \ |
|
--persistent &>> ${DISKDIR}/${VMNAME}.log && ok) || |
|
die "Could not attach disk." |
|
else |
|
die "Target ${TARGET} is already created or in use." |
|
fi |
|
|
|
fi |
|
|
|
} |
|
|
|
#-------------------------------------------------- |
|
# Main |
|
#-------------------------------------------------- |
|
|
|
subcommand="${1:-none}" |
|
[[ "${subcommand}" != "none" ]] && shift |
|
|
|
case "${subcommand}" in |
|
none) |
|
usage |
|
;; |
|
help) |
|
if [[ "${1:-none}" == "none" ]]; then |
|
usage |
|
elif [[ "$1" =~ ^create$|^remove$|^list$|^attach-disk$ ]]; then |
|
usage_subcommand "$1" |
|
else |
|
printf "'$1' is not a valid subcommand.\n\n" |
|
usage |
|
fi |
|
;; |
|
list) |
|
virsh list --all |
|
exit 0 |
|
;; |
|
create | remove | attach-disk | remove-disk) |
|
set_defaults |
|
set_custom_defaults |
|
|
|
if [[ "${1:-none}" == "none" ]]; then |
|
usage_subcommand "${subcommand}" |
|
elif [[ "$1" =~ ^help$ ]]; then |
|
usage_subcommand "${subcommand}" |
|
else |
|
"${subcommand}" "$@" |
|
exit $? |
|
fi |
|
;; |
|
*) |
|
die "'${subcommand}' is not a valid subcommand. See 'kvm-install-vm help' for a list of subcommands." |
|
;; |
|
esac
|
|
|