Cloud Run functions × TypeScript 開発をしよう！【セキュリティ：Cloud Run functionsの関数からシークレットにアクセスする】

# はじめに
こちらの記事では以下のことについて説明しています。

- Cloud Run functionsからSecret Managerを使うための環境構築
- ローカル環境からSecret Managerのデータにアクセスする方法

```bash: ディレクトリ構成
.
├── dist
├── node_modules
├── my-secret.json    # シークレット用のjson
├── main.tf           # terraformファイル
├── jest.config.js
├── package-lock.json
├── package.json
├── package.prod.json # デプロイ時に使う簡素なpackage.json。前記事参考
├── src
│   ├── hoge
│   └── index.ts
├── test
└── tsconfig.json
```

---

# 前準備
以下のコマンドを実行して、Googleのライブラリが自分のプロジェクトにアクセスできるよう認証を済ませておきましょう。

```bash
gcloud auth application-default login
```

# Node.js用の Secret Manager ライブラリをインスコする
Cloud Run functionsからSecret Managerを使えるようにするために、Node.js用のSecret Managerクライアントライブラリをインスコします。
以下から探します。

https://github.com/googleapis/google-cloud-node/tree/main/packages/google-cloud-secretmanager

```bash: Secret Managerクライアントのダウンロード
npm install --save-prod @google-cloud/secret-manager
```

ついでに本番用の `package.prod.json` も更新します。ここの手順が抜けて事故にならないように自動化したいですね。

```js: package.prod.json
{
  "name": "advent-calendar-2024",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/functions-framework": "^3.4.2",
    "@google-cloud/secret-manager": "^5.6.0" // 追加
  }
}
```
# Secret Manager呼び出しコードを書く
シークレットバージョンにアクセスする関数を書きます。一部ハードコーディングになっていますがお許しを。
```ts: シークレットバージョンにアクセスする関数
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';

const client = new SecretManagerServiceClient();

const accessSecretVersion = async (secretName: string): Promise<string | null> =>  {
  const [version] = await client.accessSecretVersion({
    name: `projects/プロジェクト番号/secrets/${secretName}/versions/latest`,
  });

  if (version && version.payload && version.payload.data) {
    return version.payload.data.toString();
  }

  return null;
}
```

そしてSecretのバージョンを取得してその内容をレスポンスに表示します（御法度ですが今回はサンプルなので...）

```ts: シークレットのバージョンを取得してレスポンス文字列に含める
export const helloGET: HttpFunction =  (req: ff.Request, res: ff.Response) => {
  const mySecret = accessSecretVersion("my-secret");
  res.send(`Hello, World! Secret is ${mySecret}.`);
};
```

全体像はこうなります。

```ts: index.ts
import * as ff from '@google-cloud/functions-framework'
import type { HttpFunction } from "@google-cloud/functions-framework";

/**
 * Secret Managerクライアントの処理追加
 */
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';
const client = new SecretManagerServiceClient();

const accessSecretVersion = async (secretName: string): Promise<string | null> =>  {
  const [version] = await client.accessSecretVersion({
    name: `projects/advent-calendar-2024-w/secrets/${secretName}/versions/latest`,
  });

  if (version && version.payload && version.payload.data) {
    return version.payload.data.toString();
  }

  return null;
}

// エントリーポイント関数
export const helloGET: HttpFunction =  async (req: ff.Request, res: ff.Response) => {
  const mySecret 
      = await accessSecretVersion("my-secret"); // 非同期通信なことを忘れずに
  res.send(`Hello, World! Secret is ${JSON.stringify(mySecret)}.`);
};
```

そしてこれを `npm run build` して `terraform plan` → `terraform apply` してみましょう。

```bash
╷
│ Error: Error waiting for Updating function: Error code 3, message: Could not create or update Cloud Run service 関数名, Container Healthcheck failed. Revision '関数名-00003-nor' is not ready and cannot serve traffic. The user-provided container failed to start and listen on the port defined provided by the PORT=8080 environment variable within the allocated timeout. This can happen when the container port is misconfigured or if the timeout is too short. The health check timeout can be extended. Logs for this revision might contain more information.
│ 
│ Logs URL: https://console.cloud.google.com/logs/viewer?project=プロジェクト名resource=cloud_run_revision/service_name/sample-crf/revision_name/関数名-00003-nor&advancedFilter=resource.type%3D%22cloud_run_revision%22%0Aresource.labels.service_name%3D%22sample-crf%22%0Aresource.labels.revision_name%3D%22関数名-00003-nor%22 
│ For more troubleshooting guidance, see https://cloud.google.com/run/docs/troubleshooting#container-failed-to-start
│ 
│   with google_cloudfunctions2_function.default,
│   on main.tf line 40, in resource "google_cloudfunctions2_function" "default":
│   40: resource "google_cloudfunctions2_function" "default" {
│ 
╵
```

あれ？失敗した？
ログを見ると以下のようなエラーが出ています。

```
Provided module can't be loaded.
Is there a syntax error in your code?
Detailed stack trace: Error: Dynamic require of "process" is not supported
    at file:///workspace/index.js:1:382
    at file:///workspace/index.js:1:76808
    at file:///workspace/index.js:1:494
    at file:///workspace/index.js:1:78508
    at file:///workspace/index.js:1:494
    at file:///workspace/index.js:1:80850
    at file:///workspace/index.js:1:494
    at file:///workspace/index.js:21:90034
    at file:///workspace/index.js:1:494
    at file:///workspace/index.js:64:670591
Could not load the function, shutting down.
```

なんか構文エラーが発生していますね。

これ、実は**Node.js用の Secret Manager クライアントライブラリは commonjs 形式で書かれており、esbuild がESMでは使えない commonjs 特有の機能を特に変換せずそのままビルド結果に含めてしまったが故にこのようなエラーが発生しております。** なのでそのままビルドしただけでは ESM形式で動く形になりません。
（ちなみにParcelでビルドしたら問題なく動きます。なんやねん）

ということで既存のビルドスクリプトを改良した、esbuild の実行ファイル `build.ts` を作りましょう。`banner` のプロパティを追加し、ESM形式で動く形に変換する処理を書きます。おまじないなのでそのままコピーして使用してください。

```ts:./build.ts
// package.json の "build" スクリプト
// esbuild ./src/index.ts \
// --bundle \
// --minify \
// --sourcemap \
// --platform=node \
// --format=esm \
// --outfile=./dist/index.js"
// に banner のプロパティを追加して切り出したもの。

import esbuild from 'esbuild';

await esbuild.build({
  logLevel: 'info',
  entryPoints: ['./src/index.ts'],
  outdir: './dist',
  minify: true,
  bundle: true,
  sourcemap: true,
  platform: 'node',
  format: 'esm',
  banner: { // ここで commonjs 特有の機能をESMで動くように変換する
    js: 'import { createRequire as topLevelCreateRequire } from "module"; import url from "url"; const require = topLevelCreateRequire(import.meta.url); const __filename = url.fileURLToPath(import.meta.url); const __dirname = url.fileURLToPath(new URL(".", import.meta.url));',
  },
})
```

### 参考にした神記事
https://zenn.dev/junkor/articles/2bcd22ca08d21d#esbuild%E3%81%AE%E3%83%93%E3%83%AB%E3%83%89%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3

そして `package.json` のビルドコマンドも変えておきましょう。

```js: package.json / buildスクリプト
{
  "scripts": {
    "build": "tsx build.ts",
}
```

`npm run build` を実行して `terraform plan` → `terraform apply` をしましょう。

```bash
Apply complete! Resources: 1 added, 1 changed, 1 destroyed.

Outputs:

function_uri = "https://xxx.a.run.app"
```

表示されたURLを curl で呼んでみます。

```bash
$ curl https://xxx.a.run.app
Hello, World! Secret is "{\n    \"hoge\": \"hoge\"\n}".
```

無事呼び出されましたね。
ちなみにローカル環境でも同様にSecret Managerの呼び出しが可能です。`gcloud auth application-default login` の実行を忘れずにね。

```bash
$ npm run start

> advent-calendar-2024@1.0.0 start
> tsx --inspect --watch node_modules/.bin/functions-framework --target=helloGET --source=./src/index.ts

Debugger listening on ws://127.0.0.1:9229/13fad6ee-e1f4-49e2-8dcd-a11df4aab6d4
For help, see: https://nodejs.org/en/docs/inspector
Serving function...
Function: helloGET
Signature type: http
URL: http://localhost:8080/
---

$ curl http://localhost:8080
Hello, World! Secret is "{\n    \"hoge\": \"hoge\"\n}".                                
```

# おわりに
Secret Manager for Node.js の環境構築とコード、ESM用の動かし方、そしてローカル環境で動かす方法について解説しました。次もセキュリティ系でなんか書きます。

# Appendix
## 便利リンク
### シークレットを作成する / アクセスする
https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets?hl=ja

### Cloud Run functionsでシークレットを構成する方法
https://cloud.google.com/functions/docs/configuring/secrets?hl=ja

### Secret Managerクライアントライブラリについて
https://cloud.google.com/secret-manager/docs/reference/libraries

### Secret Manager コードサンプル for Node.js
https://cloud.google.com/secret-manager/docs/samples?hl=ja&language=nodejs

### Secret Manager for Node.js 公式ページ
https://googleapis.dev/nodejs/secretmanager/latest/index.html

## トラブルシューティング
### Error: 7 PERMISSION_DENIED: Permission 'secretmanager.versions.access' denied for resource 'projects/xxx/secrets/シークレット名/versions/latest' (or it may not exist).

#### 原因
プログラム上のGoogle Cloud SDKがプロジェクトにアクセスする権限がないため。

#### 解決策
以下を実行してアプリケーション認証を行う。

```bash
gcloud auth application-default login
```

https://cloud.google.com/docs/authentication/application-default-credentials?hl=ja#personal

https://cloud.google.com/docs/authentication/provide-credentials-adc?hl=ja#service-account