Python實現跨平臺運維小神器

(本文已不再同步更新,最新代碼請移步github

這陣子一直在學python,碰巧最近想把線上服務器環境做一些規範化/統一化,於是便萌生了用python寫一個小工具的衝動。就功能方面來說,基本上是在“重複造輪子”吧,但是當我用這小工具完成了30多臺服務器從系統層面到應用層面的一些規範化工作之後,覺得效果還不算那麼low(高手可忽略這句話~~),這纔敢拿出來跟小夥伴們分享一下。

(注:筆者所用爲python版本爲3.5,其他版本未經測試~~)

現在主要功能包括:

  1. 可批量執行遠程命令,上傳下載文件
  2. 支持多線程併發執行(對於某些耗時的命令或上傳文件,可大大減少等待時間)
  3. 嚴格模式(批量執行中若某一臺server執行錯誤則退出)和非嚴格模式
  4. 上傳下載文件實現了類似rsync的機制
  5. 完善的命令行提示
  6. 跨平臺,Linux和Windows均可

大致實現思路如下:

  •     外部包依賴docopt和paramiko
  •     有一個server信息文件,內容格式爲 : “主機名-IP:端口”。腳本讀取此文件來決定要對哪些server進行操作(該文件內部支持#註釋掉某些server)
  •     採用了docopt提供命令行界面
  •     paramiko模塊實現遠程命令和sftp客戶端功能。這裏paramiko的sftp實例其只包含了基本的單個文件傳輸功能;並且不保存文件相關時間信息。
  •     paramiko 通過sftp實例傳輸文件環節,這裏額外實現“保持文件時間信息”和“實現目錄傳輸”以及“實現類似rsync的傳輸機制”是要考慮很多問題和邏輯的。傳輸機制模仿rsync的默認機制,檢查文件的mtime和size,有差異才會真正傳輸。
  •     實現了參數中原路徑和目標路徑的自動判斷,例如傳輸目錄時不要求路徑後面加‘/’
  •     對於遠程命令(cmd),可以通過設置(--skip-err)跳過某些server的錯誤繼續執行。例如批量執行‘ls’命令,一般情況會因爲某些server上不存在而報錯退出
  •     全面的錯誤信息提示。對於執行中的幾乎所有可能出現的錯誤,都有捕獲機制獲取並輸出

下面先來看一些基本的使用截圖吧

幫助信息:

批量執行遠程命令:


上傳:

下載:


其實批量執行命令,傳輸文件在Linux上用shell也是可以很好的實現(而且ssh或rsync等也肯定比這套腳本功能點更多),但是考慮到併發執行以及在Linux和win平臺的通用性,用Python來實現就有必要了。尤其是想在Win客戶端和Linux服務器之間模仿rsync機制傳輸文件時,這個腳本就能派上用場了。


接下來直接看代碼吧(我的老(lan)習慣,代碼裏註釋還算詳細,所以我就懶得再解釋那麼多嘍)

#!/bin/env python3
# coding:utf-8
"""
Usage:
  auto_task [options] cmd <command> [--skip-err] [--parallel]
  auto_task [options] put <src> <dst> [--parallel]
  auto_task [options] get <src> <dst>


Options:
  -h --help             Show this screen.
  -u <user>             Remote username [default: root]
  -p <password>         User's password
  --pkey <private-key>  Local private key [default: /root/.ssh/id_rsa]
  --server <server_info_file>  
                        File include the remote server's information,
                        With the format of 'name-ip:port', such as 'web1-192.168.1.100:22',one server one line.
  --skip-err            Use with cmd, if sikp any server's error and continue process the other servers [default: False].
  --parallel            Parallel execution, only use with cmd or put. This option implies the --skip-err [default: False].

  cmd                   Run command on remote server(s),multiple commands sperate by ';'
  put                   Transfer from local to remote. Transport mechanism similar to rsync.
  get                   Transfer from remote to local. Transport mechanism similar to rsync.

  Notice:       cmd, get, put can only use one at once
  For Windows:  always use double quotes for quote something;
                it's highly recommend that with get or put in Windows,always use '/' instead of '\\'
"""

"""
by ljk 20160704
update at 2017011,20170320
"""
from docopt import docopt
from paramiko import SSHClient, AutoAddPolicy
from os import path, walk, makedirs, stat, utime
from re import split, match, search
from sys import exit, stdout
import platform
from math import floor
import threading

"""
因爲涉及了(多)線程,所以我們將串行也歸爲單線程,這樣可以統一用線程的一些思路,而不必編寫一套多線程模型一套串行模型。
也因爲多線程,所以輸出用print()的話,各server的輸出會對不上號,所以引入了OutputText類,將每個server的輸出統一保存起來,最後打印出來
但是這樣依然無法避免多個線程同時完成了,同時打印各自的最終結果。也就是說多線程任務最終需要輸出時,輸出這個動作必須要串行
"""


class OutputText:
    """該類的對象具有write()方法,用來存儲每臺server的執行結果.
    因爲引入了多線程異步執行才需要這麼做,以保證異步執行多臺server的輸出不會亂.
    爲了簡潔,並行與串行的輸出就都用這一套東西了"""
    def __init__(self):
        self.buffer = []

    def write(self, *args, color=None):
        if color:
            if platform.uname().system == 'Windows':
                self.buffer.extend(args)
            else:
                self.buffer.extend('\033[0;{}m'.format(color))
                self.buffer.extend(args)
                self.buffer.extend('\033[0m')
        else:
            self.buffer.extend(args)

    def print_lock(self):
        """併發模式下,所有的輸出動作都要加鎖"""
        global_lock.acquire()
        for line in self.buffer:
            print(line, end='')
        global_lock.release()


def print_color(text, color=31, sep=' ', end='\n', file=stdout, flush=False):
    """打印彩色字體,color默認爲紅色
    該方法只針對Linux有效"""
    if platform.uname().system == 'Windows':
        print(text, sep=sep, end=end, file=file, flush=flush)
    else:
        print('\033[0;{}m'.format(color), end='')
        print(text, sep=sep, end=end, file=file, flush=flush)
        print('\033[0m', end='')


def get_ip_port(fname):
    """從制定文件(特定格式)中,取得主機名/主機ip/端口
    output:存儲輸出的對象"""
    try:
        with open(fname, 'r') as fobj:
            for line in fobj.readlines():
                if line != '\n' and not match('#', line):  # 過濾空行和註釋行
                    list_tmp = split('[-:]', line)
                    server_name = list_tmp[0]
                    server_ip = list_tmp[1]
                    port = int(list_tmp[2])
                    yield (server_name, server_ip, port)
    except Exception as err:
        print_color('{}\n'.format(err))
        exit(10)


def create_sshclient(server_ip, port, output):
    """根據命令行提供的參數,建立到遠程server的ssh鏈接.這段本應在run_command()函數內部。
    摘出來的目的是爲了讓sftp功能也通過sshclient對象來創建sftp對象,因爲初步觀察t.connect()方法在使用key時有問題
    output:存儲輸出的對象"""
    local_client = threading.local()  # 多線程中每個線程要在函數內某些保持自己特定值
    local_client.client = SSHClient()
    local_client.client.set_missing_host_key_policy(AutoAddPolicy())
    try:
        local_client.client.connect(server_ip, port=port, username=arguments['-u'], password=arguments['-p'], key_filename=arguments['--pkey'])
    except Exception as err:  # 有異常,打印異常,並返回'error'
        output.write('{}----{} ssh connect error: {}\n'.format(' ' * 4, server_ip, err), color=31)
        return 'error'
    else:
        return local_client.client  # 返回的client對象在每個線程內是不同的


# ----------
# run_command()執行遠程命令
# ----------
def run_command(client, output):
    """
    執行遠程命令的主函數
    client: paramiko.client.SSHClient object
    output: 存儲輸出的對象
    """
    # stdout 假如通過分號提供單行的多條命令,所有命令的輸出(在linux終端會輸出的內容)都會存儲於stdout
    # 據觀察,下面三個變量的特點是無論"如何引用過一次"之後,其內容就會清空
    # 有readlines()的地方都是流,用過之後就沒有了
    stdin, stdout, stderr = client.exec_command(arguments['<command>'])
    copy_out, copy_err = stdout.readlines(), stderr.readlines()
    if len(copy_out) and len(copy_err):
        output.write('%s----result:\n' % (' ' * 8))
        for i in copy_out:
            output.write('%s%s' % (' ' * 12, i))
        for i in copy_err:
            output.write('%s%s' % (' ' * 12, i), color=31)
        if not arguments['--skip-err']:    # 忽略命令執行錯誤的情況
            output.print_lock()
            exit(10)
    elif len(copy_out):
        output.write('%s----result:\n' % (' ' * 8))
        for i in copy_out:
            output.write('%s%s' % (' ' * 12, i))
    elif len(copy_err):
        output.write('%s----error:\n' % (' ' * 8), color=31)
        for i in copy_err:
            output.write('%s%s' % (' ' * 12, i), color=31)
        if not arguments['--skip-err']:
            client.close()
            output.print_lock()
            exit(10)
    client.close()


# ----------
# sftp_transfer() 遠程傳輸文件的主函數
# ----------
def sftp_transfer(source_path, destination_path, method, client, output):
    """
    文件傳輸的 主函數
    paramiko的sftp client傳輸,只能單個文件作爲參數,並且不會保留文件的時間信息,這兩點都需要代碼裏額外處理
    client: paramiko.client.SSHClient object
    output:存儲輸出的對象
    """
    sftp = client.open_sftp()
    
    if platform.system() == 'Windows':
        '''根據put或get,將windows路徑中的 \ 分隔符替換爲 / '''
        if arguments["put"]:
            source_path = source_path.replace('\\', '/')
        elif arguments["get"]:
            destination_path = destination_path.replace('\\', '/')

    # -----下面定義sftp_transfer()函數所需的一些子函數-----
    def process_arg_dir(target):
        """處理目錄時,檢查用戶輸入,在路徑後面加上/"""
        if not target.endswith('/'):
            target = target + '/'
        return target

    def sftp_put(src, dst, space):
        """封裝put,增加相應輸出,並依據m_time和size判斷兩端文件一致性,決定是否傳輸該文件"""
        if check_remote_path(dst) == 'file':
            src_stat = stat(src)
            dst_stat = sftp.stat(dst)
        else:
            src_stat = ''
            dst_stat = ''
        if (src_stat == '' and dst_stat == '') or not (floor(src_stat.st_mtime) == dst_stat.st_mtime and src_stat.st_size == dst_stat.st_size):
            try:
                sftp.put(src, dst)
                output.write('%s%s\n' % (' ' * space, src))
            except Exception as err:
                output.write('%s----Uploading %s Failed\n' % (' ' * (space-4), src), color=31)
                output.write('{}----{}\n'.format(' ' * (space-4), err), color=31)
                client.close()
                output.print_lock()
                exit(10)

    def sftp_get(src, dst, space):
        """封裝get,增加相應輸出,並依據m_time和size判斷兩端文件一致性,決定是否傳輸該文件"""
        if path.isfile(dst):
            src_stat = sftp.stat(src)
            dst_stat = stat(dst)
        else:
            src_stat = ''
            dst_stat = ''
        if (src_stat == '' and dst_stat == '') or not (src_stat.st_mtime == floor(dst_stat.st_mtime) and src_stat.st_size == dst_stat.st_size):
            try:
                sftp.get(src, dst)
                output.write('%s%s\n' % (' ' * space, src))
            except Exception as err:
                output.write('%s----Downloading %s Failed\n' % (' ' * (space-4), src), color=31)
                output.write('{}----{}\n'.format(' ' * (space-4), err), color=31)
                client.close()
                output.print_lock()
                exit(10)

    def sftp_transfer_rcmd(cmd=None, space=None):
        """
        在文件傳輸功能中,有些時候需要在遠程執行一些命令來獲取某些信息
        client: paramiko.client.SSHClient object
        output:存儲輸出的對象
        """
        stdin, stdout, stderr = client.exec_command(cmd)
        copy_out, copy_err = stdout.readlines(), stderr.readlines()
        if len(copy_err):
            for i in copy_err:
                output.write('%s----%s' % (' ' * space, i), color=31)
            output.print_lock()
            exit(10)
        elif len(copy_out):
            return copy_out

    def check_remote_path(r_path):
        """通過client對象在遠程linux執行命令,來判斷遠程路徑是否存在,是文件還是目錄"""
        check_cmd = 'if [ -e {0} ];then if [ -d {0} ];then echo directory;elif [ -f {0} ];then echo file;fi;else echo no_exist;fi'.format(r_path)
        # check_cmd命令會有三種‘正常輸出’directory  file  no_exist
        check_result = sftp_transfer_rcmd(cmd=check_cmd)[0].strip('\n')
        if check_result == 'directory':
            return 'directory'
        elif check_result == 'file':
            return 'file'
        else:
            return 'no_exist'

    def file_time(target, location):
        """獲取源文件的atime和mtime"""
        if location == 'local':
            target_stat = stat(target)
        elif location == 'remote':
            target_stat = sftp.stat(target)
        return target_stat.st_atime, target_stat.st_mtime

    def create_dir(target, location, space):
        """將創建目錄的代碼集中到一個函數"""
        if location == 'local':
            try:
                output.write('%s----Create Local Dir: %s\n' % (' ' * space, target))
                makedirs(target)
            except Exception as err:
                # print_color('%s----%s' % (' ' * space, str(err)))
                output.write('%s----%s\n' % (' ' * space, str(err)), color=31)
                output.print_lock()
                exit(10)
        elif location == 'remote':
            output.write('%s----Create Remote Dir: %s\n' % (' ' * space, target))
            sftp_transfer_rcmd(cmd='mkdir -p {}'.format(target), space=space)
    # -----子函數定義完畢-----

    # -----上傳邏輯-----
    if method == 'put':
        output.write('%s----Uploading %s TO %s\n' % (' ' * 4, source_path, destination_path))
        if path.isfile(source_path):
            '''判斷src是文件'''
            check_remote_path_result = check_remote_path(destination_path)
            if check_remote_path_result == 'file':
                pass
            elif check_remote_path_result == 'directory':  # dst經判斷爲目錄
                destination_path = process_arg_dir(destination_path) + path.basename(source_path)
            else:
                if not check_remote_path(path.dirname(destination_path)) == 'directory':
                    create_dir(path.dirname(destination_path), 'remote', 8)
                if destination_path.endswith('/') or destination_path.endswith('\\'):
                    destination_path = destination_path + path.basename(source_path)

            sftp_put(source_path, destination_path, 12)
            sftp.utime(destination_path, file_time(source_path, 'local'))
        elif path.isdir(source_path):
            '''判斷src是目錄'''
            if check_remote_path(destination_path) == 'file':
                output.write('%s----%s is file\n' % (' ' * 8, destination_path), color=31)
                output.print_lock()
                exit(10)
            source_path, destination_path = process_arg_dir(source_path), process_arg_dir(destination_path)
            for root, dirs, files in walk(source_path):
                '''通過 os.walk()函數取得目錄下的所有文件,此函數默認包含 . ..的文件/目錄,需要去掉'''
                for file_name in files:
                    s_file = path.join(root, file_name)  # 逐級取得每個sftp client端文件的全路徑
                    if not search('.*/\..*', s_file):
                        '''過濾掉路徑中包含以.開頭的目錄或文件'''
                        d_file = s_file.replace(source_path, destination_path, 1)  # 由local_file取得每個遠程文件的全路徑
                        d_path = path.dirname(d_file)
                        if check_remote_path(d_path) == 'directory':
                            sftp_put(s_file, d_file, 12)
                        else:
                            create_dir(d_path, 'remote', 8)
                            sftp_put(s_file, d_file, 12)

                        sftp.utime(d_file, file_time(s_file, 'local'))
        else:
            output.write('%s%s is not exist\n' % (' ' * 8, source_path), color=31)
            output.print_lock()
            exit(10)

    # -----下載邏輯-----
    elif method == 'get':
        output.write('%s----Downloading %s TO %s\n' % (' ' * 4, source_path, destination_path))
        check_remote_path_result = check_remote_path(source_path)

        if check_remote_path_result == 'file':
            '''判斷source_path是文件'''
            if path.isfile(destination_path):  # destination_path爲文件
                pass
            elif path.isdir(destination_path):  # destination_path爲目錄
                destination_path = process_arg_dir(destination_path) + path.basename(source_path)
            else:
                if not path.isdir(path.dirname(destination_path)):
                    create_dir(path.dirname(destination_path), 'local', 8)
                if destination_path.endswith('/') or destination_path.endswith('\\'):
                    destination_path = destination_path + path.basename(source_path)

            sftp_get(source_path, destination_path, 12)
            utime(destination_path, file_time(source_path, 'remote'))
        elif check_remote_path_result == 'directory':
            '''判斷source_path是目錄'''
            if path.isfile(destination_path):
                output.write('%s----%s is file\n' % (' ' * 8, destination_path), color=31)
                output.print_lock()
                exit(10)
            source_path, destination_path = process_arg_dir(source_path), process_arg_dir(destination_path)

            def process_sftp_dir(path_name):
                """
                此函數遞歸處理sftp server端的目錄和文件,並在client端創建所有不存在的目錄,然後針對每個文件在兩端的全路徑執行get操作.
                path_name第一次的引用值應該是source_path的值
                """
                d_path = path_name.replace(source_path, destination_path, 1)
                if not path.exists(d_path):  # 若目標目錄不存在則創建
                    create_dir(d_path, 'local', 8)
                for name in (i for i in sftp.listdir(path=path_name) if not i.startswith('.')):
                    '''去掉以.開頭的文件或目錄'''
                    s_file = path.join(path_name, name)  # 源文件全路徑 
                    d_file = s_file.replace(source_path, destination_path, 1)  # 目標端全路徑
                    chk_r_path_result = check_remote_path(s_file)
                    if chk_r_path_result == 'file':  # 文件
                        sftp_get(s_file, d_file, 12)
                        utime(d_file, file_time(s_file, 'remote'))
                    elif chk_r_path_result == 'directory':  # 目錄
                        process_sftp_dir(s_file)  # 遞歸調用本身

            process_sftp_dir(source_path)
        else:
            output.write('%s%s is not exist\n' % (' ' * 8, source_path), color=31)
            output.print_lock()
            exit(10)
    client.close()


def process_single_server(server_name, server_ip, port):
    """處理一臺server的邏輯"""
    local_data = threading.local()  # 可以看到多線程情況下,確實是不同的OutputText實例,說明threading.local()起到了預期作用
    local_data.output = OutputText()
    local_data.output.write('\n--------{}\n'.format(server_name))  # 這行寫入的數據可以在多線程環境下正常打出
    client = create_sshclient(server_ip, port, local_data.output)
    if client == 'error':
        if not arguments['--skip-err']:
            exit(10)
        else:
            return
    # 區別處理 cmd put get參數
    if arguments['cmd']:
        run_command(client, local_data.output)
    elif arguments['put']:
        sftp_transfer(arguments['<src>'], arguments['<dst>'], 'put', client, local_data.output)
    elif arguments['get']:
        sftp_transfer(arguments['<src>'], arguments['<dst>'], 'get', client, local_data.output)
    # 前面的邏輯可以並行,打印必須要加鎖實現串行
    local_data.output.print_lock()


if __name__ == "__main__":
    global global_lock
    global_lock = threading.Lock()
    arguments = docopt(__doc__)
    try:
        if not arguments['--parallel']:
            for server_name, server_ip, port in get_ip_port(arguments['--server']):
                '''循環處理每個主機'''
                process_single_server(server_name, server_ip, port)
        else:
            for server_name, server_ip, port in get_ip_port(arguments['--server']):
                # executor.submit(process_single_server, server_name, server_ip, port)
                t = threading.Thread(target=process_single_server, args=(server_name, server_ip, port))
                t.start()
                # t.join()  # 誰對t線程發起join,誰就阻塞直到t線程執行完
    except KeyboardInterrupt:
        print_color('\n-----bye-----') 


另外腳本里包含了兩個有用的函數(類):

  • print_color()函數方便的在Linux下實現打印不同顏色的字體;
  • OutputText類在多線程任務需要在中終端打印結果時會非常有用

其實之所以想造這麼一個輪子,一方面能鍛鍊python coding,另一方面當時確實有這麼一個需求。而且用自己的工具完成工作也是小有成就的(請勿拍磚~)。

另外,在開發過程中對於一些概念性的東西也都有了更深入的瞭解:

  • 例如在使用paramiko模塊的過程中,又促使我深入的瞭解了一些ssh登陸的詳細過程。
  • 又如用到了線程模型,更深入的瞭解了線程進程相關的概念。

所以作爲一枚運維老司機,越來越深刻的理解到“運維”和“開發”這倆概念之間的相互促進。希望大家共勉。

發佈了50 篇原創文章 · 獲贊 33 · 訪問量 18萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章