create capsul and capsuls list page working

This commit is contained in:
forest 2020-05-11 15:13:20 -05:00
parent 452f236c6b
commit 231d1ed7c0
11 changed files with 210 additions and 124 deletions

View File

@ -32,6 +32,12 @@ Run the app
FLASK_APP=capsulflask flask run FLASK_APP=capsulflask flask run
``` ```
Run the app in gunicorn locally
```
pip install gunicorn
.venv/bin/gunicorn --bind 127.0.0.1:5000 capsulflask:app
```
## postgres database schema management ## postgres database schema management
capsulflask has a concept of a schema version. When the application starts, it will query the database for a table named capsulflask has a concept of a schema version. When the application starts, it will query the database for a table named

View File

@ -6,36 +6,35 @@ from flask import render_template
from capsulflask import virt_model from capsulflask import virt_model
def create_app(): app = Flask(__name__)
app = Flask(__name__) app.config.from_mapping(
app.config.from_mapping( BASE_URL=os.environ.get("BASE_URL", default="http://localhost:5000"),
BASE_URL=os.environ.get("BASE_URL", default="http://localhost:5000"), SECRET_KEY=os.environ.get("SECRET_KEY", default="dev"),
SECRET_KEY=os.environ.get("SECRET_KEY", default="dev"), DATABASE_URL=os.environ.get("DATABASE_URL", default="sql://postgres:dev@localhost:5432/postgres"),
DATABASE_URL=os.environ.get("DATABASE_URL", default="sql://postgres:dev@localhost:5432/postgres"), DATABASE_SCHEMA=os.environ.get("DATABASE_SCHEMA", default="public"),
DATABASE_SCHEMA=os.environ.get("DATABASE_SCHEMA", default="public"),
MAIL_SERVER=os.environ.get("MAIL_SERVER", default="m1.nullhex.com"), MAIL_SERVER=os.environ.get("MAIL_SERVER", default="m1.nullhex.com"),
MAIL_PORT=os.environ.get("MAIL_PORT", default="587"), MAIL_PORT=os.environ.get("MAIL_PORT", default="587"),
MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="True").lower() in ['true', '1', 't', 'y', 'yes'], MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="True").lower() in ['true', '1', 't', 'y', 'yes'],
MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default="forest@nullhex.com"), MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default="forest@nullhex.com"),
MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""), MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""),
MAIL_DEFAULT_SENDER=os.environ.get("MAIL_DEFAULT_SENDER", default="forest@nullhex.com"), MAIL_DEFAULT_SENDER=os.environ.get("MAIL_DEFAULT_SENDER", default="forest@nullhex.com"),
) )
app.config['FLASK_MAIL_INSTANCE'] = Mail(app) app.config['FLASK_MAIL_INSTANCE'] = Mail(app)
app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization() app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization()
from capsulflask import db from capsulflask import db
db.init_app(app) db.init_app(app)
from capsulflask import auth, landing, console from capsulflask import auth, landing, console
app.register_blueprint(landing.bp) app.register_blueprint(landing.bp)
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(console.bp) app.register_blueprint(console.bp)
app.add_url_rule("/", endpoint="index") app.add_url_rule("/", endpoint="index")
return app
if __name__ == "__main__":
app.run(host='127.0.0.1')

View File

@ -33,17 +33,17 @@ def account_required(view):
def login(): def login():
if request.method == "POST": if request.method == "POST":
email = request.form["email"] email = request.form["email"]
error = None errors = list()
if not email: if not email:
error = "email is required" errors.append("email is required")
elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0: elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0:
error = "enter a valid email address" errors.append("enter a valid email address")
if error is None: if len(errors) == 0:
token = get_model().login(email) token = get_model().login(email)
if token is None: if token is None:
error = "too many logins. please use one of the existing login links that have been emailed to you" errors.append("too many logins. please use one of the existing login links that have been emailed to you")
else: else:
link = f"{current_app.config['BASE_URL']}/auth/magic/{token}" link = f"{current_app.config['BASE_URL']}/auth/magic/{token}"
@ -65,7 +65,8 @@ def login():
return render_template("login-landing.html", email=email) return render_template("login-landing.html", email=email)
flash(error) for error in errors:
flash(error)
return render_template("login.html") return render_template("login.html")

View File

@ -25,62 +25,63 @@ def makeCapsulId():
@bp.route("/") @bp.route("/")
@account_required @account_required
def index(): def index():
return render_template("capsuls.html", vms=get_model().list_vms_for_account(session["account"])) vms = list(map(
lambda x: dict(
id=x['id'],
size=x['size'],
ipv4=(x['ipv4'] if x['ipv4'] else "..booting.."),
ipv4_status=("ok" if x['ipv4'] else "waiting-pulse"),
os=x['os'],
created=x['created'].strftime("%b %d %Y %H:%M")
),
get_model().list_vms_for_account(session["account"])
))
return render_template("capsuls.html", vms=vms, has_vms=len(vms) > 0)
@bp.route("/ssh", methods=("GET", "POST")) @bp.route("/ssh", methods=("GET", "POST"))
@account_required @account_required
def ssh_public_keys(): def ssh_public_keys():
db_model = get_model() db_model = get_model()
error = None errors = list()
if request.method == "POST": if request.method == "POST":
method = request.form["method"] method = request.form["method"]
name = request.form["name"] name = request.form["name"]
if not name or len(name.strip()) < 1: if not name or len(name.strip()) < 1:
error = "Name is required" errors.append("Name is required")
elif not re.match(r"^[0-9A-Za-z_ -]+$", name): elif not re.match(r"^[0-9A-Za-z_ -]+$", name):
error = "Name must match \"^[0-9A-Za-z_ -]+$\"" errors.append("Name must match \"^[0-9A-Za-z_ -]+$\"")
if method == "POST": if method == "POST":
content = request.form["content"] content = request.form["content"]
if not content or len(content.strip()) < 1: if not content or len(content.strip()) < 1:
error = "Content is required" errors.append("Content is required")
else: else:
content = content.replace("\r", "").replace("\n", "") content = content.replace("\r", "").replace("\n", "")
if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$", content): if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$", content):
error = "Content must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$\"" errors.append("Content must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$\"")
if db_model.ssh_public_key_name_exists(session["account"], name): if db_model.ssh_public_key_name_exists(session["account"], name):
error = "A key with that name already exists" errors.append("A key with that name already exists")
if error is None: if len(errors) == 0:
db_model.create_ssh_public_key(session["account"], name, content) db_model.create_ssh_public_key(session["account"], name, content)
elif method == "DELETE": elif method == "DELETE":
if error is None: if len(errors) == 0:
db_model.delete_ssh_public_key(session["account"], name) db_model.delete_ssh_public_key(session["account"], name)
if error: for error in errors:
flash(error) flash(error)
# keys_list= keys_list=list(map(
# for key in keys_list: lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"),
# if len(key['content']) > 40: db_model.list_ssh_public_keys_for_account(session["account"])
# print(key['content']) ))
# print(f"{key['content'][:20]}...{key['content'][len(key['content'])-20:]}")
# key["content"] =
# return return render_template("ssh-public-keys.html", ssh_public_keys=keys_list, has_ssh_public_keys=len(keys_list) > 0)
return render_template(
"ssh-public-keys.html",
ssh_public_keys=map(
lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"),
db_model.list_ssh_public_keys_for_account(session["account"])
)
)
@bp.route("/create", methods=("GET", "POST")) @bp.route("/create", methods=("GET", "POST"))
@account_required @account_required
@ -89,22 +90,45 @@ def create():
vm_sizes = db_model.vm_sizes_dict() vm_sizes = db_model.vm_sizes_dict()
operating_systems = db_model.operating_systems_dict() operating_systems = db_model.operating_systems_dict()
ssh_public_keys = db_model.list_ssh_public_keys_for_account(session["account"]) ssh_public_keys = db_model.list_ssh_public_keys_for_account(session["account"])
error = None errors = list()
created_os = None
if request.method == "POST": if request.method == "POST":
size = request.form["size"] size = request.form["size"]
os = request.form["os"] os = request.form["os"]
if not size: if not size:
error = "Size is required" errors.append("Size is required")
elif size not in vm_sizes: elif size not in vm_sizes:
error = f"Invalid size {size}" errors.append(f"Invalid size {size}")
if not os: if not os:
error = "OS is required" errors.append("OS is required")
elif os not in operating_systems: elif os not in operating_systems:
error = f"Invalid os {os}" errors.append(f"Invalid os {os}")
if error is None: posted_keys_count = int(request.form["ssh_public_key_count"])
posted_keys_contents = list()
if posted_keys_count > 1000:
errors.append("something went wrong with ssh keys")
else:
for i in range(0, posted_keys_count):
if f"ssh_key_{i}" in request.form:
posted_name = request.form[f"ssh_key_{i}"]
key_content = None
for key in ssh_public_keys:
if key['name'] == posted_name:
key_content = key['content']
if key_content:
posted_keys_contents.append(key_content)
else:
errors.append(f"SSH Key \"{posted_name}\" doesn't exist")
if len(posted_keys_contents) == 0:
errors.append("At least one SSH Public Key is required")
if len(errors) == 0:
id = makeCapsulId() id = makeCapsulId()
db_model.create_vm( db_model.create_vm(
email=session["account"], email=session["account"],
@ -115,17 +139,22 @@ def create():
current_app.config["VIRTUALIZATION_MODEL"].create( current_app.config["VIRTUALIZATION_MODEL"].create(
email = session["account"], email = session["account"],
id=id, id=id,
template_image_file_name=operating_systems[os].template_image_file_name, template_image_file_name=operating_systems[os]['template_image_file_name'],
vcpus=vm_sizes[size].vcpus, vcpus=vm_sizes[size]['vcpus'],
memory=vm_sizes[size].memory memory_mb=vm_sizes[size]['memory_mb'],
ssh_public_keys=posted_keys_contents
) )
created_os = os
if error: for error in errors:
flash(error) flash(error)
return render_template( return render_template(
"create-capsul.html", "create-capsul.html",
created_os=created_os,
ssh_public_keys=ssh_public_keys, ssh_public_keys=ssh_public_keys,
ssh_public_key_count=len(ssh_public_keys),
has_ssh_public_keys=len(ssh_public_keys) > 0,
operating_systems=operating_systems, operating_systems=operating_systems,
vm_sizes=vm_sizes vm_sizes=vm_sizes
) )

View File

@ -34,7 +34,7 @@ class DBModel:
def all_vm_ids(self,): def all_vm_ids(self,):
self.cursor.execute("SELECT id FROM vms") self.cursor.execute("SELECT id FROM vms")
return map(lambda x: x[0], self.cursor.fetchall()) return list(map(lambda x: x[0], self.cursor.fetchall()))
def operating_systems_dict(self,): def operating_systems_dict(self,):
self.cursor.execute("SELECT id, template_image_file_name, description FROM os_images") self.cursor.execute("SELECT id, template_image_file_name, description FROM os_images")
@ -56,10 +56,10 @@ class DBModel:
def list_ssh_public_keys_for_account(self, email): def list_ssh_public_keys_for_account(self, email):
self.cursor.execute("SELECT name, content, created FROM ssh_public_keys WHERE email = %s", (email, )) self.cursor.execute("SELECT name, content, created FROM ssh_public_keys WHERE email = %s", (email, ))
return map( return list(map(
lambda x: dict(name=x[0], content=x[1], created=x[2]), lambda x: dict(name=x[0], content=x[1], created=x[2]),
self.cursor.fetchall() self.cursor.fetchall()
) ))
def ssh_public_key_name_exists(self, email, name): def ssh_public_key_name_exists(self, email, name):
self.cursor.execute( "SELECT name FROM ssh_public_keys where email = %s AND name = %s", (email, name) ) self.cursor.execute( "SELECT name FROM ssh_public_keys where email = %s AND name = %s", (email, name) )
@ -80,15 +80,14 @@ class DBModel:
def list_vms_for_account(self, email): def list_vms_for_account(self, email):
self.cursor.execute(""" self.cursor.execute("""
SELECT vms.id, vms.last_seen_ipv4, vms.last_seen_ipv6, vms.size, os_images.description, vms.created, vms.deleted SELECT vms.id, vms.last_seen_ipv4, vms.last_seen_ipv6, vms.size, vms.os, vms.created, vms.deleted
FROM vms JOIN os_images on os_images.id = vms.os FROM vms WHERE vms.email = %s""",
WHERE vms.email = %s""",
(email, ) (email, )
) )
return map( return list(map(
lambda x: dict(id=x[0], ipv4=x[1], ipv6=x[2], size=x[3], os=x[4], created=x[5], deleted=x[6]), lambda x: dict(id=x[0], ipv4=x[1], ipv6=x[2], size=x[3], os=x[4], created=x[5], deleted=x[6]),
self.cursor.fetchall() self.cursor.fetchall()
) ))
def create_vm(self, email, id, size, os): def create_vm(self, email, id, size, os):
self.cursor.execute(""" self.cursor.execute("""

View File

@ -30,10 +30,13 @@ a:hover, a:active, a:visited {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
.nav-row a {
white-space: nowrap;
}
.nav-row:last-child { .nav-row:last-child {
justify-content: center; justify-content: center;
} }
.nav-row:last-child a, .nav-row:last-child div { .nav-row:last-child a {
margin: 0 1em; margin: 0 1em;
} }
@ -73,13 +76,13 @@ main {
justify-content: space-around; justify-content: space-around;
width: 100%; width: 100%;
} }
.row.wrap { .wrap {
flex-wrap: wrap; flex-wrap: wrap;
} }
.row.justify-start { .justify-start {
justify-content: flex-start; justify-content: flex-start;
} }
.row.justify-end { .justify-end {
justify-content: flex-end; justify-content: flex-end;
} }
@ -116,6 +119,7 @@ select {
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: bottom 0.65em right 0.8em; background-position: bottom 0.65em right 0.8em;
background-size: 0.5em; background-size: 0.5em;
padding-right: 2em;
} }
input, textarea { input, textarea {
@ -139,14 +143,16 @@ textarea {
height: 6em; height: 6em;
} }
input[type=checkbox] {
margin: 0;
}
input[type=submit], select, textarea { input[type=submit], select, textarea {
font: calc(0.40rem + 1vmin) monospace; font: calc(0.40rem + 1vmin) monospace;
border: 1px solid #777e73; border: 1px solid #777e73;
background-color: #bdc7b810; background-color: #bdc7b810;
} }
input[type=submit], select { input[type=submit], select {
cursor: pointer; cursor: pointer;
} }
@ -162,6 +168,31 @@ ul li {
margin: 0.5em 0; margin: 0.5em 0;
} }
table{
border-collapse: collapse;
}
thead {
background: #bdc7b812;
}
td, th {
padding: 0.2em 1em;
}
th {
border-right: 4px solid #241e1e;
text-align: left;
}
td {
border-bottom: 2px dotted #777e7355;
}
.waiting-pulse {
animation: waiting-pulse 1s ease-in-out 0s infinite forwards alternate;
}
@keyframes waiting-pulse {
from { color: rgba(221, 169, 56, 0.8); }
to { color: rgba(221, 169, 56, 0.2); }
}
.code { .code {
display: inline-block; display: inline-block;
padding: 0.5em 2em; padding: 0.5em 2em;

View File

@ -21,12 +21,14 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="nav-row half-margin"> <div class="nav-row half-margin wrap">
<a href="/faq">FAQ</a> <a href="/faq">FAQ</a>
<a href="/changelog">Changelog</a> <a href="/changelog">Changelog</a>
{% if session["account"] %} {% if session["account"] %}
<a href="/console/">Console</a> <a href="/console">Capsuls</a>
<a href="/console/ssh">SSH Public Keys</a>
<a href="/console/billing">Account Balance</a>
{% endif %} {% endif %}
<a href="/support">Support</a> <a href="/support">Support</a>

View File

@ -1,10 +1,16 @@
{% extends 'console-base.html' %} {% extends 'base.html' %}
{% block title %}Console{% endblock %} {% block title %}Console{% endblock %}
{% block consoletitle %}Console{% endblock %}
{% block consolecontent %} {% block content %}
{% if vms[0] is defined %} <div class="third-margin">
<h1>Capsuls</h1>
</div>
<div class="third-margin">
{% if has_vms %}
<div class="row third-margin justify-end">
<a href="/console/create">Create Capsul</a>
</div>
<table> <table>
<thead> <thead>
<tr> <tr>
@ -17,21 +23,20 @@
</thead> </thead>
<tbody> <tbody>
{% for vm in vms %} {% for vm in vms %}
<a href="/console/{{ vm['id'] }}">
<tr> <tr>
<td>{{ vm["id"] }}</td> <td><a href="/console/{{ vm['id'] }}">{{ vm["id"] }}</a></td>
<td>{{ vm["size"] }}</td> <td>{{ vm["size"] }}</td>
<td>{{ vm["ipv4"] }}</td> <td class="{{ vm['ipv4_status'] }}">{{ vm["ipv4"] }}</td>
<td>{{ vm["os"] }}</td> <td>{{ vm["os"] }}</td>
<td>{{ vm["created"] }}</td> <td>{{ vm["created"] }}</td>
</tr> </tr>
</a>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
You don't have any Capsuls running. <a href="/console/create">Create one</a> today! You don't have any Capsuls running. <a href="/console/create">Create one</a> today!
{% endif %} {% endif %}
</div>
{% endblock %} {% endblock %}
{% block pagesource %}/templates/capsuls.html{% endblock %} {% block pagesource %}/templates/capsuls.html{% endblock %}

View File

@ -1,11 +1,26 @@
{% extends 'console-base.html' %} {% extends 'base.html' %}
{% block title %}Create{% endblock %} {% block title %}Create{% endblock %}
{% block consoletitle %}Console - Create Capsul{% endblock %}
{% block consolecontent %} {% block content %}
<div class="third-margin">
{% if ssh_public_keys[0] is defined %} <h1>Create Capsul</h1>
</div>
<div class="third-margin">
{% if created_os %}
<p>
Your Capsul was successfully created! You should already see it listed on the
<a href="/console/">Capsuls page</a>, but it may not have obtained an IP address yet.
Its IP address should become visible once the machine has booted and taken a DHCP lease.
</p>
{% if created_os == 'debian10' %}
<p>
Note: because Debian delays fully booting until after entropy has been generated, Debian Capsuls
may take an extra-long time to obtain an IP address, like up to 10 minutes. Be patient.
</p>
{% endif %}
{% else %}
{% if has_ssh_public_keys %}
<pre> <pre>
CAPSUL SIZES CAPSUL SIZES
============ ============
@ -36,6 +51,18 @@ f1-xxx $57.58 8 16G 25G 16TB
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="row justify-start">
<input type="hidden" name="ssh_public_key_count" value="{{ ssh_public_key_count}}"/>
<label class="align" for="ssh_keys">SSH Public Keys</label>
<div id="ssh_keys">
{% for key in ssh_public_keys %}
<label for="ssh_key_{{ loop.index - 1 }}">
<input type="checkbox" id="ssh_key_{{ loop.index - 1 }}" name="ssh_key_{{ loop.index - 1 }}" value="{{ key['name'] }}"/>
{{ key['name'] }}
</label>
{% endfor %}
</div>
</div>
<div class="row justify-end"> <div class="row justify-end">
<input type="submit" value="Create"> <input type="submit" value="Create">
</div> </div>
@ -45,6 +72,8 @@ f1-xxx $57.58 8 16G 25G 16TB
<p>You must <a href="/console/ssh/upload">upload one</a> before you can create a Capsul.</p> <p>You must <a href="/console/ssh/upload">upload one</a> before you can create a Capsul.</p>
{% endif %} {% endif %}
{% endif %}
</div>
{% endblock %} {% endblock %}
{% block subcontent %} {% block subcontent %}

View File

@ -1,12 +1,13 @@
{% extends 'console-base.html' %} {% extends 'base.html' %}
{% block title %}SSH Public Keys{% endblock %} {% block title %}SSH Public Keys{% endblock %}
{% block consoletitle %}Console - SSH Public Keys{% endblock %}
{% block consolecontent %} {% block content %}
<div class="third-margin">
<h1>SSH PUBLIC KEYS</h1>
{% if ssh_public_keys[0] is defined %} <hr/> {% endif %} </div>
<div class="third-margin">
{% if has_ssh_public_keys %} <hr/> {% endif %}
{% for ssh_public_key in ssh_public_keys %} {% for ssh_public_key in ssh_public_keys %}
<form method="post"> <form method="post">
@ -20,7 +21,7 @@
</form> </form>
{% endfor %} {% endfor %}
{% if ssh_public_keys[0] is defined %} <hr/> {% endif %} {% if has_ssh_public_keys %} <hr/> {% endif %}
<div class="third-margin"> <div class="third-margin">
<h1>UPLOAD A NEW SSH KEY</h1> <h1>UPLOAD A NEW SSH KEY</h1>
@ -50,6 +51,7 @@
<input type="submit" value="Upload"> <input type="submit" value="Upload">
</div> </div>
</form> </form>
</div>
{% endblock %} {% endblock %}
{% block pagesource %}/templates/ssh-public-keys.html{% endblock %} {% block pagesource %}/templates/ssh-public-keys.html{% endblock %}

View File

@ -25,7 +25,7 @@ class VirtualizationInterface:
def list_ids(self) -> list: def list_ids(self) -> list:
pass pass
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory: int, ssh_public_keys: list) -> VirtualMachine: def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory: int, ssh_public_keys: list):
pass pass
def destroy(self, email: str, id: str): def destroy(self, email: str, id: str):
@ -42,8 +42,7 @@ class MockVirtualization(VirtualizationInterface):
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list): def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list):
validate_capsul_id(id) validate_capsul_id(id)
print(f"mock create: {id} for {email}") print(f"mock create: {id} for {email}")
sleep(5) sleep(1)
return VirtualMachine(id, ipv4="1.1.1.1")
def destroy(self, email: str, id: str): def destroy(self, email: str, id: str):
print(f"mock destroy: {id} for {email}") print(f"mock destroy: {id} for {email}")
@ -127,22 +126,6 @@ class ShellScriptVirtualization(VirtualizationInterface):
{completedProcess.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 {id} ({email}) to obtain an IP address:
{vmSettings}
""")
def destroy(self, email: str, id: str): def destroy(self, email: str, id: str):
validate_capsul_id(id) validate_capsul_id(id)
completedProcess = run([join(current_app.root_path, 'shell_scripts/destroy.sh'), id], capture_output=True) completedProcess = run([join(current_app.root_path, 'shell_scripts/destroy.sh'), id], capture_output=True)