Resourceとは?
ResourceはJobで利用されるオブジェクトです。
Resourceは振る舞いが抽象化されており、少ない記述量で決められた振る舞いを行うことができます。
ConcourseのWebUIでいうと下図の赤線になります。
正直言うと、Concourseは大抵のことをTaskで行うことができます。
それでもなお、Resourceを利用するのには以下のメリットがあります。
- トリガーとして利用することが可能
- パイプラインビューとしてInput/Outputを表示することが可能
例えば、対象のRepositoryに新しいcommitがある場合に、Job Aを起動する、ある時刻になったらJob Bを起動するといった場合にトリガーとなるのはResourceでしかできません。
さらに、Concourseの特徴の1つに魅力的なUIがあげられるのですが、それを活かすのであればResourceを使うべきです。TaskはJobをクリックしないと見ることはできません。
Resourceの振る舞い
Resourceの振る舞いにはcheck
, in
, out
の3つがあります。
ResourceにはすべてVersionがあります。
ここでは、concourse/git-resource: tracks commits in a branch of a Git repositoryを例に説明します。
check
対象のResource(例: githubのrepository)に更新があるか確認します。
git-resourceでは、コミットハッシュに最新のものがあるかを確認します。
先述しましたが、新しいVersionをcheckしたらJobを起動するなんてこともできます。
このcheckの振る舞いに必要な情報はresourceのSource Configurationのみです。
resources:
- name: repository
type: git
source:
uri: https://github.com/concourse/concourse
branch: master
Source Configurationは上記の例のsource
以下です。
in
対象のResourceの指定されたVersionをfetchします。
git-resouceでは、Versionはコミットハッシュになっており、inの振る舞いでは対象のコミットハッシュのものが特定のディレクトリにfetchされています。
(厳密にはclone: https://github.com/concourse/git-resource/blob/master/assets/in#L76)
inではget
step時に指定されたパラメータの情報を利用することができます。
jobs:
- name: get-repository
plan:
- get: replository
params:
depth: 1
パラメータを利用して、最新1件のコミットしか取得しないようにしています。
out
対象のResourceに対しての操作になります。
Resourceは操作されるとVersionが更新されます。
git-resourceで対象のrepoに対してpushを行います。
outではput
step時に指定されたパラメータの情報を利用することができます。
jobs:
- name: get-repository
plan:
- task: add-brank
file: repository/tasks/add-brank.yml
- put: repository
params:
repository: output
上記の例ではわかりづらいですが、task: add-brankで出力されたoutputをrepositoryとしてpushしています。
Resourceの実装
Resourceの実装を始める前にResource実装の鉄則(by cappyzawa)を紹介します。
それはBe Simpleです。
つまりは以下です。
- 1つのResourceは1つの対象に対して操作を行うべき
- 1つの振る舞いは1つのことしかできないようにするべき
Resourceを利用するとパイプラインの見た目がすっきりしたり、yamlの記述量が少なくなったりと誘惑がたくさんあります。
そうすると、あれ?ここまとめることできないか。。。?といった発想に陥ることがあります(体験談)。
また、1つの振る舞いで複数のことをしようとするのも避けるべきです。
例えば、put
stepでパラメータによって振る舞いを変えよう。。!というのはやめておくべきです。
上記のいずれもパイプラインビューをわかりづらくします。
せっかくResourceとしてパイプラインビューで見えているものが勘違いを生んでしまうというのは残念ですよね。
見えている1つのResourceの操作対象がAなのかBなのかわからない、put
されているが実際は何をしたのかわからないという事態を避けましょう。
各振る舞いの簡単な説明と実装の鉄則の説明も済んだので、本題のResourceの実装をします。
今回はあくまで実装例ですので、振る舞い自体はdummyのものにします。
下記のようなパイプラインで動作するResourceを作成します。
resource_types:
- name: dummy
type: docker-image
source:
repository: cappyzawa/dummy-resource
resouces:
- name: dummy
type: dummy
source:
config1: config-value1
config2: config-value2
jobs:
- name: sample
plan:
- get: dummy
params:
get_param1: get-param-value1
- put: dummy
params:
put_param1: put-param-value1
この明らかにdummyなサンプルを用いて、Resourceの実装の理解をしていければと思います。
ファイル構造は以下のようになっています
.
├── Dockerfile
├── cmd
│ ├── check
│ │ └── main.go
│ ├── in
│ │ └── main.go
│ └── out
│ └── main.go
└── models.go
models.go
まずはResourceの大枠を決めて記述していきます。
package resource
type Source struct {
Config1 string `json:"config1"`
Config2 string `json:"config2"`
}
type Version struct {
Date string `json:"date"`
}
type MetadataPair struct {
Name string `json:"name"`
Value string `json:"value"`
}
ResourceにはすべてVersionがあると先述しましたが、今回は時刻をVersion
にすることにしました。
MetadataPair
はResourceのメタデータです。
Versionは一意にする必要がありますが、メタデータは一意でなくても構いません。
また、メタデータは必須でもありません。
ですが、今回はメタデータに年、月、日をいれてみましょう。
cmd/check/main.go
check
時には標準入力に以下のようなpayloadが流れてきます。
{
"source": {
"config1": "config-value1",
"config2": "config-value2"
},
"version": {
"date": ""
}
}
そして処理の後は標準出力に以下のようなpayloadを出力します。
[
{"version": { "date": "2018-12-14 13:02:51.4822437 +0000 UTC m=+0.007553201"}}
]
なのでコードは以下のようになります。
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/cappyzawa/dummy-resource"
)
type Request struct {
Source resource.Source `json:"source"`
Version resource.Version `json:"version"`
}
type Response []resource.Version
func main() {
var request Request
decoder := json.NewDecoder(os.Stdin)
err := decoder.Decode(&request)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to decode: %s\n", err.Error())
os.Exit(1)
return
}
fmt.Fprintf(os.Stderr, "source: %v\n", request.Source)
response := Response{}
response = append(response, resource.Version{Date: time.Now().String()})
json.NewEncoder(os.Stdout).Encode(response)
}
現在の日付をVersionとしているので1日1回しか更新されないはずです。
先述の通り、Sourceを受け取ることができます。
cmd/in/main.go
in
時には以下のpayloadが標準入力に流れてきます。
{
"source": {
"config1": "config-value1",
"config2": "config-value2"
},
"version": { "date": "2018-12-14 13:02:51.4822437 +0000 UTC m=+0.007553201"},
"params": {
"get_param1": "get-param-value1"
},
}
処理が完了したら以下のpayloadを標準出力に出力します。
{
"version": { "date": "2018-12-14 13:02:51.4822437 +0000 UTC m=+0.007553201"},
"metadata": [
{ "name": "year", "value": "2018" },
{ "name": "month", "value": "12" },
{ "name": "day", "value": "14" },
]
}
コードは以下のようになります。
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
"time"
"github.com/cappyzawa/dummy-resource"
)
type Request struct {
Source resource.Source `json:"source"`
Version resource.Version `json:"version"`
Params Params `json:"params"`
}
type Response struct {
Version resource.Version `json:"version"`
Metadata []resource.MetadataPair `json:"metadata"`
}
type Params struct {
GetParam1 string `json:"get_param1"`
}
func main() {
var request Request
decoder := json.NewDecoder(os.Stdin)
err := decoder.Decode(&request)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to decode: %s\n", err.Error())
os.Exit(1)
return
}
dest := os.Args[1]
fmt.Fprintf(os.Stderr, "source: %v\n", request.Source)
fmt.Fprintf(os.Stderr, "params: %v\n", request.Params)
dayPath := fmt.Sprintf("%s/day", dest)
yearPath := fmt.Sprintf("%s/year", dest)
monthPath := fmt.Sprintf("%s/month", dest)
dayFile, _ := os.Create(dayPath)
yearFile, _ := os.Create(yearPath)
monthFile, _ := os.Create(monthPath)
defer dayFile.Close()
defer yearFile.Close()
defer monthFile.Close()
t := time.Now()
dayFile.WriteString(strconv.Itoa(t.Day()))
yearFile.WriteString(strconv.Itoa(t.Year()))
monthFile.WriteString(t.Month().String())
response := Response{
resource.Version{Date: request.Version.Date},
[]resource.MetadataPair{
{Name: "Year", Value: strconv.Itoa(t.Year())},
{Name: "Month", Value: t.Month().String()},
{Name: "Day", Value: strconv.Itoa(t.Day())},
},
}
json.NewEncoder(os.Stdout).Encode(response)
}
SourceConfigrationとget
stepで指定されたパラメータを扱うことができます。
inコマンドは第一引数にファイルの書き込み先のディレクトリが渡されるのが特徴です。
例えば、get: dummy
などの時は、tmp/build/get/dummy
が渡されます。
今回は年、月、日付をファイルに書き込んでみました。
なので、get: dummy
が成功した場合、dummy/year
, dummy/month
, dummy/day
というファイルが作成され、それぞれに年、月、日が書き込まれているはずです。
git-resourceなどはこれがrepositoryの情報になっているだけです。
ファイルに書き込んでおくと、get
の後のstepでそのファイルを利用することできます。
日付はVersionとしているので、メタデータを年、月にしました。
メタデータはVersionの情報を補強してあげるイメージです。
cmd/out/main.go
out
時には以下のpayloadが標準入力に流れてきます。
{
"source": {
"config1": "config-value1",
"config2": "config-value2"
},
"params": {
"put_param1": "put-param-value1"
},
}
versionは流れてこないので注意です。
処理が完了したら標準出力に以下を出力します。
{
"version": { "date": "2018-12-14 13:02:51.4822437 +0000 UTC m=+0.007553201"},
"metadata": [
{ "name": "year", "value": "2018" },
{ "name": "month", "value": "12" },
{ "name": "day", "value": "14" },
]
}
ここはinと一緒ですね。
コードは以下のようになります。
package main
import (
"encoding/json"
"fmt"
"os"
"strconv"
"time"
"github.com/cappyzawa/dummy-resource"
)
type Request struct {
Source resource.Source `json:"source"`
Params Params `json:"params"`
}
type Params struct {
PutParam1 string `json:"put_param1"`
}
type Response struct {
Version resource.Version `json:"version"`
Metadata []resource.MetadataPair `json:"metadata"`
}
func main() {
var request Request
decoder := json.NewDecoder(os.Stdin)
err := decoder.Decode(&request)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to decode: %s\n", err.Error())
os.Exit(1)
return
}
// 今回はresourceに対する操作がないためsrcは必要ない。
//src := os.Args[1]
fmt.Fprintf(os.Stderr, "source: %v\n", request.Source)
fmt.Fprintf(os.Stderr, "params: %v\n", request.Params)
t := time.Now()
response := Response{
resource.Version{Date: t.String()},
[]resource.MetadataPair{
{Name: "Year", Value: strconv.Itoa(t.Year())},
{Name: "Month", Value: t.Month().String()},
{Name: "Day", Value: strconv.Itoa(t.Day())},
},
}
json.NewEncoder(os.Stdout).Encode(response)
}
SourceConfigrationとput
stepで指定されたパラメータを扱うことができます。
本来ならここでresourceへの操作を行うのですが、今回はただVersionを更新するのみとしました。
out
は第1引数に作業ディレクトリ(/tmp/build/put
)が渡されます。
今回は利用していません。
Dockerfile
最後にDockerfileを作成します。
FROM golang:1 as builder
COPY . /src
WORKDIR /src
ENV CGO_ENABLED 0
RUN go get -d ./...
RUN go build -o /assets/in ./cmd/in
RUN go build -o /assets/out ./cmd/out
RUN go build -o /assets/check ./cmd/check
FROM alpine:edge AS resource
RUN apk add --no-cache bash
COPY --from=builder assets/ /opt/resource/
RUN chmod +x /opt/resource/*
FROM resource
Resourceはcheck,in,out時に/opt/resource/check
,/opt/resource/in
, /opt/resource/out
が実行されるのでそうなるように配置してあげるだけです。
Pipeline
Resouceをみてみましょう。
赤枠がVersionで、青枠がMetadataです。
Jobをみてみましょう。
get
/put
stepでSource Configurationと指定したパラメータを利用できることがわかります。
(標準エラーしか出力されないことに注意です。。。!)
まとめ
今回はResouceの振る舞いの簡単な説明と、簡単なResouceの実装方法について説明しました。
どんなResourceであれ、inputとoutputは変わらないのでそれさえわかれば一般的な開発と変わりありません。
最終的に/opt/resource/check
, /opt/resource/in
, /opt/resource/out
に実行可能なファイルが存在していればよいので、どんな言語でも開発することができます。
今回のコードは以下においておきました。
cappyzawa/dummy-resource