From 6950b46fc19ade1ec91cf45db7b480ea56e6a917 Mon Sep 17 00:00:00 2001 From: akutz Date: Fri, 14 Nov 2025 19:10:28 -0600 Subject: [PATCH] Scripts for creating N VMs / VM timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch introduces two scripts: * hack/create-vm.sh * hack/vm-timeline.sh The first script may be used to generate N VMs as members of a VM group. The script emits the YAML to create the VMs and the group. For example, the following command would emit YAML to create two VMs and a group: $ hack/create-vm.sh -n 2 apiVersion: vmoperator.vmware.com/v1alpha5 kind: VirtualMachine metadata: name: my-vm-drc75v9 namespace: default spec: className: best-effort-small imageName: vmi-0a0044d7c690bcbea storageClass: wcplocal-storage-profile groupName: my-vm-group-mnyci9f --- apiVersion: vmoperator.vmware.com/v1alpha5 kind: VirtualMachine metadata: name: my-vm-7vtp106 namespace: default spec: className: best-effort-small imageName: vmi-0a0044d7c690bcbea storageClass: wcplocal-storage-profile groupName: my-vm-group-mnyci9f --- apiVersion: vmoperator.vmware.com/v1alpha5 kind: VirtualMachineGroup metadata: name: my-vm-group-mnyci9f namespace: default spec: bootOrder: - members: - name: my-vm-drc75v9 - name: my-vm-7vtp106 The second script parses the YAML of a deployed VM and constructs a timeline based on the VM's creation timestamp and conditions. For example, take the following VM YAML: apiVersion: vmoperator.vmware.com/v1alpha5 kind: VirtualMachine metadata: creationTimestamp: "2025-11-14T23:58:31Z" name: my-vm-rchb56v namespace: wcpbench-vm-ns1 resourceVersion: "2027273" uid: e06d7a16-fc34-4bfc-85cf-dd5df17f2bb4 spec: biosUUID: f12c13d0-a087-46b1-9fad-cb384468556d className: best-effort-small groupName: my-vm-group-1eiirpq hardware: ideControllers: - busNumber: 0 - busNumber: 1 image: kind: VirtualMachineImage name: vmi-e65f20bb4354ec918 imageName: vmi-e65f20bb4354ec918 instanceUUID: a15a2e3c-5d11-49ab-9598-4e4333cad673 network: interfaces: - name: eth0 network: apiVersion: crd.nsx.vmware.com/v1alpha1 kind: SubnetSet name: "" powerOffMode: TrySoft powerState: PoweredOn promoteDisksMode: Online restartMode: TrySoft storageClass: wcp-policy suspendMode: TrySoft status: biosUUID: f12c13d0-a087-46b1-9fad-cb384468556d changeBlockTracking: false class: apiVersion: vmoperator.vmware.com/v1alpha5 kind: VirtualMachineClass name: best-effort-small conditions: - lastTransitionTime: "2025-11-14T23:59:16Z" message: "" reason: "True" status: "True" type: GroupLinked - lastTransitionTime: "2025-11-15T00:03:23Z" message: "" reason: GuestCustomizationRunning status: "False" type: GuestCustomization - lastTransitionTime: "2025-11-15T00:03:23Z" message: 'Backup version: 1763165001729' reason: "True" status: "True" type: VirtualMachineBackupUpToDate - lastTransitionTime: "2025-11-15T00:02:41Z" message: "" reason: "True" status: "True" type: VirtualMachineClassConfigurationSynced - lastTransitionTime: "2025-11-14T23:58:31Z" message: "" reason: "True" status: "True" type: VirtualMachineClassReady - lastTransitionTime: "2025-11-15T00:02:36Z" message: "" reason: "True" status: "True" type: VirtualMachineConditionPlacementReady - lastTransitionTime: "2025-11-15T00:02:41Z" message: "" reason: "True" status: "True" type: VirtualMachineCreated - lastTransitionTime: "2025-11-15T00:03:32Z" message: Pending guest customization reason: DiskPromotionPending status: "False" type: VirtualMachineDiskPromotionSynced - lastTransitionTime: "2025-11-13T10:21:26Z" message: "" reason: "True" status: "True" type: VirtualMachineImageReady - lastTransitionTime: "2025-11-14T23:58:33Z" message: "" reason: "True" status: "True" type: VirtualMachineNetworkReady - lastTransitionTime: "2025-11-15T00:03:23Z" message: "" reason: PoweredOn status: "True" type: VirtualMachinePowerStateSynced - lastTransitionTime: "2025-11-15T00:02:41Z" message: "" reason: "True" status: "True" type: VirtualMachineReconcileReady - lastTransitionTime: "2025-11-14T23:58:31Z" message: "" reason: "True" status: "True" type: VirtualMachineStorageReady - lastTransitionTime: "2025-11-15T00:03:23Z" message: "" reason: "True" status: "True" type: VirtualMachineTools guest: guestFullName: Ubuntu Linux (64-bit) guestID: ubuntu64Guest hardware: controllers: - busNumber: 0 deviceKey: 200 type: IDE - busNumber: 1 deviceKey: 201 type: IDE - busNumber: 0 deviceKey: 1000 devices: - type: Disk unitNumber: 0 type: SCSI cpu: total: 2 memory: total: 4096M hardwareVersion: 22 instanceUUID: a15a2e3c-5d11-49ab-9598-4e4333cad673 network: config: dns: hostName: my-vm-rchb56v nameservers: - 192.19.189.30 - 192.19.189.10 interfaces: - ip: addresses: - 172.30.0.195/27 gateway4: 172.30.0.193 name: eth0 nodeName: vcfpperf133.lvn.broadcom.net powerState: PoweredOn storage: requested: disks: 20Gi total: "21585987556" usage: disks: "9252634624" other: "111151076" uniqueID: vm-3612 volumes: - attached: true diskUUID: 6000C291-16a9-1764-24bf-27cc4a11b1d1 limit: 20Gi name: disk-96c3cc82 requested: 20Gi type: Classic used: "9252634624" zone: mgmt-zone The script vm-timeline.sh would produce the following report from the above YAML: ================================================================================ VM Timeline for my-vm-rchb56v ================================================================================ Creation Time: 2025-11-14T23:58:31Z -------------------------------------------------------------------------------- Timeline of Events -------------------------------------------------------------------------------- T+0s (2025-11-14T23:58:31Z) Event: VM Created (metadata.creationTimestamp) Duration from creation: 0s Duration from previous: — Conditions set: • VirtualMachineClassReady: True • VirtualMachineStorageReady: True T+2s (2025-11-14T23:58:33Z) Event: NetworkReady Duration from creation: 2s Duration from previous: 2s Conditions set: • VirtualMachineNetworkReady: True T+45s (2025-11-14T23:59:16Z) Event: GroupLinked Duration from creation: 45s Duration from previous: 43s Conditions set: • GroupLinked: True T+4m 5s (2025-11-15T00:02:36Z) Event: PlacementReady Duration from creation: 4m 5s Duration from previous: 3m 20s Conditions set: • VirtualMachineConditionPlacementReady: True T+4m 10s (2025-11-15T00:02:41Z) Event: ClassConfigurationSynced & Created & ReconcileReady Duration from creation: 4m 10s Duration from previous: 5s Conditions set: • VirtualMachineClassConfigurationSynced: True • VirtualMachineCreated: True • VirtualMachineReconcileReady: True T+4m 52s (2025-11-15T00:03:23Z) Event: GuestCustomization & BackupUpToDate & PowerStateSynced & Tools Duration from creation: 4m 52s Duration from previous: 42s Conditions set: • GuestCustomization: False (GuestCustomizationRunning) • VirtualMachineBackupUpToDate: True Message: Backup version: 1763165001729 • VirtualMachinePowerStateSynced: True (PoweredOn) • VirtualMachineTools: True T+5m 1s (2025-11-15T00:03:32Z) Event: DiskPromotionSynced Duration from creation: 5m 1s Duration from previous: 9s Conditions set: • VirtualMachineDiskPromotionSynced: False (DiskPromotionPending) Message: Pending guest customization -------------------------------------------------------------------------------- Summary -------------------------------------------------------------------------------- Total elapsed time: 5m 1s Current power state: PoweredOn Conditions not ready: GuestCustomization,VirtualMachineDiskPromotionSynced ================================================================================ --- hack/create-vm.sh | 137 ++++++++++++++++ hack/vm-timeline.sh | 389 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 526 insertions(+) create mode 100755 hack/create-vm.sh create mode 100755 hack/vm-timeline.sh diff --git a/hack/create-vm.sh b/hack/create-vm.sh new file mode 100755 index 000000000..641d73c27 --- /dev/null +++ b/hack/create-vm.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash + +set -o errexit +set -o pipefail +set -o nounset + +# Change directories to the parent directory of the one in which this +# script is located. +cd "$(dirname "${BASH_SOURCE[0]}")/.." + + +################################################################################ +## functions +################################################################################ + + +usage() { + cat <&2 + usage + ;; + : ) + echo "Option -${OPTARG} requires an argument." >&2 + usage + ;; + esac +done + +shift $((OPTIND - 1)) + +if [[ $# -ne 0 ]]; then + echo "Unexpected arguments: $*" >&2 + usage +fi + +# Validate NUM_VMS is a positive integer +if ! [[ "${NUM_VMS}" =~ ^[0-9]+$ ]] || [ "${NUM_VMS}" -lt 1 ]; then + echo "Error: Number of VMs must be a positive integer" >&2 + exit 1 +fi + +create_vms "${NUM_VMS}" "${NAMESPACE}" "${CLASS_NAME}" "${IMAGE_NAME}" "${STORAGE_CLASS}" + diff --git a/hack/vm-timeline.sh b/hack/vm-timeline.sh new file mode 100755 index 000000000..f670c727c --- /dev/null +++ b/hack/vm-timeline.sh @@ -0,0 +1,389 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +# Change directories to the parent directory of the one in which this +# script is located. +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +################################################################################ +## USAGE +################################################################################ + +usage() { + cat <0 +error() { + exit_code="${?}" + echo "${@}" 1>&2 + return "${exit_code}" +} +fatal() { + error "${@}" + exit 1 +} +check_command() { + command -v "${1}" >/dev/null 2>&1 || fatal "${1} is required" +} +check_dependencies() { + check_command jq + check_command yq +} + + +# Parse timestamp to epoch seconds (works on both macOS and Linux) +parse_timestamp() { + local ts="${1}" + if date --version &>/dev/null; then + # GNU date (Linux) + date -d "${ts}" "+%s" 2>/dev/null || echo "0" + else + # BSD date (macOS) + date -j -f "%Y-%m-%dT%H:%M:%SZ" "${ts}" "+%s" 2>/dev/null || echo "0" + fi +} + +# Calculate duration between two epoch timestamps +calc_duration() { + local start="${1}" + local end="${2}" + local diff=$((end - start)) + + if [ "${diff}" -lt 0 ]; then + echo "invalid" + return + fi + + local days=$((diff / 86400)) + local hours=$(((diff % 86400) / 3600)) + local minutes=$(((diff % 3600) / 60)) + local seconds=$((diff % 60)) + + local result="" + [ "${days}" -gt 0 ] && result="${days}d " + [ "${hours}" -gt 0 ] && result="${result}${hours}h " + [ "${minutes}" -gt 0 ] && result="${result}${minutes}m " + [ "${seconds}" -gt 0 ] || [ -z "${result}" ] && result="${result}${seconds}s" + + echo "${result% }" +} + +# Format relative time from creation +format_relative_time() { + local diff="${1}" + + if [ "${diff}" -eq 0 ]; then + echo "T+0s" + return + fi + + local days=$((diff / 86400)) + local hours=$(((diff % 86400) / 3600)) + local minutes=$(((diff % 3600) / 60)) + local seconds=$((diff % 60)) + + local result="T+" + [ "${days}" -gt 0 ] && result="${result}${days}d " + [ "${hours}" -gt 0 ] && result="${result}${hours}h " + [ "${minutes}" -gt 0 ] && result="${result}${minutes}m " + [ "${seconds}" -gt 0 ] || [ -z "${result#T+}" ] && result="${result}${seconds}s" + + echo "${result% }" +} + +generate_timeline() { + local vm_file="${1}" + local output_file="${2}" + + # Extract VM name and creation timestamp + local vm_name + local creation_ts + vm_name=$(yq eval '.metadata.name // "unknown"' "${vm_file}") + creation_ts=$(yq eval '.metadata.creationTimestamp // ""' "${vm_file}") + + if [ -z "${creation_ts}" ]; then + echo "Error: Could not find metadata.creationTimestamp in ${vm_file}" >&2 + exit 1 + fi + + local creation_epoch + creation_epoch=$(parse_timestamp "${creation_ts}") + + # Extract all conditions with their timestamps and build a sorted timeline + local conditions_json + conditions_json=$(yq eval -o=json '.status.conditions // []' "${vm_file}") + + # Create a temporary file to store timeline entries + local tmpfile + tmpfile=$(mktemp) + + # Add creation time - use special conditions that are always set at creation + local creation_conditions="" + if echo "${conditions_json}" | jq -e '.[] | select(.type == "VirtualMachineClassReady" and .lastTransitionTime == "'"${creation_ts}"'")' >/dev/null 2>&1; then + creation_conditions="VirtualMachineClassReady: True" + fi + if echo "${conditions_json}" | jq -e '.[] | select(.type == "VirtualMachineStorageReady" and .lastTransitionTime == "'"${creation_ts}"'")' >/dev/null 2>&1; then + [ -n "${creation_conditions}" ] && creation_conditions="${creation_conditions};" + creation_conditions="${creation_conditions}VirtualMachineStorageReady: True" + fi + if [ -z "${creation_conditions}" ]; then + creation_conditions="VM resource created" + fi + echo "${creation_epoch}|VM Created (metadata.creationTimestamp)||${creation_conditions}" >> "${tmpfile}" + + # Process all conditions + local num_conditions + num_conditions=$(echo "${conditions_json}" | jq 'length') + + for ((i = 0; i < num_conditions; i++)); do + local condition + condition=$(echo "${conditions_json}" | jq -r ".[$i]") + + local cond_type cond_status cond_reason cond_message cond_ts + cond_type=$(echo "${condition}" | jq -r '.type // "Unknown"') + cond_status=$(echo "${condition}" | jq -r '.status // "Unknown"') + cond_reason=$(echo "${condition}" | jq -r '.reason // ""') + cond_message=$(echo "${condition}" | jq -r '.message // ""') + cond_ts=$(echo "${condition}" | jq -r '.lastTransitionTime // ""') + + if [ -z "${cond_ts}" ]; then + continue + fi + + local cond_epoch + cond_epoch=$(parse_timestamp "${cond_ts}") + + # Skip pre-creation timestamps (like VirtualMachineImageReady) + if [ "${cond_epoch}" -lt "${creation_epoch}" ]; then + continue + fi + + # Skip creation-time conditions we already added + if [ "${cond_epoch}" -eq "${creation_epoch}" ] && { [ "${cond_type}" = "VirtualMachineClassReady" ] || [ "${cond_type}" = "VirtualMachineStorageReady" ]; }; then + continue + fi + + # Build condition description + local cond_desc="${cond_type}: ${cond_status}" + [ -n "${cond_reason}" ] && [ "${cond_reason}" != "True" ] && cond_desc="${cond_desc} (${cond_reason})" + [ -n "${cond_message}" ] && cond_desc="${cond_desc}^^${cond_message}" + + # Add to timeline + echo "${cond_epoch}|${cond_type}||${cond_desc}" >> "${tmpfile}" + done + + # Sort and group by timestamp + local sorted_timeline + sorted_timeline=$(sort -t'|' -k1 -n "${tmpfile}") + + # Group entries by timestamp + local grouped_tmpfile + grouped_tmpfile=$(mktemp) + + local prev_ts="" + local prev_event="" + local prev_conditions="" + + while IFS='|' read -r ts event empty conditions; do + if [ "${ts}" = "${prev_ts}" ]; then + # Same timestamp, merge conditions + prev_conditions="${prev_conditions};${conditions}" + else + # Different timestamp, output previous entry if exists + if [ -n "${prev_ts}" ]; then + echo "${prev_ts}|${prev_event}||${prev_conditions}" >> "${grouped_tmpfile}" + fi + prev_ts="${ts}" + prev_event="${event}" + prev_conditions="${conditions}" + fi + done <<< "${sorted_timeline}" + + # Output last entry + if [ -n "${prev_ts}" ]; then + echo "${prev_ts}|${prev_event}||${prev_conditions}" >> "${grouped_tmpfile}" + fi + + rm -f "${tmpfile}" + + # Generate output + { + echo "================================================================================" + echo "VM Timeline for ${vm_name}" + echo "================================================================================" + echo "" + echo "Creation Time: ${creation_ts}" + echo "" + echo "--------------------------------------------------------------------------------" + echo "Timeline of Events" + echo "--------------------------------------------------------------------------------" + echo "" + + local prev_epoch=0 + while IFS='|' read -r ts_epoch event_name empty conditions; do + # Calculate durations + local from_creation=$((ts_epoch - creation_epoch)) + + # Format timestamp + local abs_ts + if date --version &>/dev/null; then + # GNU date (Linux) + abs_ts=$(date -d "@${ts_epoch}" "+%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "unknown") + else + # BSD date (macOS) + abs_ts=$(date -j -r "${ts_epoch}" "+%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "unknown") + fi + + # Generate event name from conditions if needed + if [ "${event_name}" != "VM Created (metadata.creationTimestamp)" ]; then + # Create a meaningful event name from the conditions + local cond_types="" + IFS=';' read -ra COND_ARRAY <<< "${conditions}" + for cond in "${COND_ARRAY[@]}"; do + local ctype=$(echo "${cond}" | cut -d':' -f1 | sed 's/VirtualMachine//g' | sed 's/Condition//g') + [ -n "${cond_types}" ] && cond_types="${cond_types} & " + cond_types="${cond_types}${ctype}" + done + event_name="${cond_types}" + fi + + # Output event + echo "$(format_relative_time ${from_creation}) (${abs_ts})" + echo " Event: ${event_name}" + echo " Duration from creation: $(calc_duration ${creation_epoch} ${ts_epoch})" + if [ "${prev_epoch}" -eq 0 ]; then + echo " Duration from previous: —" + else + echo " Duration from previous: $(calc_duration ${prev_epoch} ${ts_epoch})" + fi + + # Output conditions + if [ -n "${conditions}" ]; then + echo " Conditions set:" + IFS=';' read -ra COND_ARRAY <<< "${conditions}" + for cond in "${COND_ARRAY[@]}"; do + if [[ "${cond}" == *"^^"* ]]; then + local cond_name=$(echo "${cond}" | cut -d'^' -f1) + local cond_msg=$(echo "${cond}" | cut -d'^' -f3) + echo " • ${cond_name}" + echo " Message: ${cond_msg}" + else + echo " • ${cond}" + fi + done + fi + + echo "" + prev_epoch="${ts_epoch}" + done < "${grouped_tmpfile}" + + rm -f "${grouped_tmpfile}" + + # Generate summary + local total_duration + total_duration=$(calc_duration ${creation_epoch} ${prev_epoch}) + + echo "--------------------------------------------------------------------------------" + echo "Summary" + echo "--------------------------------------------------------------------------------" + echo "" + echo "Total elapsed time: ${total_duration}" + + # Check current power state + local power_state + power_state=$(yq eval '.status.powerState // "Unknown"' "${vm_file}") + echo "Current power state: ${power_state}" + + # Check for any false conditions + local false_conditions + false_conditions=$(yq eval '.status.conditions[] | select(.status == "False") | .type' "${vm_file}" | tr '\n' ', ' | sed 's/,$//') + if [ -n "${false_conditions}" ]; then + echo "Conditions not ready: ${false_conditions}" + fi + + echo "" + echo "================================================================================" + } > "${output_file}" +} + + +################################################################################ +## MAIN +################################################################################ + +# Verify the required dependencies are met +check_dependencies + +# Default values +OUTPUT_FILE="/dev/stdout" + +while getopts ":o:h" opt; do + case $opt in + "o" ) OUTPUT_FILE="${OPTARG}" ;; + "h" ) usage ;; + \? ) + echo "Invalid option: -${OPTARG}" >&2 + usage + ;; + : ) + echo "Option -${OPTARG} requires an argument." >&2 + usage + ;; + esac +done + +shift $((OPTIND - 1)) + +if [[ $# -ne 1 ]]; then + echo "Error: VM YAML file is required" >&2 + usage +fi + +VM_FILE="${1}" + +if [ ! -f "${VM_FILE}" ] && [ "${VM_FILE}" != "/dev/stdin" ]; then + echo "Error: File ${VM_FILE} does not exist" >&2 + exit 1 +fi + +# Check for required commands +for cmd in yq jq date; do + if ! command -v "${cmd}" &> /dev/null; then + echo "Error: ${cmd} is required but not installed" >&2 + exit 1 + fi +done + +generate_timeline "${VM_FILE}" "${OUTPUT_FILE}"