运维平台的建设是一个很大的工程,分为很多部分如cmdb、发布系统、监控系统、成本管理、工单系统、任务调度、配置管理、堡垒机、日志平台等等。在进行运维平台体系化建设时,建议的做法是将涉及到的不同系统的前端整合起来一体化呈现,而平台的后端调用各个系统接口来完成相应的底层功能,研发人员只需要登录一个平台就能够完成所有的需求。下图是一个运维平台的发体系架构

image.png

        工单系统作为提供标准服务支持的工具,可以出现在各种场景中,如服务器的申请、发布权限申请、告警处理、运维服务申请(环境部署、问题解答等等)。当然我们比较常见的请假申请、报销申请也属于工单。本文将介绍如何建设一套完善的工单系统,用以支持各个系统中的工单服务。

      首先介绍一些工单相关的一些术语

        工单:具体的待处理事项,用户新建的是工单,工单按照工作流的设计来实现不同状态不同处理人之间的流转

        工作流:即工作流的设计,定义了工单的审批链、各状态的处理人、各状态可以执行的操作(提交、保存,处理完成,退回,关闭等等)、每个状态下显示哪些字段、哪些字段可以在哪些编辑

        子工单:主要用于工单流转存在子集的情况,如在项目开发周期中存在项目周期和应用周期两个层级, 当项目处于开发中时,项目的多个涉及应用在项目开发中可能正处于不同的阶段(代码编写、静态扫描、单元测试、完成开发等状态)。当应用状态都完成开发时将触发项目的状态到提测中。在这个场景中应用的工单即为项目工单的子工单。 应用工单的父状态即为项目的“开发中”

        子工作流:工作流的父子层级不体现在工作流记录中,而体现在状态记录中。在配置工作流时,可以给某个工作流的某个状态设置一个子工作流。可以在工作流的不同状态设置不同的子工作流。

        流程图:为了方便用户了解工作流的流转规则,可以通过流程图的方式展示给用户,如下图是一个服务器登录权限申请的流程图,用户新建工单时填写服务器ip地址,点击“提交”按钮完成工单的申请,或者点击“保存”进入编辑中状态(可以理解为草稿),编辑中的工单可以继续修改然后再提交。途中圆角方框代表的是不同的状态,线条表明了每个状态可以执行的操作。在工作流配置时将“赋权脚本-自动执行中”状态的参与人类型设置为脚本,参与人设置为脚本的名称。当leader审批后,工单状态变为“赋权脚本-自动执行中”。后台自动开始执行赋权的一系列操作。赋权完成后工单状态变为“发起人-验证中”,发起人在工单处理界面中通过点击“关闭”或者“验证不通过”来确认工单的处理结果。

image.png

        转交:正常情况下工单的流转都是按照其对应工作流设定的规则来流转(状态、处理人类型、处理人等).在实际操作中,比如A提交了个工单,到达运维处理中状态,B接单处理,B在处理过程中发现自己其实处理不了,需要C才能处理。于是将工单转交给C。

        加签:加签与转交不同。正常情况下工单的流转都是按照其对应工作流设定的规则来流转(状态、处理人类型、处理人等).在实际操作中,比如A提交了个工单,到达运维处理中状态,B接单处理,B在处理过程中发现需要C做些操作或者提供些信息,才能处理,于是将工单加签给C.C处理完成后工单处理人会回到B.于是B可以继续处理。

        工单自定义字段与工作流自定义字段的区别: workflow里面自定义字段规定工作流有哪些自定义的字段。比如配置一个请假的工作流。 需要有请假天数这个字段。工单里面的自定义字段 存的是自定义字段具体的值。 比如现在用于新建了一个请假工单,填写了请假天数。那么工单的自定义字段表中会保存这个值。

        工作流处理过程可以理解为工单状态的变化,如一个工作流处理过程中可以有:发起人新建中、发起人编辑中、部门经理审核中、技术人员处理中、发起人验证中、结束等状态,每个状态对应相应的处理人(如部门经理审核中这个状态下只有部门经理才可以处理该工单)。如用户在新建工单的时候处于“发起人新建中”,(用户)提交后工单处于“部门经理审核中”, 部门经理(即“部门经理审核中”状态的处理人)审批通过后,工单的状态变更为“技术人员处理中”。 注意:"转交"和"加签"使用场景不同,使用时前端需要做必要的说明,避免用户使用错误。

        笔者近期开源了一个工作流的引擎loonflow,用于提供统一的工单服务下面来介绍下如何使用loonflow实现不同场景的工单服务。

        LOONFLOW 分为两部分:

  • 使用django自带的admin来管理工作流的配置信息

  • 提供http api供各个系统(如果oa、cmdb、运维系统、客服系统)的后端调用以完成各自系统定制化的工单需求

    下图是api的调用逻辑:

    新建工单

admin_homapage

处理工单

admin_homapage


    

使用flask restful时可以通过reqparse.RequestParser来方便的获取请求参数以及对参数合法性做校验.下面完整的示例

# vies/ticket.py
import six
from flask_restful import Resource, reqparse, marshal
from opsflow.services import format_service
from opsflow.services.ticket_service.ticket_base_service import TicketBaseService


ticket_list_parser = reqparse.RequestParser()
ticket_list_parser.add_argument('per_page', type=int, location='args')
ticket_list_parser.add_argument('page', type=int, location='args')
ticket_list_parser.add_argument('title', type=six.text_type, location='args')  #因为title可能会包含中文,所以使用six也可以不指定type


class TicketListApi(Resource):
    """
    工单列表
    """
    def get(self):
        request_data_dict = ticket_list_parser.parse_args()
        for key in list(request_data_dict.keys()):
            if request_data_dict.get(key) is None:
                del (request_data_dict[key])
        page = request_data_dict.get('page', 1)
        per_page = request_data_dict.get('per_page', 10)
        title = request_data_dict.get('title', '')
        ticket_objs, msg = TicketBaseService.get_ticket_list(page, per_page, title)
        if ticket_fields is not False:
            response_data = dict(value=[marshal(ticket_obj, ticket_fields) for ticket_obj in ticket_objs.items], total=ticket_objs.total, page=ticket_objs.page, per_page=ticket_objs.per_page)
            return format_service.response(200, '', response_data)
        else:
            return format_service.response(500, msg, '')

说明:

add_argument中通过指定参数名、参数类型、参数获取方式来获取参数对象并支持做合法性校验

第一个参数是需要获取的参数的名称

参数type: 参数指的类型, 如果参数中可能包含中文需要使用six.text_type. 或直接不指定type

参数location: 获取参数的方式,可选的有args(url中获取)、json(json类型的)、form(表单方式提交)

参数required:是否必要,默认非必要提供

参数help:针对必要的参数,如果请求时没有提供,则会返回help中相应的信息


注意:

RequestParser可以通过在resource class外部指定,或者在class内部通过__init__的方式指定。但是如果在__init__中指定, 则无法区分get\post等method使用不同的参数规则,__init__用法如下

class TicketListApi(Resource):
    """
    工单列表
    """
    def __init__(self):
        self.ticket_list_parser = reqparse.RequestParser()
        self.ticket_list_parser.add_argument('per_page', type=int, location='args')
        
    def get(self):
        request_data_dict = self.ticket_list_parser.parse_args()
        for key in list(request_data_dict.keys()):
            if request_data_dict.get(key) is None:
                del (request_data_dict[key])
        page = request_data_dict.get('page', 1)
        per_page = request_data_dict.get('per_page', 10)
        title = request_data_dict.get('title', '')
        ticket_objs, msg = TicketBaseService.get_ticket_list(page, per_page, title)
        if ticket_fields is not False:
            response_data = dict(value=[marshal(ticket_obj, ticket_fields) for ticket_obj in ticket_objs.items], total=ticket_objs.total, page=ticket_objs.page, per_page=ticket_objs.per_page)
            return format_service.response(200, '', response_data)
        else:
            return format_service.response(500, msg, '')



考虑搭建python内部仓库的原因主要有两点:

  1. 公司生产区无法访问官方仓库,无法安装python包

  2. 使用python开发众多系统会有一些通用的功能,如统一登录、统一权限等等。可以将这些功能整合到一个内部通用的python包来分发,避免重复工作,升级管理也比较方便

在搭建内部仓库时,首先要考虑的是选型,在深入调研了pypiserver、devpi后,我选择了devpi.原因如下:

  1. devpi支持本地缓存(只需在部署仓库的服务器上开启外网访问,需要安装包的服务器只需要与仓库连通)

  2. devpi支持从其他python源下载包后缓存到本地,而不从官方仓库下载(国内访问官方参考比较慢)--这个是与devpi的开发人员沟通后才搞定的。pypiserver只支持重定向(仓库中不存在时让需要安装包的服务器从其他源下载,这种对于服务器无法访问外网的情况就无法使用)。有兴趣的可以看下  how to change default mirror url pypi.python.org/simple/ with devpi-server 


下面是一些我认为比较重要的地方,详细文档可以直接看官方文档

  1. devpi 是分为server、client、web几个部分的,通过pip安装时会自动安装这三个库

  2. 一些配置信息可以通过client 来设置(如创建用户、设置密码、配置mirror_url等)

  3. 安装完成后可以通过如下命令来启用: devpi-server --port 4041 --serverdir /data/project/devpiserver --outside-url=http://pypi.xxxxx.com    可以通过supervisor启动多个端口,然后nginx做反向代理



以下一些命令:

  • 客户端连接服务端:

    devpi use http://pypi.xxx.co

  • 首次登陆:

    devpi login root --password ''   (首次启动密码为空)

  • 修改密码:

    devpi user -m root password=123

  • 退出登陆:

    devpi logoff

  • 创建新用户

    devpi user -c alice password=456  email=alice@example.com

  • 登陆新用户

    devpi login alice --password=456

  • 创建索引

    devpi index -c dev bases=root/pypi

  • 使用索引

    devpi use alice/dev 

  • 上传包

    devpi upload   (在setup.py所在目录下执行)

    devpi upload --with-docs  (支持sphinx创建的文档,需要docs目录和setup.py在同个目录下)

  • 设置当仓库中不存在包时 从豆瓣下载包缓存到本地(默认是从官方源下载的)

    devpi index alice/dev mirror_url="https://pypi.doubanio.com/simple/"

  • 修改devpi web的首页

    如果想要定制首页,可以修改 site-packages/devpi_web/templates/root.pt

下面是效果图:

image.pngimage.png

image.png


服务是centos6,默认python是2.6的。安装完virtualenvwrapper执行source /usr/bin/virtualenvwrapper.sh初始化时出现如下错误:

Traceback (most recent call last):
  File "/usr/lib64/python2.6/runpy.py", line 122, in _run_module_as_main
    "__main__", fname, loader, pkg_name)
  File "/usr/lib64/python2.6/runpy.py", line 34, in _run_code
    exec code in run_globals
  File "/usr/lib/python2.6/site-packages/virtualenvwrapper/hook_loader.py", line 16, in <module>
    from stevedore import ExtensionManager
  File "/usr/lib/python2.6/site-packages/stevedore/__init__.py", line 23, in <module>
    LOG.addHandler(logging.NullHandler())
AttributeError: 'module' object has no attribute 'NullHandler'
virtualenvwrapper.sh: There was a problem running the initialization hooks.


原因python2.6的loggin模块没有NullHandler.解决方法如下:

修改

/usr/lib/python2.6/site-packages/stevedore/__init__.py

将最后一行的LOG.addHandler(logging.NullHandler())内容改为:

try:
    from logging import NullHandler
except ImportError:
    class NullHandler(logging.Handler):
        def emit(self, record):
            pass

LOG.addHandler(NullHandler())