mirror of
https://github.com/ComputerScienceHouse/proxstar.git
synced 2025-03-09 15:40:09 +00:00
use cloud-init for templates, add ssh key for cloud-init, delete expire before vm creation
This commit is contained in:
parent
dd876a8b2c
commit
a3f15134f2
12 changed files with 55 additions and 88 deletions
|
@ -410,6 +410,7 @@ def create():
|
||||||
template = request.form['template']
|
template = request.form['template']
|
||||||
disk = request.form['disk']
|
disk = request.form['disk']
|
||||||
iso = request.form['iso']
|
iso = request.form['iso']
|
||||||
|
ssh_key = request.form['ssh_key']
|
||||||
if iso != 'none':
|
if iso != 'none':
|
||||||
iso = "{}:iso/{}".format(app.config['PROXMOX_ISO_STORAGE'],
|
iso = "{}:iso/{}".format(app.config['PROXMOX_ISO_STORAGE'],
|
||||||
iso)
|
iso)
|
||||||
|
@ -438,17 +439,16 @@ def create():
|
||||||
iso,
|
iso,
|
||||||
timeout=300)
|
timeout=300)
|
||||||
else:
|
else:
|
||||||
password = gen_password(16)
|
|
||||||
q.enqueue(
|
q.enqueue(
|
||||||
setup_template_task,
|
setup_template_task,
|
||||||
template,
|
template,
|
||||||
name,
|
name,
|
||||||
username,
|
username,
|
||||||
password,
|
ssh_key,
|
||||||
cores,
|
cores,
|
||||||
memory,
|
memory,
|
||||||
timeout=600)
|
timeout=600)
|
||||||
return password, 200
|
return '', 200
|
||||||
return '', 200
|
return '', 200
|
||||||
else:
|
else:
|
||||||
return '', 403
|
return '', 403
|
||||||
|
@ -557,10 +557,8 @@ def template_disk(template_id):
|
||||||
def template_edit(template_id):
|
def template_edit(template_id):
|
||||||
if 'rtp' in session['userinfo']['groups']:
|
if 'rtp' in session['userinfo']['groups']:
|
||||||
name = request.form['name']
|
name = request.form['name']
|
||||||
username = request.form['username']
|
|
||||||
password = request.form['password']
|
|
||||||
disk = request.form['disk']
|
disk = request.form['disk']
|
||||||
set_template_info(db, template_id, name, username, password, disk)
|
set_template_info(db, template_id, name, disk)
|
||||||
return '', 200
|
return '', 200
|
||||||
else:
|
else:
|
||||||
return '', 403
|
return '', 403
|
||||||
|
@ -584,4 +582,4 @@ def exit_handler():
|
||||||
atexit.register(exit_handler)
|
atexit.register(exit_handler)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run()
|
app.run(threaded=False)
|
||||||
|
|
|
@ -145,8 +145,6 @@ def get_templates(db):
|
||||||
template_dict = dict()
|
template_dict = dict()
|
||||||
template_dict['id'] = template.id
|
template_dict['id'] = template.id
|
||||||
template_dict['name'] = template.name
|
template_dict['name'] = template.name
|
||||||
template_dict['username'] = template.username
|
|
||||||
template_dict['password'] = template.password
|
|
||||||
template_dict['disk'] = template.disk
|
template_dict['disk'] = template.disk
|
||||||
templates.append(template_dict)
|
templates.append(template_dict)
|
||||||
return templates
|
return templates
|
||||||
|
@ -158,8 +156,6 @@ def get_template(db, template_id):
|
||||||
template = db.query(Template).filter(Template.id == template_id).one()
|
template = db.query(Template).filter(Template.id == template_id).one()
|
||||||
template_dict['id'] = template.id
|
template_dict['id'] = template.id
|
||||||
template_dict['name'] = template.name
|
template_dict['name'] = template.name
|
||||||
template_dict['username'] = template.username
|
|
||||||
template_dict['password'] = template.password
|
|
||||||
template_dict['disk'] = template.disk
|
template_dict['disk'] = template.disk
|
||||||
return template_dict
|
return template_dict
|
||||||
|
|
||||||
|
@ -194,13 +190,10 @@ def delete_allowed_user(db, user):
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def set_template_info(db, template_id, name, username, password, disk):
|
def set_template_info(db, template_id, name, disk):
|
||||||
if db.query(exists().where(Template.id == template_id, )).scalar():
|
if db.query(exists().where(Template.id == template_id, )).scalar():
|
||||||
template = db.query(Template).filter(Template.id == template_id,
|
template = db.query(Template).filter(Template.id == template_id,
|
||||||
).one()
|
).one()
|
||||||
template.name = name
|
template.name = name
|
||||||
template.username = username
|
|
||||||
if password:
|
|
||||||
template.password = password
|
|
||||||
template.disk = disk
|
template.disk = disk
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
@ -34,8 +34,6 @@ class Template(Base):
|
||||||
__tablename__ = 'template'
|
__tablename__ = 'template'
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String(32), nullable=False)
|
name = Column(String(32), nullable=False)
|
||||||
username = Column(Text, nullable=False)
|
|
||||||
password = Column(Text, nullable=False)
|
|
||||||
disk = Column(Integer, nullable=False)
|
disk = Column(Integer, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -10,8 +10,9 @@ def connect_proxmox():
|
||||||
proxmox = ProxmoxAPI(
|
proxmox = ProxmoxAPI(
|
||||||
host,
|
host,
|
||||||
user=app.config['PROXMOX_USER'],
|
user=app.config['PROXMOX_USER'],
|
||||||
password=app.config['PROXMOX_PASS'],
|
private_key_file='proxmox_ssh_key',
|
||||||
verify_ssl=False)
|
password=app.config['PROXMOX_SSH_KEY_PASS'],
|
||||||
|
backend='ssh_paramiko')
|
||||||
version = proxmox.version.get()
|
version = proxmox.version.get()
|
||||||
return proxmox
|
return proxmox
|
||||||
except:
|
except:
|
||||||
|
|
|
@ -129,3 +129,7 @@ table, th, td {
|
||||||
.swal-button--ok {
|
.swal-button--ok {
|
||||||
background-color: #4caf50;
|
background-color: #4caf50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#show-for-template {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
|
@ -390,6 +390,7 @@ $("#create-vm").click(function(){
|
||||||
const cores = document.getElementById('cores').value;
|
const cores = document.getElementById('cores').value;
|
||||||
const mem = document.getElementById('mem').value;
|
const mem = document.getElementById('mem').value;
|
||||||
const template = document.getElementById('template').value;
|
const template = document.getElementById('template').value;
|
||||||
|
const ssh_key = document.getElementById('ssh-key').value;
|
||||||
const iso = document.getElementById('iso').value;
|
const iso = document.getElementById('iso').value;
|
||||||
const user = document.getElementById('user');
|
const user = document.getElementById('user');
|
||||||
const max_cpu = $(this).data('max_cpu');
|
const max_cpu = $(this).data('max_cpu');
|
||||||
|
@ -451,6 +452,7 @@ $("#create-vm").click(function(){
|
||||||
data.append('template', template);
|
data.append('template', template);
|
||||||
data.append('disk', disk);
|
data.append('disk', disk);
|
||||||
data.append('iso', iso);
|
data.append('iso', iso);
|
||||||
|
data.append('ssh_key', ssh_key);
|
||||||
if (user) {
|
if (user) {
|
||||||
data.append('user', user.value);
|
data.append('user', user.value);
|
||||||
}
|
}
|
||||||
|
@ -459,12 +461,10 @@ $("#create-vm").click(function(){
|
||||||
method: 'post',
|
method: 'post',
|
||||||
body: data
|
body: data
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
return response.text()
|
|
||||||
}).then((password) => {
|
|
||||||
if (template == 'none') {
|
if (template == 'none') {
|
||||||
var swal_text = `${name} is now being created. Check back soon and it should be good to go.`
|
var swal_text = `${name} is now being created. Check back soon and it should be good to go.`
|
||||||
} else {
|
} else {
|
||||||
var swal_text = `${name} is now being created. Check back soon and it should be good to go. The SSH credentials are your CSH username for the user and ${password} for the password. Save this password because you will not be able to retrieve it again!`
|
var swal_text = `${name} is now being created. Check back soon and it should be good to go. The SSH credentials are your CSH username for the user and the SSH key you provided.`
|
||||||
}
|
}
|
||||||
return swal(`${swal_text}`, {
|
return swal(`${swal_text}`, {
|
||||||
icon: "success",
|
icon: "success",
|
||||||
|
@ -753,16 +753,19 @@ $(".add-ignored-pool").click(function(){
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function hide_for_template(obj) {
|
function change_for_template(obj) {
|
||||||
var template_element = obj;
|
var template_element = obj;
|
||||||
var selected = template_element.options[template_element.selectedIndex].value;
|
var selected = template_element.options[template_element.selectedIndex].value;
|
||||||
var hide_area = document.getElementById('hide-for-template');
|
var hide_area = document.getElementById('hide-for-template');
|
||||||
|
var show_area = document.getElementById('show-for-template');
|
||||||
|
|
||||||
if(selected === 'none'){
|
if (selected === 'none') {
|
||||||
hide_area.style.display = 'block';
|
hide_area.style.display = 'block';
|
||||||
|
show_area.style.display = 'none';
|
||||||
}
|
}
|
||||||
else{
|
else {
|
||||||
hide_area.style.display = 'none';
|
hide_area.style.display = 'none';
|
||||||
|
show_area.style.display = 'block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -869,7 +872,6 @@ $(".resize-disk").click(function(){
|
||||||
$(".edit-template").click(function(){
|
$(".edit-template").click(function(){
|
||||||
const template_id = $(this).data('template_id');
|
const template_id = $(this).data('template_id');
|
||||||
const template_name = $(this).data('template_name');
|
const template_name = $(this).data('template_name');
|
||||||
const template_username = $(this).data('template_username');
|
|
||||||
const template_disk = $(this).data('template_disk');
|
const template_disk = $(this).data('template_disk');
|
||||||
var options = document.createElement('div');
|
var options = document.createElement('div');
|
||||||
name_text = document.createElement('p');
|
name_text = document.createElement('p');
|
||||||
|
@ -878,18 +880,6 @@ $(".edit-template").click(function(){
|
||||||
var name = document.createElement('input');
|
var name = document.createElement('input');
|
||||||
name.defaultValue = template_name;
|
name.defaultValue = template_name;
|
||||||
options.append(name);
|
options.append(name);
|
||||||
username_text = document.createElement('p');
|
|
||||||
username_text.innerHTML = 'Username';
|
|
||||||
options.append(username_text);
|
|
||||||
var username = document.createElement('input');
|
|
||||||
username.defaultValue = template_username;
|
|
||||||
options.append(username);
|
|
||||||
password_text = document.createElement('p');
|
|
||||||
password_text.innerHTML = 'Password';
|
|
||||||
options.append(password_text);
|
|
||||||
var password = document.createElement('input');
|
|
||||||
password.type = 'password';
|
|
||||||
options.append(password);
|
|
||||||
disk_text = document.createElement('p');
|
disk_text = document.createElement('p');
|
||||||
disk_text.innerHTML = 'Disk Size (GB)';
|
disk_text.innerHTML = 'Disk Size (GB)';
|
||||||
options.append(disk_text);
|
options.append(disk_text);
|
||||||
|
@ -917,8 +907,6 @@ $(".edit-template").click(function(){
|
||||||
if (willChange) {
|
if (willChange) {
|
||||||
var data = new FormData();
|
var data = new FormData();
|
||||||
data.append('name', $(name).val());
|
data.append('name', $(name).val());
|
||||||
data.append('username', $(username).val());
|
|
||||||
data.append('password', $(password).val());
|
|
||||||
data.append('disk', $(disk).val());
|
data.append('disk', $(disk).val());
|
||||||
fetch(`/template/${template_id}/edit`, {
|
fetch(`/template/${template_id}/edit`, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
|
|
|
@ -58,6 +58,7 @@ def create_vm_task(user, name, cores, memory, disk, iso):
|
||||||
get_next_ip(starrs, app.config['STARRS_IP_RANGE']))
|
get_next_ip(starrs, app.config['STARRS_IP_RANGE']))
|
||||||
job.meta['status'] = 'setting VM expiration'
|
job.meta['status'] = 'setting VM expiration'
|
||||||
job.save_meta()
|
job.save_meta()
|
||||||
|
delete_vm_expire(db, vmid)
|
||||||
get_vm_expire(db, vmid, app.config['VM_EXPIRE_MONTHS'])
|
get_vm_expire(db, vmid, app.config['VM_EXPIRE_MONTHS'])
|
||||||
job.meta['status'] = 'complete'
|
job.meta['status'] = 'complete'
|
||||||
job.save_meta()
|
job.save_meta()
|
||||||
|
@ -114,7 +115,7 @@ def generate_pool_cache_task():
|
||||||
store_pool_cache(db, pools)
|
store_pool_cache(db, pools)
|
||||||
|
|
||||||
|
|
||||||
def setup_template_task(template_id, name, user, password, cores, memory):
|
def setup_template_task(template_id, name, user, ssh_key, cores, memory):
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
job = get_current_job()
|
job = get_current_job()
|
||||||
proxmox = connect_proxmox()
|
proxmox = connect_proxmox()
|
||||||
|
@ -139,6 +140,11 @@ def setup_template_task(template_id, name, user, password, cores, memory):
|
||||||
vm = VM(vmid)
|
vm = VM(vmid)
|
||||||
vm.set_cpu(cores)
|
vm.set_cpu(cores)
|
||||||
vm.set_mem(memory)
|
vm.set_mem(memory)
|
||||||
|
print("[{}] Applying cloud-init config.".format(name))
|
||||||
|
job.meta['status'] = 'applying cloud-init'
|
||||||
|
vm.set_ci_user(user)
|
||||||
|
vm.set_ci_ssh_key(ssh_key)
|
||||||
|
vm.set_ci_network()
|
||||||
print(
|
print(
|
||||||
"[{}] Waiting for STARRS to propogate before starting VM.".format(
|
"[{}] Waiting for STARRS to propogate before starting VM.".format(
|
||||||
name))
|
name))
|
||||||
|
@ -149,46 +155,6 @@ def setup_template_task(template_id, name, user, password, cores, memory):
|
||||||
job.meta['status'] = 'starting VM'
|
job.meta['status'] = 'starting VM'
|
||||||
job.save_meta()
|
job.save_meta()
|
||||||
vm.start()
|
vm.start()
|
||||||
print("[{}] Waiting for VM to start before SSHing.".format(name))
|
|
||||||
job.meta['status'] = 'waiting for VM to start'
|
|
||||||
job.save_meta()
|
|
||||||
time.sleep(20)
|
|
||||||
print("[{}] Creating SSH session.".format(name))
|
|
||||||
job.meta['status'] = 'creating SSH session'
|
|
||||||
job.save_meta()
|
|
||||||
client = paramiko.SSHClient()
|
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
retry = 0
|
|
||||||
while retry < 30:
|
|
||||||
try:
|
|
||||||
client.connect(
|
|
||||||
ip,
|
|
||||||
username=template['username'],
|
|
||||||
password=template['password'])
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
retry += 1
|
|
||||||
time.sleep(3)
|
|
||||||
print("[{}] Running user creation commands.".format(name))
|
|
||||||
job.meta['status'] = 'running user creation commands'
|
|
||||||
job.save_meta()
|
|
||||||
stdin, stdout, stderr = client.exec_command("useradd {}".format(user))
|
|
||||||
exit_status = stdout.channel.recv_exit_status()
|
|
||||||
root_password = gen_password(32)
|
|
||||||
stdin, stdout, stderr = client.exec_command(
|
|
||||||
"echo '{}' | passwd root --stdin".format(root_password))
|
|
||||||
exit_status = stdout.channel.recv_exit_status()
|
|
||||||
stdin, stdout, stderr = client.exec_command(
|
|
||||||
"echo '{}' | passwd '{}' --stdin".format(password, user))
|
|
||||||
exit_status = stdout.channel.recv_exit_status()
|
|
||||||
stdin, stdout, stderr = client.exec_command(
|
|
||||||
"passwd -e '{}'".format(user))
|
|
||||||
exit_status = stdout.channel.recv_exit_status()
|
|
||||||
stdin, stdout, stderr = client.exec_command(
|
|
||||||
"echo '{} ALL=(ALL:ALL) ALL' | sudo EDITOR='tee -a' visudo".format(
|
|
||||||
user))
|
|
||||||
exit_status = stdout.channel.recv_exit_status()
|
|
||||||
client.close()
|
|
||||||
print("[{}] Template successfully provisioned.".format(name))
|
print("[{}] Template successfully provisioned.".format(name))
|
||||||
job.meta['status'] = 'completed'
|
job.meta['status'] = 'completed'
|
||||||
job.save_meta()
|
job.save_meta()
|
||||||
|
|
|
@ -39,8 +39,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="template" class="pull-left">Template</label>
|
<label for="template" class="pull-left">Template</label>
|
||||||
<select name="template" id="template" class="form-control" onchange="hide_for_template(this)">
|
<select name="template" id="template" class="form-control" onchange="change_for_template(this)">
|
||||||
<option value="none"></option>
|
<option value="none">None</option>
|
||||||
{% for template in templates %}
|
{% for template in templates %}
|
||||||
<option value="{{ template['id'] }}">{{ template['name'] }} ({{ template['disk'] }}GB)</option>
|
<option value="{{ template['id'] }}">{{ template['name'] }} ({{ template['disk'] }}GB)</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -61,6 +61,12 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="show-for-template">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ssh-key" class="pull-left">Public SSH Key</label>
|
||||||
|
<input type="text" name="ssh-key" id="ssh-key" class="form-control">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% if user['rtp'] %}
|
{% if user['rtp'] %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="user" class="pull-left">User</label>
|
<label for="user" class="pull-left">User</label>
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
<tr role="row">
|
<tr role="row">
|
||||||
<th>Proxmox ID</th>
|
<th>Proxmox ID</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Username</th>
|
|
||||||
<th>Disk Size (GB)</th>
|
<th>Disk Size (GB)</th>
|
||||||
<th>Delete</th>
|
<th>Delete</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -24,10 +23,9 @@
|
||||||
<tr role="row">
|
<tr role="row">
|
||||||
<td>{{ template['id'] }}</td>
|
<td>{{ template['id'] }}</td>
|
||||||
<td>{{ template['name'] }}</td>
|
<td>{{ template['name'] }}</td>
|
||||||
<td>{{ template['username'] }}</td>
|
|
||||||
<td>{{ template['disk'] }}</td>
|
<td>{{ template['disk'] }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-info edit-template" data-template_id="{{ template['id'] }}" data-template_name="{{ template['name'] }}" data-template_username="{{ template['username'] }}" data-template_disk="{{ template['disk'] }}">
|
<button class="btn btn-sm btn-info edit-template" data-template_id="{{ template['id'] }}" data-template_name="{{ template['name'] }}" data-template_disk="{{ template['disk'] }}">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-danger delete-template" data-template_id="{{ template['id'] }}">
|
<button class="btn btn-sm btn-danger delete-template" data-template_id="{{ template['id'] }}">
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
import urllib
|
||||||
from proxstar import db, starrs
|
from proxstar import db, starrs
|
||||||
from proxstar.db import get_vm_expire
|
from proxstar.db import get_vm_expire
|
||||||
from proxstar.util import lazy_property
|
from proxstar.util import lazy_property
|
||||||
|
@ -202,6 +203,19 @@ class VM(object):
|
||||||
def expire(self):
|
def expire(self):
|
||||||
return get_vm_expire(db, self.id, app.config['VM_EXPIRE_MONTHS'])
|
return get_vm_expire(db, self.id, app.config['VM_EXPIRE_MONTHS'])
|
||||||
|
|
||||||
|
def set_ci_user(self, user):
|
||||||
|
proxmox = connect_proxmox()
|
||||||
|
proxmox.nodes(self.node).qemu(self.id).config.put(ciuser=user)
|
||||||
|
|
||||||
|
def set_ci_ssh_key(self, ssh_key):
|
||||||
|
proxmox = connect_proxmox()
|
||||||
|
escaped_key = urllib.parse.quote(ssh_key, safe='')
|
||||||
|
proxmox.nodes(self.node).qemu(self.id).config.put(sshkey=escaped_key)
|
||||||
|
|
||||||
|
def set_ci_network(self):
|
||||||
|
proxmox = connect_proxmox()
|
||||||
|
proxmox.nodes(self.node).qemu(self.id).config.put(ipconfig0='ip=dhcp')
|
||||||
|
|
||||||
|
|
||||||
def create_vm(proxmox, user, name, cores, memory, disk, iso):
|
def create_vm(proxmox, user, name, cores, memory, disk, iso):
|
||||||
node = proxmox.nodes(get_node_least_mem(proxmox))
|
node = proxmox.nodes(get_node_least_mem(proxmox))
|
||||||
|
@ -237,7 +251,7 @@ def clone_vm(proxmox, template_id, name, pool):
|
||||||
name=name,
|
name=name,
|
||||||
pool=pool,
|
pool=pool,
|
||||||
full=1,
|
full=1,
|
||||||
description='Managed by Proxstar',
|
description='Managed\ by\ Proxstar',
|
||||||
target=target)
|
target=target)
|
||||||
retry = 0
|
retry = 0
|
||||||
while retry < 60:
|
while retry < 60:
|
||||||
|
|
|
@ -80,6 +80,7 @@ def start_ssh_tunnel(node, port):
|
||||||
node,
|
node,
|
||||||
ssh_username='root',
|
ssh_username='root',
|
||||||
ssh_pkey='proxmox_ssh_key',
|
ssh_pkey='proxmox_ssh_key',
|
||||||
|
ssh_private_key_password=app.config['PROXMOX_SSH_KEY_PASS'],
|
||||||
remote_bind_address=('127.0.0.1', port),
|
remote_bind_address=('127.0.0.1', port),
|
||||||
local_bind_address=('127.0.0.1', port))
|
local_bind_address=('127.0.0.1', port))
|
||||||
server.start()
|
server.start()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
flask
|
flask
|
||||||
flask_pyoidc
|
flask_pyoidc
|
||||||
proxmoxer
|
git+https://github.com/merinos/proxmoxer.git@python3#egg=bad01313d9f32a239882195325b21767a25f72e0
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
python-dateutil
|
python-dateutil
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue