GoogleフォームとGASでGoogle Cloudプロジェクト作成を完全自動化してみた


Googleフォーム・GAS・GitLab・Terraformを組み合わせて、Google Cloudプロジェクト作成をセルフサービス化してみました。

1. はじめに

Google Cloudの利用が社内に広がってくると、「新しくプロジェクトを作りたい」という申請が徐々に増え、そのたびにインフラ管理者がTerraformのコードを書いて対応する、という運用は次第に負担になっていきます。

そこで今回は、申請・承認・Terraformによるプロジェクト作成までを一気通貫で自動化する仕組みを構築してみました。使用した技術要素は、Googleフォーム、Google Apps Script(GAS)、GitLab、Terraform(Terraform Cloud)の4つです。申請者はフォームに入力するだけ、インフラ管理者はGitLabのマージリクエストを承認するだけで、Google Cloudのプロジェクトが作成されるセルフサービス型の仕組みを目指しました。

この仕組みで払い出したGoogle Cloudプロジェクトは、Compute Engine(GCE)、Google Kubernetes Engine(GKE)、Cloud Runなどのサービスを利用するための基盤になります。本記事では、これらの個別リソースを作成するのではなく、その前段となるプロジェクト作成とIAM設定の自動化に焦点を当てます。

本記事では、その構築方法と、実際に動かしてみた様子、そして構築の過程でハマった落とし穴について紹介します。

2. 自動化の全体アーキテクチャ

今回構築した仕組みの全体像は以下の通りです。

申請者からTerraform Cloudまでの自動化アーキテクチャ図

図1 自動化の全体アーキテクチャ

申請者がGoogleフォームにプロジェクト情報を入力すると、回答は連携先のGoogleスプレッドシートに自動で記録されます。フォームの送信をトリガーにGAS(Google Apps Script)が起動し、入力内容をもとにTerraformのHCLコードを動的に生成します。生成したコードはGitLabのREST APIを通じて新規ブランチにコミットされ、そのままマージリクエスト(MR)として自動作成されます。

インフラ管理者は、このMRの内容をレビューし、問題がなければ承認・マージするだけです。マージをきっかけにTerraform Cloud側のパイプラインが実行され、実際にGoogle Cloud上にプロジェクトが作成される、という流れになっています。申請者が意識するのはフォーム入力だけ、インフラ管理者が意識するのはMRの承認だけ、という状態を実現できたのが今回のポイントです。

3. 構築のステップ

実際にどのように構築したのか、順を追って紹介します。

Step1:Googleフォームとスプレッドシートの作成

まずはGoogleフォームを作成し、申請に必要な項目を用意しました。用意した項目は次の4つです。

  • ・プロジェクト名:記述式
  • ・プロジェクトID:記述式
  • ・管理者メールアドレス:記述式
  • ・環境:ラジオボタン(本番/開発/検証)

このうちプロジェクトIDは、Google Cloud側の命名ルール(小文字の英数字とハイフンのみ)に沿わないと後工程のTerraformでエラーになってしまいます。そこでフォームの「回答の検証」機能を使い、正規表現 ^[a-z0-9-]+$ を指定することで、小文字英数字とハイフン以外を入力できないようにしました。

申請用Googleフォームの入力画面(プロジェクト名・プロジェクトID・管理者メールアドレス・環境)

図2 申請用Googleフォームの入力画面

フォームの回答は、標準機能でGoogleスプレッドシートに自動連携されるように設定しました。このスプレッドシートを、後続のGASが読み取るデータソースとして使用します。

Step2:GitLabのトークンとプロジェクトIDの取得

GASからGitLabを操作できるようにするため、GitLab側で個人アクセストークン(スコープは「api」)を発行し、あわせて操作対象リポジトリのプロジェクトID(数字)も控えておきます。これらは、GASのスクリプト内でGitLabのREST APIを呼び出す際の認証情報・宛先として使用します。

Step3:GAS(Apps Script)の作成

スプレッドシートの「拡張機能」からApps Scriptエディタを開き、スクリプトを実装していきます。処理の中身を大まかに説明すると、次のような役割を持つ処理を用意しました。

  • ・申請データの取得:スプレッドシートから最新のフォーム回答を読み取る処理
  • ・Terraformコードの生成:読み取った内容をもとに、google_project・google_project_iam_memberといったリソースのHCLコードを組み立てる処理
  • ・GitLabへの反映:GitLab APIを呼び出し、ブランチ作成→ファイルのコミット→マージリクエスト作成までを順番に実行する処理

実装にあたっては、GitLabのトークンや対象プロジェクトID、読み込むシート名などをスクリプト冒頭の設定用の定数(CONFIG/GITLAB_CONFIG)としてまとめておき、環境が変わっても該当箇所だけ書き換えれば良いようにしています。実際に使用しているGASのコード全文は以下の通りです。

なお、コード中のトークンやプロジェクトIDなどの重要情報はそのまま公開せず、下記の通り伏字にしています。ご自身の環境で利用する際は、該当箇所を個別に設定してください。

  • ・GITLAB_CONFIG.TOKEN:GitLabの個人アクセストークンです。本記事では伏字(xxxxxxxxxxxxx)にしています。コードに直接書き込むのではなく、GASの「プロジェクトの設定」→「スクリプト プロパティ」に保存し、PropertiesService経由で読み込む形にすることを推奨します。
  • ・GITLAB_CONFIG.PROJECT_ID:操作対象のGitLabリポジトリのプロジェクトIDです。同じく伏字(xxxxxxxx)にしていますので、ご自身の環境の値に置き換えてください。
  • ・CONFIG.SHEET_NAME:フォーム回答が連携されるスプレッドシートのシート名(タブ名)です。実際の環境に合わせて変更してください。
/**
 * ==============================================================================
 * ツール名: Google Sheets to Terraform Converter (Project Creation)
 * 概要: スプレッドシートのプロジェクト申請を読み取り、Terraform(HCL)に変換してGitLabへMRを作成する
 * ==============================================================================
 */

const CONFIG = {
  SHEET_NAME: 'プロジェクト作成申請', // ★実際のスプレッドシートのシート名(タブ名)に合わせて変更してください
  COLUMNS: {
    TIMESTAMP: 'タイムスタンプ',
    PROJECT_NAME: 'プロジェクト名',
    PROJECT_ID: 'プロジェクトID',
    ADMIN_EMAIL: '管理者メールアドレス',
    ENVIRONMENT: '環境'
  }
};

const GITLAB_CONFIG = {
  API_URL: 'https://gitlab.com/api/v4',
  PROJECT_ID: 'xxxxxxxx', // ★GitLabのプロジェクトID(ご自身の値に書き換えてください)
  TOKEN: 'xxxxxxxxxxxxx', // ★有効なGitLabトークンに書き換えてください(コードに直書きせず、スクリプトプロパティでの管理を推奨)
  BASE_BRANCH: 'main',
  FILE_PATH: 'managed_projects.tf' // ★作成・更新するTerraformファイルへのパス
};

function main() {
  const projects = fetchProjectRequests();
  if (projects.length === 0) {
    Browser.msgBox('処理対象のプロジェクト申請がありません。');
    return;
  }
  const terraformCode = generateProjectsHcl(projects);
  createGitLabMrFlow(terraformCode);
}

// スプレッドシートからデータを取得する
function fetchProjectRequests() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  if (!sheet) throw new Error(`シート「${CONFIG.SHEET_NAME}」が見つかりません。`);

  const displayValues = sheet.getDataRange().getDisplayValues();
  const headers = displayValues[0].map(h => h.toString().replace(/[\s\r\n\t ]+/g, ''));
  const rows = displayValues.slice(1);

  const getIdx = (colName) => headers.findIndex(h => h.includes(colName.replace(/[\s\r\n\t ]+/g, '')));

  let projectRequests = [];

  rows.forEach(row => {
    const projectName = row[getIdx(CONFIG.COLUMNS.PROJECT_NAME)];
    const projectId = row[getIdx(CONFIG.COLUMNS.PROJECT_ID)];
    const adminEmail = row[getIdx(CONFIG.COLUMNS.ADMIN_EMAIL)];
    const env = row[getIdx(CONFIG.COLUMNS.ENVIRONMENT)];

    // プロジェクトIDが空の行はスキップ
    if (!projectId) return;

    projectRequests.push({
      name: projectName,
      id: projectId,
      adminEmail: adminEmail,
      env: env
    });
  });

  return projectRequests;
}

// 取得したデータからTerraform(HCL)コードを生成する
function generateProjectsHcl(projects) {
  let hcl = '# ==============================================================================\n';
  hcl += '# このファイルはGAS(セルフサービスフォーム)によって自動生成されています。\n';
  hcl += '# 手動での編集は上書きされるため行わないでください。\n';
  hcl += '# ==============================================================================\n\n';

  projects.forEach(proj => {
    // Terraformのリソース名として使えるように、ハイフンをアンダースコアに変換
    const resourceName = proj.id.replace(/-/g, '_');

    hcl += `# --- プロジェクト: ${proj.name} (${proj.env}環境) ---\n`;
    hcl += `resource "google_project" "proj_${resourceName}" {\n`;
    hcl += `  name            = "${proj.name}"\n`;
    hcl += `  project_id      = "${proj.id}"\n`;
    hcl += `  org_id          = var.organization_id\n`;
    hcl += `  billing_account = var.billing_account_id\n`;
    hcl += `}\n\n`;

    hcl += `# プロジェクト管理者への権限付与\n`;
    hcl += `resource "google_project_iam_member" "admin_${resourceName}" {\n`;
    hcl += `  project = google_project.proj_${resourceName}.project_id\n`;
    hcl += `  role    = "roles/editor"\n`; // ※要件に応じて変更可能
    hcl += `  member  = "user:${proj.adminEmail}"\n`;
    hcl += `}\n\n`;
  });

  return hcl;
}

// --- 以下、GitLab連携ロジック ---

function createGitLabMrFlow(fileContent) {
  const timestamp = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd-HHmmss');
  const newBranchName = `feature/project-create-${timestamp}`;
  try {
    createBranch(newBranchName, GITLAB_CONFIG.BASE_BRANCH);
    const action = fileExists(GITLAB_CONFIG.FILE_PATH, newBranchName) ? 'update' : 'create';
    createCommit(newBranchName, GITLAB_CONFIG.FILE_PATH, fileContent, action);
    const mrUrl = createMergeRequest(newBranchName, GITLAB_CONFIG.BASE_BRANCH, `New Project Request ${timestamp}`);
    Browser.msgBox(`完了!MRが作成されました:\n${mrUrl}`);
  } catch (e) {
    console.error(e.toString());
    Browser.msgBox(`エラーが発生しました:\n${e.toString()}`);
  }
}

function callGitLabApi(method, endpoint, payload = null) {
  const url = `${GITLAB_CONFIG.API_URL}/projects/${GITLAB_CONFIG.PROJECT_ID}${endpoint}`;
  const options = {
    method,
    headers: { 'PRIVATE-TOKEN': GITLAB_CONFIG.TOKEN, 'Content-Type': 'application/json' },
    muteHttpExceptions: true
  };
  if (payload) options.payload = JSON.stringify(payload);
  const response = UrlFetchApp.fetch(url, options);
  const json = JSON.parse(response.getContentText());
  if (response.getResponseCode() >= 400) throw new Error(`GitLab API Error: ${JSON.stringify(json)}`);
  return json;
}

function createBranch(branch, ref) {
  return callGitLabApi('post', `/repository/branches?branch=${branch}&ref=${ref}`);
}

function fileExists(filePath, branch) {
  try {
    callGitLabApi('get', `/repository/files/${encodeURIComponent(filePath)}?ref=${branch}`);
    return true;
  } catch (e) {
    return false;
  }
}

function createCommit(branch, filePath, content, action) {
  return callGitLabApi('post', '/repository/commits', {
    branch,
    commit_message: `Update ${GITLAB_CONFIG.FILE_PATH} (Action: ${action})`,
    actions: [{ action, file_path: filePath, content }]
  });
}

function createMergeRequest(sourceBranch, targetBranch, title) {
  return callGitLabApi('post', '/merge_requests', {
    source_branch: sourceBranch,
    target_branch: targetBranch,
    title
  }).web_url;
}

// スプレッドシートを開いたときにメニューを追加
function onOpen() {
  SpreadsheetApp.getUi().createMenu('⚡プロジェクト自動化')
    .addItem('▶ MR作成を実行する', 'main')
    .addToUi();
}

main関数がトリガーで呼び出されると、スプレッドシートから申請データを読み取り(fetchProjectRequests)、Terraformのリソース定義を組み立て(generateProjectsHcl)、GitLab APIでブランチ作成・コミット・マージリクエスト作成までを一気に実行します(createGitLabMrFlow)。GitLab APIとのやり取りはcallGitLabApiという共通関数に集約しており、ブランチ作成・ファイル存在確認・コミット作成・マージリクエスト作成の各処理は、このcallGitLabApiを呼び出すだけのシンプルな構成になっています。

Step4:完全自動化のためのトリガー設定

最後に、フォームが送信されるたびにこのスクリプトが自動実行されるよう、GASのトリガー設定で「実行する関数」「イベントの種類(スプレッドシートから/フォーム送信時)」を指定します。

GASのトリガー設定画面(スプレッドシートから・フォーム送信時)

図3 GASのトリガー設定

これで、申請者がフォームを送信した瞬間に、スプレッドシートへの記録→GAS起動→Terraformコード生成→GitLabへのMR作成、という一連の流れがすべて自動で走るようになりました。

4. 動作確認

フォーム入力からマージリクエスト作成まで

実際にテスト用のデータでフォームからプロジェクト情報を入力し、動作を確認してみます。ここでは例として、プロジェクト名・プロジェクトIDに「gctech-demopj-01」、管理者メールアドレスに「hisato@gctechlab.com」、環境に「検証」を入力しました。

テスト用データを入力したGoogleフォームの画面

図4 テスト用データを入力したフォーム

フォームを送信すると、スプレッドシートへの記録・GASの起動・Terraformコードの生成・GitLabへのマージリクエスト作成までが自動で行われます。作成されたマージリクエストの差分には、フォームの入力内容がそのまま反映されたgoogle_projectリソースの定義が含まれており、インフラ管理者はコードを1から書くことなく、内容を確認して承認するだけで済みます。

Terraform CloudでのPlan・Apply確認

マージリクエストがマージされると、連携しているTerraform Cloud側でPlanが自動実行され、内容に問題がなければApplyまで進みます。

Terraform CloudでPlanとApplyがどちらも正常に完了した画面

図5 Terraform CloudでのPlan・Apply完了画面

Planの結果には、申請したプロジェクトの作成リソース(google_project.proj_gctech_demopj_01)と、指定した管理者へのIAM付与リソース(google_project_iam_member.admin_gctech_demopj_01)の2件が「追加」として検出されており、Apply完了後にはどちらも正常に作成されたことを確認できました。

5. 構築でハマった4つの落とし穴と解決策

便利な仕組みが完成した一方で、構築の過程ではいくつかの落とし穴にはまりました。同じような構成を検討されている方の参考になればと思い、まとめておきます。

Cloud Identity(Free)だとフォームが作成できない

最初、組織アカウント(Cloud Identityの無料プラン)でGoogleフォームを作成しようとしたところ、フォーム自体を作成できないという問題に直面しました。これはCloud Identity Freeプランの制約によるものです。結果として、フォームとスプレッドシートの作成だけは個人の@gmail.comアカウントで行う、という運用に切り替えることで回避しました。

外部ユーザーへの「roles/owner」付与は弾かれる

Terraformでプロジェクト管理者にroles/owner(オーナー権限)を付与しようとしたところ、ORG_MUST_INVITE_EXTERNAL_OWNERSというエラーで弾かれてしまいました。これは、組織外のユーザーに対してオーナー権限を直接付与できない、という組織ポリシー上の制約によるものです。今回は運用上roles/editorでも十分だったため、付与するロールをroles/ownerからroles/editorに変更することで解決しました。

プロジェクトIDの「禁止ワード」

申請されたプロジェクトIDによっては、project_id contains prohibited wordsというエラーでプロジェクト作成が弾かれることがありました。Google Cloud側で予約・禁止されている単語がIDに含まれていたことが原因です。

Terraform ApplyでプロジェクトIDの禁止ワードエラーが発生した画面

図6 プロジェクトIDの禁止ワードエラー

この問題については、社内のプロジェクトIDに共通のプレフィックス(例:gctechlab-)を付与する運用ルールに変更し、禁止ワードと衝突しにくい命名にすることで対応しました。

存在しないユーザーにはIAMを付与できない

フォームで入力された管理者メールアドレスに対してIAMロールを付与しようとした際、「User ... does not exist」というエラーが発生することがありました。これは、指定されたメールアドレスのユーザーが組織内にまだ作成されていない(Google Workspace上に存在しない)ことが原因でした。この場合はTerraform側だけでは解決できないため、事前にGoogle管理コンソール側でユーザーを作成しておく必要がある、という運用上の注意点として整理しました。

6. 最後に

今回は、Googleフォーム・GAS・GitLab・Terraformを組み合わせることで、Google Cloudプロジェクトの作成をセルフサービス化する仕組みを構築しました。申請者はフォーム入力だけ、インフラ管理者はマージリクエストの承認だけで実際のプロジェクト作成まで完了する状態を作れたのは、大きな収穫でした。

なお、プロジェクトを削除する場合は、Terraformのstateとの整合性を保つため、Google Cloud側から直接削除するのではなく、スプレッドシートから該当行を削除したうえでGASを再実行し、削除用のマージリクエストを作成・マージするという運用にしています。同じような「申請〜承認〜構築」のセルフサービス化を検討されている方の参考になれば幸いです。