| #!/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 // ""), |
| "iso_os_type": (.externaldetails.virtualmachine.iso_os_type // "l26"), |
| "disk_size_gb": (.externaldetails.virtualmachine.disk_size_gb // "64"), |
| "storage": (.externaldetails.virtualmachine.storage // "local-lvm"), |
| "is_full_clone": (.externaldetails.virtualmachine.is_full_clone // "false"), |
| "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=$iso_os_type" |
| data+="&scsihw=virtio-scsi-single" |
| data+="&scsi0=$(urlencode "$storage:$disk_size_gb,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" |
| clone_flag=$(( is_full_clone == "true" )) |
| data+="&storage=$storage&full=$clone_flag" |
| 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 |