亚马逊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,网络管理,解决方案工程化部署等方面也有深入的研究。

李佳

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