亚马逊AWS官方博客

使用 AWS serverless 服务搭建基于 Gitlab 和飞书的 CICD 审批流

背景介绍

很多公司的软件开发团队都会使用一些 SaaS 或者自建的开源 Git 平台来管理代码,比如说 GitHub 或者 Gitlab,并且基于该平台的能力构建一些自动化的 CICD 流程,避免过多手动操作,节省一些运维的成本。在这些 CICD 流程中经常需要引入通知审批的环节,比如说项目经理是否同意把开发分支的代码可以合并到主分支。刚才提到的 SaaS 平台或者开源平台多为国外的产品,所以更多以电子邮件通知为主,而对国内很多企业,特别是以软件开发为主要业务的公司来说,电子邮件并不是效率最高通讯手段,他们很多使用效率更高的办公 APP,比如飞书,企业微信等等,然而对于这类软件,那些 Git 平台并没有提供支持。本文以一个场景为例,展示了如何使用亚马逊云科技的 Serverless 服务,整合 Git 平台和国内办公 APP,完成审批流的功能。

本文模拟了软件开发团队选择 GitOps 平台作为持续交付工具的前提下,使用 Gitlab 来管理软件新版本发布的场景,并提供了架构建议和关键部分的代码。新版本发布流程:当工程师在 Gitlab 上提交了一个 Merge request 的时候,审批人可以收到一个飞书审批消息,同时工程师也会收到一个飞书消息通知。

当审批人在飞书上做出了审批(批准或者拒绝)后,Gitlab 仓库上的 Merge request 也会做出相应处理,批准之后 Merge request 会变为 approved 状态,并做出 merge 的动作,可以触发下面的 cd 流程;拒绝之后,Merge request 会被关闭。同时工程师也会收到消息通知。

基于以上用户场景,这边设计出了一个时序图,把各方的动作和整体流程梳理一下:

整体架构

我们可以把整个流程分成两个部分,第一个部分,叫做 Gitlab 侧架构,由工程师提交 Merge request 触发,一直到发送审批者和工程师收到待审批的飞书消息通知结束;第二个部分,叫做飞书侧架构,这个部分起始于审批者批准/拒绝飞书消息,一直到动作反馈会 Gitlab 并发送消息通知结束。下面会对这两个部分分别进行介绍。

Gitlab 侧架构

架构图

AWS 服务介绍

Amazon API Gateway – 是一项 AWS 服务,用于创建、发布、维护、监控和保护任意规模的 REST、HTTP 和 WebSocket API。这里我们使用 Amazon API Gateway 创建了一个 REST API。

AWS Lambda – 是一项计算服务,可使您无需预配置或管理服务器即可运行代码。我们的示例代码使用 python 编写,版本为 3.11。

Amazon VPC – 您可以在自己定义的逻辑隔离的虚拟网络中启动 AWS 资源。

Subnet – 是您的 VPC 内的 IP 地址范围。您可以在特定子网中创建 AWS 资源(例如 EC2 实例)。

Internet Gateway – 网关是一种横向扩展、冗余且高度可用的 VPC 组件,支持在 VPC 和 Internet 之间进行通信。它支持 IPv4 和 IPv6 流量。它不会对您的网络流量造成可用性风险或带宽限制。

NAT Gateway – 是一种网络地址转换 (NAT) 服务。您可以使用 NAT 网关,以便私有子网中的实例可以连接到 VPC 外部的服务,但外部服务无法启动与这些实例的连接。

Amazon RDS – 是一种 Web 服务,可让用户更轻松地在云中设置、操作和扩展关系数据库。我们这里使用的是 Aurora MySQL 数据库,您可以按照您的喜好或者公司要求选择适合的数据库产品。

RDS Proxy – 您可以允许您的应用程序池化和共享数据库连接,以提高其扩展能力。RDS Proxy 通过在保留应用程序连接的同时自动连接到备用数据库实例,使应用程序能够更好地抵御数据库故障。使用 RDS Proxy 还使您能够为数据库强制执行 AWS Identity and Access Management (IAM) 身份验证,并将凭证安全地存储在 AWS Secrets Manager。

AWS IAM – 是一项 Web 服务,用于安全地控制对 AWS 服务的访问。借助 IAM,您可以集中管理用户、安全凭证(如访问密钥),以及控制用户和应用程序可以访问哪些 AWS 资源的权限。

Amazon CloudWatch – 提供可靠、可扩展且灵活的监控解决方案,使您能够在几分钟内开始使用。您不再需要设置、管理和扩展监控系统和基础设施了。

AWS Secrets Manager – 帮助您安全地加密、存储和检索数据库和其他服务的凭证。您可以在需要时调用安全的 Secrets Manager 来检索凭证,而不是在应用程序中对凭证进行硬编码。

架构介绍

  1. 当一个 Merge request 发生时,Gitlab webhook 被触发,会有一个 post 请求发送到 API Gateway。请求的 body 请见示例代码 a
  2. API gateway 会使用 Lambda authorizer 验证请求并把请求发送到位于 VPC 私有子网里的 Lambda function (gitlab-hook-function)。请见示例代码 b。
  3. gitlab-hook-function 会把消息持久化在 Aurora MySQL 里面。Lambda function 通过 RDS Proxy 来连接 RDS 数据库,这样就不需要自己管理 connection pool,一切都可以通过 RDS proxy 管理,同时也不需要从 secret manager 获取 RDS 数据库用户名密码来连接数据库,只需要有 RDS proxy 的 connection role 就可以访问数据库。
  4. gitlab-hook-function 最后会调用飞书的 API,向审批人和通知人发送消息。处于私有子网的 Lambda 需要通过位于公有子网的 NAT gateway 获取公网 IP,然后通过 VPC 上的 Internet Gateway 来连接飞书 API。请见示例代码 c。

示例代码

  1. a. gitlab webhook 请求 body (其中我们会选择标红字段作为飞书消息的内容)
    {
      "object_kind": "merge_request",
      "event_type": "merge_request",
      "user": {
        "id": 1091276,
        "name": "***",
        "username": "******",
        "avatar_url": "******",
        "email": "[REDACTED]"
      },
      "project": {
        "id": 47778247,
        "name": "webhook",
        "description": null,
        "web_url": "https://gitlab.com/******/webhook",
        "avatar_url": null,
        "git_ssh_url": "git@gitlab.com:******/webhook.git",
        "git_http_url": "https://gitlab.com/******/webhook.git",
        "namespace": "F**",
        "visibility_level": 0,
        "path_with_namespace": "******/webhook",
        "default_branch": "main",
        "ci_config_path": "",
        "homepage": "https://gitlab.com/******/webhook",
        "url": "git@gitlab.com:******/webhook.git",
        "ssh_url": "git@gitlab.com:******/webhook.git",
        "http_url": "https://gitlab.com/******/webhook.git"
      },
      "object_attributes": {
        "assignee_id": null,
        "author_id": 1091276,
        "created_at": "2023-08-09 05:41:16 UTC",
        "description": "goal",
        "draft": false,
        "head_pipeline_id": null,
        "id": 242020793,
        "iid": 7,
        "last_edited_at": null,
        "last_edited_by_id": null,
        "merge_commit_sha": null,
        "merge_error": null,
        "merge_params": {
          "force_remove_source_branch": "1"
        },
        "merge_status": "preparing",
        "merge_user_id": null,
        "merge_when_pipeline_succeeds": false,
        "milestone_id": null,
        "source_branch": "new",
        "source_project_id": 47778247,
        "state_id": 1,
        "target_branch": "main",
        "target_project_id": 47778247,
        "time_estimate": 0,
        "title": "add some random text",
        "updated_at": "2023-08-09 05:41:16 UTC",
        "updated_by_id": null,
        "url": "https://gitlab.com/******/webhook/-/merge_requests/7",
        "source": {
          "id": 47778247,
          "name": "webhook",
          "description": null,
          "web_url": "https://gitlab.com/******/webhook",
          "avatar_url": null,
          "git_ssh_url": "git@gitlab.com:******/webhook.git",
          "git_http_url": "https://gitlab.com/******/webhook.git",
          "namespace": "F**",
          "visibility_level": 0,
          "path_with_namespace": "******/webhook",
          "default_branch": "main",
          "ci_config_path": "",
          "homepage": "https://gitlab.com/******/webhook",
          "url": "git@gitlab.com:******/webhook.git",
          "ssh_url": "git@gitlab.com:******/webhook.git",
          "http_url": "https://gitlab.com/******/webhook.git"
        },
        "target": {
          "id": 47778247,
          "name": "webhook",
          "description": null,
          "web_url": "https://gitlab.com/******/webhook",
          "avatar_url": null,
          "git_ssh_url": "git@gitlab.com:******/webhook.git",
          "git_http_url": "https://gitlab.com/******/webhook.git",
          "namespace": "F**",
          "visibility_level": 0,
          "path_with_namespace": "******/webhook",
          "default_branch": "main",
          "ci_config_path": "",
          "homepage": "https://gitlab.com/******/webhook",
          "url": "git@gitlab.com:******/webhook.git",
          "ssh_url": "git@gitlab.com:******/webhook.git",
          "http_url": "https://gitlab.com/******/webhook.git"
        },
        "last_commit": {
          "id": "856c10e99992733286ea5d2cb053ccb0a5eb789b",
          "message": "add some random text\n",
          "title": "add some random text",
          "timestamp": "2023-08-09T13:05:59+08:00",
          "url": "https://gitlab.com/******/webhook/-/commit/856c10e99992733286ea5d2cb053ccb0a5eb789b",
          "author": {
            "name": "***",
            "email": "[REDACTED]"
          }
        },
        "work_in_progress": false,
        "total_time_spent": 0,
        "time_change": 0,
        "human_total_time_spent": null,
        "human_time_change": null,
        "human_time_estimate": null,
        "assignee_ids": [
    
        ],
        "reviewer_ids": [
    
        ],
        "labels": [
    
        ],
        "state": "opened",
        "blocking_discussions_resolved": true,
        "first_contribution": false,
        "detailed_merge_status": "mergeable",
       "action": "open"
      },
      "labels": [
    
      ],
      "changes": {
      },
      "repository": {
        "name": "webhook",
        "url": "git@gitlab.com:******/webhook.git",
        "description": null,
        "homepage": "https://gitlab.com/******/webhook"
      }
    }
  2. b. 验证 gitlab 请求的 Lambda authorizer
    来自 gitlab 的 webhook 会带有一个专属的 header:X-Gitlab-Token,可以通过这个 token 来验证请求真实性。

    if event['headers']['X-Gitlab-Token'] == os.environ.get('TOKEN'):
            policy.allowAllMethods()
        else:
            policy.denyAllMethods()
    
  3. c. 调用飞书 API 
    首先获取飞书的 access token

    feishu_secret = get_secret(os.environ.get('FEISHU_SECRET_NAME'))
    token = ''
    url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal'
    credential = {
        "app_id": feishu_secret['app_id'],
        "app_secret": feishu_secret['app_secret']
    }
    
    body = bytes(json.dumps(credential), 'utf8')
    
    headers = {"Content-Type":"application/json"}
    
    req = urllib.request.Request(url=url, headers=headers, data=body)
    
    try:
        response = urllib.request.urlopen(req).read()
        response_body = json.loads(response.decode('utf-8'))
        print(response_body)
        if response_body['code'] == 0:
            #success
            token = response_body['tenant_access_token']
        else:
            print(response_body)
    

    使用 access token 调用发送消息的接口

    url = 'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id'
    request_body = {
        "receive_id": open_id,
        "msg_type": "interactive",
        "content": message
    }
    body = bytes(json.dumps(request_body), 'utf8')
        
    headers = {"Content-Type":"application/json", "Authorization": "Bearer {}".format(token)}
    
    req = urllib.request.Request(url=url, headers=headers, data=body)
    

飞书侧架构

架构图

架构介绍

  1. 当用户和卡片消息进行交互时,飞书会把交互事件通过 post 请求发送给 API gateway,在我们的 PoC 中,卡片交互事件就是审批者点击了 Merge request 审批卡片上的“通过”或者“拒绝”的按钮。
  2. API gateway 会把请求发送到位于 VPC 私有子网里的 Lambda function (feishu-hook-function)。
  3. feishu-hook-function 会首先验证消息真伪,把消息存入 RDS 数据库,并且返回更新的飞书消息(对于审批者)。更新的消息会把原本的审批卡片更新成为通知卡片消息,这样审批者就不能反复点击原本审批卡片上的按钮。请见示例代码 a。
  4. feishu-hook-function 还会异步调用 VPC 私有子网里面的 gitlab-merge-function。这里使用异步调用主要是因为飞书的事件调用需要在 3 秒内返回结果。请见示例代码 b。
  5. gitlab-merge-function 会根据审批结果调用 Gitlab 的 API,如果通过,则会调用 approve merge request 接口,然后调用 merge 接口;如果拒绝,则会调用 update 接口把 request 关闭。请见示例代码 c。
  6. gitlab-merge-function 还会获取飞书的 token,并且发送通知消息给通知者。

示例代码

  1. a. 验证飞书消息
    message_id = body['open_message_id']
    raw = json.dumps(body)
    selected_value = body['action']['value']
    is_approved = selected_value['chosen']
    git_event_id = selected_value['id']
    
    header_timestamp = event['headers']['X-Lark-Request-Timestamp']
    header_nonce= event['headers']['X-Lark-Request-Nonce']
    verification_Token = os.environ.get('FEISHU_VERIFICATION_TOKEN')
    request_body = event['body']
    x_Lark_Signature = event['headers']['X-Lark-Signature']
    
    # --- verification message
    
    bytes_tnv = (header_timestamp + header_nonce + verification_Token).encode('utf-8')
    self_Signature = hashlib.sha1(bytes_tnv + request_body.encode('utf-8')).hexdigest()
    
    if self_Signature != x_Lark_Signature:
        print('verify signature failed')
        return {
            'statusCode': 403,
            'body': json.dumps({'Error':'Verify signature failed!'})
        }
    
  2. b. 使用 boto3 异步调用 Lambda 函数
    lambda_client = boto3.client('lambda')
        
    lambda_event = {
        "project_id": git_event['project_id'],
        "iid": git_event['iid'],
        "is_approved": is_approved,
        'notification_message': notification_message
    }
        
    print(lambda_event)
        
    response = lambda_client.invoke(
          FunctionName=os.environ.get('MERGE_FUNCTION_NAME'),
          InvocationType='Event',
          Payload=json.dumps(lambda_event),
    )
    
  3. c. 调用 gitlab 接口 approve request,do merge,close request
    gitlab_secret = get_secret(os.environ.get('GITLAB_SECRET_NAME'))
    token = gitlab_secret['token']
    project_id = event['project_id']
    iid = event['iid']
    
    def approve_merge_request(token, project_id, iid):
        
        url = 'https://gitlab.com/api/v4/projects/{}/merge_requests/{}/approve'.format(project_id, iid)
        
        headers = {"Content-Type":"application/json", "Authorization": "Bearer {}".format(token)}
        
        req = urllib.request.Request(url=url, headers=headers, method='POST')
        
        try:
            response = urllib.request.urlopen(req).read()
            response_body = json.loads(response.decode('utf-8'))
            
            print(response_body)
            
        except Exception as e:
            print(e)
            
    def do_merge(token, project_id, iid):
        
        url = 'https://gitlab.com/api/v4/projects/{}/merge_requests/{}/merge'.format(project_id, iid)
        
        headers = {"Content-Type":"application/json", "Authorization": "Bearer {}".format(token)}
        
        req = urllib.request.Request(url=url, headers=headers, method='PUT')
        
        try:
            response = urllib.request.urlopen(req).read()
            response_body = json.loads(response.decode('utf-8'))
            
            print(response_body)
            
        except Exception as e:
            print(e)
            
    def close_merge_request(token, project_id, iid):
        
        url = 'https://gitlab.com/api/v4/projects/{}/merge_requests/{}'.format(project_id, iid)
        
        body = '{"state_event":"close"}'
        
        data = bytes(body, 'utf8')
        
        headers = {"Content-Type":"application/json", "Authorization": "Bearer {}".format(token)}
        
        req = urllib.request.Request(url=url, headers=headers, data=data, method='PUT')
        
        try:
            response = urllib.request.urlopen(req).read()
            response_body = json.loads(response.decode('utf-8'))
            
            print(response_body)
            
        except Exception as e:
            print(e)
    

准备工作

Gitlab 侧准备工作

GItlab 的代码 repositary 需要开启 webhook,填写 API gateway 的 URL,在 Trigger 选项中选择 Merge request events:

飞书开放者平台应用创建

  • 根据实际情况添加名称与描述
  • 点击左侧凭证与基础信息

    记录应用凭证中的 App ID 与 App Secret,供 CDK 部署的时候使用。

  • 点击创建机器人,为后续的交互做准备。

此步骤结束后,执行部署文档部署 AWS 端服务。

测试

  • 为自己监控的项目创建一个分支,在分支上提交一些代码,使用 Gitlab console 或者 CLI 创建一个 Merge request 到主分支。
  • 这是审批者的飞书上应该收到消息,消息上可以看到一些代码提交的信息。
  • 审批者在消息上点击“通过”。
  • 这个 Merge request 会自动被 approve 并且自动执行 merge 操作。
  • 如果审批者在消息上点击“拒绝”。
  • 这个 Merge request 会被关闭。

总结

本文介绍了如何使用亚马逊云科技的无服务器技术实现从 Gitlab 的 Merge request 事件触发飞书消息,然后从飞书事件触发 Gitlab merge 的流程。通过此架构,可以实现对 Gitlab 不同分支,不同项目,不同事件的监测;同时也可以把 git 源替换成 Github;后面结合其他即时通讯平台的接口,也可以实现其他平台的通知以及审批(比如企业微信,叮叮等)。

本篇作者

Fox Qin

AWS 快速原型团队解决方案架构师,主要负责 IoT 和移动端方向的架构设计和原型开发,此外对 AWS 的无服务器架构,跨地区的多账号 Organization,网络管理,解决方案工程化部署等方面也有深入的研究。

李佳

亚马逊云科技快速原型解决方案研发架构师,主要负责微服务与容器原型设计与研发。