🛠

Google Sheets から Terraform の実行

2023/11/08に公開

クラウドエースの北野です。

Google Sheets から Terraform を実行し、Google Cloud を管理する方法を紹介します。

要約

以下の順序で、タスクを実施することで、Google Sheets から Terraform を実施させます。

  1. Google Apps Script で Google Sheets のデータを Cloud Storage (GCS) へアップロード
  2. GCS の変更を Eventarc で検知し、Workflows の実行
  3. Workflows から Cloud Build トリガーを実行し、Google Cloud の変更

システム構成は、以下のようになります。

システム構成

背景

構成管理ツールである Terraform を使い継続的デプロイメントによりシステムを運用管理すると、運用作業の再現性が高くなり運用作業において失敗が起きにくくなります。
そのため、システムの運用管理には構成管理ツールが欠かせなくなりつつあります。しかし、 Terraform などの構成管理ツールは学習コストが発生するため、全員の利用は難しいです。
システムの運用管理のすべてをツールに長けたチームができれば問題ないですが、管理者権限を必要とする作業はツールに長けていない作業者が実施する必要が多いかと思います。
そうすると、管理者権限を必要とする作業を大量に実施する場合、オペレーションミスが起きる可能性が高くなります。

そこで、もし、Google Sheets などのユーザーフレンドリーな UI を有しているツールから構成管理ツールを操作できれば、全員が構成管理ツールを使いシステムの運用管理を実現できるようになり、管理者権限での作業ミスの可能性が低くなります。

本記事では、 Google Apps Script (GAS) を使い Google Sheets から Terraform を操作し、 プロジェクトを作成する方法を説明します。以下では、 当該処理の実現方法と、実装方法について説明します。

システムの設計

GAS から Terraform を実行するシステムは、以下のようになります。

システム構成

処理は以下のようになります。

  1. GAS で Google Sheets のデータを CSV 化し、そのデータを GCS にアップロード
  2. GCS の変更を監視する Eventarc がファイルのアップロードを検知し、Workflows に処理命令
  3. Workflows が Terraform を実行する Cloud Build トリガーの実行し、プロジェクトの作成

以下では、GAS での GCS へのファイルのアップロード方法、Eventarc で監視をし Workflows から Cloud Build トリガーを実行する方法を説明します。

本記事でGASから実行する Terraform コード

本記事では、以下の Google Cloud のプロジェクトを作成する Terraform コードを Google Sheets から実行します。

terraform {
  backend "gcs" {
    bucket = <GCSBUCKET>
  }

  required_providers {
    google = {
      source = "hashicorp/google"
    }
    google-beta = {
      source = "hashicorp/google-beta"
    }
  }
}

locals {
  projects = csvdecode(file("../configs/projects.csv"))
}

resource "google_project" "main" {
  for_each = { for v in local.projects : v.project_id => v }

  name            = each.value.name
  project_id      = each.value.project_id
  folder_id       = each.value.folder_id
  billing_account = each.value.billing_account
}

GCSBUCKET は、Terraform の State を管理するバケット名です。
当該 google_project リソースの設定値である local.projects は、CSV ファイルで管理しており、以下のようなデータとなっています。

No,name,project_id,folder_id,billing_account
1,sample,sample-pj,,
2,test,test-pj,,

本記事では、上記の projects.csv を Google Sheets で管理し、GAS を使い Google Sheets から当該 Terraform コードを実行させます。

GAS で Google Sheets のデータを GCS にアップロードする方法

GAS で Google Sheets のデータを GCS にアップロードするには、以下をおこないます。

  • GAS のコード作成
  • Google Cloud で ウェブアプリケーションの OAuth クライアント ID の作成
  • GAS に認証情報の登録

GAS のコード作成

Google Sheets の拡張機能から Apps Script を選択し、 Apps Script のスクリプトエディタを起動させます。

GAS

コードを記載する以下の赤枠にコードを記載し、保存します。
GAS

var SHEET_NAME = 'Project';
var GCS = PropertiesService.getScriptProperties().getProperty("GCS");
var FILE = 'pj_config.csv'
var CLIENTID = PropertiesService.getScriptProperties().getProperty("CLIENTID");
var CLIENTSECRET = PropertiesService.getScriptProperties().getProperty("CLIENTSECRET");

function convertToCSV(){
	var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
	var values = sheet.getDataRange().getValues();
	var csv = values.join("\r\n");
	return csv;
}

function requestGCBJob(){
	var csv = convertToCSV();
	var content = Utilities.newBlob(csv)
	if(storeContentsIntoGCS(content, GCS, FILE)){
		Browser.msgBox('プロジェクト作成依頼を実行しました。 Cloud Buildの履歴を確認してください。');		
	}
}

function getService() {
	return OAuth2.createService('provisioning')
		.setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/auth')
		.setTokenUrl('https://accounts.google.com/o/oauth2/token')
		.setClientId(CLIENTID)
		.setClientSecret(CLIENTSECRET)
		.setCallbackFunction('auth')
		.setPropertyStore(PropertiesService.getScriptProperties())
		.setScope('https://www.googleapis.com/auth/devstorage.full_control')
		.setParam('login_hint', Session.getActiveUser().getEmail())
}

function auth(request) {
	var service = getService();
	var isAuthorized = service.handleCallback(request);
	if (isAuthorized) {
		Logger.log("認証成功");
		return HtmlService.createHtmlOutput('<center>認証完了<br>タブを閉じてください。</center>');
	} else {
		Logger.log("認証失敗");
		return HtmlService.createHtmlOutput('認証エラー<br>認証情報を確かめてください。');
	}
}

function storeContentsIntoGCS(content, gcs, file) {
	var service = getService();
	if (!service.hasAccess()) {
		var authorizationUrl = service.getAuthorizationUrl();
		var template = HtmlService.createTemplate(
			'<center><a href="<?= authorizationUrl ?>" target="_blank">認証実行</a></center>' +
				'<br><br><center>Google Accountの認証が必要です。<br>認証後に再度タスクを実行ください。</center>'
		);
		template.authorizationUrl = authorizationUrl;
		var page = template.evaluate();
		SpreadsheetApp.getUi().showModalDialog(page, "Google API認証");
		return false
	} else {
		var url='https://storage.googleapis.com/' + gcs + '/' + file;
		UrlFetchApp.fetch(url,{
			headers: {
				Authorization: "Bearer " + service.getAccessToken(),
			},
			method: "PUT",
			contentType: "application/javascript;charset=utf-8",
			host: gcs + ".storage.googleapis.com",
			payload: content
		});
	}
	return true;
}

function onOpen(){
	var ui = SpreadsheetApp.getUi();
	ui.createMenu('タスク実行')
	    .addItem('プロジェクト作成', 'requestGCBJob')
	    .addToUi();
}
  • SHEET_NAME: データを登録するシート名
  • GCS: CSV ファイルをアップロードする GCS バケット名
  • FILE: GCS にアップロードするときのファイル名
  • CLIENTID: OAuth2 のクライアントID
  • CLIENTSECRET: OAuth2 のクライアントシークレット

上記パラメータのうち、 GCS,CLIENTID,CLIENTSECRET は、スクリプトプロパティとなっています。これらの値は、スクリプトエディタのプロジェクトの設定から設定します。
画面下のスクリプトプロパティから設定します。

スクリプトプロパティ

CLIENTIDCLIENTSECRET は Google Cloud から生成し入力します。 oauth2.provisioning は、 OAuth2 のライブラリを追加すると自動的に入力されます。
しばらくすると、ヘルプの横に タスク実行 表示され、選択すると プロジェクト生成 のボタンが表示されます。

Google Sheets

OAuth2 の CLIENTID と CLIENTSECRET の生成方法

OAuth2 の CLIENTIDCLIENTSECRET は、Google Cloud の API とサービスの認証情報から作成します。
認証情報を作成 のボタンを選択し、OAuth クライアント ID を選択し、クライアント ID を生成します。

Google Sheets UI

アプリケーションの種類では、ウェブアプリケーションを選択し、承認済みのリダイレクト URI に GAS の URI を入力します。
以下の値を入力し、保存を選択します。

https://script.google.com/macros/d/<GAS_ID>/usercallback

  • GAS_ID: GAS の ID で GAS のプロジェクト設定から確認できる値

Configuring OAuth2 ClientID

保存して生成される クライアント IDクライアントシークレット のそれぞれが、 CLIENTID,CLIENTSECRET となっています。

Checking OAuth2 Client Configs

GAS の Oauth2 ライブラリの追加

本スクリプトは、Google Cloud の GCS にデータを CSV に変換し、アップロードします。
そのため、 Google Cloud への認証が必要となります。本コードは、 OAuth2 のライブラリを使い認証させファイルをアップロードするため、OAuth2 のライブラリを追加する必要があります。

スクリプトエディタのライブラリの追加ボタンを選択し、スクリプトIDに 1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF を入力し、
検索ボタンを押し OAuth2 検索し、最新のバージョンを選び追加します。

Adding OAuth2 Library From Script Editor
Searching OAuth2 Library
Adding OAuth2 Library

Terraform コードを実行するための Google Cloud リソース

Terraform を実行するための以下の Google Cloud のリソースの作成方法を説明します。

  • Cloud Build
  • GCS
  • Workflows
  • Eventarc

Eventarc は、GCS と Workflows の情報を確認して作成されるので、最後に作成する必要があるので、作成の順序に気をつけてください。
また、Workflows の flows の設定には、 Cloud Build IDが必要となり、当該 ID もまた、トリガーの作成の後にしか確認できないので、ご注意ください。

Terraform を実行する Cloud Build の作成

Terraform コードの実行について、今回は Cloud Build を用いておこなう。
Cloud Build は、 terraform plan を実行し変更内容を確認するトリガーと、 terraform apply を実行し変更を実行する2つのトリガーを作成します。
terraform apply を実行し変更するトリガーは、 Approve を有効にし、変更内容の確認の後に承認者が明示的に実行するようします。

今回は検証のため、手動実行するトリガーを作成するものとします。
そのため、連携した Cloud Build に連携した GitHub リポシトリに変更があっても、起動しません。

terraform plan を実行する変更内容を確認するトリガーは、以下のコマンドで実行します。

gcloud alpha builds triggers create manual --name $TFPLANTRIGGER \
	   --project $PJ \
	   --region $LOCATION \
	   --branch main \
	   --repo $REPO \
	   --repo-type $TYPE \
	   --build-config $TFPLANFILE

当該トリガーのビルド構成ファイルの内容は、以下となっています。

steps:
  - id: get pj config files
    name: 'gcr.io/cloud-builders/gcloud'
    entrypoint: /bin/sh
    dir: src/org/project/configs
    args:
      - -c
      - |
        gcloud storage cp gs://${PROJECT_ID}-mng-pj-configs/pj_config.csv projects.csv 
        
  - id: terraform init
    name: 'hashicorp/terraform'
    entrypoint: /bin/sh
    dir: src/org/project/create_project_terraform_codes
    args:
      - -c
      - |
        terraform init

  - id: terraform plan
    name: 'hashicorp/terraform'
    entrypoint: /bin/sh
    dir: src/org/project/create_project_terraform_codes
    args:
      - -c
      - |
        terraform plan

続いて、 terraform apply を実行し、構成変更をおこなうトリガーは以下のコマンドで作成します。

gcloud alpha builds triggers create manual --name $TFAPPLYTRIGGER \
	   --project $PJ \
	   --region $LOCATION \
	   --branch main \
	   --repo $REPO \
	   --repo-type $TYPE \
	   --build-config $TFAPPLYFILE \
	   --require-approval

構成変更をおこなうトリガーのビルド構成ファイルの内容は、以下となっています。

steps:
  - id: get pj config files
    name: 'gcr.io/cloud-builders/gcloud'
    entrypoint: /bin/sh
    dir: src/org/project/configs
    args:
      - -c
      - |
        gcloud storage cp gs://${PROJECT_ID}-mng-pj-configs/pj_config.csv projects.csv 
        
  - id: terraform init
    name: 'hashicorp/terraform'
    entrypoint: /bin/sh
    dir: src/org/project/create_project_terraform_codes
    args:
      - -c
      - |
        terraform init

  - id: terraform apply
    name: 'hashicorp/terraform'
    entrypoint: /bin/sh
    dir: src/org/project/create_project_terraform_codes
    args:
      - -c
      - |
        terraform apply --autoapprove

当該 Cloud Build トリガーは、 GCS バケットにアクセスするため、GCS バケットへの権限を付与させます。
権限の付与は以下でおこないます。

gcloud projects add-iam-policy-binding $PJ \
	   --member serviceAccount:$(gcloud beta services identity create --service cloudbuild.googleapis.com --format value'(email)') \
	   --role roles/storage.objectAdmin

また、今回、プロジェクトの作成をおこなうので、プロジェクトの作成権限を付与します。

gcloud organizations add-iam-policy-binding $ORGID\
	--member serviceAccount:$(gcloud beta services identity create --service cloudbuild.googleapis.com --format value'(email)') \
	--role roles/resourcemanager.projectCreator

GAS から送信される GCS の作成

GAS から送信される CSV ファイルを保存する GCS は、以下のコマンドで作成します。

gcloud storage buckets create --project $PJ gs://$GCS

また、 GCS のサービスエージェントは、変更を Pub/Sub に通知するの pubsub パブリッシャーの権限が必要となります。
以下のコマンドで権限を付与します。

gcloud projects add-iam-policy-binding $PJ \
	   --member serviceAccount:$(gcloud storage service-agent --project $PJ) \
	   --role roles/pubsub.publisher

Cloud Build トリガーを実行する Workflows の作成

Cloud Build トリガーは、 Workflows から発火させます。
今回、変更を実行するトリガーは Approve ボタンを押さないと、実行されないので、
Workflows からは両者のトリガーを並行に実行させます。

Workflows の作成は、 以下のコマンドで作成します。

gcloud workflows deploy $WF \
	   --project $PJ \
	   --location $LOCATION \
	   --source $FILE

ソースのファイルの内容は以下となります。

- init:
    assign:
      - project_id: ${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
      - location: "asia-northeast1"
      - branch: "main"
	  - plan_trigger: <Terraform Plan Cloud Build Trigger ID>
	  - apply_trigger: <Terraform Apply Cloud Build Trigger ID>

- invoke_builds:
    parallel:
      shared: [project_id,location,branch,plan_trigger,apply_trigger]
      branches:
        - terrafom_plan:
            steps:
              - plan_tr_cloudbuild_run:
                  call: googleapis.cloudbuild.v1.projects.locations.triggers.run
                  args:
                    name: ${"projects/" + project_id + "/locations/" + location + "/triggers/" + plan_trigger}
                    body:
                      projectId: ${project_id}
                      source:
                        branchName: ${branch}
                      triggerId: ${plan_trigger}

        - terrafom_apply:
            steps:
              - apply_tr_cloudbuild_run:
                  call: googleapis.cloudbuild.v1.projects.locations.triggers.run
                  args:
                    name: ${"projects/" + project_id + "/locations/" + location + "/triggers/" + apply_trigger}
                    body:
                      projectId: ${project_id}
                      source:
                        branchName: ${branch}
                      triggerId: ${apply_trigger}
        
- the_end:
    return: "SUCCESS"

上記のうち以下の内容は、 Cloud Build トリガーのIDです。

  • <Terraform Plan Cloud Build Trigger ID>: 変更を確認する Cloud Build トリガーの ID
  • <Terraform Apply Cloud Build Trigger ID>: 変更を実行する Cloud Build トリガーの ID

以上より先に作成した Cloud Build のトリガーIDを確認します。
トリガーIDの確認は、以下のコマンドでおこないます。

gcloud builds triggers list --region $LOCATION --project $PJ --format value'(name,id)'

Workflows を起動する Eventarc の作成

Workflows は、 Eventarc から起動させます。 Eventarc は、GCS の変更を監視し、 先に作成した Workflows を起動するように作成します。

Eventarc の作成では、サービスアカウントを指定する必要があるので、サービスアカウントを作成し、起動に必要な権限を付与してから、Workflows を作成します。
サービスアカウントには、 Workflows InvokerEventarc Event Reciver の権限を付与させます。

サービスアカウントの作成は、以下のコマンドでおこないます。

gcloud iam service-accounts create --project $PJ $SA

サービスアカウントへの権限付与は、以下のコマンドでおこないます。

gcloud projects add-iam-policy-binding $PJ \
	   --member serviceAccount:$SA@$PJ.iam.gserviceaccount.com \
	   --role roles/eventarc.eventReceiver
	   
gcloud projects add-iam-policy-binding $PJ \
	   --member serviceAccount:$SA@$PJ.iam.gserviceaccount.com \
	   --role roles/workflows.invoker

Workflows は以下のコマンドで作成します。

gcloud eventarc triggers create $EVENTARC \
	   --project $PJ \
	   --location $LOCATION \
	   --destination-workflow $WF \
	   --destination-workflow-location $LOCATION \
	   --event-filters="type=google.cloud.storage.object.v1.finalized" \
	   --event-filters="bucket=$GCS" \
	   --service-account $SA@$PJ.iam.gserviceaccount.com

GAS の 実行方法

GAS の作成をおこない、 Google Cloud 側のリソースを作成すると、実行できるようになります。Google Sheets の タスク実行 の プロジェクト作成 ボタンを選択すると、認証画面が表示されるので、認証ボタンを押し認証させます。認証完了の後に再度、プロジェクト作成 ボタンを押すと、処理が成功するので、Cloud Build の履歴画面を確認ください。

Google Sheets
Auth Popup
Done Auth
Done Task

しばらくすると、2つのタスクが実行されるので、実行中の履歴を確認し、問題がなければ、承認待ちのタスクを実行すると、プロジェクトの作成が実行されます。

Task List
Plan Task
Approve

まとめ

Google Sheets から Terraform を実行し、 Google Cloud のプロジェクトを作成する方法を説明しました。Google Sheets のようなユーザーフレンドリーな UI を使えば、誰でも Terraform を使えるようになるので組織全体で一貫した構成管理が可能となります。しかし、表形式のため配列などの表現はできないので、視認性が下がる点はあるので、すべてを当該方法で管理するのはおすすめしませんが、 Google Cloud のプロジェクトの作成や、 Google アカウントの作成など高権限を必要とするタスクは当該方法で実行するのもよいかもしれません。

最後に、当該 Google Cloud のリソースを作成する Terraform コードと Workflows の yaml ファイルを紹介して終わりにいたします。もし、気になった方は、活用ください。

variable "project" {}

locals {
  region = "asia-northeast1"
  roles = [
    "roles/eventarc.eventReceiver",
    "roles/workflows.invoker"
  ]
  
  wf_config = yamldecode(file("../workflows/create_project.yaml"))

  _init = {
    init = {
      assign = flatten(concat([for v in local.wf_config : try(v.init.assign, [])], [{
        plan_trigger = google_cloudbuild_trigger.main["pj-terraform-plan"].trigger_id
        }, {
        apply_trigger = google_cloudbuild_trigger.main["pj-terraform-apply"].trigger_id
      }]))
    }
  }

  _wf_config = concat([local._init], [element(local.wf_config, 1)], [element(local.wf_config, 2)])
  
  tr_config = {
    repo = "https://github.com/REPONAME"
    ref  = "refs/heads/main"
    type = "GITHUB"
  }

  triggers = [
    {
      name = "pj-terraform-plan"
      file = "BUILDFILEPATH_TERRAFORMPLAN"
    },
    {
      name     = "pj-terraform-apply"
      file     = "BUILDFILEPATH_TERRAFORMAPPLY"
      approval = true
    }
  ]
}

resource "google_service_account" "main" {
  account_id = "evt-wf-kick"

  project = var.project
}

resource "google_project_iam_member" "main" {
  for_each = toset(local.roles)
  project  = var.project

  role   = each.value
  member = format("serviceAccount:%s", google_service_account.main.email)
}

resource "google_storage_bucket" "main" {
  name = format("%s-mng-pj-configs", var.project)

  location      = local.region
  force_destroy = true
  project       = var.project
  storage_class = "STANDARD"
}

resource "google_eventarc_trigger" "main" {
  depends_on = [
    google_project_iam_member.main,
    google_project_iam_member.gcs_sa
  ]
  name = "pj-create-wf-kick"

  project  = var.project
  location = local.region

  matching_criteria {
    attribute = "type"
    value     = "google.cloud.storage.object.v1.finalized"
  }

  matching_criteria {
    attribute = "bucket"
    value     = format("%s-mng-pj-configs", var.project)
  }

  destination {
    workflow = google_workflows_workflow.main.id
  }

  service_account = google_service_account.main.email
}

resource "google_workflows_workflow" "main" {
  name = "invoke-pj-tf-plan-job"

  project         = var.project
  region          = local.region
  source_contents = yamlencode(local._wf_config)
}

resource "google_cloudbuild_trigger" "main" {
  for_each = { for v in local.triggers : v.name => v }

  name = each.value.name

  location = local.region
  project  = var.project

  source_to_build {
    uri       = local.tr_config.repo
    ref       = local.tr_config.ref
    repo_type = local.tr_config.type
  }

  git_file_source {
    path      = local.tr_config.file
    uri       = local.tr_config.repo
    revision  = local.tr_config.ref
    repo_type = local.tr_config.type
  }

  dynamic "approval_config" {
    for_each = try(each.value.approval, null) != null ? [each.value.name] : []

    content {
      approval_required = each.value.approval
    }
  }
}

data "google_storage_project_service_account" "main" {
  project = var.project
}


resource "google_project_iam_member" "gcs_sa" {
  project = var.project

  role   = "roles/pubsub.publisher"
  member = format("serviceAccount:%s", data.google_storage_project_service_account.main.id)
}
- init:
    assign:
      - project_id: ${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
      - location: "asia-northeast1"
      - branch: "main"

- invoke_builds:
    parallel:
      shared: [project_id,location,branch,plan_trigger,apply_trigger]
      branches:
        - terrafom_plan:
            steps:
              - plan_tr_cloudbuild_run:
                  call: googleapis.cloudbuild.v1.projects.locations.triggers.run
                  args:
                    name: ${"projects/" + project_id + "/locations/" + location + "/triggers/" + plan_trigger}
                    body:
                      projectId: ${project_id}
                      source:
                        branchName: ${branch}
                      triggerId: ${plan_trigger}

        - terrafom_apply:
            steps:
              - apply_tr_cloudbuild_run:
                  call: googleapis.cloudbuild.v1.projects.locations.triggers.run
                  args:
                    name: ${"projects/" + project_id + "/locations/" + location + "/triggers/" + apply_trigger}
                    body:
                      projectId: ${project_id}
                      source:
                        branchName: ${branch}
                      triggerId: ${apply_trigger}
        
- the_end:
    return: "SUCCESS"
  • REPONAME: GitHub のリポシトリ名
  • BUILDFILEPATH_TERRAFORMPLAN: Terraform Plan を実行する Build 構成ファイルの格納先
  • BUILDFILEPATH_TERRAFORMAPPLY: Terraform Apply を実行する Build 構成ファイルの格納先

Discussion