add host batch sync

pull/342/head
vapao 2021-07-05 00:54:08 +08:00
parent f74293c286
commit d86cc16e43
8 changed files with 171 additions and 18 deletions

View File

@ -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),
] ]

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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>
);
})

View File

@ -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}>

View File

@ -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>

View File

@ -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;