mirror of https://github.com/openspug/spug
add host batch sync
parent
f74293c286
commit
d86cc16e43
|
@ -16,4 +16,5 @@ urlpatterns = [
|
||||||
path('import/cloud/', cloud_import),
|
path('import/cloud/', cloud_import),
|
||||||
path('import/region/', get_regions),
|
path('import/region/', get_regions),
|
||||||
path('parse/', post_parse),
|
path('parse/', post_parse),
|
||||||
|
path('valid/', batch_valid),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
# Copyright: (c) <spug.dev@gmail.com>
|
# Copyright: (c) <spug.dev@gmail.com>
|
||||||
# Released under the AGPL-3.0 License.
|
# Released under the AGPL-3.0 License.
|
||||||
|
from django_redis import get_redis_connection
|
||||||
from libs.helper import make_ali_request, make_tencent_request
|
from libs.helper import make_ali_request, make_tencent_request
|
||||||
from libs.ssh import SSH, AuthenticationException
|
from libs.ssh import SSH, AuthenticationException
|
||||||
from libs.utils import AttrDict, human_datetime
|
from libs.utils import AttrDict, human_datetime
|
||||||
from apps.host.models import HostExtend
|
from apps.host.models import HostExtend
|
||||||
|
from apps.setting.utils import AppSetting
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from concurrent import futures
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
def check_os_type(os_name):
|
def check_os_type(os_name):
|
||||||
|
@ -176,22 +180,6 @@ def fetch_tencent_instances(ak, ac, region_id, page_number=1):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def sync_host_extend(host, private_key, public_key, password=None):
|
|
||||||
kwargs = host.to_dict(selects=('hostname', 'port', 'username'))
|
|
||||||
ssh = _get_ssh(kwargs, host.pkey, private_key, public_key, password)
|
|
||||||
form = AttrDict(fetch_host_extend(ssh))
|
|
||||||
form.disk = json.dumps(form.disk)
|
|
||||||
form.public_ip_address = json.dumps(form.public_ip_address)
|
|
||||||
form.private_ip_address = json.dumps(form.private_ip_address)
|
|
||||||
form.updated_at = human_datetime()
|
|
||||||
form.os_type = check_os_type(form.os_name)
|
|
||||||
if hasattr(host, 'hostextend'):
|
|
||||||
extend = host.hostextend
|
|
||||||
extend.update_by_dict(form)
|
|
||||||
else:
|
|
||||||
HostExtend.objects.create(host=host, **form)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_host_extend(ssh):
|
def fetch_host_extend(ssh):
|
||||||
commands = [
|
commands = [
|
||||||
"lscpu | grep '^CPU(s)' | awk '{print $2}'",
|
"lscpu | grep '^CPU(s)' | awk '{print $2}'",
|
||||||
|
@ -223,6 +211,40 @@ def fetch_host_extend(ssh):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def batch_sync_host(token, hosts, password, ):
|
||||||
|
private_key, public_key = AppSetting.get_ssh_key()
|
||||||
|
threads, latest_exception, rds = [], None, get_redis_connection()
|
||||||
|
max_workers = min(10, os.cpu_count() * 4)
|
||||||
|
with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
for host in hosts:
|
||||||
|
t = executor.submit(_sync_host_extend, host, private_key, public_key, password)
|
||||||
|
t.h_id = host.id
|
||||||
|
threads.append(t)
|
||||||
|
for t in futures.as_completed(threads):
|
||||||
|
exception = t.exception()
|
||||||
|
if exception:
|
||||||
|
rds.rpush(token, json.dumps({'key': t.h_id, 'status': 'fail', 'message': f'{exception}'}))
|
||||||
|
else:
|
||||||
|
rds.rpush(token, json.dumps({'key': t.h_id, 'status': 'ok'}))
|
||||||
|
rds.expire(token, 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_host_extend(host, private_key, public_key, password=None):
|
||||||
|
kwargs = host.to_dict(selects=('hostname', 'port', 'username'))
|
||||||
|
ssh = _get_ssh(kwargs, host.pkey, private_key, public_key, password)
|
||||||
|
form = AttrDict(fetch_host_extend(ssh))
|
||||||
|
form.disk = json.dumps(form.disk)
|
||||||
|
form.public_ip_address = json.dumps(form.public_ip_address)
|
||||||
|
form.private_ip_address = json.dumps(form.private_ip_address)
|
||||||
|
form.updated_at = human_datetime()
|
||||||
|
form.os_type = check_os_type(form.os_name)
|
||||||
|
if hasattr(host, 'hostextend'):
|
||||||
|
extend = host.hostextend
|
||||||
|
extend.update_by_dict(form)
|
||||||
|
else:
|
||||||
|
HostExtend.objects.create(host=host, **form)
|
||||||
|
|
||||||
|
|
||||||
def _get_ssh(kwargs, pkey=None, private_key=None, public_key=None, password=None):
|
def _get_ssh(kwargs, pkey=None, private_key=None, public_key=None, password=None):
|
||||||
try:
|
try:
|
||||||
if pkey:
|
if pkey:
|
||||||
|
@ -233,6 +255,7 @@ def _get_ssh(kwargs, pkey=None, private_key=None, public_key=None, password=None
|
||||||
ssh = SSH(password=str(password), **kwargs)
|
ssh = SSH(password=str(password), **kwargs)
|
||||||
ssh.add_public_key(public_key)
|
ssh.add_public_key(public_key)
|
||||||
return _get_ssh(kwargs, private_key)
|
return _get_ssh(kwargs, private_key)
|
||||||
except AuthenticationException:
|
except AuthenticationException as e:
|
||||||
if password:
|
if password:
|
||||||
return _get_ssh(kwargs, None, public_key, public_key, password)
|
return _get_ssh(kwargs, None, public_key, public_key, password)
|
||||||
|
raise e
|
||||||
|
|
|
@ -8,12 +8,15 @@ from libs import json_response, JsonParser, Argument, AttrDict
|
||||||
from apps.setting.utils import AppSetting
|
from apps.setting.utils import AppSetting
|
||||||
from apps.account.utils import get_host_perms
|
from apps.account.utils import get_host_perms
|
||||||
from apps.host.models import Host, Group
|
from apps.host.models import Host, Group
|
||||||
|
from apps.host.utils import batch_sync_host
|
||||||
from apps.app.models import Deploy
|
from apps.app.models import Deploy
|
||||||
from apps.schedule.models import Task
|
from apps.schedule.models import Task
|
||||||
from apps.monitor.models import Detection
|
from apps.monitor.models import Detection
|
||||||
from libs.ssh import SSH, AuthenticationException
|
from libs.ssh import SSH, AuthenticationException
|
||||||
from paramiko.ssh_exception import BadAuthenticationType
|
from paramiko.ssh_exception import BadAuthenticationType
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
from threading import Thread
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class HostView(View):
|
class HostView(View):
|
||||||
|
@ -149,3 +152,19 @@ def post_parse(request):
|
||||||
return json_response(data.decode())
|
return json_response(data.decode())
|
||||||
else:
|
else:
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
|
||||||
|
def batch_valid(request):
|
||||||
|
form, error = JsonParser(
|
||||||
|
Argument('password', required=False),
|
||||||
|
Argument('range', filter=lambda x: x in ('1', '2'), help='参数错误')
|
||||||
|
).parse(request.body)
|
||||||
|
if error is None:
|
||||||
|
if form.range == '1': # all hosts
|
||||||
|
hosts = Host.objects.all()
|
||||||
|
else:
|
||||||
|
hosts = Host.objects.filter(is_verified=False).all()
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
Thread(target=batch_sync_host, args=(token, hosts, form.password)).start()
|
||||||
|
return json_response({'token': token, 'hosts': {x.id: {'name': x.name} for x in hosts}})
|
||||||
|
return json_response(error=error)
|
||||||
|
|
|
@ -46,6 +46,8 @@ class ComConsumer(WebsocketConsumer):
|
||||||
self.key = f'{settings.BUILD_KEY}:{token}'
|
self.key = f'{settings.BUILD_KEY}:{token}'
|
||||||
elif module == 'request':
|
elif module == 'request':
|
||||||
self.key = f'{settings.REQUEST_KEY}:{token}'
|
self.key = f'{settings.REQUEST_KEY}:{token}'
|
||||||
|
elif module == 'host':
|
||||||
|
self.key = token
|
||||||
else:
|
else:
|
||||||
raise TypeError(f'unknown module for {module}')
|
raise TypeError(f'unknown module for {module}')
|
||||||
self.rds = get_redis_connection()
|
self.rds = get_redis_connection()
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||||
|
* Copyright (c) <spug.dev@gmail.com>
|
||||||
|
* Released under the AGPL-3.0 License.
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { observer } from 'mobx-react';
|
||||||
|
import { Modal, Form, Input, Button, Radio } from 'antd';
|
||||||
|
import { LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import { http, X_TOKEN } from 'libs';
|
||||||
|
import store from './store';
|
||||||
|
|
||||||
|
export default observer(function () {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [password, setPassword] = useState();
|
||||||
|
const [range, setRange] = useState('2');
|
||||||
|
const [hosts, setHosts] = useState({});
|
||||||
|
const [token, setToken] = useState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
let index = 0;
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/host/${token}/?x-token=${X_TOKEN}`);
|
||||||
|
socket.onopen = () => socket.send(String(index));
|
||||||
|
socket.onmessage = e => {
|
||||||
|
if (e.data === 'pong') {
|
||||||
|
socket.send(String(index))
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
const {key, status, message} = JSON.parse(e.data);
|
||||||
|
hosts[key]['status'] = status;
|
||||||
|
hosts[key]['message'] = message;
|
||||||
|
setHosts({...hosts})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => socket && socket.close()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setLoading(true);
|
||||||
|
http.post('/api/host/valid/', {password, range})
|
||||||
|
.then(res => {
|
||||||
|
setHosts(res.hosts);
|
||||||
|
setToken(res.token);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
store.showSync();
|
||||||
|
store.fetchRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
const unVerifiedLength = store.records.filter(x => !x.is_verified).length;
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible
|
||||||
|
maskClosable={false}
|
||||||
|
title="批量验证(同步)"
|
||||||
|
okText="导入"
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}>
|
||||||
|
<Form hidden={token} labelCol={{span: 6}} wrapperCol={{span: 14}}>
|
||||||
|
<Form.Item name="password" label="默认密码" tooltip="会被用于未验证主机的验证。">
|
||||||
|
<Input.Password value={password} onChange={e => setPassword(e.target.value)}/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="选择主机" tooltip="要批量验证/同步哪些主机,全部主机或仅未验证主机。" extra="将会覆盖已有的扩展信息(CPU、内存、磁盘等)。">
|
||||||
|
<Radio.Group
|
||||||
|
value={range}
|
||||||
|
onChange={e => setRange(e.target.value)}
|
||||||
|
options={[
|
||||||
|
{label: `全部(${store.records.length})`, value: '1'},
|
||||||
|
{label: `未验证(${unVerifiedLength})`, value: '2'}
|
||||||
|
]}
|
||||||
|
optionType="button"/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item wrapperCol={{span: 14, offset: 6}}>
|
||||||
|
<Button loading={loading} type="primary" onClick={handleSubmit}>提交验证</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Form hidden={!token} labelCol={{span: 8}} wrapperCol={{span: 14}}>
|
||||||
|
{Object.entries(hosts).map(([key, item]) => (
|
||||||
|
<Form.Item key={key} label={item.name} help={item.message}>
|
||||||
|
{item.status === 'ok' && <span style={{color: "#52c41a"}}>成功</span>}
|
||||||
|
{item.status === 'fail' && <span style={{color: "red"}}>失败</span>}
|
||||||
|
{item.status === undefined && <LoadingOutlined style={{fontSize: 20}}/>}
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
})
|
|
@ -6,7 +6,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { Table, Modal, Dropdown, Button, Menu, Avatar, Tooltip, Space, Tag, Radio, message } from 'antd';
|
import { Table, Modal, Dropdown, Button, Menu, Avatar, Tooltip, Space, Tag, Radio, message } from 'antd';
|
||||||
import { PlusOutlined, DownOutlined } from '@ant-design/icons';
|
import { PlusOutlined, DownOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
import { Action, TableCard, AuthButton, AuthFragment } from 'components';
|
import { Action, TableCard, AuthButton, AuthFragment } from 'components';
|
||||||
import { http, hasPermission } from 'libs';
|
import { http, hasPermission } from 'libs';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
@ -58,6 +58,11 @@ function ComTable() {
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined/>}
|
icon={<PlusOutlined/>}
|
||||||
onClick={() => store.showForm()}>新建</AuthButton>,
|
onClick={() => store.showForm()}>新建</AuthButton>,
|
||||||
|
<AuthButton
|
||||||
|
auth="host.host.add"
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined/>}
|
||||||
|
onClick={() => store.showSync()}>批量验证</AuthButton>,
|
||||||
<AuthFragment auth="host.host.import">
|
<AuthFragment auth="host.host.import">
|
||||||
<Dropdown overlay={(
|
<Dropdown overlay={(
|
||||||
<Menu onClick={handleImport}>
|
<Menu onClick={handleImport}>
|
||||||
|
|
|
@ -13,6 +13,7 @@ import ComTable from './Table';
|
||||||
import ComForm from './Form';
|
import ComForm from './Form';
|
||||||
import ComImport from './Import';
|
import ComImport from './Import';
|
||||||
import CloudImport from './CloudImport';
|
import CloudImport from './CloudImport';
|
||||||
|
import BatchSync from './BatchSync';
|
||||||
import Detail from './Detail';
|
import Detail from './Detail';
|
||||||
import Selector from './Selector';
|
import Selector from './Selector';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
@ -46,6 +47,7 @@ export default observer(function () {
|
||||||
{store.formVisible && <ComForm/>}
|
{store.formVisible && <ComForm/>}
|
||||||
{store.importVisible && <ComImport/>}
|
{store.importVisible && <ComImport/>}
|
||||||
{store.cloudImport && <CloudImport/>}
|
{store.cloudImport && <CloudImport/>}
|
||||||
|
{store.syncVisible && <BatchSync/>}
|
||||||
{store.selectorVisible &&
|
{store.selectorVisible &&
|
||||||
<Selector oneGroup={!store.addByCopy} onCancel={() => store.selectorVisible = false} onOk={store.updateGroup}/>}
|
<Selector oneGroup={!store.addByCopy} onCancel={() => store.selectorVisible = false} onOk={store.updateGroup}/>}
|
||||||
</AuthDiv>
|
</AuthDiv>
|
||||||
|
|
|
@ -21,6 +21,7 @@ class Store {
|
||||||
@observable isFetching = false;
|
@observable isFetching = false;
|
||||||
@observable formVisible = false;
|
@observable formVisible = false;
|
||||||
@observable importVisible = false;
|
@observable importVisible = false;
|
||||||
|
@observable syncVisible = false;
|
||||||
@observable cloudImport = null;
|
@observable cloudImport = null;
|
||||||
@observable detailVisible = false;
|
@observable detailVisible = false;
|
||||||
@observable selectorVisible = false;
|
@observable selectorVisible = false;
|
||||||
|
@ -94,6 +95,10 @@ class Store {
|
||||||
this.record = info
|
this.record = info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showSync = () => {
|
||||||
|
this.syncVisible = !this.syncVisible
|
||||||
|
}
|
||||||
|
|
||||||
showDetail = (info) => {
|
showDetail = (info) => {
|
||||||
this.record = info;
|
this.record = info;
|
||||||
this.detailVisible = true;
|
this.detailVisible = true;
|
||||||
|
|
Loading…
Reference in New Issue