#!/usr/bin/env bash # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. parse_json() { local json_string="$1" echo "$json_string" | jq '.' > /dev/null || { echo '{"status": "error", "error": "Invalid JSON input"}'; exit 1; } local -A details while IFS="=" read -r key value; do details[$key]="$value" done < <(echo "$json_string" | jq -r '{ "extension_url": (.externaldetails.extension.url // ""), "extension_user": (.externaldetails.extension.user // ""), "extension_token": (.externaldetails.extension.token // ""), "extension_secret": (.externaldetails.extension.secret // ""), "host_url": (.externaldetails.host.url // ""), "host_user": (.externaldetails.host.user // ""), "host_token": (.externaldetails.host.token // ""), "host_secret": (.externaldetails.host.secret // ""), "node": (.externaldetails.host.node // ""), "network_bridge": (.externaldetails.host.network_bridge // ""), "verify_tls_certificate": (.externaldetails.host.verify_tls_certificate // "true"), "vm_name": (.externaldetails.virtualmachine.vm_name // ""), "template_id": (.externaldetails.virtualmachine.template_id // ""), "template_type": (.externaldetails.virtualmachine.template_type // ""), "iso_path": (.externaldetails.virtualmachine.iso_path // ""), "snap_name": (.parameters.snap_name // ""), "snap_description": (.parameters.snap_description // ""), "snap_save_memory": (.parameters.snap_save_memory // ""), "vmid": (."cloudstack.vm.details".details.proxmox_vmid // ""), "vm_internal_name": (."cloudstack.vm.details".name // ""), "vmmemory": (."cloudstack.vm.details".minRam // ""), "vmcpus": (."cloudstack.vm.details".cpus // ""), "vlans": ([."cloudstack.vm.details".nics[]?.broadcastUri // "" | sub("vlan://"; "")] | join(",")), "mac_addresses": ([."cloudstack.vm.details".nics[]?.mac // ""] | join(",")) } | to_entries | .[] | "\(.key)=\(.value)"') for key in "${!details[@]}"; do declare -g "$key=${details[$key]}" done # set url, user, token, secret to host values if present, otherwise use extension values url="${host_url:-$extension_url}" user="${host_user:-$extension_user}" token="${host_token:-$extension_token}" secret="${host_secret:-$extension_secret}" check_required_fields vm_internal_name url user token secret node } urlencode() { encoded_data=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$1'''))") echo "$encoded_data" } check_required_fields() { local missing=() for varname in "$@"; do local value="${!varname}" if [[ -z "$value" ]]; then missing+=("$varname") fi done if [[ ${#missing[@]} -gt 0 ]]; then echo "{\"error\":\"Missing required fields: ${missing[*]}\"}" exit 1 fi } validate_name() { local entity="$1" local name="$2" if [[ ! "$name" =~ ^[a-zA-Z0-9-]+$ ]]; then echo "{\"error\":\"Invalid $entity name '$name'. Only alphanumeric characters and dashes (-) are allowed.\"}" exit 1 fi } call_proxmox_api() { local method=$1 local path=$2 local data=$3 curl_opts=( -s --fail -X "$method" -H "Authorization: PVEAPIToken=${user}!${token}=${secret}" ) if [[ "$verify_tls_certificate" == "false" ]]; then curl_opts+=(-k) fi if [[ -n "$data" ]]; then curl_opts+=(-d "$data") fi response=$(curl "${curl_opts[@]}" "https://${url}:8006/api2/json${path}") local status=$? if [[ $status -ne 0 ]]; then echo "{\"errors\":{\"curl\":\"API call failed with status $status: $(echo "$response" | jq -Rsa . | jq -r .)\"}}" return $status fi echo "$response" return 0 } wait_for_proxmox_task() { local upid="$1" local timeout="${2:-$wait_time}" local interval="${3:-1}" local start_time start_time=$(date +%s) while true; do local now now=$(date +%s) if (( now - start_time > timeout )); then echo '{"status": "error", "error":"Timeout while waiting for async task"}' exit 1 fi local status_response status_response=$(call_proxmox_api GET "/nodes/${node}/tasks/$(urlencode "$upid")/status") if [[ -z "$status_response" || "$status_response" == *'"errors":'* ]]; then local msg msg=$(echo "$status_response" | jq -r '.message // "Unknown error"') echo "{\"status\": \"error\", \"error\": \"$msg\"}" exit 1 fi local task_status task_status=$(echo "$status_response" | jq -r '.data.status') if [[ "$task_status" == "stopped" ]]; then local exit_status exit_status=$(echo "$status_response" | jq -r '.data.exitstatus') if [[ "$exit_status" != "OK" ]]; then echo "{\"error\":\"Task failed with exit status: $exit_status\"}" exit 1 fi return 0 fi sleep "$interval" done } execute_and_wait() { local method="$1" local path="$2" local data="$3" local response upid msg response=$(call_proxmox_api "$method" "$path" "$data") upid=$(echo "$response" | jq -r '.data // ""') if [[ -z "$upid" ]]; then msg=$(echo "$response" | jq -r '.message // "Unknown error"') echo "{\"error\":\"Failed to execute API or retrieve UPID. Message: $msg\"}" exit 1 fi wait_for_proxmox_task "$upid" } vm_not_present() { response=$(call_proxmox_api GET "/cluster/nextid?vmid=$vmid") vmid_result=$(echo "$response" | jq -r '.data // empty') if [[ "$vmid_result" == "$vmid" ]]; then return 0 else return 1 fi } prepare() { response=$(call_proxmox_api GET "/cluster/nextid") vmid=$(echo "$response" | jq -r '.data // ""') echo "{\"details\":{\"proxmox_vmid\": \"$vmid\"}}" } create() { if [[ -z "$vm_name" ]]; then vm_name="$vm_internal_name" fi validate_name "VM" "$vm_name" check_required_fields vmid network_bridge vmcpus vmmemory if [[ "${template_type^^}" == "ISO" ]]; then check_required_fields iso_path local data="vmid=$vmid" data+="&name=$vm_name" data+="&ide2=$(urlencode "$iso_path,media=cdrom")" data+="&ostype=l26" data+="&scsihw=virtio-scsi-single" data+="&scsi0=$(urlencode "local-lvm:64,iothread=on")" data+="&sockets=1" data+="&cores=$vmcpus" data+="&numa=0" data+="&cpu=x86-64-v2-AES" data+="&memory=$((vmmemory / 1024 / 1024))" execute_and_wait POST "/nodes/${node}/qemu/" "$data" cleanup_vm=1 else check_required_fields template_id local data="newid=$vmid" data+="&name=$vm_name" execute_and_wait POST "/nodes/${node}/qemu/${template_id}/clone" "$data" cleanup_vm=1 data="cores=$vmcpus" data+="&memory=$((vmmemory / 1024 / 1024))" execute_and_wait POST "/nodes/${node}/qemu/${vmid}/config" "$data" fi IFS=',' read -ra vlan_array <<< "$vlans" IFS=',' read -ra mac_array <<< "$mac_addresses" for i in "${!vlan_array[@]}"; do network="net${i}=$(urlencode "virtio=${mac_array[i]},bridge=${network_bridge},tag=${vlan_array[i]},firewall=0")" call_proxmox_api PUT "/nodes/${node}/qemu/${vmid}/config/" "$network" > /dev/null done execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/start" cleanup_vm=0 echo '{"status": "success", "message": "Instance created"}' } start() { execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/start" echo '{"status": "success", "message": "Instance started"}' } delete() { if vm_not_present; then echo '{"status": "success", "message": "Instance deleted"}' return 0 fi execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}" echo '{"status": "success", "message": "Instance deleted"}' } stop() { if vm_not_present; then echo '{"status": "success", "message": "Instance stopped"}' return 0 fi execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/stop" echo '{"status": "success", "message": "Instance stopped"}' } reboot() { execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/reboot" echo '{"status": "success", "message": "Instance rebooted"}' } status() { local status_response vm_status powerstate status_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/status/current") vm_status=$(echo "$status_response" | jq -r '.data.status') case "$vm_status" in running) powerstate="poweron" ;; stopped) powerstate="poweroff" ;; *) powerstate="unknown" ;; esac echo "{\"status\": \"success\", \"power_state\": \"$powerstate\"}" } get_node_host() { check_required_fields node local net_json host if ! net_json="$(call_proxmox_api GET "/nodes/${node}/network")"; then echo "" return 1 fi # Prefer a static non-bridge IP host="$(echo "$net_json" | jq -r ' .data | map(select( (.type // "") != "bridge" and (.type // "") != "bond" and (.method // "") == "static" and ((.address // .cidr // "") != "") )) | map(.address // (.cidr | split("/")[0])) | .[0] // empty ' 2>/dev/null)" # Fallback: first interface with a CIDR if [[ -z "$host" ]]; then host="$(echo "$net_json" | jq -r ' .data | map(select((.cidr // "") != "")) | map(.cidr | split("/")[0]) | .[0] // empty ' 2>/dev/null)" fi echo "$host" } get_console() { check_required_fields node vmid local api_resp port ticket if ! api_resp="$(call_proxmox_api POST "/nodes/${node}/qemu/${vmid}/vncproxy")"; then echo "$api_resp" | jq -c '{status:"error", error:(.errors.curl // (.errors|tostring))}' exit 1 fi port="$(echo "$api_resp" | jq -re '.data.port // empty' 2>/dev/null || true)" ticket="$(echo "$api_resp" | jq -re '.data.ticket // empty' 2>/dev/null || true)" if [[ -z "$port" || -z "$ticket" ]]; then jq -n --arg raw "$api_resp" \ '{status:"error", error:"Proxmox response missing port/ticket", upstream:$raw}' exit 1 fi # Derive host from node’s network info local host host="$(get_node_host)" if [[ -z "$host" ]]; then jq -n --arg msg "Could not determine host IP for node $node" \ '{status:"error", error:$msg}' exit 1 fi jq -n \ --arg host "$host" \ --arg port "$port" \ --arg password "$ticket" \ --argjson passwordonetimeuseonly true \ '{ status: "success", message: "Console retrieved", console: { host: $host, port: $port, password: $password, passwordonetimeuseonly: $passwordonetimeuseonly, protocol: "vnc" } }' } list_snapshots() { snapshot_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/snapshot") echo "$snapshot_response" | jq ' def to_date: if . == "-" then "-" elif . == null then "-" else (. | tonumber | strftime("%Y-%m-%d %H:%M:%S")) end; { status: "success", printmessage: "true", message: [.data[] | { name: .name, snaptime: ((.snaptime // "-") | to_date), description: .description, parent: (.parent // "-"), vmstate: (.vmstate // "-") }] } ' } create_snapshot() { check_required_fields snap_name validate_name "Snapshot" "$snap_name" local data vmstate data="snapname=$snap_name" if [[ -n "$snap_description" ]]; then data+="&description=$snap_description" fi if [[ -n "$snap_save_memory" && "$snap_save_memory" == "true" ]]; then vmstate="1" else vmstate="0" fi data+="&vmstate=$vmstate" execute_and_wait POST "/nodes/${node}/qemu/${vmid}/snapshot" "$data" echo '{"status": "success", "message": "Instance Snapshot created"}' } restore_snapshot() { check_required_fields snap_name validate_name "Snapshot" "$snap_name" execute_and_wait POST "/nodes/${node}/qemu/${vmid}/snapshot/${snap_name}/rollback" status_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/status/current") vm_status=$(echo "$status_response" | jq -r '.data.status') if [ "$vm_status" = "stopped" ];then execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/start" fi echo '{"status": "success", "message": "Instance Snapshot restored"}' } delete_snapshot() { check_required_fields snap_name validate_name "Snapshot" "$snap_name" execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}/snapshot/${snap_name}" echo '{"status": "success", "message": "Instance Snapshot deleted"}' } action=$1 parameters_file="$2" wait_time=$3 if [[ -z "$action" || -z "$parameters_file" ]]; then echo '{"status": "error", "error": "Missing required arguments"}' exit 1 fi if [[ ! -r "$parameters_file" ]]; then echo '{"status": "error", "error": "File not found or unreadable"}' exit 1 fi # Read file content as parameters (assumes space-separated arguments) parameters=$(<"$parameters_file") parse_json "$parameters" || exit 1 cleanup_vm=0 cleanup() { if (( cleanup_vm == 1 )); then execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}" fi } trap cleanup EXIT case $action in prepare) prepare ;; create) create ;; delete) delete ;; start) start ;; stop) stop ;; reboot) reboot ;; status) status ;; getconsole) get_console ;; ListSnapshots) list_snapshots ;; CreateSnapshot) create_snapshot ;; RestoreSnapshot) restore_snapshot ;; DeleteSnapshot) delete_snapshot ;; *) echo '{"status": "error", "error": "Invalid action"}' exit 1 ;; esac exit 0