この記事はZOZO AdventCalender 2024シリーズ5の23日目の記事です。
はじめに
本記事では、Go言語でGitリポジトリを操作するためのライブラリであるgo-git
を使って、Gitリポジトリの操作を行う方法について解説します。
また、Go言語からGitHubのAPIを利用するためのライブラリであるgo-github
を使って、GitHubのリポジトリの操作を行う方法についても解説します。
さらに、go-git
とgo-github
を組み合わせて、Lambdaなどのサーバーレス環境でリポジトリをCloneする方法についても解説します。
Go言語でGit操作
Go言語でGit操作をするためのライブラリはいくつかありますが、主要な選択肢としてはGit bookで紹介されているものになります。
ライブラリ | 特徴 |
---|---|
git2go | libgit2のGoバインディング Gitのコアがスコープ外としている機能も備わっており、かなり高度なことができる デメリットとしてはCGOで実装されているため、クロスコンパイルのハードルが上がる |
go-git | Pure Goで実装されているため、クロスコンパイルが容易 libgit2のGoバインディングであるgit2goと比べると機能が少ないが、基本的な操作はカバーしている インメモリストレージが簡単に使える Gitコマンドとの互換性もドキュメント化されている |
Go言語でどのライブラリを使うかですが、個人的にはgo-git
を使うことをオススメします。
理由としては、go-git
はPure Goで実装されているため、クロスコンパイルが容易であること、また、基本的な操作はカバーしていることが挙げられます。
COMPATIBLITY.mdを確認して、自分が使いたい機能がサポートされていない場合は、git2go
を選択することになります。
本記事では、go-git
を使ってGitリポジトリの操作を行う方法について解説します。
go-git
本記事では、go-git
を使ってGitリポジトリを操作するためのサンプルコードを紹介します。
ただし簡単な操作は、go-gitのexampleを紹介する形として、直感的にわかりずらい操作を中心に解説します。
またインメモリストレージを使う方法と、ハマりがちなポイントを解説します。
主要なオブジェクト
go-git
は、Gitオブジェクトを表すオブジェクトを通して、Gitリポジトリを操作します。
そのため、Gitコマンドと対応する関数が用意されているわけではないため、まずはGitオブジェクトを理解しておくことが重要です。
Gitオブジェクトについては、完全に理解するのは中々難しいんですが、Git bookを読むとある程度わかるようになると思います。
リポジトリを操作するための主要なオブジェクトは以下の通りです。
Repository
Repository
は、Gitリポジトリを表すオブジェクトです。
go-git
を使ってGitリポジトリを操作するためには、まずRepository
オブジェクトを取得する必要があり、全ての操作の起点となります。
Repository
オブジェクトの取得は大別して以下の3つの方法があります。
- localに既に存在するリポジトリから取得する方法(open)
- リモートリポジトリから取得する方法(clone)
- 新規にリポジトリを作成する方法(init)
Open
既にlocalにclone済みのリポジトリでRepository
オブジェクトを取得する方法です。
PlainOpenを使います。
package main
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
)
func main() {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println(repo)
}
clone
リモートリポジトリをcloneしてRepository
オブジェクトを取得する方法です。
PlainCloneを使います。
またCloneする場合、いくつかの認証方法がありますが、go-git
ではusername/passwordを使うbasic認証、AccessTokenによる認証、ssh認証の3つがサポートされています。
exampleが用意されているものを紹介します。
- 認証を必要としない場合のexample
- Basic認証のexample
- PersonalAccessToken認証のexample
- SSH認証のexample
- SSH-Agentを利用した認証のexample
またGitHubAppsが発行するInstallAccessTokenを使う場合、URLをhttps://x-access-token:<token>@<owner>/<repository name>.git
の形式にして、PlainClone
を使うことでCloneできます。
package main
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
)
func main() {
// Repositoryオブジェクトを取得
repo, err := git.PlainClone("/path/to/repo", false, &git.CloneOptions{
URL: "https://x-access-token:<token>@<owner>/<repoisoty name>.git",
})
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println(repo)
}
なおGitHubのドキュメントに従ってssh-agentに秘密鍵が登録済みの場合、URLをgit@github.com:<owner>/<repository name>.git
の形式で指定するだけでCloneできます。
package main
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
)
func main() {
// Repositoryオブジェクトを取得
repo, err := git.PlainClone("/path/to/repo", false, &git.CloneOptions{
URL: "git@github.com:<owner>/<repository name>.git",
})
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println(repo)
}
Init
新規にリポジトリを作成してRepository
オブジェクトを取得する方法です。
PlainInitを使います。
package main
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
)
func main() {
// Repositoryオブジェクトを取得
repo, err := git.PlainInit("/path/to/repo", false)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println(repo)
}
上記のコードを実行すると、指定したパスに.git
ディレクトリを持つ新しいリポジトリが作成されます。
Reference
Referenceオブジェクトは、Git bookでいうところのRef
に相当します。Gitの参照という章で詳しく説明されています。
CreateBranch
新しいブランチの作成は、元となるブランチのhashを取得して、取得したhashを使って新しいReference
を作成することで行います。
作成したReference
をRepository
オブジェクトのStorer
(ストレージを実装するオブジェクト)を使って保存することで、新しいブランチが作成されます。
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
func CreateBranch(branchName string) error {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
return errors.Join(err, errors.New("failed to open repository"))
}
headRef, err := repo.Head()
if err != nil {
return errors.Join(err, errors.New("failed to get head ref"))
}
ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), headRef.Hash())
if err := repo.Storer.SetReference(ref); err != nil {
return errors.Join(err, errors.New(fmt.Sprintf("failed to set reference. ref: %s", ref)))
}
return nil
}
他のやり方としては、Repository
オブジェクトのCreateBranch
を使う方法や、Worktree
オブジェクトのCheckout
メソッドを使って新しいブランチを作成する方法もあります。
Worktree
Worktree
オブジェクトは、Repository
オブジェクトから取得できるリポジトリのワーキングツリーを表すオブジェクトで、checkout
, status
, add
, pull
、commit
などの操作を行うために使います。
基本的なGitコマンドの操作は、Worktree
オブジェクトを使って行うケースが多いです。
Checkout
現在のブランチを切り替えるためには、Worktree
オブジェクトのCheckout
メソッドを使います。
CheckoutOptionsのCreate
オプションを有効にすることで、新しいブランチを作成して切り替えることもできます。
func CheckoutBranch(branchName string) error {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
return errors.Join(err, errors.New("failed to open repository"))
}
w, err := repo.Worktree()
if err != nil {
return errors.Join(err, errors.New("failed to get worktree"))
}
return w.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branchName),
Create: false, // trueで新しいブランチを作成して切り替える
})
}
Status
ワーキングツリーの状態を確認するためには、Worktree
オブジェクトのStatus
メソッドを使います。
Status
メソッドは、git status
コマンドと同じように、ワーキングツリーの状態を確認することができます。
Status
で返されるFileStatus
は、keyがファイルパス、valueがFileStatusオブジェクトのMapです。
func Status() error {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
return errors.Join(err, errors.New("failed to open repository"))
}
w, err := repo.Worktree()
if err != nil {
return errors.Join(err, errors.New("failed to get worktree"))
}
status, err := w.Status()
if err != nil {
return errors.Join(err, errors.New("failed to get status"))
}
for fname, st := range status {
fmt.Printf("%s: %s\n", string(st.Worktree), fname)
}
return nil
}
Add
ワーキングツリーに変更をステージングするためには、Worktree
オブジェクトのAdd
メソッドを使います。
Add
メソッドは、git add
コマンドと同じように、指定したファイルをステージングします。
// targets: ステージングするファイルのパスのリスト
func Add(targets []string) error {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
return errors.Join(err, errors.New("failed to open repository"))
}
w, err := repo.Worktree()
if err != nil {
return errors.Join(err, errors.New("failed to get worktree"))
}
for _, p := range targets {
if _, err := w.Add(p); err != nil {
return errors.Join(err, errors.New(fmt.Sprintf("failed to add. path: %s", p)))
}
}
return nil
}
Pull
リモートリポジトリから変更を取得するためには、Worktree
オブジェクトのPull
メソッドを使います。
Pull
メソッドは、git pull
コマンドと同じように、リモートリポジトリから変更を取得します。
func Pull() error {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
return errors.Join(err, errors.New("failed to open repository"))
}
// RepositoryからWorktreeオブジェクトを取得
w, err := repo.Worktree()
if err != nil {
return errors.Join(err, errors.New("failed to get worktree"))
}
err = w.Pull(&git.PullOptions{
RemoteName: "origin",
Progress: os.Stdout,
})
if err != nil && err != git.NoErrAlreadyUpToDate {
return errors.Join(err, errors.New("failed to pull from remote. name: origin"))
}
return nil
}
Commit
ワーキングツリーの変更をコミットするためには、Worktree
オブジェクトのCommit
メソッドを使います。
Commit
メソッドは、git commit
コマンドと同じように、ワーキングツリーの変更をコミットします。
import (
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
)
func Commit(message string) error {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
return errors.Join(err, errors.New("failed to open repository"))
}
w, err := repo.Worktree()
if err != nil {
return errors.Join(err, errors.New("failed to get worktree"))
}
author := &object.Signature{
Name: <git ユーザ名 ex: command[bot]>,
Email: <git email ex: command[bot]@users.noreply.github.com>,
When: time.Now(),
}
_, err = w.Commit(message, &git.CommitOptions{Author: author})
if err != nil {
return errors.Join(err, errors.New("failed to commit"))
}
return nil
}
Remote
ローカルリポジトリの変更をリモートリポジトリに反映するためには、Repository
オブジェクトのRemote
メソッドを使ってRemoteオブジェクトを取得し、Push
メソッドを使います。
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
)
func Push(branchName string) error {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
return errors.Join(err, errors.New("failed to open repository"))
}
remote, err := repo.Remote("origin")
if err != nil {
return errors.Join(err, errors.New("failed to get remote. name: origin"))
}
return remote.Push(&git.PushOptions{
Progress: os.Stdout,
RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branchName, branchName))},
})
return nil
}
Merge
ブランチをマージするためには、Repository
オブジェクトのMerge
メソッドを使います。
Merge
メソッドは、git merge
コマンドと同じように、指定したブランチをマージします。
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
func Merge(branchName string) error {
// Repositoryオブジェクトを取得
repo, err := git.PlainOpen("/path/to/repo")
if err != nil {
return errors.Join(err, errors.New("failed to open repository"))
}
mergeRef, err := repo.Reference(plumbing.ReferenceName(fmt.Sprintf("refs/heads/%s", branchName)), false)
if err != nil {
return errors.Join(err, errors.New(fmt.Sprintf("failed to get merge ref. name: %s", branchName)))
}
return repo.Merge(mergeRef, &git.MergeOptions{})
}
インメモリストレージ
go-git
は、memory
パッケージを使ってインメモリストレージを使うことができます。
インメモリストレージを使うことで、ファイルシステムにファイルを書き込むことなく、Gitリポジトリを操作することができます。
インメモリストレージを使う場合、memory.NewStorage()
を使ってStorage
オブジェクトを作成し、CloneやInitの際にStorage
オブジェクトを指定することで、インメモリストレージを使うことができます。
またメモリ上に展開したファイルを扱うために、go-git orgが提供している仮想ファイルシステムの実装ライブラリであるgo-billyを使うことができます。
go-billy
を使うことで、ファイルシステムに依存しないコードを書くことができます。
go-git
のCloneやInitは、billy.Filesystem
Interface型を引数に渡せるようになっているため、billy.Filesystem
のインメモリ実装であるmemfs
パッケージを使ってCloneやInitを行います。
package main
import (
"fmt"
"os"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/go-git/go-billy/v5/memfs"
)
func main() {
fs = memfs.New()
r, err = git.Clone(memory.NewStorage(), fs, &git.CloneOptions{
URL: "git@github.com/<owner>/<repository name>.git",
})
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
ここで注意する点として、メモリ上に展開したファイルを扱うため、ファイルシステムに依存しないコードを書くことができますが、メモリを消費するため、大きなリポジトリを扱う場合は注意が必要です。
大きなリポジトリをインメモリで扱う場合は、CloneOptions
のDepth
オプションやSingleBranch
オプションを使うことで、サイズを抑えてメモリの消費を抑えることができます。
次にメモリ上に展開したファイルを扱う例です。
go-billyのfilesystemを使います。
package main
import (
"fmt"
"os"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/storage/memory"
)
func main() {
fs := memfs.New()
repo, err := git.Init(memory.NewStorage(), fs)
if err != nil {
panic(err)
}
// memfsを通してディレクトリを作成
if err := fs.MkdirAll("test_dir", 0o755); err != nil {
panic(err)
}
// memfsを通してファイルを作成
f, err := fs.Create("test_dir/test_file")
if err != nil {
panic(err)
}
defer f.Close()
// ファイルに書き込み
if _, err := f.Write([]byte("hello, go-git!")); err != nil {
panic(err)
}
// ファイルの中身を表示してみる
read_f, err := fs.Open("test_dir/test_file")
if err != nil {
panic(err)
}
defer read_f.Close()
content, err := io.ReadAll(read_f)
if err != nil {
panic(err)
}
fmt.Println(string(content))
w, err := repo.Worktree()
if err != nil {
panic(err)
}
status, err := w.Status()
if err != nil {
panic(err)
}
for fname, st := range status {
fmt.Printf("%s: %s\n", string(st.Worktree), fname)
}
// localをlsしてみる
// localに対する操作なのでosパッケージを使う
files, err := os.ReadDir("test_dir")
if err != nil {
if os.IsNotExist(err) {
fmt.Println("test_dir not found")
return
}
panic(err)
}
// ここには到達しない
for _, file := range files {
fmt.Println(file.Name())
}
}
上記のコードを実行すると、以下の出力が得られます。
$ go run main.go
hello, go-git!
?: test_dir/test_file
test_dir not found
$
1行目の出力は、ファイルの中身が表示されていることを示しています。
2行目の出力は、test_dir/test_file
がワーキングツリーに存在していることを示しています。?
は、git status
コマンドで表示されるステータスのうち、Untracked
を表しています。
ステータスの詳細はこちらを参照してください。
3行目の出力は、test_dir
がローカルファイルシステムに存在していないことを示しています。
ここでの注意点は、メモリに展開したリポジトリのファイルを扱う場合は、memfs
を通してファイルを操作する必要があることです。
memfs
を通して作成したファイルが、os.ReadDir
で取得できなかったのと同様にlocalのファイルシステムに存在するファイルもmemfs
を通して操作することができません。
go-billy
にはlocalのファイルを扱うためのosfs
も実装されています。
ファイルを扱う処理は、billy.Filesystemを受け取る関数として実装することで、filesystemがmemfs
かosfs
かを意識することなく、ファイルを扱うことができます。
package main
import (
"fmt"
"io"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/storage/memory"
)
func main() {
// memoru上にリポジトリを作成
mfs := memfs.New()
memrepo, err := git.Init(memory.NewStorage(), mfs)
if err != nil {
panic(err)
}
// localのファイルシステム上にリポジトリを作成
ofs := osfs.New("/tmp/temp-repo")
osrepo, err := git.PlainInit("/tmp/temp-repo", false)
if err != nil {
panic(err)
}
// memory上のリポジトリにファイルを追加
if err := CreateFile(mfs, "hello.txt", "Hello, memfs!"); err != nil {
panic(err)
}
// localのリポジトリにファイルを追加
if err := CreateFile(ofs, "hello.txt", "Hello, osfs!"); err != nil {
panic(err)
}
// memory上のリポジトリのstatusを表示
memwt, err := memrepo.Worktree()
memstatus, err := memwt.Status()
if err != nil {
panic(err)
}
for path, status := range memstatus {
fmt.Printf("memory status: %s %s\n", string(status.Worktree), path)
}
// memoriy上のリポジトリのファイルを読み込む
content, err := ReadFile(mfs, "hello.txt")
if err != nil {
panic(err)
}
fmt.Printf("memory: %s\n", content)
// localのリポジトリのstatusを表示
oswt, err := osrepo.Worktree()
osstatus, err := oswt.Status()
if err != nil {
panic(err)
}
for path, status := range osstatus {
fmt.Printf("local status: %s %s\n", string(status.Worktree), path)
}
// localのリポジトリのファイルを読み込む
content, err = ReadFile(ofs, "hello.txt")
if err != nil {
panic(err)
}
fmt.Printf("local: %s\n", content)
}
func CreateFile(fs billy.Filesystem, path string, content string) error {
f, err := fs.Create(path)
if err != nil {
return err
}
defer f.Close()
if _, err := f.Write([]byte(content)); err != nil {
return err
}
return nil
}
func ReadFile(fs billy.Filesystem, path string) (string, error) {
f, err := fs.Open(path)
if err != nil {
return "", err
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(content), nil
}
上記のコードを実行すると、以下の出力が得られます。
$ go run main.go
memory status: ? hello.txt
memory: Hello, memfs!
local status: ? hello.txt
local: Hello, osfs!
$
Go言語でGitHub操作
Go言語でGitHubを操作するには、GitHub APIを利用します。
Go言語でGitHub APIを実行する手段としては、直接HTTPリクエストを送信する方法もありますが、googleが提供しているgo-githubを使うのがオススメです。
go-github
は、GitHub APIをGo言語で利用するためのライブラリで、GitHub APIの各エンドポイントに対応したメソッドが提供されています。
また、go-github
は、GitHub APIのレスポンスをGoの構造体にマッピングして返すため、APIのレスポンスを扱いやすくなっています。
go-github
go-github
を使ってGitHub APIを利用するためのサンプルコードを紹介します。
go-github
を使ってGitHub APIを利用するためには、まずGitHubのClientを作成する必要があります。
GitHubのクライアントは、github.NewClient
を使って作成します。
また、GitHub APIを利用するためにはTokenが必要です。
これから示すサンプルでは、一番ベーシックな認証方法である、PersonalAccessTokenを使ってGitHub APIを利用する方法を紹介します。
import (
"github.com/google/go-github/v67/github"
)
// GitHubのクライアントを作成
client := github.NewClient(nil).WithAuthToken("... your access token ...")
PullRequestの作成
go-github
を使ってPullRequestを作成するためのサンプルコードを紹介します。
PullRequestを作成するためには、PullRequestService
のCreate
メソッドを使います。PullRequestService
は、github.NewClient
で作成したクライアントを経由してアクセスします。
Create
メソッドは、owner
、repo
、title
、body
, head
、base
を引数に取り、PullRequest
オブジェクトを返します。
body
はPullRequestの本文、head
はPullRequestを作成するブランチ、base
はPullRequestをマージするブランチを指定します。
import (
"context"
"github.com/google/go-github/v67/github"
)
func CreatePullRequest(token, owner, repo, base, head, title, body string) (*github.PullRequest, error) {
// GitHubのクライアントを作成
client := github.NewClient(nil).WithAuthToken(token)
pr, resp, err := client.PullRequests.Create(context.Background(), owner, repo, &github.NewPullRequest{
Title: github.String(title),
Body: github.String(body),
Base: github.String(base),
Head: github.String(head),
})
if err != nil {
return nil, errors.Join(err, errors.New("failed to create pull request"))
}
if resp.StatusCode != 201 {
return nil, fmt.Errorf("Failed to create pull request: %s", resp.Status)
}
return pr, nil
}
PullRequestのマージ
go-github
を使ってPullRequestをマージするためのサンプルコードを紹介します。
PullRequestをマージするためには、PullRequestService
のMerge
メソッドを使います。
Merge
メソッドは、owner
、repo
、number
、commitMessage
を引数に取り、PullRequestMergeResult
オブジェクトを返します。
number
はPullRequestの番号、commitMessage
はマージコミットのメッセージを指定します。
サンプルでは指定していませんが、PullRequestOptions
を使ってマージオプションを指定することもできます。
import (
"context"
"github.com/google/go-github/v67/github"
)
func MergePullRequest(token, owner, repo, commitMessage string, number int) (*github.PullRequestMergeResult, error) {
// GitHubのクライアントを作成
client := github.NewClient(nil).WithAuthToken(token)
result, resp, err := client.PullRequests.Merge(c.ctx, owner, repo, number, commitMessage, &github.PullRequestOptions{})
if err != nil {
return nil, errors.Join(err, errors.New("failed to merge pull request"))
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Failed to merge pull request: %s", resp.Status)
}
return result, nil
}
その他にも、ここでは紹介しきれないぐらいgo-github
には、GitHub APIの各エンドポイントに対応したメソッドが提供されています。
やりたいことに応じて、GitHub APIのドキュメントを参照して、エンドポイントに対応したメソッドを使って実装してみてください。
Lambda環境でGitHub AppsからInstallAccessTokenを使ってリポジトリをCloneする
Lambda環境でGitHub AppsからInstallAccessTokenを使ってリポジトリをCloneするサンプルコードを紹介します。
Lambda環境でGitHub AppsからTokenを取得する
GitHub AppsからTokenを取得するには、GitHub AppsのAppID
, PrivateKey
, InstallationID
が必要です。
AppID
・PrivateKey
はGitHub Appsの設定画面から取得できます。
InstallationID
は、GitHub AppsがインストールされているリポジトリのIDです。以下のURLから取得できます。
https://github.com/apps/<App name>/installations/<InstallationID>
画面は以下のようなRepository Accessを設定する画面になります。
これらの値をSecretMangerに登録しておき、実行時に取得するようにします。
AWS Parameters and Secrets Lambda Extension
Lambda関数からSecretManagerにアクセスするには、AWS Parameters and Secrets Lambda Extensionを使用するとシークレットの値をキャッシュすることができ、効率よくシークレットを取得することができます。
GoでAWS Parameters and Secrets Lambda Extension
を使う場合、aws lambda get-layer-version-by-arn
コマンドを使ってextensionのLayerを取得し、Lambda関数のイメージに追加しておきます。
筆者の場合は、Dockerfileでawsコマンドを使うための認証情報を設定するのが面倒だったため、事前に以下のスクリプトで取得したLayerをDockerfileでコピーする形でLambda関数のイメージに追加しました。
#!/usr/bin/env bash
# Download the Lambda extension from the AWS Lambda Extensions API
script_dir="$(dirname "$0")"
curl -o "${script_dir}/../bin/AWS-Parameters-and-Secrets-Lambda-Extension.zip" "$(aws lambda get-layer-version-by-arn --arn arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension:12 --query Content.Location --output text)"
--arn
で指定するARNは、AWSのリージョンとLambda関数のアーキテクチャによって異なるため、適宜変更してください。
ARNの一覧はこちらで確認できます。
Dockerfileは以下のようになります。
FROM public.ecr.aws/docker/library/golang:1.23-bookworm AS builder
RUN apt update && apt install -y unzip
WORKDIR /go/src/dir
COPY . .
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -trimpath -o /usr/local/bin/command-name .
RUN mkdir -p /opt
COPY AWS-Parameters-and-Secrets-Lambda-Extension.zip .
RUN unzip AWS-Parameters-and-Secrets-Lambda-Extension.zip -d /opt
RUN rm AWS-Parameters-and-Secrets-Lambda-Extension.zip
ENTRYPOINT ["command-name"]
GoでAWS Parameters and Secrets Lambda Extension
を使う場合の実装例です。
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
type GetSecretValueOutput struct {
ARN string `json:"ARN"`
CreatedDate string `json:"CreatedDate"`
Name string `json:"Name"`
SecretBinary string `json:"SecretBinary"`
SecretString string `json:"SecretString"`
}
func GetSecretValueWithLambdaExtension(secretName string) (string, error) {
secretExtensionEP := fmt.Sprintf("http://localhost:2773/secretsmanager/get?secretId=%s", secretName)
headers := map[string]string{
"X-Aws-Parameters-Secrets-Token": os.Getenv("AWS_SESSION_TOKEN"),
}
client := &http.Client{}
req, err := http.NewRequest("GET", secretExtensionEP, nil)
if err != nil {
return "", err
}
for key, value := range headers {
req.Header.Set(key, value)
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Failed to get secret: received status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var secretValue GetSecretValueOutput
if err := json.Unmarshal(body, &secretValue); err != nil {
return "", errors.Join(err, errors.New("failed to unmarshal secret value"))
}
return secretValue.SecretString, nil
}
GitHub AppsからInstallAccessTokenを取得してGitHubクライアントを作成する
GitHub AppsからInstallAccessTokenを取得してGitHubクライアントを作成するサンプルコードを紹介します。
Tokenの取得には、go-githubauthというライブラリを使っています。
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strconv"
"time"
"github.com/google/go-github/v64/github"
"github.com/jferrl/go-githubauth"
"golang.org/x/oauth2"
)
type GitHubAppValues struct {
AppID string `json:"app_id"`
InstallationID string `json:"installation_id"`
PrivateKey string `json:"private_key"`
}
func ParseJson[T any](jsonStr string, result *T) error {
if err := json.Unmarshal([]byte(jsonStr), &result); err != nil {
return errors.Join(err, errors.New("failed to unmarshal json"))
}
return nil
}
// AWS Parameters and Secrets Lambda Extensionを使ってGitHub Appのシークレットを取得
// AppIDとInstallationIDが入ったシークレット名とプライベートキーが入ったシークレット名を指定する
// プライベートキーは、秘密鍵形式でSecretManagerに登録するため、個別に取得する
func getGitHubAppSecret() (GitHubAppValues, error) {
jsonstr, err := GetSecretValueWithLambdaExtension("AppIDとInstallationIDが入ったシークレット名")
if err != nil {
return GitHubAppValues{}, errors.Join(err, errors.New("failed to get secret value with lambda extension"))
}
privkey, err := GetSecretValueWithLambdaExtension("プライベートキーが入ったシークレット名")
if err != nil {
return GitHubAppValues{}, errors.Join(err, errors.New("failed to get secret value with lambda extension"))
}
var appValues GitHubAppValues
if err := ParseJson(jsonstr, &appValues); err != nil {
return GitHubAppValues{}, errors.Join(err, errors.New("failed to parse json"))
}
appValues.PrivateKey = privkey
return appValues, nil
}
// GitHub AppsからTokenを取得
func FetchToken() (oauth2.TokenSource, error) {
appValues, err := getGitHubAppSecret()
if err != nil {
return nil, errors.Join(err, errors.New("failed to get github app secret"))
}
appid, err := strconv.ParseInt(appValues.AppID, 10, 64)
if err != nil {
return nil, errors.Join(err, errors.New("failed to parse app id"))
}
appTokenSource, err := githubauth.NewApplicationTokenSource(appid, []byte(appValues.PrivateKey))
if err != nil {
return nil, errors.Join(err, errors.New("failed to create application token source"))
}
instid, err := strconv.ParseInt(appValues.InstallationID, 10, 64)
if err != nil {
return nil, errors.Join(err, errors.New("failed to parse installation id"))
}
return githubauth.NewInstallationTokenSource(instid, appTokenSource), nil
}
// Tokenを使って認証するGitHubクライアントを作成
func getClient() (*github.Client, error) {
tokenSource, err := FetchToken()
if err != nil {
return nil, errors.Join(err, errors.New("failed to fetch token"))
}
httpClient := oauth2.NewClient(context.Background(), tokenSource)
client := github.NewClient(httpClient)
return client, nil
}
type GitHubAPIClient struct {
client *github.Client
ctx context.Context
}
func NewGitHubAPIClient() (*GitHubAPIClient, error) {
client, err := getClient()
if err != nil {
return nil, errors.Join(err, errors.New("failed to get github client"))
}
return &GitHubAPIClient{client: client, ctx: context.Background()}, nil
}
// Tokenの無効化を行う関数
func (c *GitHubAPIClient) RevokeToken() error {
res, err := c.client.Apps.RevokeInstallationToken(c.ctx)
if err != nil {
return errors.Join(err, errors.New("failed to revoke token"))
}
if res.StatusCode != 204 {
return fmt.Errorf("Failed to revoke token: %s", res.Status)
}
return nil
}
Lambda環境でインメモリストレージを使ってリポジトリをCloneする
最後にこれまで紹介した実装を使いつつ、Lambda環境でインメモリストレージを使ってリポジトリをCloneするサンプルコードを紹介します。
import (
"time"
"github.com/go-git/go-billy/v5"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/memory"
)
type GitClient struct {
FS billy.Filesystem
Author *object.Signature
Repository *git.Repository
}
func NewGitClient(repositoryPath, repositoryName string) (*GitClient, error) {
var r *git.Repository
var err error
var fs billy.Filesystem
author := &object.Signature{
Name: <git ユーザ名 ex: command[bot]>,
Email: <git email ex: command[bot]@users.noreply.github.com>,
When: time.Now(),
}
tokenSrc, err := FetchToken()
if err != nil {
return nil, errors.Join(err, errors.New("failed to fetch token"))
}
token, err := tokenSrc.Token()
if err != nil {
return nil, errors.Join(err, errors.New("failed to get token"))
}
fs = memfs.New()
r, err = git.Clone(memory.NewStorage(), fs, &git.CloneOptions{
URL: fmt.Sprintf("https://x-access-token:%s@github.com/<owner>/%s.git", token.AccessToken, repositoryName),
ReferenceName: plumbing.ReferenceName("refs/heads/master"),
SingleBranch: true,
Depth: 1,
})
if err != nil {
return nil, errors.Join(err, errors.New(fmt.Sprintf("failed to clone repository. name: %s", repositoryName)))
}
return &GitClient{Author: author, Repository: r, FS: fs}, nil
}
上記コードのNewGitClient
を実行すると、GitHub AppsのInstallAccessTokenを使ってリポジトリをメモリ上にCloneすることができます。
NewGitClient
で取得した、GitClient
オブジェクトが持つ、Repository
オブジェクトを使って、Checkout
やCommit
、Push
などの操作を行い、FS
オブジェクトを使ってファイルの取得や書き込みを行うことで、メモリ上に展開したリポジトリを操作することができます。
まとめ
Go言語でGitリポジトリを操作する方法と、GitHub APIを使ってGitHubを操作する方法を紹介しました。
go-git
の特徴である、メモリストレージを使うことで、Lambda関数などのサーバレス環境でもリポジトリのCloneから操作までを行うことができます。
これらのライブラリを使って、自動化スクリプトやCI/CDパイプラインを作成することで、開発効率を向上させることができます。