7
4

More than 3 years have passed since last update.

Google Cloud Buildのステータスバッジを自動生成する

Last updated at Posted at 2020-07-15

はじめに

GitHub ActionsやCircleCIを使ったことがある人が Google Cloud Build を触って思うこと・・・。
ステータスのバッジが付けられない!!

調べてみると似たようなこと思っている方はいるようで「ビルドの結果からバッジを自動で作成する」的なものはOSSで何個か見つけたのですが、どうにもかゆいところに手が届かない感じで使いにくい・・・。 :sweat_drops:

ということで、自分で作ってみました。
こんな感じにREADME上にバッジを表示できます( [build|success] の部分)。

45af1e09-c7e9-2401-69ac-6f8aecb647ea.png

以下一連のシステムを構築するまでのまとめです。
結構長めなので話とかどうでもいいからさっさと試してみたい!という方は こちら へどうぞ。

Google Cloud Buildとは

CI/CDを実現するためのマネージドサービスです(CI/CDってなんぞや?という方はググってください・・・)。
GitHub ActionsAWS CodeBuildAWS CodePipelineCircleCI なんかが親戚にあたります。
マネージドでないサービスも挙げると JenkinsGitLab CI/CD が有名どころかと。
・・・ここら辺の名前を知っている方は、それらのGoogle版だと思ってもらえれば間違いありません。

で、例えばGitHub Actionsの場合 こんな感じ にビルドステータスのバッジを生成してくれるのですが、どうもGoogle Cloud Buildにはこの機能がないっぽい(ドキュメントあさったけど見つけられなかったです・・・)。

CI/CDでビルドやテスト、デプロイが自動化できるのはいいことですが、最終的に開発者にフィードバックが来ないというのは困りものですよね。
もちろんコンソールから結果は確認できますが、いちいちコンソール開くのもダルいですし、作業していてよく目につくリポジトリのREADMEに、バッジとして貼っておきたいと思うのは自然な感覚だと思います。

バッジがないなら作ればいいじゃない!

その前に一応探してみた

プログラマなら自作すればいいじゃん!って思考に至りますよね。 :sweat_smile:
でもまぁその前に、一応似たようなものが無いか調べてみたところそれっぽいものが見つかりました。

なんとなくやりたいことは実現できてそうな感じですが、いくつか問題点がありました。

問題1:固定のバッジしか生成できない
バッジのメッセージ部分がビルドのステータスによって固定なのは分かりますが、ラベルの部分は動的に変更したくないですか?
例えば [test|success][deploy|success] みたいな感じです。
既存のものだとバッジのラベル部分が固定になるので、実際には使いづらいような気がします。

問題2:ブランチに対して複数のバッジが発行できない
上の問題とからみますが、既存のものだとリポジトリのブランチの単位でバッジが保存されるため、例えば開発環境用のブランチで、テストとデプロイ用のビルドを回している場合、それら2つのバッジは同時に保存できないことになります(既存のものはブランチに対して1つしかバッジが発行できないので、ラベル部分が固定だったと考えられます)。
その他にも、静的解析や脆弱性試験などブランチに対して様々なビルドを設定していることは十分ありえますし、実際Cloud Buildではそのような設定ができるので、この制限はかなり痛いです・・・。

問題3:テンプレートとなるバッジの用意および配置の作業が面倒
既存のものはテンプレートのバッジを予め用意しておき、ビルドの結果に応じてそのテンプレートをコピーするという動きになっていました。
諸々の必要な作業を実施するためのスクリプトを用意しておけなくもないですが、導入のための作業はなるだけ単純にしておきたいところです。

要件定義

既存のものを使うのは難しいと判断したので、以上のことも踏まえてこんな感じのものを目指しました(太字が改善部分です)。

  • バッジの ラベル部分は設定によって動的に変えられる ようにする。
  • バッジのメッセージ部分はビルドのステータスによって切り替わるようにする。
  • リポジトリ/ブランチに対して 複数のバッジを生成・保存できる ようにする。
  • バッジは完全に動的に生成 し、テンプレート等の事前準備は不要にする。
  • システム構築にはマネージドなサービスを使い、極力メンテナンスが不要になるようにする。
  • なるべくお金がかからない構成にする(←大事)。

システム構成

全体の流れは既存のプロダクトを参考にさせてもらいました。 :pray:
GCP上では、Cloud Source Repository、Cloud Build、Cloud Pub/Sub、Cloud Functions、Cloud Storageを連携させます(Cloud Source Repositoryの部分は代わりにGitHubでも大丈夫です)。

リポジトリにPushすると、それをトリガにCloud Buildが実行されます。
Cloud Buildの実行に合わせて、Cloud Pub/Subへメッセージが送信されます。
Cloud Pub/Subのメッセージをトリガに、Cloud Functionsが発火し、Cloud Functions内でバッジの画像を生成し、それをCloud Storageへ保存するという流れです。

マネージドのサービスを利用しているのでメンテナンスもほとんど不要ですし、各サービスとも無料枠があったり課金も実際に使った分だけなのでお財布にも優しいです。

ab6fde3e-203e-872b-01aa-645acc92e922.png

実際に作っていきます

Cloud Functionsによるバッジ(SVG画像)の生成

Cloud Functionsですが、自分がPythonistaなのでPythonで作るのは確定事項・・・。
で、Cloud Functions内でバッジを動的に生成したいので、SVG形式の画像を作成できるライブラリとかいろいろ探してみましたが、最終的にもっとヤバい pybadges というものを見つけました。
こちら名前の通りバッジを生成できるライブラリで、正にやりたかったことそのもの!
ありがたく使わせてもらいます。 :pray:

使い方はいたってシンプル。
さすがPython、簡単すぎてバカになりそう・・・。

$ pip install pybadges==2.2.1
from pybadges import badge

# バッジを生成する、これだけ・・・。
svg_image = badge(left_text="build", right_text="success", right_color="green")
print(svg_image[:64])  # '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.'

バッジの動的生成の部分についてはこれにて解決ですね!

Cloud Functionsからのバッジの保存

バッジを保存するためにCloud FunctionsからCloud Storageを操作する必要があります。
公式のクライアントライブラリ があるので、それを使いましょう。

$ pip install google-cloud-storage==1.29.0
from google.cloud import storage

# Cloud Functions上で実行する分には、認証情報などの指定は不要です。
gcs_client = storage.Client()
bucket = gcs_client.bucket("your-bucket-name")
blob = bucket.blob("any/path/badge.svg")

# キャッシュの設定を明示的に行わないと、公開コンテンツの場合1時間キャッシュされます・・・。
blob.cache_control = "max-age=60, s-maxage=60"

# ファイルでなくても文字列をそのまま保存できるらしいです。
# コンテンツタイプを指定しないと、外からアクセスした際に画像ではなくただのテキストファイルになるため注意!
blob.upload_from_string(svg_image, content_type="image/svg+xml")

バッジの保存についてもこれで問題なさそうです。

リポジトリの接続とCloud Buildの設定

メインの処理部分の目処がついたので、システムの設定周りを準備していきたいと思います。

とりあえず、GitHubなどの外部サービスを使っている場合はリポジトリを接続したり、Cloud Buildの設定をする必要がありますが、ここら辺については割愛します。 :sweat_drops:
冒頭にCloud Buildに関する説明は入れましたが、一応Cloud Buildを使ったことがある方、もしくは触ったことがなくても ドキュメント を読めば使えるレベルのGCPユーザを対象としているので、既にリポジトリの接続やビルドの設定はしてある前提で先に進みます。

システムを構築するにあたり、ここで確認しておく必要があるのはCloud Buildのトリガについてです。

Cloud Buildのトリガ

Cloud Buildではトリガというものを設定してビルドを自動化します。
こちらはトリガの設定画面の抜粋です。

32e68ec2-4a21-75ac-4293-07209e47afcc.png

トリガにはビルドの処理を定義した構成ファイル(俗に言う cloudbuild.yaml )を1つ設定できます。
このビルドの処理の結果を1つのバッジとして生成します。

トリガはリポジトリに対して設定しますが、リポジトリに対してトリガは複数設定可能です(1対多の関係)。
つまり、テストやデプロイなど、複数のビルドが設定できるということです。

例:リポジトリとトリガの関係
リポジトリ
 |
 +-- テストのトリガ
 |
 +-- 負荷試験のトリガ
 |
 +-- デプロイのトリガ

そして、トリガが実行された際の対象のブランチも同時に設定しますが、正規表現になっていることからも分かるようにトリガに対してブランチは複数設定可能です(1対多の関係)。
つまり、一つのトリガで開発環境や本番環境など異なる環境に対するビルドが設定できるということです。

例:リポジトリとトリガとブランチの関係
リポジトリ
 |
 +-- テストのトリガ
 |    |
 |    +-- masterブランチ
 |    |
 |    +-- developブランチ
 |
 +-- 負荷試験のトリガ
 |    |
 |    +-- developブランチ
 |
 +-- デプロイのトリガ
      |
      +-- masterブランチ
      |
      +-- developブランチ

しかし実際には、下記のように環境ごとにトリガを作成し、環境ごとに異なる値はトリガの代入変数(Cloud Buildのトリガに設定できる環境変数のようなもので、ビルドの処理を定義した構成ファイル内で参照できます)に設定するという構成が一般的かと思われます。

例:リポジトリとトリガとブランチの関係(よくある構成)
リポジトリ
 |
 +-- テストのトリガ(本番環境用)
 |    |
 |    +-- masterブランチ
 |
 +-- テストのトリガ(開発環境用)
 |    |
 |    +-- developブランチ
 |
 +-- 負荷試験のトリガ(開発環境用)
 |    |
 |    +-- developブランチ
 |
 +-- デプロイのトリガ(本番環境用)
 |    |
 |    +-- masterブランチ
 |
 +-- デプロイのトリガ(開発環境用)
      |
      +-- developブランチ

このリポジトリ、トリガ、ブランチの関係を把握すれば、自ずとビルド結果のバッジをどのような階層で保存すべきかが見えてきますね。

Cloud Pub/Subのトピック

Cloud Buildの実行をトリガにCloud Functionsが実行できれば良いのですが、それはできないのでCloud Pub/Subを経由させます(GCPのサービスは大体そんな感じです)。
というわけで、Cloud Buildの実行をトリガにCloud Pub/Subへメッセージを送信する必要がありますが、これは特に作業いりません!
ドキュメント にある通り、実はCloud Buildからの通知はCloud Pub/Subを有効にした段階でGCP側で勝手に設定されています。

Cloud Build がビルド更新メッセージを公開する Pub/Sub トピックは cloud-builds です。Pub/Sub API を有効にすると、 cloud-builds トピックが自動的に作成されます。

ということで cloud-builds というトピックが利用できるので、これをCloud Functions発火のトリガとして設定しましょう。

Cloud Storageのバケット作成

Cloud Functionsの作業に入る前に、先にバッジの保存先であるバケットを用意しておきます。
GUIでポチポチしても良いですが、コマンドでサクッと作れます。

$ BUCKET_NAME='your-bucket-name'
$ gsutil mb -c standard -l us-central1 gs://${BUCKET_NAME}
$ gsutil iam ch allUsers:objectViewer gs://${BUCKET_NAME}

リージョンは asia-northeast1 とかでも大丈夫ですが us-central1 にすると 無料枠 が適用されます!
少しでも安く済ませたい方にはオススメです。

3つ目のコマンドは全ユーザに対してバケットの読み取り権限を付与しています。
これをしないとREADME上にバッジをペタペタしても外から見れません・・・。

バケットの準備については以上です。

Cloud Functionsの作成

cloud-builds トピックをトリガに発火するCloud Functionsを作成します。
Cloud Pub/Subから受け取ったメッセージはJSON形式で取り出せるため、Cloud Buildに関する情報はそこから頑張って抽出します。

・・・で、出来上がったものがこちらです。
1/3くらいはコメントやログ出力なのでコードの量ほど複雑ではないはずです。

requirements.txt
google-cloud-storage==1.29.0
pybadges==2.2.1
main.py
"""Cloud Build Badge

Cloud Buildの情報からバッジを生成し、GCSへ保存する。
GCSのバケットは予め作成し、環境変数 ``_CLOUD_BUILD_BADGE_BUCKET`` にバケット名を設定しておくこと。

"""

import base64
from collections import defaultdict
import dataclasses
import json
import logging
import os
import sys
from typing import List, Optional, overload

from google.cloud import storage
import pybadges


@dataclasses.dataclass(frozen=True)
class Build:
    """ビルドの情報。

    Parameters
    ----------
    status : str
        ステータス。
    trigger : str
        トリガID。
    repository : str, optional
        リポジトリ名。
    branch : str, optional
        ブランチ名。

    """

    status: str
    trigger: str
    repository: Optional[str] = None
    branch: Optional[str] = None


@dataclasses.dataclass(frozen=True)
class Badge:
    """バッジオブジェクト。

    Parameters
    ----------
    label : str
        ラベル。
    message : str
        メッセージ。
    color : str
        16進数カラーコード。
    logo : str, optional
        Data URI形式のロゴ画像。

    """

    label: str
    message: str
    color: str
    logo: Optional[str] = None

    def to_svg(self) -> str:
        """SVG形式のバッジを生成する。

        Returns
        -------
        str
            SVG形式の画像データ。

        """

        return pybadges.badge(
            logo=self.logo,
            left_text=self.label,
            right_text=self.message,
            right_color=self.color,
        )


def entry_point(event, context):
    """エントリポイント。"""

    try:
        return run(event, context)
    except Exception as e:
        logging.error(e)
        sys.exit(1)


def run(event, context):
    """メイン処理。"""

    # Pub/Subのメッセージを取得する。
    pubsub_msg = base64.b64decode(event["data"]).decode("utf-8")
    pubsub_msg_dict = json.loads(pubsub_msg)

    # 関係ないステータスの場合は抜ける。
    build = parse_build_info(pubsub_msg_dict)
    if build.status not in {
        "WORKING", "SUCCESS", "FAILURE", "CANCELLED", "TIMEOUT", "FAILED"
    }:
        return

    # 設定でバッジの生成が無効化されている場合は抜ける。
    badge_generation_setting = get_setting(
        "_CLOUD_BUILD_BADGE_GENERATION", pubsub_msg_dict, default="enabled"
    )
    if badge_generation_setting == "disabled":
        logging.info("The badge generation setting is disabled.")
        return

    # 保存先のGCSバケットが設定されていることを確認する。
    bucket_name = get_setting("_CLOUD_BUILD_BADGE_BUCKET", pubsub_msg_dict)
    if not bucket_name:
        raise RuntimeError(
            "Bucket name is not set. "
            "Set the value to the environment variable '_CLOUD_BUILD_BADGE_BUCKET'."
        )

    if not build.repository:
        logging.info("Unknown repository.")
    if not build.branch:
        logging.info("Unknown branch.")

    # バッジを生成し、GCSへ保存する。
    badge = create_badge(pubsub_msg_dict)
    uploaded_badges = upload_badge_to_gcs(badge, bucket_name, build)

    for url in uploaded_badges:
        logging.info(f"Uploaded the badge to '{url}'.")


def parse_build_info(msg: dict) -> Build:
    """Cloud Buildの情報から必要なデータを取り出す。

    Parameters
    ----------
    msg : dict
        Pub/Subから受け取ったメッセージ。

    Returns
    -------
    Build
        Pub/Subのメッセージをパースしたデータ。

    """

    status = msg["status"]
    trigger = msg["buildTriggerId"]

    # ビルドの定義ファイル自体が壊れていた場合など、情報が取得できないことがある。
    repository, branch = None, None
    if "substitutions" in msg:
        repository = msg["substitutions"].get("REPO_NAME")
        branch = msg["substitutions"].get("BRANCH_NAME")

    return Build(
        status=status, trigger=trigger, repository=repository, branch=branch
    )


def create_badge(msg: dict) -> Badge:
    """バッジを生成する。

    Parameters
    ----------
    msg : dict
        Pub/Subから受け取ったメッセージ。

    Returns
    -------
    Badge
        ビルドのステータスを示すバッジ。

    """

    status = msg["status"]
    label = get_setting("_CLOUD_BUILD_BADGE_LABEL", msg, default="build")
    logo = get_setting("_CLOUD_BUILD_BADGE_LOGO", msg)

    status_to_color = defaultdict(lambda: "#9f9f9f")
    status_to_color["WORKING"] = "#dfb317"
    status_to_color["SUCCESS"] = "#44cc11"
    status_to_color["FAILURE"] = "#e05d44"

    return Badge(
        label=label,
        message=status.lower(),
        color=status_to_color[status],
        logo=logo,
    )


def upload_badge_to_gcs(badge: Badge, bucket_name: str, build: Build) -> List[str]:
    """バッジをGCSに保存する。

    Parameters
    ----------
    badge : Badge
        バッジ。
    bucket_name : str
        バケット名。
    build : Build
        ビルドの情報。

    Returns
    -------
    list of str
        GCSに保存したバッジのURLを格納したリスト。

    """

    def upload(path: str) -> None:
        bucket = gcs_client.get_bucket(bucket_name)
        blob = bucket.blob(path)
        blob.cache_control = "max-age=60, s-maxage=60"
        blob.upload_from_string(badge.to_svg(), content_type="image/svg+xml")

    def to_url(path: str) -> str:
        return f"https://storage.googleapis.com/{bucket_name}/{path}"

    uploaded = []

    gcs_client = storage.Client()

    path = f"triggers/{build.trigger}/badge.svg"
    upload(path)
    uploaded.append(to_url(path))

    if not build.repository or not build.branch:
        return uploaded

    branch = build.branch.replace("/", "_")  # スラッシュは使えないので置換する。
    path = f"repositories/{build.repository}/triggers/{build.trigger}/branches/{branch}/badge.svg"
    upload(path)
    uploaded.append(to_url(path))

    return uploaded


@overload
def get_setting(key: str, msg: dict) -> Optional[str]: ...
@overload
def get_setting(key: str, msg: dict, default: None) -> Optional[str]: ...
@overload
def get_setting(key: str, msg: dict, default: str) -> str: ...


def get_setting(key, msg, default=None):
    """設定値を取得する。

    Parameters
    ----------
    key : str
        設定値取得のためのキー。
    msg : dict
        Pub/Subから受け取ったメッセージ。
    default : str, optional
        設定値が存在しなかった際のデフォルト値。

    Returns
    -------
    str or None
        設定値(存在しない場合は `None` もしくは `default` に指定した値)。

    """

    value = None

    if "substitutions" in msg:
        value = msg["substitutions"].get(key)
    if not value:
        value = os.getenv(key)
    if not value:
        value = default

    return None if value is None else str(value)

デプロイはこちらもコマンドで行なえます。
なお、リージョンが us-central1 なのは保存先のバケットと近い方が効率いいかなぁ・・・というちょっとした配慮です。

$ FUNCTION_NAME='any-function-name'
$ BUCKET_NAME='your-bucket-name'
$ gcloud functions deploy ${FUNCTION_NAME} \
  --runtime python38 \
  --entry-point entry_point \
  --trigger-topic cloud-builds \
  --region us-central1 \
  --set-env-vars _CLOUD_BUILD_BADGE_BUCKET=${BUCKET_NAME}

以上で準備は完了です。
あとはCloud Buildが実行されればバッジが自動で生成されます!

使い方について

対象となるビルドの指定

不要です!
プロジェクト内で走る全てのビルドに対して自動でバッジが作成されます。
どんどんビルドを回してガンガンバッジを作りましょう! :muscle:

もし課金周りのことが心配で不要なバッジの生成は極力抑えたいということがあれば、設定でバッジの生成およびCloud Storageのバケットへの書き込みを抑制できます。
Cloud Buildのトリガの代入変数(もしくは cloudbuild.yaml 内の substitutions 句)の _CLOUD_BUILD_BADGE_GENERATIONdisabled を設定してください。

bc93f8c8-1ae4-7f57-eedc-346ebd62a854.png

なお、抑制できるのはバケットへの書き込みだけでCloud Functions自体はビルドの度に実行されてしまうため注意してください。 :sweat_drops:

バッジの種類

ビルドのステータス に合わせてこんなバッジがほぼリアルタイムに生成されます。

fc1cabc3-e4e1-0a71-ae93-5e225eb80b30.png

種類はステータスごとに全部で6つです。

  • ビルド中:working
  • ビルドの成功:success
  • ビルドの失敗:failure
  • ビルドのキャンセル:cancelled
  • ビルドのタイムアウト:timeout
  • ステップのタイムアウト:failed

バッジの参照

Cloud Buildのトリガの起動イベントが「ブランチへのpush」で対象のブランチが1つしか設定されていない場合、もしくはトリガの起動イベントが「タグのpush」の場合はこちらを参照ください。

バッジの参照
https://storage.googleapis.com/<BUCKET>/triggers/<TRIGGER>/badge.svg

Cloud Buildのトリガの起動イベントが「ブランチへのpush」で対象のブランチが複数設定されている場合はこちらを参照ください(ビルドの転け方によっては更新されない場合があります・・・)。

バッジの参照
https://storage.googleapis.com/<BUCKET>/repositories/<REPOSITORY>/triggers/<TRIGGER>/branches/<BRANCH>/badge.svg
  • BUCKET :Cloud Storageのバケット名。
  • TRIGGER :Cloud BuildのトリガID。
    Cloud Buildのトリガの編集画面を開いた際のURLに含まれるUUIDっぽい値がそれに当たります。
    例えば https://console.cloud.google.com/cloud-build/triggers/edit/ca8fc3a9-f8cd-4b0e-ab1a-267e27eba1d3?project=your-gcp-projectca8fc3a9-f8cd-4b0e-ab1a-267e27eba1d3 の部分です。
  • REPOSITORY :ビルド対象のリポジトリ名。
  • BRANCH :ビルド対象のブランチ名。

最初は下の長い方の保存先だけで十分かなと考えていたのですが、ビルドの失敗具合によってはCloud Functions内でリポジトリ名やブランチ名が取得できない場合がありました(ビルドの構成ファイル自体のシンタックスエラーとか)。
加えてビルドのトリガが「ブランチへのpush」ではなく「タグのpush」の場合、そもそもブランチ名の情報は存在しませんでした。 :sweat_smile:

そこでどんな場合でも最低限情報が取れて、かつ一意のビルドを示す場所ということで上のパターンと、「リポジトリ/ブランチに対して複数のバッジを生成・保存できるようにする」という要件を満たすため、下のパターンの2個所で保存するようにしました。

ラベルのカスタマイズ

自分で言うのもあれですが、個人的にはかなり便利だなぁと思う機能です。
バッジのラベル部分を自由かつ簡単にカスタマイズできます。 :v:

例えば「build」でなく「cloud build」をデフォルトにしたいという場合は、Cloud Functionsの環境変数に _CLOUD_BUILD_BADGE_LABEL を設定してください。

a56ffafb-5055-ed2b-ebf1-efa69049e0c7.png

さらに、ビルドがテストやデプロイなど明確なタスクを持っていて「test」や「deploy」というラベルにしたいという場合は、Cloud Buildのトリガの代入変数(もしくは cloudbuild.yaml 内の substitutions 句)に _CLOUD_BUILD_BADGE_LABEL を設定してください。

cloudbuild.yaml
substitutions:
  _CLOUD_BUILD_BADGE_LABEL: deploy

なお、設定は重複しても構いません。
Cloud Functions側とCloud Build側で同じ設定がある場合、Cloud Build側の設定が優先されます。
なので、Cloud Functions側でデフォルトのラベルを「cloud build」と設定し、ビルドにデプロイという明確なタスクがある場合、Cloud Build側の設定でラベルを「deploy」に変更するということが自然に行なえます。

・・・さらにさらに、ロゴを入れたいなぁという場合は _CLOUD_BUILD_BADGE_LOGO にData URI形式の画像を設定できます!

1b20924b-7aa0-78d6-0b87-458564f87864.png

本当はデフォルトでCloud Buildのロゴ付けたいなと思ったんですが、なんか権利関係の問題とかいろいろありそうなので、ロゴを出したい方は自分でカスタマイズしてください・・・。 :sweat_smile:
ちなみに、アーキテクチャ図用ですが公式が アイコンセット 出してくれています。

で、カスタマイズを施すとこんな感じになります。
これはもはや公式のバッジですね。

6250475f-e549-69f8-e424-5b71f25226a8.png

まとめ

GCPのサービスを使ってCloud Buildのステータスバッジを自動生成するシステムを構築できました。
GitHub に置いておくので、同じようにCloud Buildのバッジをペタペタ貼りたい方がいたらぜひ使ってみてください。
それでは良きCI/CDライフを!

7
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
4