capsul.org webapp

display last backup on capsul detail page, add acpi shutdown

forest 869625c3 0edcdb9a

+2 -2
capsulflask/__init__.py
··· 70 70 71 71 VM_STORAGE_DIR=os.environ.get("VM_STORAGE_DIR", default="/tank/vm"), 72 72 BACKUP_STORAGE_MOUNTS=os.environ.get("BACKUP_STORAGE_MOUNTS", default="/mnt/backup1"), 73 - BACKUP_DEBUG_LOG=os.environ.get("BACKUP_DEBUG_LOG", default="true"), 73 + BACKUP_DEBUG_LOG=os.environ.get("BACKUP_DEBUG_LOG", default="false"), 74 74 75 75 MAIL_DELIVERY_MANAGER_URL=os.environ.get("MAIL_DELIVERY_MANAGER_URL", default=""), 76 76 MAIL_DELIVERY_MANAGER_SECRET=os.environ.get("MAIL_DELIVERY_MANAGER_SECRET", default=""), ··· 221 221 222 222 backup_maintenance_task_url = f"{app.config['HUB_URL']}/hub/backup-maintenance" 223 223 trigger_backup_maintenance_task = lambda: requests.post(backup_maintenance_task_url, headers=hub_token_headers) 224 - scheduler.add_job(name="backup-maintenance", func=trigger_backup_maintenance_task, trigger="interval", seconds=10) 224 + scheduler.add_job(name="backup-maintenance", func=trigger_backup_maintenance_task, trigger="interval", seconds=15) 225 225 226 226 scheduler.start() 227 227
+40 -7
capsulflask/console.py
··· 17 17 from capsulflask.metrics import metric_durations 18 18 from capsulflask.auth import account_required 19 19 from capsulflask.db import get_model 20 - from capsulflask.shared import my_exec_info_message, get_warnings_list, get_warning_headline, get_vm_months_float, get_account_balance, average_number_of_days_in_a_month 20 + from capsulflask.shared import my_exec_info_message, get_warnings_list, get_warning_headline, get_vm_months_float, get_account_balance, average_number_of_days_in_a_month, VirtualMachine 21 21 from capsulflask.payment import poll_btcpay_session, check_if_shortterm_flag_can_be_unset 22 22 from capsulflask import cli 23 23 ··· 27 27 letters_n_nummers = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10) 28 28 return f"capsul-{letters_n_nummers}" 29 29 30 - def double_check_capsul_address(id, existing_ipv4, get_ssh_host_keys): 30 + def double_check_capsul_address(id, existing_ipv4, get_ssh_host_keys, get_agent_details) -> VirtualMachine: 31 31 try: 32 - result = current_app.config["HUB_MODEL"].get(id, get_ssh_host_keys) 32 + result = current_app.config["HUB_MODEL"].get(id, get_ssh_host_keys, get_agent_details) 33 33 34 34 if result != None and result.ipv4 != "" and (existing_ipv4 == None or existing_ipv4 == ""): 35 35 get_model().update_vm_ip(email=session["account"], id=id, ipv4=result.ipv4) 36 36 37 37 if result != None and result.ssh_host_keys != None and get_ssh_host_keys: 38 38 get_model().update_vm_ssh_host_keys(email=session["account"], id=id, ssh_host_keys=result.ssh_host_keys) 39 + 40 + if result != None and get_agent_details: 41 + get_model().update_vm_agent_details(email=session["account"], id=id, agent_socket=result.agent_socket, guest_agent=result.guest_agent, acpid=result.acpid) 39 42 except: 40 43 current_app.logger.error(f""" 41 44 the virtualization model threw an error in double_check_capsul_address of {id}: ··· 59 62 # for now we are going to check the IP according to the virt model 60 63 # on every request. this could be done by a background job and cached later on... 61 64 for vm in vms: 62 - result = double_check_capsul_address(vm["id"], vm["ipv4"], False) 65 + result = double_check_capsul_address(vm["id"], vm["ipv4"], False, False) 63 66 if result is not None: 64 67 vm["ipv4"] = result.ipv4 65 68 vm["state"] = result.state ··· 145 148 check_if_shortterm_flag_can_be_unset(session['account']) 146 149 147 150 return render_template("capsul-detail.html", vm=vm, deleted=True) 151 + elif request.form['action'] == "acpi-shutdown": 152 + current_app.logger.info(f"acpi-shutdown {vm['id']} per user request ({session['account']})") 153 + get_model().set_desired_state(email=session["account"], vm_id=id, desired_state="shut off") 154 + current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="acpi-shutdown") 155 + vm["state"] = "stopping" 156 + flash("please note that your account will still be billed for this capsul while it is in a stopped state") 157 + 158 + return render_template( 159 + "capsul-detail.html", 160 + csrf_token = session["csrf-token"], 161 + vm=vm, 162 + durations=list(map(lambda x: x.strip("_"), metric_durations.keys())), 163 + duration=duration 164 + ) 165 + 148 166 elif request.form['action'] == "force-stop": 149 167 if 'are_you_sure' not in request.form or not request.form['are_you_sure']: 150 168 return render_template( ··· 174 192 175 193 176 194 else: 177 - needs_ssh_host_keys = "ssh_host_keys" not in vm or len(vm["ssh_host_keys"]) == 0 195 + get_ssh_host_keys = "ssh_host_keys" not in vm or len(vm["ssh_host_keys"]) == 0 196 + get_agent_details = True 178 197 179 - vm_from_virt_model = double_check_capsul_address(vm["id"], vm["ipv4"], needs_ssh_host_keys) 198 + vm_from_virt_model = double_check_capsul_address(vm["id"], vm["ipv4"], get_ssh_host_keys, get_agent_details) 180 199 181 200 if vm_from_virt_model is not None: 182 201 vm["ipv4"] = vm_from_virt_model.ipv4 183 202 vm["state"] = vm_from_virt_model.state 184 - if needs_ssh_host_keys: 203 + if get_ssh_host_keys: 185 204 vm["ssh_host_keys"] = vm_from_virt_model.ssh_host_keys 205 + if get_agent_details: 206 + vm["agent_socket"] = vm_from_virt_model.agent_socket 207 + vm["guest_agent"] = vm_from_virt_model.guest_agent 208 + vm["acpid"] = vm_from_virt_model.acpid 186 209 else: 187 210 vm["state"] = "unknown" 188 211 189 212 if vm["state"] == "running" and not vm["ipv4"]: 190 213 vm["state"] = "starting" 214 + 215 + vm['last_backup_str'] = 'never' 216 + if vm['last_backup_finished']: 217 + seconds_since_last_backup_finished = (datetime.utcnow() - vm['last_backup_finished']).total_seconds() 218 + if seconds_since_last_backup_finished < 60*60: 219 + vm['last_backup_str'] = f"{round(seconds_since_last_backup_finished/60)} minutes ago" 220 + elif seconds_since_last_backup_finished < 60*60*24: 221 + vm['last_backup_str'] = f"{round(seconds_since_last_backup_finished/(60*60))} hours ago" 222 + elif seconds_since_last_backup_finished < 60*60*24*365: 223 + vm['last_backup_str'] = f"{round(seconds_since_last_backup_finished/(60*60*24))} days ago" 191 224 192 225 return render_template( 193 226 "capsul-detail.html",
+7 -2
capsulflask/db_model.py
··· 164 164 ) 165 165 self.connection.commit() 166 166 167 + def update_vm_agent_details(self, email, id, agent_socket, guest_agent, acpid): 168 + self.cursor.execute("UPDATE vms SET has_agent_socket = %s, has_guest_agent = %s, has_acpid = %s WHERE email = %s AND id = %s", (agent_socket, guest_agent, acpid, email, id)) 169 + self.connection.commit() 170 + 167 171 def create_vm(self, email, id, size, shortterm, os, host, network_name, public_ipv4, ssh_authorized_keys): 168 172 self.cursor.execute(""" 169 173 INSERT INTO vms (email, id, size, shortterm, os, host, network_name, public_ipv4) ··· 188 192 def get_vm_detail(self, email, id): 189 193 self.cursor.execute(""" 190 194 SELECT vms.id, vms.public_ipv4, vms.public_ipv6, os_images.description, vms.created, vms.deleted, 191 - vm_sizes.id, vms.shortterm, vm_sizes.dollars_per_month, vm_sizes.vcpus, vm_sizes.memory_mb, vm_sizes.bandwidth_gb_per_month 195 + vm_sizes.id, vms.shortterm, vm_sizes.dollars_per_month, vm_sizes.vcpus, vm_sizes.memory_mb, vm_sizes.bandwidth_gb_per_month, 196 + vms.has_agent_socket, vms.has_guest_agent, vms.has_acpid, vms.last_backup_finished 192 197 FROM vms 193 198 JOIN os_images on vms.os = os_images.id 194 199 JOIN vm_sizes on vms.size = vm_sizes.id ··· 202 207 vm = dict( 203 208 id=row[0], ipv4=row[1], ipv6=row[2], os_description=row[3], created=row[4], deleted=row[5], 204 209 size=row[6], shortterm=row[7], dollars_per_month=row[8], vcpus=row[9], memory_mb=row[10], 205 - bandwidth_gb_per_month=row[11], 210 + bandwidth_gb_per_month=row[11], agent_socket=row[12], guest_agent=row[13], acpid=row[14], last_backup_finished=row[15] 206 211 ) 207 212 208 213 self.cursor.execute("""
+6 -6
capsulflask/hub_model.py
··· 24 24 def capacity_avaliable(self, additional_ram_bytes): 25 25 return True 26 26 27 - def get(self, id, get_ssh_host_keys): 27 + def get(self, id, get_ssh_host_keys: bool, get_agent_details: bool): 28 28 validate_capsul_id(id) 29 29 30 + ssh_host_keys = [] 30 31 if get_ssh_host_keys: 31 32 ssh_host_keys = json.loads("""[ 32 33 {"key_type":"ED25519", "content":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8cna0zeKSKl/r8whdn/KmDWhdzuWRVV0GaKIM+eshh", "sha256":"V4X2apAF6btGAfS45gmpldknoDX0ipJ5c6DLfZR2ttQ"}, 33 34 {"key_type":"RSA", "content":"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvotgzgEP65JUQ8S8OoNKy1uEEPEAcFetSp7QpONe6hj4wPgyFNgVtdoWdNcU19dX3hpdse0G8OlaMUTnNVuRlbIZXuifXQ2jTtCFUA2mmJ5bF+XjGm3TXKMNGh9PN+wEPUeWd14vZL+QPUMev5LmA8cawPiU5+vVMLid93HRBj118aCJFQxLgrdP48VPfKHFRfCR6TIjg1ii3dH4acdJAvlmJ3GFB6ICT42EmBqskz2MPe0rIFxH8YohCBbAbrbWYcptHt4e48h4UdpZdYOhEdv89GrT8BF2C5cbQ5i9qVpI57bXKrj8hPZU5of48UHLSpXG8mbH0YDiOQOfKX/Mt", "sha256":"ghee6KzRnBJhND2kEUZSaouk7CD6o6z2aAc8GPkV+GQ"}, 34 35 {"key_type":"ECDSA", "content":"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLLgOoATz9R4aS2kk7vWoxX+lshK63t9+5BIHdzZeFE1o+shlcf0Wji8cN/L1+m3bi0uSETZDOAWMP3rHLJj9Hk=", "sha256":"aCYG1aD8cv/TjzJL0bi9jdabMGksdkfa7R8dCGm1yYs"} 35 36 ]""") 36 - return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=self.default_ipv4, ssh_host_keys=ssh_host_keys) 37 - 38 - return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=self.default_ipv4) 37 + 38 + return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=self.default_ipv4, state="running", ssh_host_keys=ssh_host_keys, guest_agent=True, agent_socket=True, acpid=True) 39 39 40 40 def get_all_by_id(self) -> dict: 41 41 by_host_and_network = get_model().non_deleted_vms_by_host_and_network(None) ··· 166 166 return False 167 167 168 168 169 - def get(self, id, get_ssh_host_keys) -> VirtualMachine: 169 + def get(self, id, get_ssh_host_keys, get_agent_details) -> VirtualMachine: 170 170 validate_capsul_id(id) 171 171 host = get_model().host_of_capsul(id) 172 172 if host is not None: 173 - payload = json.dumps(dict(type="get", id=id, get_ssh_host_keys=get_ssh_host_keys)) 173 + payload = json.dumps(dict(type="get", id=id, get_ssh_host_keys=get_ssh_host_keys, get_agent_details=get_agent_details)) 174 174 results = self.synchronous_operation([host], None, payload) 175 175 for result in results: 176 176 try:
+2
capsulflask/schema_migrations/27_up_backups.sql
··· 1 + ALTER TABLE vms ADD COLUMN has_agent_socket BOOLEAN NOT NULL DEFAULT FALSE; 1 2 ALTER TABLE vms ADD COLUMN has_guest_agent BOOLEAN NOT NULL DEFAULT FALSE; 3 + ALTER TABLE vms ADD COLUMN has_acpid BOOLEAN NOT NULL DEFAULT FALSE; 2 4 ALTER TABLE vms ADD COLUMN last_backup_started TIMESTAMP; 3 5 ALTER TABLE vms ADD COLUMN last_backup_finished TIMESTAMP; 4 6
+4 -1
capsulflask/shared.py
··· 19 19 # self.sha256 = sha256 20 20 21 21 class VirtualMachine: 22 - def __init__(self, id, host, ipv4=None, ipv6=None, state="unknown", ssh_host_keys: List[dict] = list()): 22 + def __init__(self, id, host, ipv4=None, ipv6=None, state="unknown", ssh_host_keys: List[dict] = list(), agent_socket: bool = False, guest_agent: bool = False, acpid = False ): 23 23 self.id = id 24 24 self.host = host 25 25 self.ipv4 = ipv4 26 26 self.ipv6 = ipv6 27 27 self.state = state 28 28 self.ssh_host_keys = ssh_host_keys 29 + self.agent_socket = agent_socket 30 + self.guest_agent = guest_agent 31 + self.acpid = acpid 29 32 30 33 class VirtualizationInterface: 31 34 def capacity_avaliable(self, additional_ram_bytes: int) -> bool:
+36
capsulflask/shell_scripts/acpi-shutdown.sh
··· 1 + #!/bin/sh -e 2 + 3 + # State Key 4 + # NOSTATE: 0 - no state 5 + # RUNNING: 1 - the domain is running 6 + # BLOCKED: 2 - the domain is blocked on resource 7 + # PAUSED: 3 - the domain is paused by user 8 + # SHUTDOWN: 4 - the domain is being shut down 9 + # SHUTOFF: 5 - the domain is shut off 10 + # CRASHED: 6 - the domain is crashed 11 + # PMSUSPENDED: 7 - the domain is suspended by guest power management 12 + # LAST: 8 NB: this enum value will increase over time as new events are added to the libvirt API. It reflects the last state supported by this version of the libvirt API. 13 + 14 + vmname="$1" 15 + 16 + [ "$vmname" = '' ] && printf 'you must set $vmname\n' && exit 1 17 + 18 + if printf "$vmname" | grep -vqE '^(cvm|capsul)-[a-z0-9]{10}$'; then 19 + printf 'vmname %s must match ^capsul-[a-z0-9]{10}$\n' "$vmname" 20 + exit 1 21 + fi 22 + 23 + state="$(virsh domstats $vmname | grep state.state | cut -d '=' -f 2)" 24 + 25 + [ "$state" = '' ] && printf 'state was not detected. must match ^[0-8]$\n' && exit 1 26 + 27 + if printf "$state" | grep -vqE '^[0-8]$'; then 28 + printf 'state %s must match ^[0-8]$\n' "$state" 29 + exit 1 30 + fi 31 + 32 + case "$state" in 33 + [1-47]) virsh shutdown --mode acpi "$vmname" > /dev/null ;; 34 + [5-6]) printf "%s is already off\n" "$vmname" ;; 35 + esac 36 +
+44
capsulflask/shell_scripts/get-guest-agent.sh
··· 1 + #!/bin/sh -e 2 + 3 + vmname="$1" 4 + 5 + if echo "$vmname" | grep -vqE '^(cvm|capsul)-[a-z0-9]{10}$'; then 6 + echo "vmname $vmname must match "'"^capsul-[a-z0-9]{10}$"' 7 + exit 1 8 + fi 9 + 10 + qemuAgentCommandOutput="no-guest-agent-socket" 11 + if virsh dumpxml "$vmname" | grep -q guest_agent ; then 12 + isAcpidRunning='{"execute": "guest-exec", "arguments":{"path":"/usr/bin/env", "arg":["pidof","acpid"], "capture-output":true}}' 13 + qemuAgentCommandOutput="$( { virsh qemu-agent-command "$vmname" --cmd "$isAcpidRunning" || printf '%s' 'no-qemu-guest-agent'; } | tr -d '\n' )" 14 + 15 + if [ "$qemuAgentCommandOutput" != "no-qemu-guest-agent" ] ; then 16 + # {"return":{"pid":2967}} 17 + pidInsideVm="$(echo "$qemuAgentCommandOutput" | jq -r '.return.pid')" 18 + qemuAgentCommandOutput="error" 19 + if echo "$pidInsideVm" | grep -vqE '^[0-9]+$'; then 20 + echo "pidInsideVm: $pidInsideVm WTF!!?!?" 21 + exit 1 22 + fi 23 + getStatusOfProcessInsideVm='{"execute": "guest-exec-status", "arguments":{"pid":'"$pidInsideVm"'}}' 24 + exited='false' 25 + tries=0 26 + while [ "$exited" = "false" ] && [ "$tries" -lt 10 ] ; do 27 + tries=$((tries+1)) 28 + sleep 0.1 29 + processStatusInfo="$( virsh qemu-agent-command "$vmname" --cmd "$getStatusOfProcessInsideVm" )" 30 + # {"return":{"exitcode":0,"out-data":"MTgwNAo=","exited":true}} 31 + exited="$(echo "$processStatusInfo" | jq -r '.return.exited')" 32 + if [ "$exited" = "true" ] ; then 33 + exitCode="$(echo "$processStatusInfo" | jq -r '.return.exitcode')" 34 + if [ "$exitCode" = "0" ] ; then 35 + qemuAgentCommandOutput="ok" 36 + else 37 + qemuAgentCommandOutput="no-acpid" 38 + fi 39 + fi 40 + done 41 + fi 42 + fi 43 + 44 + echo "$qemuAgentCommandOutput"
+1 -1
capsulflask/shell_scripts/get.sh
··· 32 32 # this gets the ipv4 33 33 ipv4="$(virsh domifaddr "$vmname" | awk '/vnet/ {print $4}' | cut -d'/' -f1)" 34 34 35 - echo "$exists $state $ipv4" 35 + echo "$exists $state $ipv4"
+2 -2
capsulflask/spoke_api.py
··· 91 91 if 'id' not in request_body: 92 92 current_app.logger.error(f"/hosts/operation returned 400: id is required for get") 93 93 return abort(400, f"bad request; id is required for get") 94 - 95 - vm = current_app.config['SPOKE_MODEL'].get(request_body['id'], request_body['get_ssh_host_keys']) 94 + 95 + vm = current_app.config['SPOKE_MODEL'].get(request_body['id'], request_body['get_ssh_host_keys'], request_body['get_agent_details']) 96 96 if vm is None: 97 97 return jsonify(dict(assignment_status="assigned")) 98 98
+29 -7
capsulflask/spoke_model.py
··· 27 27 def capacity_avaliable(self, additional_ram_bytes): 28 28 return True 29 29 30 - def get(self, id, get_ssh_host_keys): 30 + def get(self, id, get_ssh_host_keys: bool, get_agent_details: bool): 31 31 validate_capsul_id(id) 32 32 33 33 ipv4 = "1.1.1.1" 34 34 if id in self.capsuls: 35 35 ipv4 = self.capsuls[id]['public_ipv4'] 36 36 37 + ssh_host_keys=[] 37 38 if get_ssh_host_keys: 38 39 ssh_host_keys = json.loads("""[ 39 40 {"key_type":"ED25519", "content":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8cna0zeKSKl/r8whdn/KmDWhdzuWRVV0GaKIM+eshh", "sha256":"V4X2apAF6btGAfS45gmpldknoDX0ipJ5c6DLfZR2ttQ"}, 40 41 {"key_type":"RSA", "content":"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvotgzgEP65JUQ8S8OoNKy1uEEPEAcFetSp7QpONe6hj4wPgyFNgVtdoWdNcU19dX3hpdse0G8OlaMUTnNVuRlbIZXuifXQ2jTtCFUA2mmJ5bF+XjGm3TXKMNGh9PN+wEPUeWd14vZL+QPUMev5LmA8cawPiU5+vVMLid93HRBj118aCJFQxLgrdP48VPfKHFRfCR6TIjg1ii3dH4acdJAvlmJ3GFB6ICT42EmBqskz2MPe0rIFxH8YohCBbAbrbWYcptHt4e48h4UdpZdYOhEdv89GrT8BF2C5cbQ5i9qVpI57bXKrj8hPZU5of48UHLSpXG8mbH0YDiOQOfKX/Mt", "sha256":"ghee6KzRnBJhND2kEUZSaouk7CD6o6z2aAc8GPkV+GQ"}, 41 42 {"key_type":"ECDSA", "content":"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLLgOoATz9R4aS2kk7vWoxX+lshK63t9+5BIHdzZeFE1o+shlcf0Wji8cN/L1+m3bi0uSETZDOAWMP3rHLJj9Hk=", "sha256":"aCYG1aD8cv/TjzJL0bi9jdabMGksdkfa7R8dCGm1yYs"} 42 43 ]""") 43 - return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=ipv4, state="running", ssh_host_keys=ssh_host_keys) 44 44 45 - return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=ipv4, state="running") 45 + return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=ipv4, state="running", ssh_host_keys=ssh_host_keys, guest_agent=True, agent_socket=True, acpid=True) 46 46 47 47 def get_all_by_id(self) -> dict: 48 48 by_host_and_network = get_model().non_deleted_vms_by_host_and_network(current_app.config["SPOKE_HOST_ID"]) ··· 114 114 115 115 return True 116 116 117 - def get(self, id, get_ssh_host_keys): 117 + def get(self, id, get_ssh_host_keys: bool, get_agent_details: bool): 118 118 validate_capsul_id(id) 119 119 completed_process = run([join(current_app.root_path, 'shell_scripts/get.sh'), id], capture_output=True) 120 120 self.validate_completed_process(completed_process) ··· 143 143 if not re.match(r"^([0-9]{1,3}\.){3}[0-9]{1,3}$", ipaddr): 144 144 return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state) 145 145 146 + guest_agent=False 147 + agent_socket=False 148 + acpid=False 149 + ssh_host_keys = [] 146 150 if get_ssh_host_keys: 147 151 try: 148 152 completed_process2 = run([join(current_app.root_path, 'shell_scripts/ssh-keyscan.sh'), ipaddr], capture_output=True) 149 153 self.validate_completed_process(completed_process2) 150 154 ssh_host_keys = json.loads(completed_process2.stdout.decode("utf-8")) 151 - return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state, ipv4=ipaddr, ssh_host_keys=ssh_host_keys) 152 155 except: 153 156 current_app.logger.warning(f""" 154 157 failed to ssh-keyscan {id} at {ipaddr}: 155 158 {my_exec_info_message(sys.exc_info())}""" 156 159 ) 157 160 158 - return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state, ipv4=ipaddr) 161 + if get_agent_details: 162 + try: 163 + completed_process3 = run([join(current_app.root_path, 'shell_scripts/get-guest-agent.sh'), id], capture_output=True) 164 + self.validate_completed_process(completed_process3) 165 + result = completed_process3.stdout.decode("utf-8") 166 + if result != 'no-guest-agent-socket': 167 + agent_socket = True 168 + if result == "ok" or result == 'no-acpid': 169 + guest_agent = True 170 + if result == "ok": 171 + acpid = True 172 + if result not in ['ok', 'no-acpid', 'no-qemu-guest-agent', 'no-guest-agent-socket']: 173 + raise ValueError(result) 174 + except: 175 + current_app.logger.warning(f""" 176 + failed to get-guest-agent {id} at {ipaddr}: 177 + {my_exec_info_message(sys.exc_info())}""" 178 + ) 179 + 180 + return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], state=state, ipv4=ipaddr, ssh_host_keys=ssh_host_keys, agent_socket=agent_socket, guest_agent=guest_agent, acpid=acpid) 159 181 160 182 def get_all_by_id(self) -> dict: 161 183 ··· 300 322 301 323 def vm_state_command(self, email: str, id: str, command: str): 302 324 validate_capsul_id(id) 303 - if command not in ["stop", "force-stop", "start", "restart"]: 325 + if command not in ["stop", "force-stop", "acpi-shutdown", "start", "restart"]: 304 326 raise ValueError(f"command ({command}) must be one of stop, force-stop, start, or restart") 305 327 306 328 completed_process = run([join(current_app.root_path, f"shell_scripts/{command}.sh"), id], capture_output=True)
+12 -4
capsulflask/templates/capsul-detail.html
··· 108 108 <label class="align" for="ssh_username">SSH Username</label> 109 109 <span id="ssh_username">cyberian</span> 110 110 </div> 111 - 112 111 <div class="row justify-start"> 113 - <!-- spacer to make authorized keys show up on its own line --> 114 - <label class="align" for="spacer">&nbsp;</label> 115 - <span id="spacer">&nbsp;</span> 112 + <label class="align" for="last_backup">Last Backup</label> 113 + <span id="last_backup">{{ vm['last_backup_str'] }}</span> 116 114 </div> 117 115 <div class="row justify-start"> 118 116 <label class="align" for="ssh_authorized_keys">SSH Authorized Keys</label> ··· 121 119 </div> 122 120 123 121 </div> 122 + <div class="row"> 123 + <hr/> 124 + </div> 124 125 <div class="row center justify-start vm-actions"> 125 126 <label class="align" for="delete_action">Actions</label> 126 127 <form id="delete_action" method="post"> ··· 136 137 </form> 137 138 {% endif %} 138 139 {% if vm['state'] != 'stopped' %} 140 + {% if vm['acpid'] %} 141 + <form id="acpi_shutdown_action" method="post"> 142 + <input type="hidden" name="action" value="acpi-shutdown"/> 143 + <input type="hidden" name="csrf-token" value="{{ csrf_token }}"/> 144 + <input type="submit" class="form-submit-link" value="[ ACPI Shutdown... ]"> 145 + </form> 146 + {% endif %} 139 147 <form id="force_stop_action" method="post"> 140 148 <input type="hidden" name="action" value="force-stop"/> 141 149 <input type="hidden" name="csrf-token" value="{{ csrf_token }}"/>