Go

GoでFUSEを使ってGitHubのIssuesをマウントする

More than 1 year has passed since last update.

この記事は、Goアドベントカレンダー(その2)の19日目です。

何を言っているのか分からないかもしれませんので、動画を用意しました。

何をするものなのか

これは、GitHubのIssueやその他サービスにおける同等なものを、ファイルツリーとしてひとつのディレクトリにマウントするものです。

現在の職場では、色々な事情があり、

  • GitHub Enterprise
  • GitLab
  • Backlog
  • Redmine

など、いくつかのプロジェクト管理ツールを使って業務を行なっています。その中には、自社で用意したものもあれば、お客様によって用意されたものもあって、理由はわかるけれど自分のタスクが分散されてしんどいなーと思っていました。

一方で、今年のアドベントカレンダーはPlan 9と絡めた話にしようと心に決めていたので、せっかくだし9PのLinux版とも言えるFUSE(Filesystem in Userspace)を使って、色々なプロジェクト管理ツールのうち自分が担当者となっているチケットを読むファイルサーバを作ろうと思って実装しました。

現時点では、GitHub, GitHub Enterprise, GitLabに対応しています。

FUSEとは

FUSE(9P)というのは、ディスクに限らず、プロセスもネットワークも全てをファイルとして表現することができるプロトコルです。Linuxではprocfsやsshfs、最近はs3fsとかgoofysが有名ですし、本家のPlan 9ではTCPやHTTP、ウィンドウとかエディタのようなものもファイルとして表現されています。ファイルにするメリットは、普通のread, writeだけでなんでもできる点かなと思います。シェルやAwk等のツールでもある程度のことができますし、本格的なプログラミングが必要になったとしても、裏で実行されているAPIを気にする必要なく単純にファイルの読み書きで済むのは便利なんじゃないかなと思います。

macOSは、標準ではFUSEを使えませんが、FUSE for macOSを導入すればLinuxと同様に使えることを確認しています(macOS 10.12.1)。また、WindowsにはDokanというものがあるみたいですが、こっちは使えるかどうか分かりません。

ソースコードはlufia/taskfsで公開しています。

インストール方法

Linuxでの準備

CentOSまたはRHELの場合、fuseパッケージが必要です。

$ sudo yum install fuse

DebianやUbuntu等も、同じようなパッケージが提供されていると思います。

macOSでの準備

macOSではFUSE for macOSからインストーラをダウンロードして実行するのが簡単です。私は試していませんが、Homebrewでも提供されているようです。

コマンドのインストール

go getでインストールできます。

$ go get github.com/lufia/taskfs

使い方

taskfsを実行すると、mtptにファイルツリーを構築します。mtptが省略された場合は/mnt/taskfsを使います。

$ taskfs [-d] [mtpt]

このコマンドは、アンマウントされるまでプロンプトへ戻りません。必要なら&を付けてバックグラウンドで実行させてください。

ファイル操作

最初は、mtpt以下にctlというファイルだけ存在しています。このファイルに、以下の書式で文字列を書くことによって、書き込んだURLのドメイン名でディレクトリが作られます。

GitHubの場合

add github {github_token} {github_api_url}

GitLabの場合

add gitlab {gitlab_token} {gitlab_api_url}

このうち、{github_api_url}は、github.comの場合のみ省略可能です。GitHub EnterpriseやGitLabの場合は省略できません。

$ cd /mnt/taskfs
$ echo add github $github_token >ctl
$ echo add github $ghe_token $ghe_url >ctl
$ echo add gitlab $lab_token $lab_url >ctl
$ ls
ctl     ghe.example.com     github.com  lab.example.com

ドメインディレクトリの中にはctlというファイルと、repository@organization#numberのルールで複数のディレクトリがあります。ディレクトリがひとつのIssueに対応していて、例えばtaskfs@lufia#1/messageを読むと、#1のIssueに書かれたコメントが読めます。
また、ドメインディレクトリにあるctlファイルは、refreshという文字列を書くとIssueを再取得します。

$ cd github.com
$ ls
ctl     taskfs@lufia#1
$ cat taskfs@lufia#1/message
メッセージ内容
$ echo refresh >ctl
$ ls
ctl     taskfs@lufia#1      taskfs@lufia#2

アンマウントする

Linuxの場合は、fusermount -uコマンドを使います。

$ fusermount -u /mnt/taskfs

macOSは普通のumountコマンドでアンマウントできます。

$ umount /mnt/taskfs

実装について

今回の実装ではhanwen/go-fuseと、そのサブパッケージnodefsを使ったので、これを基準に、FUSEの実装はどんな感じなのかを簡単ですが紹介します。

ファイルツリーのマウント

ファイルツリーをユーザに公開するため、最初のディレクトリをマウントする必要があります。これはnodefs.MountRoot(mtpt, root, opts)で行います。root引数は、nodefs.Nodeインターフェイスを実装している必要があります。nodefs.NodeはFUSEで必要になるメソッドが30個ほど定義されている、とても大きなインターフェイスですが、nodefs.NewDefaultNode()でデフォルト実装を用意してくれているので、必要なメソッドだけ自分で実装すればいいようになっています。

import (
    "github.com/hanwen/go-fuse/fuse"
    "github.com/hanwen/go-fuse/fuse/nodefs"
)

// ルートディレクトリをあらわす型
type Root struct {
    nodefs.Node
}

// 必要なメソッドだけ自分で実装する
func (root *Root) GetAttr(out *fuse.Attr, file nodefs.File, ctx *fuse.Context) fuse.Status {
    // GetAttrは必要な属性やパーミッションでoutを更新しなければならない
    // ルートはディレクトリなので、fuse.S_IFDIRフラグと0755をセットする
    out.Mode = fuse.S_IFDIR | 0755
    out.Atime = uint64(time.Now().Unix())
    out.Mtime = uint64(time.Now().Unix())

    // 正常な場合はfuse.OKを返す
    return fuse.OK
}

func main() {

    // 中略

    root := &Root{
        // デフォルト実装を埋め込む
        Node: nodefs.NewDefaultNode(),
    }
    s, _, err := nodefs.MountRoot("/mnt/taskfs", root, &opts)
    s.Serve()
}

これだけでは、ルートはマウントできるけど空のディレクトリでしかありません。

ファイルツリーの構築

ルート以下のファイルは、nodefs.InodeNewChild(name, isDir, fsi)を使って構築していきます。

NewChildfsi引数はnodefs.Nodeインターフェイスを実装する型でなければいけません。

type File struct {
    nodefs.Node
}

func (root *Root) CreateChildren() {
    p := root.Inode()

    // ルート以下にfile1というファイルを作成する
    // ファイルの内容はhelloという文字列になっている
    file1 := &File{
        // ファイルの場合はnodefs.NewDefaultNodeより、
        // nodefs.NewDataFileの方が便利
        Node: nodefs.NewDataFile([]byte("hello")),
    }
    p.NewChild("file1", false, file1)

    // ルート以下にdirというディレクトリを作成する
    // ディレクトリの中にはfile2というファイルがある
    dir := &File{
        Node: nodefs.NewDefaultNode(),
    }
    p.NewChild("dir", true, dir)
    p1 := dir.Inode()

    file2 := &File{
        Node: nodefs.NewDataFile([]byte("hello")),
    }
    p1.NewChild("file2", false, file2)
}

これで、以下のようなファイルツリーになりました。

mtpt/
├── dir/
│   └── file2
└── file1

通常のファイル操作(catls等)でfile1へのアクセスを行うと、FUSEによってfile1が実装したnodefs.Nodeのメソッドが実行されるようになります。同様にdirへのアクセスはdirのメソッドが実行されます。あとは、必要に応じてnodefs.Nodeのメソッドを実装すれば良いです。

代表的なメソッド

nodefs.Nodeに定義されているメソッドで、よく使うと思われるものを抜き出しました。

メソッド名 どういう時に呼ばれるか
GetAttr ファイルの情報を取得する時に呼ばれる(ls等)
Lookup ディレクトリ内で特定のファイルを探す時に呼ばれる
OpenDir ディレクトリ内のファイル取得時に呼ばれる(ls等)
Open ファイルを開く時に呼ばれる(cat等)
Read/Write ファイルを読み書きする場合に呼ばれる
Truncate ファイルサイズを切り詰める時に呼ばれる(>等)
Mkdir ディレクトリ作成時に呼ばれる(mkdir等)
Create ファイルを新規作成する時に呼ばれる(touch等)
Unlink ファイルを削除する時に呼ばれる(rm等)

基本的には、nodefs.NewDefaultNode()nodefs.NewDataFile()のどちらも、ファイルの基本的な読み書き等といった、よくある動作は行ってくれるため、足りない動作だけ実装するのが良いと思います。

まとめ

ファイルとして表現する方法を考えるという手間がかかるので、Webやコマンドラインツールと比べるとお手軽ではありませんが、程度でいえば少しめんどくさい程度です。ファイルというUIがマッチする場面は比較的多いと思いますので、アイデアがあるならぜひやってみてください。

taskfs自身については、今回は間に合わなかったのですが、Plan 9(9P)でも動かすように対応したいですね。あと、業務ではBacklogを広く使っているので、これは近いうちに対応します。または、新しいIssueの登録と編集もファイルシステム経由でできたら便利かもしれないなと思っているので、これも対応するかもしれません。

その他の情報

FUSEで実装したけどうまく動作しない

動作は間違っていないのにうまくファイルとして扱えない場合、大半はGetAttrで必要な値をセットしていないか、間違っていることが多いです。このあたりを見直してみましょう。

macOSでno FUSE devices foundエラー

macOSで実行した時、

no FUSE devices found

というエラーになる場合は、おそらくosxfuse.kextの拡張がロードされていません。以下のコマンドで、FUSE for macOSのカーネル拡張をロードしてから試してみてください。

$ sudo kextload /Library/Filesystems/osxfuse.fs/Contents/Extensions/10.12/osxfuse.kext