ถ้าใครที่กำลังใช้ Google Cloud Build เพื่อทำ CI (Continuous Integration) และมี Google Workspace หรือชื่อเก่าคือ G-Suite เราสามารถสร้าง process ให้มีข้อความบอกทันทีที่ Build เสร็จได้แล้วนะฮะ ตามนี้เลย

ต้องบอกกันก่อนว่าควรจะมีพื้นฐานภาษา Python ด้วยนะฮะ

Overview

1. Google Cloud Build

จุดเริ่มต้นของจักรวาลนี้ คือ เราต้องมี Google Cloud Build trigger กันก่อนนะฮะ ก็สร้างตามลิงก์นี้ได้เลย

สมมติว่าเรามี source code repo และ Cloud Build trigger ที่ทำหน้าที่ CI เรียบร้อยแล้ว ก็ไปข้อต่อไปเลยฮะ

2. Google Cloud Pub/Sub

เมื่อเรามี Cloud Build trigger แล้วจะมี Cloud Pub/Sub topic ชื่อ “cloud-builds” โดยอัตโนมัติฮะ ถ้าเราสร้าง subscriber มาลอง pull ดู จะได้ message ตาม format นี้ฮะ

{
  "name": "projects/123/locations/global/builds/ca523374-cf65-4acc-b13f-0c32b7c5f0b0",
  "id": "ca523374-cf65-4acc-b13f-0c32b7c5f0b0",
  "projectId": "project",
  "status": "SUCCESS",
  "source": {
    ...
  },
  "steps": [
    {
      ...
    }
  ],
  "results": {
      ...
  },
  "createTime": "2021-02-19T06:27:49.268719440Z",
  "startTime": "2021-02-19T06:27:50.900538766Z",
  "finishTime": "2021-02-19T06:29:32.266544Z",
  "timeout": "1200s",
  "queueTtl": "3600s",
  "logsBucket": "gs://log-bucket",
  "sourceProvenance": {
    ...
  },
  "buildTriggerId": "e6c46525-39be-4886-afe6-d98c18d6893e",
  "options": {
    ...
  },
  "logUrl": "https://console.cloud.google.com/cloud-build/builds/ca523374-cf65-4acc-b13f-0c32b7c5f0b0?project=123",
  "substitutions": {
    "BRANCH_NAME": "branch",
    "COMMIT_SHA": "b7738ee8b38e1a8551ec632e8e79733c9a546fa1",
    "REPO_NAME": "repo",
    "REVISION_ID": "b7738ee8b38e1a8551ec632e8e79733c9a546fa1",
    "SHORT_SHA": "b7738ee"
  },
  "tags": ["trigger-e6c46525-39be-4886-afe6-d98c18d6893e"],
  "timing": {
    "BUILD": {
      "startTime": "2021-02-19T06:28:03.203464932Z",
      "endTime": "2021-02-19T06:29:27.751418550Z"
    },
    "FETCHSOURCE": {
      "startTime": "2021-02-19T06:27:53.686032686Z",
      "endTime": "2021-02-19T06:28:03.203377780Z"
    }
  }
}
ตัวอย่าง payload ที่จะได้จาก Google Cloud Build

3. Google Chat

ต่อมาก็ต้องสร้างปลายทาง นั่นคือห้องใน Google Chat และสร้าง incoming webhook ตามลิงก์นี้ฮะ

4. Google Cloud Functions

ส่วนสุดท้ายคือสร้าง Google Cloud Functions มาเชื่อมกับ Pub/Sub ที่ว่า และกำหนดให้มันทำงานเมื่อมีข้อความถูก push เข้ามา ก่อนจะส่งไปยัง Google Chat ตาม webhook ที่เพิ่งสร้างไปตะกี้นั่นเองฮะ

ถ้าเสร็จแล้ว เรามาเริ่มเขียน Google Cloud Functions กัน

4.1 ตั้งต้นด้วย function รับข้อความจาก Pub/Sub

สำหรับ Google Cloud Functions ที่รองรับการ push message มาจาก pub/sub ใช้รูปแบบตามนี้ฮะ

import base64
import json

def cloudbuild_notifications(event, context):
    message = json.loads(base64.b64decode(event['data']).decode('utf-8'))
แกะ message จาก event

แค่เนี้ยแหละ เมื่อข้อความเข้ามา จะอยู่ในตัวแปร event ใน path ชื่อ “data” ในรูปของ byte array ที่เราต้อง decode และแปลงกลับเป็น json format ฮะ เราก็จะได้ payload ในชื่อตัวแปร message

4.2 สร้าง function ส่ง Google Chat message

มี doc อธิบายการส่งข้อความไป Google Chat อยู่สองแบบ ได้แก่ text และ card แน่นอนเราเลือก card ด้วยความหรูหราของข้อความฮะ

from httplib2 import Http
from dateutil.parser import isoparse
from datetime import datetime, timezone
import pytz
    
def send_ggchat(payload):
    time_pattern = "%Y-%m-%d %H:%M:%S"
    time_timezone = pytz.timezone("Asia/Bangkok")
    time_start = isoparse(payload['timing']['FETCHSOURCE']['startTime']).astimezone(time_timezone)
    time_end = isoparse(payload['timing']['BUILD']['endTime']).astimezone(time_timezone)

    message =  """{{ "cards": [{{
        "header": {{
            "title": "Build Notification for Backend Core",
            "subtitle": "Build {build_id} is {status}"
        }},
        "sections": [{{
            "widgets": [
                {{"keyValue": {{"topLabel": "Repo", "content": "{repo}"}} }},
                {{"keyValue": {{"topLabel": "Branch", "content": "{branch}"}} }},
                {{"keyValue": {{"topLabel": "Commit", "content": "{commit}"}} }},
                {{"keyValue": {{"topLabel": "Created", "content": "{created_time}"}} }},
                {{"keyValue": {{"topLabel": "Status", "content": "{status}"}} }},
                {{"keyValue": {{"topLabel": "Duration (sec)", "content": "{duration}"}} }},
                {{"buttons": [{{
                    "textButton": {{
                        "text": "{build_id} log Link",
                        "onClick": {{"openLink": {{"url": "{log_url}"}} }}
                    }} 
                }} ] }}
            ]
        }} ]
    }} ] }} """.format(
                    build_id=payload['id'].split("-")[0],
                    status=payload['status'],
                    repo=payload['substitutions']['REPO_NAME'],
                    branch=payload['substitutions']['BRANCH_NAME'],
                    commit=payload['substitutions']['SHORT_SHA'],
                    created_time=time_start.strftime(time_pattern),
                    duration=(time_end - time_start).total_seconds(),
                    log_url=payload['logUrl'],
                    )

    message_headers = {'Content-Type': 'application/json; charset=UTF-8'}

    http_obj = Http()
    response = http_obj.request(
        uri="https://chat.googleapis.com/v1/spaces/abc/messages?key=key&token=token",
        method='POST',
        headers=message_headers,
        body=json.dumps(json.loads(message))
    )
สร้าง message card แล้วค่อยส่งไป Google Chat

ตรง message จะเป็น json string ของ card template ฮะ โดยออกแบบประมาณนี้

  1. แสดง build id และ branch short id ด้วย
  2. ข้อมูลวันที่อยู่ในรูปแบบ nanosecond เช่น "2021-02-19T06:28:04.329309285Z" จึงใช้ method .isoparse() ของ library dateutil ช่วยแปลงให้เป็น datetime
  3. แปลง timezone จาก UTC เป็น “Asia/Bangkok” ด้วย library pytz
  4. เพิ่มลิงก์ไปหา log ด้วย
  5. คำนวณเวลาที่ใช้ build ตั้งแต่เริ่ม "FETCHSOURCE" จนจบ "BUILD"
  6. ตอน call webhook ก็ใช้ method Http().request() โดยส่ง body เป็น json string ที่ encode ถูกต้องด้วยการใช้ json.dumps(json.loads())

ผลลัพท์จะเป็นดังรูปนี้ฮะ

4.3 เปลี่ยน URL ให้เป็น environment variable

จุดนี้เพื่อเลี่ยง hardcode ในไฟล์ฮะ ตรงนี้ผมเลือกให้ environment variable ชื่อ "_URL" และอ้างอิงตัวแปรใน code เป็น "GGCHAT_URL" เพื่ออ้างอิงตอน call webhook

import os

GGCHAT_URL = os.environ.get('_URL', 'Environment variable does not exist')

def send_ggchat(payload):
    # code ...
    response = http_obj.request(
        uri=GGCHAT_URL,
        method='POST',
        headers=message_headers,
        body=json.dumps(json.loads(message))
    )

4.4 รวมทุกอย่างให้เป็น main.py

call function send_ggchat() เข้าไปใน function cloudbuild_notifications() บันทึกเป็น file ชื่อ main.py

import base64
import json
from httplib2 import Http
from dateutil.parser import isoparse
from datetime import datetime, timezone
import pytz
import os

GGCHAT_URL = os.environ.get('_URL', 'Environment variable does not exist')
    
def send_ggchat(payload):
    time_pattern = "%Y-%m-%d %H:%M:%S"
    time_timezone = pytz.timezone("Asia/Bangkok")
    time_start = isoparse(payload['timing']['FETCHSOURCE']['startTime']).astimezone(time_timezone)
    time_end = isoparse(payload['timing']['BUILD']['endTime']).astimezone(time_timezone)

    message =  """{{ "cards": [{{
        "header": {{
            "title": "Build Notification for Backend Core",
            "subtitle": "Build {build_id} is {status}"
        }},
        "sections": [{{
            "widgets": [
                {{"keyValue": {{"topLabel": "Repo", "content": "{repo}"}} }},
                {{"keyValue": {{"topLabel": "Branch", "content": "{branch}"}} }},
                {{"keyValue": {{"topLabel": "Commit", "content": "{commit}"}} }},
                {{"keyValue": {{"topLabel": "Created", "content": "{created_time}"}} }},
                {{"keyValue": {{"topLabel": "Status", "content": "{status}"}} }},
                {{"keyValue": {{"topLabel": "Duration (sec)", "content": "{duration}"}} }},
                {{"buttons": [{{
                    "textButton": {{
                        "text": "{build_id} log Link",
                        "onClick": {{"openLink": {{"url": "{log_url}"}} }}
                    }} 
                }} ] }}
            ]
        }} ]
    }} ] }} """.format(
                    build_id=payload['id'].split("-")[0],
                    status=payload['status'],
                    repo=payload['substitutions']['REPO_NAME'],
                    branch=payload['substitutions']['BRANCH_NAME'],
                    commit=payload['substitutions']['SHORT_SHA'],
                    created_time=time_start.strftime(time_pattern),
                    duration=(time_end - time_start).total_seconds(),
                    log_url=payload['logUrl'],
                    )

    message_headers = {'Content-Type': 'application/json; charset=UTF-8'}

    http_obj = Http()
    response = http_obj.request(
        uri=GGCHAT_URL,
        method='POST',
        headers=message_headers,
        body=json.dumps(json.loads(message))
    )
    print(response)


def cloudbuild_notifications(event, context):
    message = json.loads(base64.b64decode(event['data']).decode('utf-8'))
    send_ggchat(message)
main.py

4.5 เตรียม requirements.txt

ต้องเตรียมไฟล์ requirements.txt ด้วยฮะ เพราะเราได้ import library หลายตัวเสริมเข้ามา ประมาณนี้

python-dateutil
httplib2
pytz
requirements.txt

4.6 เตรียม an environment variable file

เพราะเราได้อ้างอิง environment variable เอาไว้ ก็จำเป็นต้องเตรียมไฟล์ตรงนี้ไว้ด้วย เช่นตัวอย่างนี้ฮะ

_URL: https://chat.googleapis.com/v1/spaces/abc/messages?key=key&token=token
env.yaml

กำหนดชื่อไฟล์ว่า “env.yaml” ละกัน

4.7 จัดวางไฟล์เพื่อ deploy

ประมาณนี้ฮะ

.
├── env.yaml
└── source
    ├── main.py
    └── requirements.txt

1 directory, 3 files

4.8 deploy function

ให้เปิด shell แล้ว prompt ที่ folder “source” แล้วพิมพ์คำสั่งนี้ (ต้องติดตั้ง Google Cloud SDK ในเครื่องแล้วนะฮะ ถ้ายัง ก็ทำตามลิงก์นี้ได้เลย)

gcloud functions \
--project [project_id] deploy cloudbuild-notifications \
--entry-point cloudbuild_notifications \
--runtime python38 \
--retry \
--env-vars-file ../env.yaml \
--trigger-topic cloud-builds

ตรง project_id เนี่ย เราจะต้องระบุเป็นชื่อเดียวกันกับ Pub/Sub นะฮะ ไม่งั้นมันจะ trigger functions ไม่ได้นะ

4.9 เช็คผลลัพท์

ถ้าสำเร็จ เราจะได้ข้อความประมาณนี้ฮะ

Deploying function (may take a while - up to 2 minutes)...⠹                                                                                                
For Cloud Build Stackdriver Logs, visit: https://console.cloud.google.com/logs/viewer?project=project&advancedFilter=resource.type%3Dbuild%0Aresource.labels.build_id%3Dd13aeb6e-d08d-417e-9e2d-5bdfc55aa9e5%0AlogName%3Dprojects%2Fproject%2Flogs%2Fcloudbuild
Deploying function (may take a while - up to 2 minutes)...done.                                                                                            
availableMemoryMb: 256
buildId: d13aeb6e-d08d-417e-9e2d-5bdfc55aa9e5
entryPoint: cloudbuild_notifications
environmentVariables:
  _URL: https://chat.googleapis.com/v1/spaces/abc/messages?key=key&token=token
eventTrigger:
  eventType: google.pubsub.topic.publish
  failurePolicy:
    retry: {}
  resource: projects/project/topics/cloud-builds
  service: pubsub.googleapis.com
ingressSettings: ALLOW_ALL
labels:
  deployment-tool: cli-gcloud
name: projects/project/locations/us-central1/functions/cloudbuild-notifications
runtime: python38
serviceAccountEmail: [email protected]
sourceUploadUrl: https://storage.googleapis.com/xxx
status: ACTIVE
timeout: 60s
updateTime: '2021-02-19T10:56:09.074Z'
versionId: '1'
ตัวอย่าง output หลังจาก deployed

เราสามารถแก้ไข format อะไรต่างๆ ตามที่เราต้องการได้เลยนะฮะ

สำหรับ source code ตัวเต็ม ดูได้ที่

GitHub - bluebirz/cloudbuild-to-googlechat
Contribute to bluebirz/cloudbuild-to-googlechat development by creating an account on GitHub.