お題
GCPにはSecret Managerというのがある。
Kubernetes知ってる人は「Secretsみたいなものか」思うかも。ただk8sのSecretsはBase64エンコードなので、(例えば元データの管理の名目としても)PublicなGitHubリポジトリにアップするわけにいかないけど、Secret Managerの方は暗号化されてるので(鍵を知らなければ)まさにSecret。
その他、ウリみたいなものについては以下参照。
https://cloud.google.com/blog/ja/products/identity-security/introducing-google-clouds-secret-manager
上記参考記事にも記載してあるけど、Secret Managerに限らずGCPのサービス群はCloud SDKを使えば、用意されたコマンドを叩くだけで簡単にGCP上のリソースを操作できる。
なので、今回のお題にあるように、わざわざプログラムでラッパーを書く必要はまったくない。
今回は単にgoogleのsubcommandsというライブラリを使って適当なコマンドラインツールを書く題材としてSecret Managerを操作する機能を簡易ラップしただけ。
想定する読者
- GCPについては知っている。
- Golangもそれなりに書ける。
前提
- ローカルにGoの開発環境構築済み。
- GCP契約済み。
- ローカルでCloud SDKのセットアップ済み。
- ローカルの環境変数GOOGLE_APPLICATION_CREDENTIALSに(必要な権限を全て有したサービスアカウントの)鍵JSONファイルパス設定済み。
開発環境
# OS - Linux(Ubuntu)
$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="18.04.5 LTS (Bionic Beaver)"
# バックエンド
# 言語 - Golang
$ go version
go version go1.15.2 linux/amd64
IDE - Goland
GoLand 2020.2.3
Build #GO-202.7319.61, built on September 16, 2020
今回の全ソース
個別ソース
解説を書こうと思ったけど、googleのsubcommandsを使う上でお約束の記述と、Secret ManagerのSDKを使う上でお約束の記述ばかり(つまり、それぞれのサイトに記載のある情報)なので、ほぼ説明レス。
main.go
機能として、Secretを作成するための「create」コマンドと、作成済みのSecret一覧を表示する「list」コマンドだけ用意。
package main
import (
	"context"
	"flag"
	"os"
	"github.com/google/subcommands"
)
func main() {
	os.Exit(int(execMain()))
}
func execMain() subcommands.ExitStatus {
	subcommands.Register(subcommands.HelpCommand(), "")
	subcommands.Register(newCreateCmd(), "create")
	subcommands.Register(newListCmd(), "list")
	flag.Parse()
	return subcommands.Execute(context.Background())
}
create.go
package main
import (
	"context"
	"flag"
	"fmt"
	"io/ioutil"
	"log"
	secretmanager "cloud.google.com/go/secretmanager/apiv1"
	secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
	"github.com/google/subcommands"
)
type createCmd struct {
	projectID, key, value, path string
}
func newCreateCmd() *createCmd {
	return &createCmd{}
}
func (*createCmd) Name() string {
	return "create"
}
func (*createCmd) Synopsis() string {
	return "create secret"
}
func (*createCmd) Usage() string {
	return `usage: create secret`
}
func (cmd *createCmd) SetFlags(f *flag.FlagSet) {
	f.StringVar(&cmd.projectID, "p", "", "project id")
	f.StringVar(&cmd.key, "k", "", "key")
	f.StringVar(&cmd.value, "v", "", "value")
	f.StringVar(&cmd.path, "f", "", "file path")
}
func (cmd *createCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
	if cmd.projectID == "" || cmd.key == "" || (cmd.value == "" && cmd.path == "") {
		log.Println("need -p [gcp project id] -k [secret key] -v [secret value] or -f [secret file path]")
		return subcommands.ExitFailure
	}
	var client *secretmanager.Client
	{
		var err error
		client, err = secretmanager.NewClient(ctx)
		if err != nil {
			log.Fatalf("failed to setup client: %v", err)
		}
	}
	// Create the request to create the secret.
	createSecretReq := &secretmanagerpb.CreateSecretRequest{
		Parent:   fmt.Sprintf("projects/%s", cmd.projectID),
		SecretId: cmd.key,
		Secret: &secretmanagerpb.Secret{
			Replication: &secretmanagerpb.Replication{
				Replication: &secretmanagerpb.Replication_Automatic_{
					Automatic: &secretmanagerpb.Replication_Automatic{},
				},
			},
		},
	}
	var secret *secretmanagerpb.Secret
	{
		var err error
		secret, err = client.CreateSecret(ctx, createSecretReq)
		if err != nil {
			log.Fatalf("failed to create secret: %v", err)
		}
	}
	// Declare the payload to storage.
	var payload []byte
	if cmd.value != "" {
		payload = []byte(cmd.value)
	}
	if cmd.path != "" {
		ba, err := ioutil.ReadFile(cmd.path)
		if err != nil {
			log.Fatalf("failed to read secret file: %+v", err)
		}
		payload = ba
	}
	if payload == nil {
		log.Fatal("payload is nil")
	}
	// Build the request.
	addSecretVersionReq := &secretmanagerpb.AddSecretVersionRequest{
		Parent: secret.Name,
		Payload: &secretmanagerpb.SecretPayload{
			Data: payload,
		},
	}
	var accessRequest *secretmanagerpb.AccessSecretVersionRequest
	{
		// Call the API.
		version, err := client.AddSecretVersion(ctx, addSecretVersionReq)
		if err != nil {
			log.Fatalf("failed to add secret version: %v", err)
		}
		// Build the request.
		accessRequest = &secretmanagerpb.AccessSecretVersionRequest{
			Name: version.Name,
		}
	}
	// Call the API.
	result, err := client.AccessSecretVersion(ctx, accessRequest)
	if err != nil {
		log.Fatalf("failed to access secret version: %v", err)
	}
	// Print the secret payload.
	//
	// WARNING: Do not print the secret in a production environment - this
	// snippet is showing how to access the secret material.
	log.Printf("Plaintext: %s", result.Payload.Data)
	return subcommands.ExitSuccess
}
list.go
package main
import (
	"context"
	"flag"
	"fmt"
	"log"
	"strings"
	secretmanager "cloud.google.com/go/secretmanager/apiv1"
	"google.golang.org/api/iterator"
	secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
	"github.com/google/subcommands"
)
type listCmd struct {
	projectID string
}
func newListCmd() *listCmd {
	return &listCmd{}
}
func (*listCmd) Name() string {
	return "list"
}
func (*listCmd) Synopsis() string {
	return "list secrets"
}
func (*listCmd) Usage() string {
	return `usage: list secrets`
}
func (cmd *listCmd) SetFlags(f *flag.FlagSet) {
	f.StringVar(&cmd.projectID, "p", "", "project id")
}
func (cmd *listCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
	if cmd.projectID == "" {
		log.Println("need -p [gcp project id]")
		return subcommands.ExitFailure
	}
	client, err := secretmanager.NewClient(ctx)
	if err != nil {
		log.Printf("failed to create secretmanager client: %v", err)
		return subcommands.ExitFailure
	}
	// Build the request.
	req := &secretmanagerpb.ListSecretsRequest{
		Parent: fmt.Sprintf("projects/%s", cmd.projectID),
	}
	// Call the API.
	it := client.ListSecrets(ctx, req)
	for {
		resp, err := it.Next()
		if err == iterator.Done {
			break
		}
		if err != nil {
			log.Printf("failed to list secret versions: %v", err)
			return subcommands.ExitFailure
		}
		names := strings.Split(resp.Name, "/")
		reqName := fmt.Sprintf("projects/%s/secrets/%s/versions/%s", names[1], names[3], "latest")
		// Build the request.
		req := &secretmanagerpb.AccessSecretVersionRequest{
			Name: reqName,
		}
		// Call the API.
		result, err := client.AccessSecretVersion(ctx, req)
		if err != nil {
			log.Printf("failed to access secret version: %v", err)
			return subcommands.ExitFailure
		}
		log.Printf("Found secret %s ... got value: %s\n", resp.Name, string(result.Payload.Data))
	}
	return subcommands.ExitSuccess
}
実践
Secretを追加
$ go run ./*.go create -p XXXXXXXX -k rdb-host -v localhost
2020/10/11 23:03:33 Plaintext: localhost
$ go run ./*.go create -p XXXXXXXX -k rdb-port -v 12345
2020/10/11 23:04:05 Plaintext: 12345
$ go run ./*.go create -p XXXXXXXX -k rdb-user -v user1
2020/10/11 23:04:24 Plaintext: user1
$ go run ./*.go create -p XXXXXXXX -k rdb-pass -v pass1234
2020/10/11 23:04:47 Plaintext: pass1234
※XXXXXXXXの部分は自分が持っているGCPプロジェクトのID
GCPのコンソールマネージャーで見ると、こんな感じで作成されている。

Secret一覧を表示
$ go run ./*.go list -p fs-work-21
2020/10/11 23:09:34 Found secret projects/999999999999/secrets/rdb-host ... got value: localhost
2020/10/11 23:09:35 Found secret projects/999999999999/secrets/rdb-pass ... got value: pass1234
2020/10/11 23:09:35 Found secret projects/999999999999/secrets/rdb-port ... got value: 12345
2020/10/11 23:09:35 Found secret projects/999999999999/secrets/rdb-user ... got value: user1
まとめ
Secret Managerで管理させたSecret情報って、たとえば何に使うのか?
用途はいろいろあると思うけど、自分の場合だと、Cloud Runに載せるサービスに環境変数伝いでDBパスワードとかを渡す時かな。
ビルド用のシェルの中でgcloudコマンドでSecret Managerから取得したDBパスワードをset envしてる。
具体的なやり方うんぬんは、また後ほど。