first try at creating VirtualizationInterface
This commit is contained in:
parent
7fe0d9a9c5
commit
426fad7b10
@ -8,7 +8,7 @@ from psycopg2 import pool
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask import g
|
from flask import g
|
||||||
|
|
||||||
from capsulflask.model import Model
|
from capsulflask.db_model import DBModel
|
||||||
|
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
databaseUrl = urlparse(app.config['DATABASE_URL'])
|
databaseUrl = urlparse(app.config['DATABASE_URL'])
|
||||||
@ -111,8 +111,8 @@ def get_model():
|
|||||||
if 'model' not in g:
|
if 'model' not in g:
|
||||||
connection = current_app.config['PSYCOPG2_CONNECTION_POOL'].getconn()
|
connection = current_app.config['PSYCOPG2_CONNECTION_POOL'].getconn()
|
||||||
cursor = connection.cursor()
|
cursor = connection.cursor()
|
||||||
g.model = Model(connection, cursor)
|
g.db_model = DBModel(connection, cursor)
|
||||||
return g.model
|
return g.db_model
|
||||||
|
|
||||||
|
|
||||||
def close_db(e=None):
|
def close_db(e=None):
|
||||||
|
33
capsulflask/db_model.py
Normal file
33
capsulflask/db_model.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
from nanoid import generate
|
||||||
|
|
||||||
|
|
||||||
|
class DBModel:
|
||||||
|
def __init__(self, connection, cursor):
|
||||||
|
self.connection = connection
|
||||||
|
self.cursor = cursor
|
||||||
|
|
||||||
|
def login(self, email):
|
||||||
|
self.cursor.execute("SELECT * FROM accounts WHERE email = %s", (email, ))
|
||||||
|
if len(self.cursor.fetchall()) == 0:
|
||||||
|
self.cursor.execute("INSERT INTO accounts (email) VALUES (%s)", (email, ))
|
||||||
|
|
||||||
|
self.cursor.execute("SELECT token FROM login_tokens WHERE email = %s", (email, ))
|
||||||
|
if len(self.cursor.fetchall()) > 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = generate()
|
||||||
|
self.cursor.execute("INSERT INTO login_tokens (email, token) VALUES (%s, %s)", (email, token))
|
||||||
|
self.connection.commit()
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
def consumeToken(self, token):
|
||||||
|
self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s", (token, ))
|
||||||
|
rows = self.cursor.fetchall()
|
||||||
|
if len(rows) > 0:
|
||||||
|
email = rows[0][0]
|
||||||
|
self.cursor.execute("DELETE FROM login_tokens WHERE email = %s", (email, ))
|
||||||
|
self.connection.commit()
|
||||||
|
return email
|
||||||
|
return None
|
@ -1,9 +1,17 @@
|
|||||||
DROP TABLE payments;
|
DROP TABLE payments;
|
||||||
|
|
||||||
DROP TABLE logintokens;
|
DROP TABLE login_tokens;
|
||||||
|
|
||||||
|
DROP TABLE vm_ssh_public_key;
|
||||||
|
|
||||||
DROP TABLE vms;
|
DROP TABLE vms;
|
||||||
|
|
||||||
|
DROP TABLE vm_sizes;
|
||||||
|
|
||||||
|
DROP TABLE os_images;
|
||||||
|
|
||||||
|
DROP TABLE ssh_public_keys;
|
||||||
|
|
||||||
DROP TABLE accounts;
|
DROP TABLE accounts;
|
||||||
|
|
||||||
UPDATE schemaversion SET version = 1;
|
UPDATE schemaversion SET version = 1;
|
@ -5,11 +5,44 @@ CREATE TABLE accounts (
|
|||||||
created TIMESTAMP NOT NULL DEFAULT NOW()
|
created TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE os_images (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
template_image_file_name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE vm_sizes (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
dollars_per_month NUMERIC(8, 2) NOT NULL,
|
||||||
|
memory_megabytes INTEGER NOT NULL,
|
||||||
|
vcpus INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ssh_public_keys (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
UNIQUE (id, email)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE vms (
|
CREATE TABLE vms (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
|
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
|
||||||
|
os TEXT REFERENCES os_images(id) ON DELETE RESTRICT,
|
||||||
|
size TEXT REFERENCES vm_sizes(id) ON DELETE RESTRICT,
|
||||||
created TIMESTAMP NOT NULL DEFAULT NOW(),
|
created TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
deleted TIMESTAMP NOT NULL
|
deleted TIMESTAMP,
|
||||||
|
UNIQUE (id, email)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE vm_ssh_public_key (
|
||||||
|
ssh_public_key_id INTEGER NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
vm_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (email, ssh_public_key_id) REFERENCES ssh_public_keys(email, id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (email, vm_id) REFERENCES vms(email, id) ON DELETE RESTRICT,
|
||||||
|
PRIMARY KEY (email, vm_id, ssh_public_key_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE payments (
|
CREATE TABLE payments (
|
||||||
@ -19,11 +52,28 @@ CREATE TABLE payments (
|
|||||||
PRIMARY KEY (email, created)
|
PRIMARY KEY (email, created)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE logintokens (
|
CREATE TABLE login_tokens (
|
||||||
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
|
email TEXT REFERENCES accounts(email) ON DELETE RESTRICT,
|
||||||
created TIMESTAMP NOT NULL DEFAULT NOW(),
|
created TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
token TEXT NOT NULL,
|
token TEXT NOT NULL,
|
||||||
PRIMARY KEY (email, created)
|
PRIMARY KEY (email, created)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
INSERT INTO os_images (id, template_image_file_name, description)
|
||||||
|
VALUES ('debian10', 'debian-10-genericcloud-amd64-20191117-80.qcow2', 'Debian 10 (Buster)'),
|
||||||
|
('centos7', 'CentOS-7-x86_64-GenericCloud.qcow2', 'CentOS 7'),
|
||||||
|
('centos8', 'CentOS-8-GenericCloud-8.1.1911-20200113.3.x86_64.qcow2', 'CentOS 8'),
|
||||||
|
('ubuntu18', 'ubuntu-18.04-minimal-cloudimg-amd64.img', 'Ubuntu 18.04 LTS (Bionic Beaver)'),
|
||||||
|
('alpine311', 'alpine-cloud-2020-04-18.qcow2', 'Alpine Linux 3.11'),
|
||||||
|
('openbsd66', 'openbsd-cloud-2020-05.qcow2', 'OpenBSD 6.6'),
|
||||||
|
('guix110', 'guixsystem-cloud-2020-05.qcow2', 'Guix System 1.1.0');
|
||||||
|
|
||||||
|
INSERT INTO vm_sizes (id, dollars_per_month, memory_megabytes, vcpus)
|
||||||
|
VALUES ('f1-s', 5.33, 512, 1),
|
||||||
|
('f1-m', 7.16, 1024, 1),
|
||||||
|
('f1-l', 8.92, 2048, 1),
|
||||||
|
('f1-x', 16.16, 4096, 2),
|
||||||
|
('f1-xx', 29.66, 8192, 4),
|
||||||
|
('f1-xxx', 57.58, 16384, 8);
|
||||||
|
|
||||||
UPDATE schemaversion SET version = 2;
|
UPDATE schemaversion SET version = 2;
|
79
capsulflask/shell_scripts/create.sh
Normal file
79
capsulflask/shell_scripts/create.sh
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/sh -e
|
||||||
|
#
|
||||||
|
# create VMs for the capsul service
|
||||||
|
# developed by Cyberia Heavy Industries
|
||||||
|
# POSIX or die
|
||||||
|
|
||||||
|
vmname="$1"
|
||||||
|
template_file="/tank/img/$2"
|
||||||
|
vcpus="$3"
|
||||||
|
memory="$4"
|
||||||
|
pubkeys="$5"
|
||||||
|
root_voume_size="25G"
|
||||||
|
|
||||||
|
if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then
|
||||||
|
echo "vmname $vmname must match "'"^capsul-[a-z0-9]{10}$"'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$template_file" ]; then
|
||||||
|
echo "template $template_file not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$vcpus" | grep -vqE "^[0-9]+$"; then
|
||||||
|
echo "vcpus \"$vcpus\" must be an integer"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if echo "$memory" | grep -vqE "^[0-9]+$"; then
|
||||||
|
echo "memory \"$memory\" must be an integer"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$pubkeys" | while IFS= read -r line; do
|
||||||
|
if echo "$line" | grep -vqE "^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$"; then
|
||||||
|
echo "pubkey \"$line\" must match "'"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$"'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
disk="/tank/vm/$vmname.qcow2"
|
||||||
|
cdrom="/tank/vm/$vmname.iso"
|
||||||
|
xml="/tank/vm/$vmname.xml"
|
||||||
|
|
||||||
|
if [ -f /tank/vm/$vmname.qcow2 ]; then
|
||||||
|
echo "Randomly generated name matched an existing VM! Odds are like one in a billion. Buy a lotto ticket."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
qemu-img create -f qcow2 -b "$template_file" "$disk"
|
||||||
|
cp /tank/config/cyberia-cloudinit.yml /tmp/cloudinit.yml
|
||||||
|
echo "$pubkeys" | while IFS= read -r line; do
|
||||||
|
echo " - $line" >> /tmp/cloudinit.yml
|
||||||
|
done
|
||||||
|
|
||||||
|
cloud-localds "$cdrom" /tmp/cloudinit.yml
|
||||||
|
|
||||||
|
qemu-img resize "$disk" "$root_voume_size"
|
||||||
|
virt-install \
|
||||||
|
--memory "$memory" \
|
||||||
|
--vcpus "$vcpus" \
|
||||||
|
--name "$vmname" \
|
||||||
|
--disk "$disk",bus=virtio \
|
||||||
|
--disk "$cdrom",device=cdrom \
|
||||||
|
--os-type Linux \
|
||||||
|
--os-variant generic \
|
||||||
|
--virt-type kvm \
|
||||||
|
--graphics vnc,listen=127.0.0.1 \
|
||||||
|
--network network=public1,filterref=clean-traffic,model=virtio \
|
||||||
|
--import \
|
||||||
|
--print-xml > "$xml"
|
||||||
|
|
||||||
|
# --network network=public6,filterref=clean-traffic,model=virtio
|
||||||
|
chmod 0600 "$xml" "$disk" "$cdrom"
|
||||||
|
virsh define "$xml"
|
||||||
|
virsh start "$vmname"
|
||||||
|
|
||||||
|
echo "success"
|
||||||
|
|
48
capsulflask/shell_scripts/destroy.sh
Normal file
48
capsulflask/shell_scripts/destroy.sh
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
three_dots() {
|
||||||
|
printf '.'
|
||||||
|
sleep 0.01
|
||||||
|
printf '.'
|
||||||
|
sleep 0.01
|
||||||
|
printf '.\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
vmname="$1"
|
||||||
|
|
||||||
|
if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then
|
||||||
|
echo "vmname $vmname must match "'"^capsul-[a-z0-9]{10}$"'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "deleting $vmname:"
|
||||||
|
count=$(ls /tank/vm/$vmname* 2> /dev/null | wc -l)
|
||||||
|
if [ "$count" -gt 3 ]; then
|
||||||
|
echo "too many files found, exiting cowardly:"
|
||||||
|
echo "$(ls /tank/vm/$vmname*)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
virsh list --name --all | grep -q -E "^$vmname$"
|
||||||
|
if [ "$?" -eq 1 ]; then
|
||||||
|
echo "Error: $vmname not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
virsh list --name --state-running | grep -q -E "^$vmname$"
|
||||||
|
if [ "$?" -eq 0 ]; then
|
||||||
|
printf ' stopping vm'; three_dots
|
||||||
|
virsh destroy "$vmname" > /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf ' undefining xml.'; three_dots
|
||||||
|
if ! [ -f "/tank/vm/$vmname.qcow2" ]; then
|
||||||
|
echo "/tank/vm/$vmname.qcow2 is not a file, exiting cowardly"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
virsh undefine "$vmname" > /dev/null
|
||||||
|
printf ' deleting disks.' ; three_dots
|
||||||
|
rm /tank/vm/$vmname*
|
||||||
|
|
||||||
|
echo "success"
|
16
capsulflask/shell_scripts/get.sh
Normal file
16
capsulflask/shell_scripts/get.sh
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
vmname="$1"
|
||||||
|
|
||||||
|
if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then
|
||||||
|
echo "vmname $vmname must match "'"^capsul-[a-z0-9]{10}$"'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if virsh list --name --all | grep -vqE "^$vmname$" ; then
|
||||||
|
echo "Error: $vmname not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# this gets the ipv4
|
||||||
|
virsh domifaddr "$vmname" | awk '/vnet/ {print $4}' | cut -d'/' -f1
|
3
capsulflask/shell_scripts/listIds.sh
Normal file
3
capsulflask/shell_scripts/listIds.sh
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
virsh list --all | grep running | grep -v ' Id' | grep -v -- '----' | awk '{print $2}' | sort
|
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<title>{% block title %}{% endblock %}{% if self.title() %} - {% endif %}capsul</title>
|
<title>{% block title %}{% endblock %}{% if self.title() %} - {% endif %}capsul💊</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/icon.png" />
|
<link rel="apple-touch-icon" href="/icon.png" />
|
||||||
|
134
capsulflask/virt_model.py
Normal file
134
capsulflask/virt_model.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
from time import sleep
|
||||||
|
from os.path import join
|
||||||
|
from subprocess import run
|
||||||
|
|
||||||
|
class VirtualMachine:
|
||||||
|
def __init__(self, id, ipv4=None, ipv6=None):
|
||||||
|
self.id = id
|
||||||
|
self.ipv4 = ipv4
|
||||||
|
self.ipv6 = ipv6
|
||||||
|
|
||||||
|
class VirtualizationInterface:
|
||||||
|
def get(self, id: str) -> VirtualMachine:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def listIds(self) -> list:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create(self, id: str, template_file_name: str, memory: int, vcpus: int, ssh_public_keys: list) -> VirtualMachine:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def destroy(self, id: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ShellScriptVirtualization(VirtualizationInterface):
|
||||||
|
|
||||||
|
def validateId(self, id):
|
||||||
|
if not re.match(r"^capsul-[a-z0-9]{10}$", id):
|
||||||
|
raise ValueError(f"vm id \"{id}\" must match \"^capsul-[a-z0-9]{{10}}$\"")
|
||||||
|
|
||||||
|
def validateCompletedProcess(self, completedProcess):
|
||||||
|
if completedProcess.returncode != 0:
|
||||||
|
raise RuntimeError(f"""{" ".join(completedProcess.args)} failed with exit code {completedProcess.returncode}
|
||||||
|
stdout:
|
||||||
|
{completedProcess.stdout}
|
||||||
|
stderr:
|
||||||
|
{completedProcess.stderr}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def get(self, id):
|
||||||
|
self.validateId(id)
|
||||||
|
completedProcess = run([join(current_app.root_path, 'shell_scripts/get.sh'), id], capture_output=True)
|
||||||
|
self.validateCompletedProcess(completedProcess)
|
||||||
|
lines = completedProcess.stdout.splitlines()
|
||||||
|
|
||||||
|
if not re.match(r"^([0-9]{1,3}\.){3}[0-9]{1,3}$", lines[0]):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return VirtualMachine(id, ipv4=lines[0])
|
||||||
|
|
||||||
|
def listIds(self) -> list:
|
||||||
|
completedProcess = run([join(current_app.root_path, 'shell_scripts/listIds.sh')], capture_output=True)
|
||||||
|
self.validateCompletedProcess(completedProcess)
|
||||||
|
return completedProcess.stdout.splitlines()
|
||||||
|
|
||||||
|
def create(self, id: str, template_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list):
|
||||||
|
self.validateId(id)
|
||||||
|
|
||||||
|
if not re.match(r"^[a-zA-Z0-9_.-]$", template_file_name):
|
||||||
|
raise ValueError(f"template_file_name \"{template_file_name}\" must match \"^[a-zA-Z0-9_.-]$\"")
|
||||||
|
|
||||||
|
for ssh_public_key in ssh_public_keys:
|
||||||
|
if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$", ssh_public_key):
|
||||||
|
raise ValueError(f"ssh_public_key \"{ssh_public_key}\" must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$\"")
|
||||||
|
|
||||||
|
if vcpus < 1 or vcpus > 8:
|
||||||
|
raise ValueError(f"vcpus \"{vcpus}\" must match 1 <= vcpus <= 8")
|
||||||
|
|
||||||
|
if memory_mb < 512 or memory_mb > 16384:
|
||||||
|
raise ValueError(f"memory_mb \"{memory_mb}\" must match 512 <= memory_mb <= 16384")
|
||||||
|
|
||||||
|
ssh_keys_string = "\n".join(ssh_public_keys)
|
||||||
|
|
||||||
|
completedProcess = run([
|
||||||
|
join(current_app.root_path, 'shell_scripts/create.sh'),
|
||||||
|
id,
|
||||||
|
template_file_name,
|
||||||
|
str(vcpus),
|
||||||
|
str(memory_mb),
|
||||||
|
ssh_keys_string
|
||||||
|
], capture_output=True)
|
||||||
|
|
||||||
|
self.validateCompletedProcess(completedProcess)
|
||||||
|
lines = completedProcess.stdout.splitlines()
|
||||||
|
|
||||||
|
vmSettings = f"""
|
||||||
|
id={id}
|
||||||
|
template_file_name={template_file_name}
|
||||||
|
vcpus={str(vcpus)}
|
||||||
|
memory={str(memory_mb)}
|
||||||
|
ssh_public_keys={ssh_keys_string}
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not lines[len(lines)-1] == "success":
|
||||||
|
raise ValueError(f"""failed to create vm with:
|
||||||
|
{vmSettings}
|
||||||
|
stdout:
|
||||||
|
{completedProcess.stdout}
|
||||||
|
stderr:
|
||||||
|
{completedProcess.stderr}
|
||||||
|
""")
|
||||||
|
|
||||||
|
for _ in range(0, 10):
|
||||||
|
sleep(6)
|
||||||
|
result = self.get(id)
|
||||||
|
if result != None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for _ in range(0, 10):
|
||||||
|
sleep(60)
|
||||||
|
result = self.get(id)
|
||||||
|
if result != None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
raise TimeoutError(f"""timed out waiting for vm to obtain an IP address:
|
||||||
|
{vmSettings}
|
||||||
|
""")
|
||||||
|
|
||||||
|
def destroy(self, id: str):
|
||||||
|
self.validateId(id)
|
||||||
|
completedProcess = run([join(current_app.root_path, 'shell_scripts/destroy.sh'), id], capture_output=True)
|
||||||
|
self.validateCompletedProcess(completedProcess)
|
||||||
|
lines = completedProcess.stdout.splitlines()
|
||||||
|
|
||||||
|
if not lines[len(lines)-1] == "success":
|
||||||
|
raise ValueError(f"""failed to destroy vm "{id}":
|
||||||
|
stdout:
|
||||||
|
{completedProcess.stdout}
|
||||||
|
stderr:
|
||||||
|
{completedProcess.stderr}
|
||||||
|
""")
|
Loading…
Reference in New Issue
Block a user