├── .github
└── workflows
│ ├── build.yml
│ ├── lint.yml
│ ├── pages.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .golangci.yml
├── .goreleaser.yml
├── .octocov.yml
├── LICENSE
├── Makefile
├── README.ja.md
├── README.md
├── artifact
├── artifact.go
├── artifact_test.go
├── gcs.go
├── ghr.go
├── ghr_test.go
├── s3.go
└── s3_test.go
├── cli.go
├── cmd
└── dewy
│ └── main.go
├── config.go
├── dewy.go
├── dewy_test.go
├── go.mod
├── go.sum
├── kvs
├── consul.go
├── file.go
├── file_test.go
├── kvs.go
├── kvs_test.go
├── memory.go
└── redis.go
├── misc
├── dewy-architecture.afdesign
├── dewy-architecture.png
├── dewy-architecture.svg
├── dewy-dark-bg.svg
├── dewy-icon.512.png
├── dewy-icon.afdesign
├── dewy-icon.svg
├── dewy-logo.afdesign
├── dewy-logo.svg
├── dewy.afdesign
└── dewy.svg
├── notify
├── notify.go
├── null.go
└── slack.go
├── registry
├── buf.gen.yaml
├── buf.yaml
├── dewy.proto
├── gen
│ └── dewy
│ │ ├── dewy.pb.go
│ │ └── dewy_grpc.pb.go
├── ghr.go
├── grpc.go
├── grpc_test.go
├── registry.go
├── registry_test.go
├── s3.go
├── s3_test.go
├── semver.go
└── semver_test.go
├── starter.go
└── website
├── .env.local
├── .eslintrc.json
├── .gitignore
├── README.md
├── next-env.d.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
└── index.tsx
├── styles
├── Home.module.css
└── globals.css
└── tsconfig.json
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build by matrix
2 | on:
3 | pull_request:
4 | workflow_dispatch:
5 | push:
6 | tags:
7 | - v*
8 | branches:
9 | - main
10 | jobs:
11 | build:
12 | name: Build
13 | strategy:
14 | matrix:
15 | os: [ubuntu-latest, macos-latest]
16 | runs-on: ${{ matrix.os }}
17 | steps:
18 | - uses: actions/checkout@v4
19 | - uses: actions/setup-go@v4
20 | with:
21 | go-version-file: go.mod
22 | - name: Go build
23 | run: go build ./cmd/dewy
24 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint by GolangCI LInt
2 | on:
3 | pull_request:
4 | push:
5 | tags:
6 | - v*
7 | branches:
8 | - main
9 | jobs:
10 | golangci:
11 | name: GolongCI Lint
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: actions/setup-go@v4
16 | with:
17 | go-version-file: go.mod
18 | - name: golangci-lint
19 | uses: reviewdog/action-golangci-lint@v2
20 |
--------------------------------------------------------------------------------
/.github/workflows/pages.yml:
--------------------------------------------------------------------------------
1 | name: Pages
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 | schedule:
9 | - cron: 0 0 * * *
10 |
11 | concurrency:
12 | group: "pages"
13 | cancel-in-progress: true
14 |
15 | jobs:
16 | build:
17 | runs-on: ubuntu-latest
18 | env:
19 | NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
20 | steps:
21 | - name: Checkout
22 | uses: actions/checkout@v4
23 | - name: Setup Node
24 | uses: actions/setup-node@v4
25 | with:
26 | node-version: 22
27 | cache: npm
28 | cache-dependency-path: website/package-lock.json
29 | - name: Setup pages
30 | uses: actions/configure-pages@v5
31 | with:
32 | static_site_generator: next
33 | - name: Restore next cache
34 | uses: actions/cache@v4.0.0
35 | with:
36 | path: |
37 | website/.next/cache
38 | key: |
39 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
40 | restore-keys: |
41 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
42 | - name: Restore rotion cache
43 | uses: actions/cache@v4.0.0
44 | with:
45 | path: |
46 | website/.cache
47 | website/public/images
48 | key: rotion
49 | restore-keys: rotion
50 | - name: Install dependencies
51 | working-directory: website
52 | run: npm ci
53 | - name: Build and Export
54 | working-directory: website
55 | run: |
56 | npm run build
57 | - name: Upload artifact
58 | uses: actions/upload-pages-artifact@v3
59 | with:
60 | path: ./website/out
61 |
62 | deploy:
63 | environment:
64 | name: github-pages
65 | url: ${{ steps.deployment.outputs.page_url }}
66 | runs-on: ubuntu-latest
67 | needs: build
68 | permissions:
69 | contents: read
70 | pages: write
71 | id-token: write
72 | steps:
73 | - name: Deploy to github pages
74 | id: deployment
75 | uses: actions/deploy-pages@v4
76 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release by GoReleaser
2 | on:
3 | push:
4 | tags:
5 | - '*'
6 | jobs:
7 | goreleaser:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v4
12 | with:
13 | fetch-depth: 0
14 | - name: Set up Go
15 | uses: actions/setup-go@v4
16 | with:
17 | go-version-file: go.mod
18 | - name: Run goreleaser
19 | uses: goreleaser/goreleaser-action@v5
20 | with:
21 | version: latest
22 | args: release --clean
23 | env:
24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on:
3 | push:
4 | pull_request:
5 | workflow_dispatch:
6 | jobs:
7 | test:
8 | name: Unit test
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | - uses: actions/setup-go@v4
13 | with:
14 | go-version-file: go.mod
15 | - name: Go test
16 | run: go test -v ./... -coverprofile=coverage.out -covermode=count
17 | env:
18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19 | - name: Run octocov
20 | uses: k1LoW/octocov-action@v0
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /current
2 | /releases
3 | /dewy
4 | /dist
5 | /coverage.out
6 | dist/
7 | /website/public
8 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | linters:
2 | fast: false
3 | enable:
4 | - misspell
5 | - gosec
6 | - godot
7 | - revive
8 | - errorlint
9 | linters-settings:
10 | errcheck:
11 | check-type-assertions: true
12 | misspell:
13 | locale: US
14 | ignore-words: []
15 | revive:
16 | rules:
17 | - name: unexported-return
18 | disabled: true
19 | - name: exported
20 | disabled: false
21 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | before:
2 | hooks:
3 | - go mod tidy
4 | builds:
5 | - id: 'dewy'
6 | main: ./cmd/dewy
7 | env:
8 | - GO111MODULE=on
9 | - CGO_ENABLED=0
10 | goos:
11 | - linux
12 | - darwin
13 | goarch:
14 | - amd64
15 | - arm64
16 | archives:
17 | - name_template: >-
18 | {{ .ProjectName }}_{{ .Os }}_
19 | {{- if eq .Arch "386" }}i386
20 | {{- else if eq .Arch "amd64" }}x86_64
21 | {{- else }}{{ .Arch }}{{ end }}
22 | files:
23 | - none*
24 | checksum:
25 | name_template: 'checksums.txt'
26 | snapshot:
27 | name_template: "{{ .Tag }}-next"
28 | changelog:
29 | sort: asc
30 | filters:
31 | exclude:
32 | - '^docs:'
33 | - '^test:'
34 |
--------------------------------------------------------------------------------
/.octocov.yml:
--------------------------------------------------------------------------------
1 | # generated by octocov init
2 | coverage:
3 | if: true
4 | codeToTestRatio:
5 | code:
6 | - '**/*.go'
7 | - '!**/*_test.go'
8 | test:
9 | - '**/*_test.go'
10 | testExecutionTime:
11 | if: true
12 | diff:
13 | datastores:
14 | - artifact://${GITHUB_REPOSITORY}
15 | comment:
16 | if: is_pull_request
17 | summary:
18 | if: true
19 | report:
20 | if: is_default_branch
21 | datastores:
22 | - artifact://${GITHUB_REPOSITORY}
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2018 linyows
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | TEST ?= ./...
2 | LOGLEVEL ?= info
3 |
4 | default: build
5 |
6 | build:
7 | go build ./cmd/dewy
8 |
9 | server:
10 | go run cmd/dewy/main.go server --registry ghr://linyows/dewy-testapp -p 8000 -l $(LOGLEVEL) -- $(HOME)/.go/src/github.com/linyows/dewy/current/dewy-testapp
11 |
12 | assets:
13 | go run cmd/dewy/main.go assets --registry ghr://linyows/dewy-testapp -l $(LOGLEVEL)
14 |
15 | protobuf:
16 | cd registry && buf generate
17 |
18 | deps:
19 | go install github.com/goreleaser/goreleaser@latest
20 | go install github.com/bufbuild/buf/cmd/buf@latest
21 |
22 | test:
23 | go test $(TEST) $(TESTARGS)
24 | go test -race $(TEST) $(TESTARGS) -coverprofile=coverage.out -covermode=atomic
25 |
26 | integration:
27 | go test -integration $(TEST) $(TESTARGS)
28 |
29 | lint:
30 | golangci-lint run ./...
31 |
32 | ci: deps test lint
33 | git diff go.mod
34 |
35 | xbuild:
36 | goreleaser --rm-dist --snapshot --skip-validate
37 |
38 | dist:
39 | @test -z $(GITHUB_TOKEN) || goreleaser --rm-dist --skip-validate
40 |
41 | clean:
42 | git checkout go.*
43 | git clean -f
44 |
45 | .PHONY: default dist test deps
46 |
--------------------------------------------------------------------------------
/README.ja.md:
--------------------------------------------------------------------------------
1 |
English | 日本語
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Dewy enables declarative deployment of applications in non-Kubernetes environments.
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Dewyは、主にGoで作られたアプリケーションを非コンテナ環境において宣言的にデプロイするソフトウェアです。
31 | Dewyは、アプリケーションのSupervisor的な役割をし、Dewyがメインプロセスとなり、子プロセスとしてアプリケーションを起動させます。
32 | Dewyのスケジューラーは、指定する「レジストリ」をポーリングし、セマンティックバージョニングで管理された最新のバージョンを検知すると、指定する「アーティファクト」ストアからデプロイを行います。
33 | Dewyは、いわゆるプル型のデプロイを実現します。Dewyは、レジストリ、アーティファクトストア、キャッシュストア、通知の4つのインターフェースから構成されています。
34 | 以下はDewyのデプロイプロセスと構成を図にしたものです。
35 |
36 |
37 |
38 |
39 |
40 | 主な機能
41 | --
42 |
43 | - 宣言的プル型デプロイメント
44 | - グレースフルリスタート
45 | - 選択可能なレジストリとアーティファクトストア
46 | - デプロイ状況の通知
47 | - オーディットログ
48 |
49 | 使いかた
50 | --
51 |
52 | 次のServerコマンドは、registryにgithub releasesを使い、8000番ポートでサーバ起動し、ログレベルをinfoに設定し、slackに通知する例です。
53 |
54 | ```sh
55 | $ dewy server --registry ghr://linyows/myapp \
56 | --notify slack://general?title=myapp -p 8000 -l info -- /opt/myapp/current/myapp
57 | ```
58 |
59 | レジストリと通知の指定はurlを模擬した構成になっています。urlのschemeにあたる箇所はレジストリや通知の名前です。レジストリの項目で詳しく解説します。
60 |
61 | コマンド
62 | --
63 |
64 | Dewyには、ServerとAssetsコマンドがあります。
65 | ServerはServer Application用でApplicationのプロセス管理を行い、Applicationのバージョンを最新に維持します。
66 | Assetsはhtmlやcssやjsなど、静的ファイルのバージョンを最新に維持します。
67 |
68 | - server
69 | - assets
70 |
71 | インターフェース
72 | --
73 |
74 | Dewyにはいくつかのインターフェースがあり、それぞれ選択可能な実装を持っています。以下、各インターフェースの説明をします。(もしインターフェースで欲しい実装があればissueを作ってください)
75 |
76 | - Registry
77 | - Artifact
78 | - Cache
79 | - Notify
80 |
81 | Registry
82 | --
83 |
84 | レジストリは、アプリケーションやファイルのバージョンを管理するインターフェースです。
85 | レジストリは、Github Releases、AWS S3、GRPCから選択できます。
86 |
87 | #### 共通オプション
88 |
89 | 共通オプションは以下の2つです。
90 |
91 | Option | Type | Description
92 | --- | --- | ---
93 | pre-release | bool | セマンティックバージョニングにおけるプレリリースバージョンを含める場合は `true` を設定します
94 | artifact | string | アーティファクトのファイル名が `name_os_arch.ext` のようなフォーマットであれば Dewy パターンマッチすることができますが、そうでない場合は明示的に指定してください
95 |
96 | ### Github Releases
97 |
98 | Github Releasesをレジストリに使う場合は以下の設定をします。また、Github APIを利用するために必要な環境変数の設定が必要です。
99 |
100 | ```sh
101 | # 構造
102 | ghr:///?
103 |
104 | # 例
105 | $ export GITHUB_TOKEN=****.....
106 | $ dewy --registry ghr://linyows/myapp?pre-release=true&artifact=dewy.tar ...
107 | ```
108 |
109 | ### AWS S3
110 |
111 | AWS S3をレジストリに使う場合は以下の設定をします。
112 | オプションとしては、regionの指定とendpointの指定があります。endpointは、S3互換サービスの場合に指定してください。
113 | また、AWS APIを利用するために必要な環境変数の設定が必要です。
114 |
115 | ```sh
116 | # 構造
117 | s3:////?
118 |
119 | # 例
120 | $ export AWS_ACCESS_KEY_ID=****.....
121 | $ export AWS_SECRET_ACCESS_KEY=****.....
122 | $ dewy --registry s3://jp-north-1/dewy/foo/bar/myapp?endpoint=https://s3.isk01.sakurastorage.jp ...
123 | ```
124 |
125 | S3でのオブジェクトのパスは、`//` の順になるようにしてください。例えば次の通り。
126 |
127 | ```sh
128 | # //
129 | foo/bar/baz/v1.2.4-rc/dewy-testapp_linux_x86_64.tar.gz
130 | /dewy-testapp_linux_arm64.tar.gz
131 | /dewy-testapp_darwin_arm64.tar.gz
132 | foo/bar/baz/v1.2.3/dewy-testapp_linux_x86_64.tar.gz
133 | /dewy-testapp_linux_arm64.tar.gz
134 | /dewy-testapp_darwin_arm64.tar.gz
135 | foo/bar/baz/v1.2.2/dewy-testapp_linux_x86_64.tar.gz
136 | /dewy-testapp_linux_arm64.tar.gz
137 | /dewy-testapp_darwin_arm64.tar.gz
138 | ```
139 |
140 | Dewyは、 `aws-sdk-go-v2` を使っているので regionやendpointも環境変数で指定することもできます。
141 |
142 | ```sh
143 | $ export AWS_ENDPOINT_URL="http://localhost:9000"
144 | ```
145 |
146 | ### GRPC
147 |
148 | GRPCをレギストリに使う場合は以下の設定をします。GRPCを使う場合、アーティファクトのURLをユーザが用意するGRPCサーバ側が決めるので、pre-releaseやartifactを指定できません。
149 | GRPCは、インターフェースを満たすサーバを自作することができ、動的にアーティファクトのURLやレポートをコントロールしたい場合にこのレジストリを使います。
150 |
151 | ```sh
152 | # 構造
153 | grpc://?
154 |
155 | # 例
156 | $ dewy grpc://localhost:9000?no-tls=true
157 | ```
158 |
159 | Artifact
160 | --
161 |
162 | アーティファクトは、アプリケーションやファイルそのものを管理するインターフェースです。
163 | アーティファクトの実装には、Github ReleaseとAWS S3とGoogle Cloud Storageがありますが、レジストリをGRPCに選択しなければ、自動的にレジストリと同じになります。
164 |
165 | Cache
166 | --
167 |
168 | キャッシュは、現在のバージョンやアーティファクトをDewyが保持するためのインターフェースです。キャッシュの実装には、ファイルシステムとメモリとHashicorp ConsulとRedisがあります。
169 |
170 | Notify
171 | --
172 |
173 | 通知は、デプロイの状態を通知するインターフェースです。通知は、Slack、SMTPから選択できます。
174 |
175 | ### Slack
176 |
177 | Slackを通知に使う場合は以下の設定をします。オプションには、通知に付加する `title` と そのリンクである `url` が設定できます。リポジトリ名やそのURLを設定すると良いでしょう。
178 | また、Slack APIを利用するために必要な環境変数の設定が必要です。
179 | [Slack Appを作成](https://api.slack.com/apps)し、 OAuth Tokenを発行して設定してください。OAuthのScopeは `channels:join` と `chat:write` が必要です。
180 |
181 | ```sh
182 | # 構造
183 | slack://?
184 |
185 | # 例
186 | $ export SLACK_TOKEN=****.....
187 | $ dewy --notify slack://dewy?title=myapp&url=https://dewy.liny.ws ...
188 | ```
189 |
190 | セマンティックバージョニング
191 | --
192 |
193 | Dewyは、セマンティックバージョニングに基づいてバージョンのアーティファクトの新しい古いを判別しています。
194 | そのため、ソフトウェアのバージョンをセマンティックバージョニングで管理しなければなりません。
195 |
196 | 詳しくは https://semver.org/lang/ja/
197 |
198 | ```txt
199 | # Pre release versions:
200 | v1.2.3-rc
201 | v1.2.3-beta.2
202 | ```
203 |
204 | ステージング
205 | --
206 |
207 | セマンティックバージョニングには、プレリリースという考え方があります。バージョンに対してハイフンをつけてsuffixを付加したものが
208 | プレリリースバージョンになります。ステージング環境では、registryのオプションに `pre-release=true`を追加することで、プレリリースバージョンがデプロイされるようになります。
209 |
210 | プロビジョニング
211 | --
212 |
213 | Dewy用のプロビジョニングは、ChefとPuppetがあります。Ansibleがないので誰か作ってくれると嬉しいです。
214 |
215 | - Chef: https://github.com/linyows/dewy-cookbook
216 | - Puppet: https://github.com/takumakume/puppet-dewy
217 |
218 | 背景
219 | --
220 |
221 | Goはコードを各環境に合わせたひとつのバイナリにコンパイルすることができます。
222 | Kubernetesのようなオーケストレーターのある分散システムでは、Goで作られたアプリケーションのデプロイに困ることはないでしょう。
223 | 一方で、コンテナではない単一の物理ホストや仮想マシン環境において、Goのバイナリをどうやってデプロイするかの明確な答えはないように思います。
224 | 手元からscpやrsyncするshellを書いて使うのか、サーバ構成管理のansibleを使うのか、rubyのcapistranoを使うのか、方法は色々あります。
225 | しかし、複数人のチームで誰がどこにデプロイしたといったオーディットログや情報共有を考えると、そのようなユースケースにマッチするツールがない気がします。
226 |
227 | FAQ
228 | --
229 |
230 | 質問されそうなことを次にまとめました。
231 |
232 | - Latestバージョンをレジストリから削除するとどうなりますか?
233 |
234 | Dewyは削除後のLatestバージョンに変更します。リリースしたバージョンを削除したり上書きするのは望ましくありませんが、セキュリティの問題などやむを得ず削除するケースはあるかもしれません。
235 |
236 | - オーディットログはどこにありますか?
237 |
238 | オーディットログはアーティファクトがホストされてるところにテキストファイルのファイル名として保存されます。現状は検索性がないです。何かいい方法が思いついたら変更するでしょう。
239 | オーディットとは別で通知としてOTELなどのオブザーバービリティプロダクトに送ることも必要かもしれません。
240 |
241 | - 複数Dewyからのポーリングによってレジストリのレートリミットにかかるのはどう対処できますか?
242 |
243 | キャッシュコンポーネントにHashicorp Consul やredisを使うと複数Dewyでキャッシュを共有出来るため、レジストリへの総リクエスト数は減るでしょう。その際は、レジストリTTLを適切な時間に設定するのがよいです。
244 | なお、ポーリング間隔を長くするにはコマンドのオプションで指定できます。
245 |
246 | 作者
247 | --
248 |
249 | [@linyows](https://github.com/linyows)
250 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | English | 日本語
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Dewy enables declarative deployment of applications in non-Kubernetes environments.
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | Dewy is software primarily designed to declaratively deploy applications written in Go in non-container environments. Dewy acts as a supervisor for applications, running as the main process while launching the application as a child process. Its scheduler polls specified registries and, upon detecting the latest version (using semantic versioning), deploys from the designated artifact store. This enables Dewy to perform pull-based deployments. Dewy’s architecture is composed of abstracted components: registries, artifact stores, cache stores, and notification channels. Below are diagrams illustrating Dewy's deployment process and architecture.
31 |
32 |
33 |
34 |
35 |
36 | Features
37 | --
38 |
39 | - Pull-based declaratively deployment
40 | - Graceful restarts
41 | - Configurable registries and artifact stores
42 | - Deployment status notifications
43 | - Audit logging
44 |
45 | Usage
46 | --
47 |
48 | The following Server command demonstrates how to use GitHub Releases as a registry, start a server on port 8000, set the log level to `info` and enable notifications via Slack.
49 |
50 | ```sh
51 | $ dewy server --registry ghr://linyows/myapp \
52 | --notify slack://general?title=myapp -p 8000 -l info -- /opt/myapp/current/myapp
53 | ```
54 |
55 | The registry and notification configurations are URL-like structures, where the scheme component represents the registry or notification type. More details are provided in the Registry section.
56 |
57 | Commands
58 | --
59 |
60 | Dewy provides two main commands: `Server` and `Assets`. The `Server` command is designed for server applications, managing the application’s processes and ensuring the application version stays up to date. The `Assets` command focuses on static files such as HTML, CSS, and JavaScript, keeping these assets updated to the latest version.
61 |
62 | - server
63 | - assets
64 |
65 | Interfaces
66 | --
67 |
68 | Dewy provides several interfaces, each with multiple implementations to choose from. Below are brief descriptions of each. (Feel free to create an issue if there’s an implementation you’d like added.)
69 |
70 | - Registry
71 | - Artifact
72 | - Cache
73 | - Notify
74 |
75 | Registry
76 | --
77 |
78 | The Registry interface manages versions of applications and files. It currently supports GitHub Releases, AWS S3, and GRPC as sources.
79 |
80 | ### Common Options
81 |
82 | There are two common options for the registry.
83 |
84 | Option | Type | Description
85 | --- | --- | ---
86 | pre-release | bool | Set to true to include pre-release versions, following semantic versioning.
87 | artifact | string | Specify the artifact filename if it does not follow the name_os_arch.ext pattern that Dewy matches by default.
88 |
89 | ### Github Releases
90 |
91 | To use GitHub Releases as a registry, configure it as follows and set up the required environment variables for accessing the GitHub API.
92 |
93 | ```sh
94 | # Format
95 | # ghr:///?
96 |
97 | # Example
98 | $ export GITHUB_TOKEN=****.....
99 | $ dewy --registry ghr://linyows/myapp?pre-release=true&artifact=dewy.tar ...
100 | ```
101 |
102 | ### AWS S3
103 |
104 | To use AWS S3 as a registry, configure it as follows. Options include specifying the region and endpoint (for S3-compatible services). Required AWS API credentials must also be set as environment variables.
105 |
106 | ```sh
107 | # Format
108 | # s3:////?
109 |
110 | # Example
111 | $ export AWS_ACCESS_KEY_ID=****.....
112 | $ export AWS_SECRET_ACCESS_KEY=****.....
113 | $ dewy --registry s3://jp-north-1/dewy/foo/bar/myapp?endpoint=https://s3.isk01.sakurastorage.jp ...
114 | ```
115 |
116 | Please ensure that the object path in S3 follows the order: `//`. For example:
117 |
118 | ```sh
119 | # //
120 | foo/bar/baz/v1.2.4-rc/dewy-testapp_linux_x86_64.tar.gz
121 | /dewy-testapp_linux_arm64.tar.gz
122 | /dewy-testapp_darwin_arm64.tar.gz
123 | foo/bar/baz/v1.2.3/dewy-testapp_linux_x86_64.tar.gz
124 | /dewy-testapp_linux_arm64.tar.gz
125 | /dewy-testapp_darwin_arm64.tar.gz
126 | foo/bar/baz/v1.2.2/dewy-testapp_linux_x86_64.tar.gz
127 | /dewy-testapp_linux_arm64.tar.gz
128 | /dewy-testapp_darwin_arm64.tar.gz
129 | ```
130 |
131 | Dewy leverages aws-sdk-go-v2, so you can also specify region and endpoint through environment variables.
132 |
133 | ```sh
134 | $ export AWS_ENDPOINT_URL="http://localhost:9000"
135 | ```
136 |
137 | ### GRPC
138 |
139 | For using GRPC as a registry, configure as follows. Since the GRPC server defines artifact URLs, options like pre-release and artifact are not available. This registry is suitable if you wish to control artifact URLs or reporting dynamically.
140 |
141 | ```
142 | # Format
143 | # grpc://?
144 |
145 | # Example
146 | $ dewy --registry grpc://localhost:9000?no-tls=true ...
147 | ```
148 |
149 | Artifact
150 | --
151 |
152 | The Artifact interface manages application or file content itself. If the registry is not GRPC, artifacts will automatically align with the registry type. Supported types include GitHub Releases, AWS S3, and Google Cloud Storage.
153 |
154 | Cache
155 | --
156 |
157 | The Cache interface stores the current versions and artifacts. Supported implementations include the file system, memory, HashiCorp Consul, and Redis.
158 |
159 | Notify
160 | --
161 |
162 | The Notify interface sends deployment status updates. Slack and SMTP are available as notification methods.
163 |
164 | ### Slack
165 |
166 | To use Slack for notifications, configure as follows. Options include a title and url that can link to the repository name or URL. You’ll need to [create a Slack App](https://api.slack.com/apps), generate an OAuth Token, and set the required environment variables. The app should have `channels:join` and `chat:write` permissions.
167 |
168 | ```sh
169 | # Format
170 | # slack://?
171 |
172 | # Example
173 | $ export SLACK_TOKEN=****.....
174 | $ dewy --notify slack://dewy?title=myapp&url=https://dewy.liny.ws ...
175 | ```
176 |
177 | Semantic Versioning
178 | --
179 |
180 | Dewy uses semantic versioning to determine the recency of artifact versions. Therefore, it’s essential to manage software versions using semantic versioning.
181 |
182 | ```text
183 | # Pre release versions:
184 | v1.2.3-rc
185 | v1.2.3-beta.2
186 | ```
187 |
188 | For details, visit https://semver.org/
189 |
190 | Staging
191 | --
192 |
193 | Semantic versioning includes a concept called pre-release. A pre-release version is created by appending a suffix with a hyphen to the version number. In a staging environment, adding the option `pre-release=true` to the registry settings enables deployment of pre-release versions.
194 |
195 | Provisioning
196 | --
197 |
198 | Provisioning for Dewy is available via Chef and Puppet. Ansible support is not currently available—feel free to contribute if you’re interested.
199 |
200 | - Chef: https://github.com/linyows/dewy-cookbook
201 | - Puppet: https://github.com/takumakume/puppet-dewy
202 |
203 | Background
204 | --
205 |
206 | Go can compile code into a single binary tailored for each environment. In distributed systems with orchestrators like Kubernetes, deploying Go applications is typically straightforward. However, for single physical hosts or virtual machine environments without containers, there's no clear answer on how best to deploy a Go binary. Options range from writing shell scripts to use `scp` or `rsync` manually, to using server configuration tools like Ansible or even Ruby-based tools like Capistrano. However, when it comes to managing deployments across teams with audit and visibility into who deployed what, there seems to be a gap in tools that meet these specific needs.
207 |
208 | Frequently Asked Questions
209 | --
210 |
211 | Here are some questions you may be asked:
212 |
213 | - What happens if I delete the latest version from the registry?
214 |
215 | Dewy will adjust to the new latest version after deletion. While it’s generally not recommended to delete or overwrite released versions, there may be cases, such as security concerns, where deletion is unavoidable.
216 |
217 | - Where can I find the audit logs?
218 |
219 | Audit logs are saved as text files at the location where the artifacts are hosted. Currently, there is no search functionality for these logs.
220 | If we identify a better solution, we may make adjustments. Additionally, it may be necessary to send notifications to observability tools like OTEL for enhanced monitoring, separate from the audit logs.
221 |
222 | - How can I prevent registry rate limits caused by polling from multiple Dewy instances?
223 |
224 | By using a cache component like HashiCorp Consul or Redis, you can share the cache among multiple Dewy instances, which reduces the total number of requests to the registry. In this case, it's best to set an appropriate registry TTL.
225 | You can also extend the polling interval by specifying it in the command options.
226 |
227 | Author
228 | --
229 |
230 | [@linyows](https://github.com/linyows)
231 |
--------------------------------------------------------------------------------
/artifact/artifact.go:
--------------------------------------------------------------------------------
1 | package artifact
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "strings"
8 | )
9 |
10 | const (
11 | ghrScheme = "ghr"
12 | s3Scheme = "s3"
13 | gcsScheme = "gcs"
14 | )
15 |
16 | // Fetcher is the interface that wraps the Fetch method.
17 | type Artifact interface {
18 | // Fetch fetches the artifact from the storage.
19 | Download(ctx context.Context, w io.Writer) error
20 | }
21 |
22 | func New(ctx context.Context, url string) (Artifact, error) {
23 | splitted := strings.SplitN(url, "://", 2)
24 |
25 | switch splitted[0] {
26 | case ghrScheme:
27 | return NewGHR(ctx, url)
28 |
29 | case s3Scheme:
30 | return NewS3(ctx, url)
31 |
32 | case gcsScheme:
33 | return NewGCS(ctx, url)
34 | }
35 |
36 | return nil, fmt.Errorf("unsupported scheme: %s", url)
37 | }
38 |
--------------------------------------------------------------------------------
/artifact/artifact_test.go:
--------------------------------------------------------------------------------
1 | package artifact
2 |
3 | import (
4 | "context"
5 | "os"
6 | "testing"
7 |
8 | "github.com/google/go-cmp/cmp"
9 | "github.com/google/go-cmp/cmp/cmpopts"
10 | )
11 |
12 | func TestNew(t *testing.T) {
13 | if os.Getenv("GITHUB_TOKEN") == "" {
14 | t.Skip("GITHUB_TOKEN is not set")
15 | }
16 |
17 | tests := []struct {
18 | desc string
19 | url string
20 | want Artifact
21 | }{
22 | {
23 | "use Github Releases",
24 | "ghr://linyows/dewy/tag/v1.2.3/dewy-linux-x86_64.tar.gz",
25 | &GHR{
26 | owner: "linyows",
27 | repo: "dewy",
28 | tag: "v1.2.3",
29 | artifact: "dewy-linux-x86_64.tar.gz",
30 | url: "ghr://linyows/dewy/tag/v1.2.3/dewy-linux-x86_64.tar.gz",
31 | },
32 | },
33 | {
34 | "use AWS S3",
35 | "s3://ap-northeast-1/mybucket/myapp/v1.2.3/dewy-linux-x86_64.tar.gz?endpoint=http://localhost:9000/api",
36 | &S3{
37 | Region: "ap-northeast-1",
38 | Bucket: "mybucket",
39 | Key: "myapp/v1.2.3/dewy-linux-x86_64.tar.gz",
40 | Endpoint: "http://localhost:9000/api",
41 | url: "s3://ap-northeast-1/mybucket/myapp/v1.2.3/dewy-linux-x86_64.tar.gz?endpoint=http://localhost:9000/api",
42 | },
43 | },
44 | }
45 |
46 | for _, tt := range tests {
47 | t.Run(tt.desc, func(t *testing.T) {
48 | got, err := New(context.Background(), tt.url)
49 | if err != nil {
50 | t.Fatal(err)
51 | } else {
52 | opts := []cmp.Option{
53 | cmp.AllowUnexported(GHR{}, S3{}),
54 | cmpopts.IgnoreFields(GHR{}, "cl"),
55 | cmpopts.IgnoreFields(S3{}, "cl"),
56 | }
57 | if diff := cmp.Diff(got, tt.want, opts...); diff != "" {
58 | t.Error(diff)
59 | }
60 | }
61 | })
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/artifact/gcs.go:
--------------------------------------------------------------------------------
1 | package artifact
2 |
3 | import (
4 | "context"
5 | "io"
6 | "log"
7 |
8 | "github.com/k1LoW/remote"
9 | )
10 |
11 | type GCS struct {
12 | url string
13 | }
14 |
15 | func NewGCS(ctx context.Context, u string) (*GCS, error) {
16 | return &GCS{url: u}, nil
17 | }
18 |
19 | func (g *GCS) Download(ctx context.Context, w io.Writer) error {
20 | f, err := remote.Open(g.url)
21 | if err != nil {
22 | return err
23 | }
24 | defer f.Close()
25 | log.Printf("[INFO] Downloaded from %s", g.url)
26 | if _, err := io.Copy(w, f); err != nil {
27 | return err
28 | }
29 | return nil
30 | }
31 |
--------------------------------------------------------------------------------
/artifact/ghr.go:
--------------------------------------------------------------------------------
1 | package artifact
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "log"
8 | "strings"
9 |
10 | "github.com/google/go-github/v55/github"
11 | "github.com/k1LoW/go-github-client/v55/factory"
12 | )
13 |
14 | type GHR struct {
15 | owner string
16 | repo string
17 | tag string
18 | artifact string
19 | url string
20 | cl *github.Client
21 | }
22 |
23 | func NewGHR(ctx context.Context, url string) (*GHR, error) {
24 | // ghr://owner/repo/tag/v1.0.0/artifact.zip
25 | splitted := strings.Split(strings.TrimPrefix(url, fmt.Sprintf("%s://", ghrScheme)), "/")
26 | if len(splitted) != 5 {
27 | return nil, fmt.Errorf("invalid artifact url: %s, %#v", url, splitted)
28 | }
29 |
30 | cl, err := factory.NewGithubClient()
31 | if err != nil {
32 | return nil, err
33 | }
34 |
35 | return &GHR{
36 | owner: splitted[0],
37 | repo: splitted[1],
38 | tag: splitted[3],
39 | artifact: splitted[4],
40 | url: url,
41 | cl: cl,
42 | }, nil
43 | }
44 |
45 | // Download download artifact.
46 | func (r *GHR) Download(ctx context.Context, w io.Writer) error {
47 | page := 1
48 | var assetID int64
49 | L:
50 | for {
51 | releases, res, err := r.cl.Repositories.ListReleases(ctx, r.owner, r.repo, &github.ListOptions{
52 | Page: page,
53 | PerPage: 100,
54 | })
55 | if err != nil {
56 | return err
57 | }
58 | for _, v := range releases {
59 | if v.GetTagName() != r.tag {
60 | continue
61 | }
62 | for _, a := range v.Assets {
63 | if a.GetName() != r.artifact {
64 | continue
65 | }
66 | assetID = a.GetID()
67 | break L
68 | }
69 | }
70 | if res.NextPage == 0 {
71 | break
72 | }
73 | page = res.NextPage
74 | }
75 |
76 | reader, url, err := r.cl.Repositories.DownloadReleaseAsset(ctx, r.owner, r.repo, assetID, r.cl.Client())
77 | if err != nil {
78 | return err
79 | }
80 | if url != "" {
81 | res, err := r.cl.Client().Get(url)
82 | if err != nil {
83 | return err
84 | }
85 | defer res.Body.Close()
86 | reader = res.Body
87 | }
88 |
89 | log.Printf("[INFO] Downloaded from %s", url)
90 | if _, err := io.Copy(w, reader); err != nil {
91 | return err
92 | }
93 |
94 | return nil
95 | }
96 |
--------------------------------------------------------------------------------
/artifact/ghr_test.go:
--------------------------------------------------------------------------------
1 | package artifact
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "net/http"
8 | "os"
9 | "testing"
10 |
11 | "github.com/google/go-cmp/cmp"
12 | "github.com/google/go-cmp/cmp/cmpopts"
13 | "github.com/google/go-github/v55/github"
14 | "github.com/k1LoW/go-github-client/v55/factory"
15 | "github.com/migueleliasweb/go-github-mock/src/mock"
16 | )
17 |
18 | func TestNewGHR(t *testing.T) {
19 | if os.Getenv("GITHUB_TOKEN") == "" {
20 | t.Skip("GITHUB_TOKEN is not set")
21 | }
22 |
23 | tests := []struct {
24 | desc string
25 | url string
26 | expected *GHR
27 | expectErr bool
28 | err error
29 | }{
30 | {
31 | "valid structure is returned",
32 | "ghr://linyows/dewy/tag/v1.2.3/myapp-linux-x86_64.zip",
33 | &GHR{
34 | owner: "linyows",
35 | repo: "dewy",
36 | tag: "v1.2.3",
37 | artifact: "myapp-linux-x86_64.zip",
38 | url: "ghr://linyows/dewy/tag/v1.2.3/myapp-linux-x86_64.zip",
39 | },
40 | false,
41 | nil,
42 | },
43 | {
44 | "error is returned",
45 | "ghr://foo",
46 | nil,
47 | true,
48 | fmt.Errorf("invalid artifact url: ghr://foo, []string{\"foo\"}"),
49 | },
50 | }
51 |
52 | for _, tt := range tests {
53 | t.Run(tt.desc, func(t *testing.T) {
54 | s3, err := NewGHR(context.Background(), tt.url)
55 | if tt.expectErr {
56 | if err == nil || err.Error() != tt.err.Error() {
57 | t.Errorf("expected error %s, got %s", tt.err, err)
58 | }
59 | } else {
60 | opts := []cmp.Option{
61 | cmp.AllowUnexported(GHR{}),
62 | cmpopts.IgnoreFields(GHR{}, "cl"),
63 | }
64 | if diff := cmp.Diff(s3, tt.expected, opts...); diff != "" {
65 | t.Error(diff)
66 | }
67 | }
68 | })
69 | }
70 | }
71 |
72 | func TestGHRDownload(t *testing.T) {
73 | tests := []struct {
74 | name string
75 | mockClient *http.Client
76 | expectedOutput string
77 | expectErr bool
78 | }{
79 | {
80 | name: "successful download",
81 | mockClient: mock.NewMockedHTTPClient(
82 | mock.WithRequestMatch(
83 | mock.GetReposReleasesByOwnerByRepo,
84 | []github.RepositoryRelease{
85 | {
86 | TagName: github.String("v1.0.0"),
87 | Assets: []*github.ReleaseAsset{
88 | {
89 | ID: github.Int64(12345),
90 | Name: github.String("artifact.zip"),
91 | },
92 | },
93 | },
94 | },
95 | ),
96 | mock.WithRequestMatch(
97 | mock.GetReposReleasesAssetsByOwnerByRepoByAssetId,
98 | []byte("mock content"),
99 | ),
100 | ),
101 | expectedOutput: "mock content",
102 | expectErr: false,
103 | },
104 | {
105 | name: "artifact not found",
106 | mockClient: mock.NewMockedHTTPClient(
107 | mock.WithRequestMatch(
108 | mock.GetReposReleasesByOwnerByRepo,
109 | []github.RepositoryRelease{
110 | {
111 | TagName: github.String("v1.0.0"),
112 | Assets: []*github.ReleaseAsset{
113 | {
114 | ID: github.Int64(12345),
115 | Name: github.String("other-artifact.zip"),
116 | },
117 | },
118 | },
119 | },
120 | ),
121 | ),
122 | expectedOutput: "",
123 | expectErr: true,
124 | },
125 | }
126 |
127 | for _, tt := range tests {
128 | t.Run(tt.name, func(t *testing.T) {
129 | cl, err := factory.NewGithubClient(factory.HTTPClient(tt.mockClient))
130 | if err != nil {
131 | t.Fatal(err)
132 | }
133 |
134 | ghr := &GHR{
135 | owner: "test-owner",
136 | repo: "test-repo",
137 | tag: "v1.0.0",
138 | artifact: "artifact.zip",
139 | cl: cl,
140 | }
141 |
142 | var buf bytes.Buffer
143 | err = ghr.Download(context.Background(), &buf)
144 |
145 | if tt.expectErr {
146 | if err == nil {
147 | t.Errorf("expected error but got nil")
148 | }
149 | } else {
150 | if err != nil {
151 | t.Errorf("expected no error but got %v", err)
152 | }
153 | if buf.String() != tt.expectedOutput {
154 | t.Errorf("expected %q but got %q", tt.expectedOutput, buf.String())
155 | }
156 | }
157 | })
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/artifact/s3.go:
--------------------------------------------------------------------------------
1 | package artifact
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "log"
8 | "net/url"
9 | "os"
10 | "strings"
11 |
12 | "github.com/aws/aws-sdk-go-v2/aws"
13 | "github.com/aws/aws-sdk-go-v2/config"
14 | "github.com/aws/aws-sdk-go-v2/service/s3"
15 | "github.com/gorilla/schema"
16 | )
17 |
18 | var decoder = schema.NewDecoder()
19 |
20 | type S3 struct {
21 | Region string `schema:"-"`
22 | Bucket string `schema:"-"`
23 | Key string `schema:"-"`
24 | Endpoint string `schema:"endpoint"`
25 | url string
26 | cl S3Client
27 | }
28 |
29 | // s3:////?endpoint=bbb"
30 | func NewS3(ctx context.Context, strUrl string) (*S3, error) {
31 | u, err := url.Parse(strUrl)
32 | if err != nil {
33 | return nil, err
34 | }
35 |
36 | splitted := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 2)
37 |
38 | if len(splitted) < 2 {
39 | return nil, fmt.Errorf("url parse error: %s", strUrl)
40 | }
41 |
42 | s := &S3{
43 | Region: u.Host,
44 | Bucket: splitted[0],
45 | Key: splitted[1],
46 | url: strUrl,
47 | }
48 | if err = decoder.Decode(s, u.Query()); err != nil {
49 | return nil, err
50 | }
51 |
52 | cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(s.Region))
53 | if err != nil {
54 | return nil, err
55 | }
56 |
57 | if s.Endpoint != "" {
58 | s.cl = s3.NewFromConfig(cfg, func(o *s3.Options) {
59 | o.UsePathStyle = true
60 | o.BaseEndpoint = aws.String(s.Endpoint)
61 | })
62 | } else if e := os.Getenv("AWS_ENDPOINT_URL"); e != "" {
63 | s.cl = s3.NewFromConfig(cfg, func(o *s3.Options) {
64 | o.UsePathStyle = true
65 | })
66 | } else {
67 | s.cl = s3.NewFromConfig(cfg)
68 | }
69 |
70 | return s, nil
71 | }
72 |
73 | func (s *S3) Download(ctx context.Context, w io.Writer) error {
74 | res, err := s.cl.GetObject(ctx, &s3.GetObjectInput{
75 | Bucket: aws.String(s.Bucket),
76 | Key: aws.String(s.Key),
77 | })
78 | if err != nil {
79 | return fmt.Errorf("failed to download artifact from S3: %w", err)
80 | }
81 | defer res.Body.Close()
82 |
83 | log.Printf("[INFO] Downloaded from %s", s.url)
84 | _, err = io.Copy(w, res.Body)
85 | if err != nil {
86 | return fmt.Errorf("failed to write artifact to writer: %w", err)
87 | }
88 |
89 | return nil
90 | }
91 |
92 | type S3Client interface {
93 | GetObject(ctx context.Context, input *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error)
94 | }
95 |
--------------------------------------------------------------------------------
/artifact/s3_test.go:
--------------------------------------------------------------------------------
1 | package artifact
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "testing"
10 |
11 | "github.com/aws/aws-sdk-go-v2/service/s3"
12 | "github.com/google/go-cmp/cmp"
13 | "github.com/google/go-cmp/cmp/cmpopts"
14 | )
15 |
16 | func TestNewS3(t *testing.T) {
17 | tests := []struct {
18 | desc string
19 | url string
20 | expected *S3
21 | expectErr bool
22 | err error
23 | }{
24 | {
25 | "valid small structure is returned",
26 | "s3://ap-northeast-1/mybucket/v1.2.3/myapp-linux-x86_64.zip",
27 | &S3{
28 | Region: "ap-northeast-1",
29 | Bucket: "mybucket",
30 | Key: "v1.2.3/myapp-linux-x86_64.zip",
31 | Endpoint: "",
32 | url: "s3://ap-northeast-1/mybucket/v1.2.3/myapp-linux-x86_64.zip",
33 | },
34 | false,
35 | nil,
36 | },
37 | {
38 | "valid large structure is returned",
39 | "s3://ap-northeast-1/mybucket/myteam/myapp/v1.2.3/myapp-linux-x86_64.zip?endpoint=http://localhost:9999/foobar",
40 | &S3{
41 | Region: "ap-northeast-1",
42 | Bucket: "mybucket",
43 | Key: "myteam/myapp/v1.2.3/myapp-linux-x86_64.zip",
44 | Endpoint: "http://localhost:9999/foobar",
45 | url: "s3://ap-northeast-1/mybucket/myteam/myapp/v1.2.3/myapp-linux-x86_64.zip?endpoint=http://localhost:9999/foobar",
46 | },
47 | false,
48 | nil,
49 | },
50 | {
51 | "error is returned",
52 | "s3://ap",
53 | nil,
54 | true,
55 | fmt.Errorf("url parse error: s3://ap"),
56 | },
57 | }
58 |
59 | for _, tt := range tests {
60 | t.Run(tt.desc, func(t *testing.T) {
61 | s3, err := NewS3(context.Background(), tt.url)
62 | if tt.expectErr {
63 | if err == nil || err.Error() != tt.err.Error() {
64 | t.Errorf("expected error %s, got %s", tt.err, err)
65 | }
66 | } else {
67 | opts := []cmp.Option{
68 | cmp.AllowUnexported(S3{}),
69 | cmpopts.IgnoreFields(S3{}, "cl"),
70 | }
71 | if diff := cmp.Diff(s3, tt.expected, opts...); diff != "" {
72 | t.Error(diff)
73 | }
74 | }
75 | })
76 | }
77 | }
78 |
79 | type MockS3Client struct {
80 | GetObjectFunc func(ctx context.Context, input *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error)
81 | }
82 |
83 | func (m *MockS3Client) GetObject(ctx context.Context, input *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
84 | return m.GetObjectFunc(ctx, input, opts...)
85 | }
86 |
87 | // Helper to simulate an error during io.Copy.
88 | type errorReader struct{}
89 |
90 | func (r *errorReader) Read(p []byte) (int, error) {
91 | return 0, fmt.Errorf("read error")
92 | }
93 |
94 | func TestS3Download(t *testing.T) {
95 | tests := []struct {
96 | name string
97 | mockOutput *s3.GetObjectOutput
98 | mockError error
99 | expected string
100 | expectErr bool
101 | }{
102 | {
103 | name: "successful download",
104 | mockOutput: &s3.GetObjectOutput{
105 | Body: io.NopCloser(bytes.NewReader([]byte("mock file content"))),
106 | },
107 | expected: "mock file content",
108 | expectErr: false,
109 | },
110 | {
111 | name: "S3 GetObject error",
112 | mockError: errors.New("GetObject error"),
113 | expectErr: true,
114 | },
115 | {
116 | name: "write error during io.Copy",
117 | mockOutput: &s3.GetObjectOutput{
118 | Body: io.NopCloser(&errorReader{}), // Simulate io.Copy error
119 | },
120 | expectErr: true,
121 | },
122 | }
123 |
124 | for _, tt := range tests {
125 | t.Run(tt.name, func(t *testing.T) {
126 | mockClient := &MockS3Client{
127 | GetObjectFunc: func(ctx context.Context, input *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
128 | if tt.mockError != nil {
129 | return nil, tt.mockError
130 | }
131 | return tt.mockOutput, nil
132 | },
133 | }
134 |
135 | s3 := &S3{
136 | cl: mockClient,
137 | Bucket: "test-bucket",
138 | Key: "test-key/v1.2.3/test.tar",
139 | url: "s3://ap-northeast-1/test-bucket/test-key/v1.2.3/test.tar",
140 | }
141 |
142 | var buf bytes.Buffer
143 | err := s3.Download(context.Background(), &buf)
144 |
145 | if tt.expectErr {
146 | if err == nil {
147 | t.Errorf("expected error, got nil")
148 | }
149 | } else {
150 | if err != nil {
151 | t.Errorf("expected no error, got %v", err)
152 | }
153 | if buf.String() != tt.expected {
154 | t.Errorf("expected %q, got %q", tt.expected, buf.String())
155 | }
156 | }
157 | })
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/cli.go:
--------------------------------------------------------------------------------
1 | package dewy
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io"
7 | "log"
8 | "reflect"
9 | "strings"
10 |
11 | "github.com/hashicorp/logutils"
12 | flags "github.com/jessevdk/go-flags"
13 | )
14 |
15 | const (
16 | // ExitOK for exit code.
17 | ExitOK int = 0
18 |
19 | // ExitErr for exit code.
20 | ExitErr int = 1
21 | )
22 |
23 | type cli struct {
24 | env Env
25 | command string
26 | args []string
27 | LogLevel string `long:"log-level" short:"l" arg:"(debug|info|warn|error)" description:"Level displayed as log"`
28 | Interval int `long:"interval" arg:"seconds" short:"i" description:"The polling interval to the repository (default: 10)"`
29 | Port string `long:"port" short:"p" description:"TCP port to listen"`
30 | Registry string `long:"registry" description:"Registry for application"`
31 | Notify string `long:"notify" description:"Notify for application"`
32 | BeforeDeployHook string `long:"before-deploy-hook" description:"Command to execute before deploy"`
33 | AfterDeployHook string `long:"after-deploy-hook" description:"Command to execute after deploy"`
34 | Help bool `long:"help" short:"h" description:"show this help message and exit"`
35 | Version bool `long:"version" short:"v" description:"prints the version number"`
36 | }
37 |
38 | // Env struct.
39 | type Env struct {
40 | Out, Err io.Writer
41 | Args []string
42 | Version string
43 | Commit string
44 | Date string
45 | }
46 |
47 | // RunCLI runs as cli.
48 | func RunCLI(env Env) int {
49 | cli := &cli{env: env, Interval: -1}
50 | return cli.run()
51 | }
52 |
53 | func (c *cli) buildHelp(names []string) []string {
54 | var help []string
55 | t := reflect.TypeOf(cli{})
56 |
57 | for _, name := range names {
58 | f, ok := t.FieldByName(name)
59 | if !ok {
60 | continue
61 | }
62 |
63 | tag := f.Tag
64 | if tag == "" {
65 | continue
66 | }
67 |
68 | var o, a string
69 | if a = tag.Get("arg"); a != "" {
70 | a = fmt.Sprintf("=%s", a)
71 | }
72 | if s := tag.Get("short"); s != "" {
73 | o = fmt.Sprintf("-%s, --%s%s", tag.Get("short"), tag.Get("long"), a)
74 | } else {
75 | o = fmt.Sprintf("--%s%s", tag.Get("long"), a)
76 | }
77 |
78 | desc := tag.Get("description")
79 | if i := strings.Index(desc, "\n"); i >= 0 {
80 | var buf bytes.Buffer
81 | buf.WriteString(desc[:i+1])
82 | desc = desc[i+1:]
83 | const indent = " "
84 | for {
85 | if i = strings.Index(desc, "\n"); i >= 0 {
86 | buf.WriteString(indent)
87 | buf.WriteString(desc[:i+1])
88 | desc = desc[i+1:]
89 | continue
90 | }
91 | break
92 | }
93 | if len(desc) > 0 {
94 | buf.WriteString(indent)
95 | buf.WriteString(desc)
96 | }
97 | desc = buf.String()
98 | }
99 | help = append(help, fmt.Sprintf(" %-40s %s", o, desc))
100 | }
101 |
102 | return help
103 | }
104 |
105 | func (c *cli) showHelp() {
106 | opts := strings.Join(c.buildHelp([]string{
107 | "Config",
108 | "Interval",
109 | "Registry",
110 | "Notify",
111 | "Port",
112 | "LogLevel",
113 | "BeforeDeployHook",
114 | "AfterDeployHook",
115 | }), "\n")
116 |
117 | help := `
118 | Usage: dewy [--version] [--help] command
119 |
120 | Commands:
121 | server Keep the app server up to date
122 | assets Keep assets up to date
123 |
124 | Options:
125 | %s
126 | `
127 | fmt.Fprintf(c.env.Out, help, opts)
128 | }
129 |
130 | func (c *cli) run() int {
131 | p := flags.NewParser(c, flags.PassDoubleDash)
132 | args, err := p.ParseArgs(c.env.Args)
133 | if err != nil || c.Help {
134 | c.showHelp()
135 | return ExitErr
136 | }
137 |
138 | if c.Version {
139 | fmt.Fprintf(c.env.Err, "dewy version %s [%v, %v]\n", c.env.Version, c.env.Commit, c.env.Date)
140 | return ExitOK
141 | }
142 |
143 | if len(args) == 0 || (args[0] != "server" && args[0] != "assets") {
144 | fmt.Fprintf(c.env.Err, "Error: command is not available\n")
145 | c.showHelp()
146 | return ExitErr
147 | }
148 |
149 | if c.Interval < 0 {
150 | c.Interval = 10
151 | }
152 |
153 | c.command = args[0]
154 |
155 | if len(args) > 1 {
156 | c.args = args[1:]
157 | }
158 |
159 | if c.LogLevel != "" {
160 | c.LogLevel = strings.ToUpper(c.LogLevel)
161 | } else {
162 | c.LogLevel = "ERROR"
163 | }
164 |
165 | filter := &logutils.LevelFilter{
166 | Levels: []logutils.LogLevel{"DEBUG", "INFO", "WARN", "ERROR"},
167 | MinLevel: logutils.LogLevel(c.LogLevel),
168 | Writer: c.env.Err,
169 | }
170 | log.SetOutput(filter)
171 |
172 | conf := DefaultConfig()
173 |
174 | if c.Registry == "" {
175 | fmt.Fprintf(c.env.Err, "Error: --registry is not set\n")
176 | c.showHelp()
177 | return ExitErr
178 | }
179 | conf.Registry = c.Registry
180 | conf.Notify = c.Notify
181 | conf.BeforeDeployHook = c.BeforeDeployHook
182 | conf.AfterDeployHook = c.AfterDeployHook
183 |
184 | if c.command == "server" {
185 | conf.Command = SERVER
186 | conf.Starter = &StarterConfig{
187 | ports: []string{c.Port},
188 | command: c.args[0],
189 | args: c.args[1:],
190 | }
191 | } else {
192 | conf.Command = ASSETS
193 | }
194 |
195 | conf.OverrideWithEnv()
196 | d, err := New(conf)
197 | if err != nil {
198 | fmt.Fprintf(c.env.Err, "Error: %s\n", err)
199 | return ExitErr
200 | }
201 |
202 | d.Start(c.Interval)
203 |
204 | return ExitOK
205 | }
206 |
--------------------------------------------------------------------------------
/cmd/dewy/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/linyows/dewy"
7 | )
8 |
9 | var (
10 | version = "dev"
11 | commit = "none"
12 | date = "unknown"
13 | )
14 |
15 | func main() {
16 | os.Exit(dewy.RunCLI(dewy.Env{
17 | Out: os.Stdout,
18 | Err: os.Stderr,
19 | Args: os.Args[1:],
20 | Version: version,
21 | Commit: commit,
22 | Date: date,
23 | }))
24 | }
25 |
--------------------------------------------------------------------------------
/config.go:
--------------------------------------------------------------------------------
1 | package dewy
2 |
3 | import (
4 | "os"
5 |
6 | starter "github.com/lestrrat-go/server-starter"
7 | )
8 |
9 | // Command for CLI.
10 | type Command int
11 |
12 | const (
13 | // SERVER command.
14 | SERVER Command = iota
15 | // ASSETS command.
16 | ASSETS
17 | )
18 |
19 | // String to string for Command.
20 | func (c Command) String() string {
21 | switch c {
22 | case SERVER:
23 | return "server"
24 | case ASSETS:
25 | return "assets"
26 | default:
27 | return "unknown"
28 | }
29 | }
30 |
31 | // CacheType for cache type.
32 | type CacheType int
33 |
34 | const (
35 | // NONE cache type.
36 | NONE CacheType = iota
37 | // FILE cache type.
38 | FILE
39 | )
40 |
41 | // String to string for CacheType.
42 | func (c CacheType) String() string {
43 | switch c {
44 | case NONE:
45 | return "none"
46 | case FILE:
47 | return "file"
48 | default:
49 | return "unknown"
50 | }
51 | }
52 |
53 | // CacheConfig struct.
54 | type CacheConfig struct {
55 | Type CacheType
56 | Expiration int
57 | }
58 |
59 | // Config struct.
60 | type Config struct {
61 | Command Command
62 | Registry string
63 | Notify string
64 | ArtifactName string
65 | PreRelease bool
66 | Cache CacheConfig
67 | Starter starter.Config
68 | BeforeDeployHook string
69 | AfterDeployHook string
70 | }
71 |
72 | // OverrideWithEnv overrides by environments.
73 | func (c *Config) OverrideWithEnv() {
74 | // Support env GITHUB_ENDPOINT.
75 | if e := os.Getenv("GITHUB_ENDPOINT"); e != "" {
76 | os.Setenv("GITHUB_API_URL", e)
77 | }
78 |
79 | if a := os.Getenv("GITHUB_ARTIFACT"); a != "" {
80 | c.ArtifactName = a
81 | }
82 | }
83 |
84 | // DefaultConfig returns default Config.
85 | func DefaultConfig() Config {
86 | return Config{
87 | Cache: CacheConfig{
88 | Type: FILE,
89 | Expiration: 10,
90 | },
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/dewy.go:
--------------------------------------------------------------------------------
1 | package dewy
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "log"
8 | "net/url"
9 | "os"
10 | "os/exec"
11 | "os/signal"
12 | "path/filepath"
13 | "runtime"
14 | "sort"
15 | "strings"
16 | "sync"
17 | "syscall"
18 | "time"
19 |
20 | "github.com/carlescere/scheduler"
21 | "github.com/cli/safeexec"
22 | starter "github.com/lestrrat-go/server-starter"
23 | "github.com/linyows/dewy/artifact"
24 | "github.com/linyows/dewy/kvs"
25 | "github.com/linyows/dewy/notify"
26 | "github.com/linyows/dewy/registry"
27 | )
28 |
29 | const (
30 | ISO8601 = "20060102T150405Z0700"
31 | releaseDir = ISO8601
32 | releasesDir = "releases"
33 | symlinkDir = "current"
34 | keepReleases = 7
35 |
36 | // currentkeyName is a name whose value is the version of the currently running server application.
37 | // For example, if you are using a file for the cache store, running `cat current` will show `v1.2.3--app_linux_amd64.tar.gz`, which is a combination of the tag and artifact.
38 | // dewy uses this value as a key (**cachekeyName**) to manage the artifacts in the cache store.
39 | currentkeyName = "current"
40 | )
41 |
42 | // Dewy struct.
43 | type Dewy struct {
44 | config Config
45 | registry registry.Registry
46 | artifact artifact.Artifact
47 | cache kvs.KVS
48 | isServerRunning bool
49 | disableReport bool
50 | root string
51 | job *scheduler.Job
52 | notify notify.Notify
53 | sync.RWMutex
54 | }
55 |
56 | // New returns Dewy.
57 | func New(c Config) (*Dewy, error) {
58 | kv := &kvs.File{}
59 | kv.Default()
60 |
61 | wd, err := os.Getwd()
62 | if err != nil {
63 | return nil, err
64 | }
65 |
66 | su := strings.SplitN(c.Registry, "://", 2)
67 | u, err := url.Parse(su[1])
68 | if err != nil {
69 | return nil, err
70 | }
71 | c.Registry = fmt.Sprintf("%s://%s", su[0], u.String())
72 |
73 | return &Dewy{
74 | config: c,
75 | cache: kv,
76 | isServerRunning: false,
77 | root: wd,
78 | }, nil
79 | }
80 |
81 | // Start dewy.
82 | func (d *Dewy) Start(i int) {
83 | ctx, cancel := context.WithCancel(context.Background())
84 | defer cancel()
85 |
86 | var err error
87 |
88 | d.registry, err = registry.New(ctx, d.config.Registry)
89 | if err != nil {
90 | log.Printf("[ERROR] Registry failure: %#v", err)
91 | }
92 |
93 | d.notify, err = notify.New(ctx, d.config.Notify)
94 | if err != nil {
95 | log.Printf("[ERROR] Notify failure: %#v", err)
96 | }
97 |
98 | d.notify.Send(ctx, "Automatic shipping started by *Dewy*")
99 |
100 | d.job, err = scheduler.Every(i).Seconds().Run(func() {
101 | e := d.Run()
102 | if e != nil {
103 | log.Printf("[ERROR] Dewy run failure: %#v", e)
104 | }
105 | })
106 | if err != nil {
107 | log.Printf("[ERROR] Scheduler failure: %#v", err)
108 | }
109 |
110 | d.waitSigs(ctx)
111 | }
112 |
113 | func (d *Dewy) waitSigs(ctx context.Context) {
114 | sigCh := make(chan os.Signal, 1)
115 | signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
116 |
117 | for sig := range sigCh {
118 | log.Printf("[DEBUG] PID %d received signal as %s", os.Getpid(), sig)
119 | switch sig {
120 | case syscall.SIGHUP:
121 | continue
122 |
123 | case syscall.SIGUSR1:
124 | if err := d.restartServer(); err != nil {
125 | log.Printf("[ERROR] Restart failure: %#v", err)
126 | } else {
127 | msg := fmt.Sprintf("Restarted receiving by \"%s\" signal", "SIGUSR1")
128 | log.Printf("[INFO] %s", msg)
129 | d.notify.Send(ctx, msg)
130 | }
131 | continue
132 |
133 | case syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
134 | d.job.Quit <- true
135 | msg := fmt.Sprintf("Stop receiving by \"%s\" signal", sig)
136 | log.Printf("[INFO] %s", msg)
137 | d.notify.Send(ctx, msg)
138 | return
139 | }
140 | }
141 | }
142 |
143 | // cachekeyName is "tag--artifact"
144 | // example: v1.2.3--testapp_linux_amd64.tar.gz
145 | func (d *Dewy) cachekeyName(res *registry.CurrentResponse) string {
146 | u := strings.SplitN(res.ArtifactURL, "?", 2)
147 | return fmt.Sprintf("%s--%s", res.Tag, filepath.Base(u[0]))
148 | }
149 |
150 | // Run dewy.
151 | func (d *Dewy) Run() error {
152 | ctx, cancel := context.WithCancel(context.Background())
153 | defer cancel()
154 |
155 | // Get current
156 | res, err := d.registry.Current(ctx, ®istry.CurrentRequest{
157 | Arch: runtime.GOARCH,
158 | OS: runtime.GOOS,
159 | ArtifactName: d.config.ArtifactName,
160 | })
161 | if err != nil {
162 | log.Printf("[ERROR] Current failure: %#v", err)
163 | return err
164 | }
165 |
166 | // Check cache
167 | cachekeyName := d.cachekeyName(res)
168 | currentkeyValue, _ := d.cache.Read(currentkeyName)
169 | found := false
170 | list, err := d.cache.List()
171 | if err != nil {
172 | return err
173 | }
174 |
175 | for _, key := range list {
176 | // same current version and already cached
177 | if string(currentkeyValue) == cachekeyName && key == cachekeyName {
178 | log.Print("[DEBUG] Deploy skipped")
179 | if d.isServerRunning {
180 | return nil
181 | }
182 | // when the server fails to start
183 | break
184 | }
185 |
186 | // no current version but already cached
187 | if key == cachekeyName {
188 | found = true
189 | if err := d.cache.Write(currentkeyName, []byte(cachekeyName)); err != nil {
190 | return err
191 | }
192 | break
193 | }
194 | }
195 |
196 | // Download artifact and cache
197 | if !found {
198 | buf := new(bytes.Buffer)
199 |
200 | if d.artifact == nil {
201 | d.artifact, err = artifact.New(ctx, res.ArtifactURL)
202 | if err != nil {
203 | return err
204 | }
205 | }
206 | err := d.artifact.Download(ctx, buf)
207 | d.artifact = nil
208 | if err != nil {
209 | return err
210 | }
211 |
212 | if err := d.cache.Write(cachekeyName, buf.Bytes()); err != nil {
213 | return err
214 | }
215 | if err := d.cache.Write(currentkeyName, []byte(cachekeyName)); err != nil {
216 | return err
217 | }
218 | log.Printf("[INFO] Cached as %s", cachekeyName)
219 | }
220 |
221 | d.notify.Send(ctx, fmt.Sprintf("Ready for `%s`", res.Tag))
222 |
223 | if err := d.deploy(cachekeyName); err != nil {
224 | return err
225 | }
226 |
227 | if d.config.Command == SERVER {
228 | if d.isServerRunning {
229 | err = d.restartServer()
230 | if err == nil {
231 | d.notify.Send(ctx, fmt.Sprintf("Server restarted for `%s`", res.Tag))
232 | }
233 | } else {
234 | err = d.startServer()
235 | if err == nil {
236 | d.notify.Send(ctx, fmt.Sprintf("Server started for `%s`", res.Tag))
237 | }
238 | }
239 | if err != nil {
240 | log.Printf("[ERROR] Server failure: %#v", err)
241 | }
242 | }
243 |
244 | if !d.disableReport {
245 | log.Print("[DEBUG] Report shipping")
246 | err := d.registry.Report(ctx, ®istry.ReportRequest{
247 | ID: res.ID,
248 | Tag: res.Tag,
249 | })
250 | if err != nil {
251 | log.Printf("[ERROR] Report shipping failure: %#v", err)
252 | }
253 | }
254 |
255 | log.Printf("[INFO] Keep releases as %d", keepReleases)
256 | err = d.keepReleases()
257 | if err != nil {
258 | log.Printf("[ERROR] Keep releases failure: %#v", err)
259 | }
260 |
261 | return nil
262 | }
263 |
264 | func (d *Dewy) deploy(key string) (err error) {
265 | if err := d.execHook(d.config.BeforeDeployHook); err != nil {
266 | log.Printf("[ERROR] Before deploy hook failure: %#v", err)
267 | return err
268 | }
269 | defer func() {
270 | if err != nil {
271 | return
272 | }
273 | // When deploy is success, run after deploy hook
274 | if err := d.execHook(d.config.AfterDeployHook); err != nil {
275 | log.Printf("[ERROR] After deploy hook failure: %#v", err)
276 | }
277 | }()
278 | p := filepath.Join(d.cache.GetDir(), key)
279 | linkFrom, err := d.preserve(p)
280 | if err != nil {
281 | log.Printf("[ERROR] Preserve failure: %#v", err)
282 | return err
283 | }
284 | log.Printf("[INFO] Extract archive to %s", linkFrom)
285 |
286 | linkTo := filepath.Join(d.root, symlinkDir)
287 | if _, err := os.Lstat(linkTo); err == nil {
288 | os.Remove(linkTo)
289 | }
290 |
291 | log.Printf("[INFO] Create symlink to %s from %s", linkTo, linkFrom)
292 | if err := os.Symlink(linkFrom, linkTo); err != nil {
293 | return err
294 | }
295 |
296 | return nil
297 | }
298 |
299 | func (d *Dewy) preserve(p string) (string, error) {
300 | dst := filepath.Join(d.root, releasesDir, time.Now().UTC().Format(releaseDir))
301 | if err := os.MkdirAll(dst, 0755); err != nil {
302 | return "", err
303 | }
304 |
305 | if err := kvs.ExtractArchive(p, dst); err != nil {
306 | return "", err
307 | }
308 |
309 | return dst, nil
310 | }
311 |
312 | func (d *Dewy) restartServer() error {
313 | d.Lock()
314 | defer d.Unlock()
315 |
316 | pid := os.Getpid()
317 | p, _ := os.FindProcess(pid)
318 | err := p.Signal(syscall.SIGHUP)
319 | if err != nil {
320 | return err
321 | }
322 | log.Printf("[INFO] Send SIGHUP to PID:%d for server restart", pid)
323 |
324 | return nil
325 | }
326 |
327 | func (d *Dewy) startServer() error {
328 | d.Lock()
329 | defer d.Unlock()
330 |
331 | d.isServerRunning = true
332 |
333 | log.Print("[INFO] Start server")
334 | ch := make(chan error)
335 |
336 | go func() {
337 | s, err := starter.NewStarter(d.config.Starter)
338 | if err != nil {
339 | log.Printf("[ERROR] Starter failure: %#v", err)
340 | return
341 | }
342 |
343 | ch <- s.Run()
344 | }()
345 |
346 | return nil
347 | }
348 |
349 | func (d *Dewy) keepReleases() error {
350 | dir := filepath.Join(d.root, releasesDir)
351 | files, err := os.ReadDir(dir)
352 | if err != nil {
353 | return err
354 | }
355 |
356 | sort.Slice(files, func(i, j int) bool {
357 | fi, err := files[i].Info()
358 | if err != nil {
359 | return false
360 | }
361 | fj, err := files[j].Info()
362 | if err != nil {
363 | return true
364 | }
365 | return fi.ModTime().Unix() > fj.ModTime().Unix()
366 | })
367 |
368 | for i, f := range files {
369 | if i < keepReleases {
370 | continue
371 | }
372 | if err := os.RemoveAll(filepath.Join(dir, f.Name())); err != nil {
373 | return err
374 | }
375 | }
376 |
377 | return nil
378 | }
379 |
380 | func (d *Dewy) execHook(cmd string) error {
381 | if cmd == "" {
382 | return nil
383 | }
384 | sh, err := safeexec.LookPath("sh")
385 | if err != nil {
386 | return err
387 | }
388 | stdout := new(bytes.Buffer)
389 | stderr := new(bytes.Buffer)
390 | c := exec.Command(sh, "-c", cmd)
391 | c.Dir = d.root
392 | c.Env = os.Environ()
393 | c.Stdout = stdout
394 | c.Stderr = stderr
395 | defer func() {
396 | log.Printf("[INFO] execute hook: command=%q stdout=%q stderr=%q", cmd, stdout.String(), stderr.String())
397 | }()
398 | if err := c.Run(); err != nil {
399 | return err
400 | }
401 | return nil
402 | }
403 |
--------------------------------------------------------------------------------
/dewy_test.go:
--------------------------------------------------------------------------------
1 | package dewy
2 |
3 | import (
4 | "archive/zip"
5 | "bytes"
6 | "context"
7 | "fmt"
8 | "io"
9 | "os"
10 | "path/filepath"
11 | "testing"
12 |
13 | "github.com/google/go-cmp/cmp"
14 | "github.com/google/go-cmp/cmp/cmpopts"
15 | "github.com/linyows/dewy/kvs"
16 | "github.com/linyows/dewy/notify"
17 | "github.com/linyows/dewy/registry"
18 | )
19 |
20 | func TestNew(t *testing.T) {
21 | reg := "ghr://linyows/dewy?pre-release=true"
22 | c := DefaultConfig()
23 | c.Registry = reg
24 | c.PreRelease = true
25 | dewy, err := New(c)
26 | if err != nil {
27 | t.Fatal(err)
28 | }
29 | wd, _ := os.Getwd()
30 |
31 | expect := &Dewy{
32 | config: Config{
33 | Registry: reg,
34 | PreRelease: true,
35 | Cache: CacheConfig{
36 | Type: FILE,
37 | Expiration: 10,
38 | },
39 | Starter: nil,
40 | },
41 | cache: dewy.cache,
42 | isServerRunning: false,
43 | root: wd,
44 | }
45 |
46 | opts := []cmp.Option{
47 | cmp.AllowUnexported(Dewy{}, kvs.File{}),
48 | cmpopts.IgnoreFields(Dewy{}, "RWMutex"),
49 | cmpopts.IgnoreFields(kvs.File{}, "mutex"),
50 | }
51 | if diff := cmp.Diff(dewy, expect, opts...); diff != "" {
52 | t.Error(diff)
53 | }
54 | }
55 |
56 | type mockRegistry struct {
57 | url string
58 | }
59 |
60 | func (r *mockRegistry) Current(ctx context.Context, req *registry.CurrentRequest) (*registry.CurrentResponse, error) {
61 | return ®istry.CurrentResponse{
62 | ID: "id",
63 | Tag: "tag",
64 | ArtifactURL: r.url,
65 | }, nil
66 | }
67 |
68 | func (r *mockRegistry) Report(ctx context.Context, req *registry.ReportRequest) error {
69 | return nil
70 | }
71 |
72 | type mockArtifact struct {
73 | binary string
74 | url string
75 | }
76 |
77 | func (a *mockArtifact) Download(ctx context.Context, w io.Writer) error {
78 | zw := zip.NewWriter(w)
79 | defer zw.Close()
80 |
81 | fInZip, err := zw.Create(a.binary)
82 | if err != nil {
83 | return fmt.Errorf("failed to create file in zip: %w", err)
84 | }
85 |
86 | _, err = io.Copy(fInZip, bytes.NewBufferString(a.url))
87 | if err != nil {
88 | return fmt.Errorf("failed to write content to file in zip: %w", err)
89 | }
90 |
91 | return nil
92 | }
93 |
94 | func TestRun(t *testing.T) {
95 | binary := "dewy"
96 | artifact := "ghr://linyows/dewy/tag/v1.2.3/artifact.zip"
97 |
98 | root := t.TempDir()
99 | c := DefaultConfig()
100 | c.Command = ASSETS
101 | c.Registry = "ghr://linyows/dewy"
102 | c.Cache = CacheConfig{
103 | Type: FILE,
104 | Expiration: 10,
105 | }
106 | dewy, err := New(c)
107 | if err != nil {
108 | t.Fatal(err)
109 | }
110 | dewy.root = root
111 |
112 | dewy.registry = &mockRegistry{
113 | url: artifact,
114 | }
115 | dewy.artifact = &mockArtifact{
116 | binary: binary,
117 | url: artifact,
118 | }
119 | dewy.notify, err = notify.New(context.Background(), "")
120 | if err != nil {
121 | t.Fatal(err)
122 | }
123 |
124 | if err := dewy.Run(); err != nil {
125 | t.Error(err)
126 | }
127 |
128 | if fi, err := os.Stat(filepath.Join(root, "current")); err != nil || !fi.IsDir() {
129 | t.Errorf("current directory is not found: %v", err)
130 | }
131 |
132 | if _, err := os.Stat(filepath.Join(root, "current", binary)); err != nil {
133 | t.Errorf("current dewy binary is not found: %v", err)
134 | }
135 |
136 | if fi, err := os.Stat(filepath.Join(root, "releases")); err != nil || !fi.IsDir() {
137 | t.Errorf("releases directory is not found: %v", err)
138 | }
139 | }
140 |
141 | func TestDeployHook(t *testing.T) {
142 | artifact := "ghr://linyows/dewy/tag/v1.2.3/artifact.zip"
143 | registry := "ghr://linyows/dewy"
144 |
145 | tests := []struct {
146 | name string
147 | beforeHook string
148 | afterHook string
149 | executedBeforeHook bool
150 | executedAfterHook bool
151 | }{
152 | {"execute a hook before run", "touch before", "", true, false},
153 | {"execute a hook after run", "", "touch after", false, true},
154 | {"execute both the before hook and after hook", "touch before", "touch after", true, true},
155 | }
156 |
157 | for _, tt := range tests {
158 | t.Run(tt.name, func(t *testing.T) {
159 | root := t.TempDir()
160 | c := DefaultConfig()
161 | c.Command = ASSETS
162 | if tt.beforeHook != "" {
163 | c.BeforeDeployHook = tt.beforeHook
164 | }
165 | if tt.afterHook != "" {
166 | c.AfterDeployHook = tt.afterHook
167 | }
168 | c.Registry = registry
169 | c.Cache = CacheConfig{
170 | Type: FILE,
171 | Expiration: 10,
172 | }
173 | dewy, err := New(c)
174 | if err != nil {
175 | t.Fatal(err)
176 | }
177 | dewy.registry = &mockRegistry{
178 | url: artifact,
179 | }
180 | dewy.artifact = &mockArtifact{
181 | binary: "dewy",
182 | url: artifact,
183 | }
184 | dewy.notify, err = notify.New(context.Background(), "")
185 | if err != nil {
186 | t.Fatal(err)
187 | }
188 | dewy.root = root
189 | _ = dewy.Run()
190 | if _, err := os.Stat(filepath.Join(root, "before")); err != nil {
191 | if tt.executedBeforeHook {
192 | t.Errorf("before hook is not executed: %v", err)
193 | }
194 | } else {
195 | if !tt.executedBeforeHook {
196 | t.Error("before hook is executed")
197 | }
198 | }
199 | if _, err := os.Stat(filepath.Join(root, "after")); err != nil {
200 | if tt.executedAfterHook {
201 | t.Errorf("after hook is not executed: %v", err)
202 | }
203 | } else {
204 | if !tt.executedAfterHook {
205 | t.Error("after hook is executed")
206 | }
207 | }
208 | })
209 | }
210 | }
211 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/linyows/dewy
2 |
3 | go 1.21.1
4 |
5 | require (
6 | github.com/aws/aws-sdk-go-v2 v1.32.3
7 | github.com/aws/aws-sdk-go-v2/config v1.28.1
8 | github.com/aws/aws-sdk-go-v2/service/s3 v1.66.2
9 | github.com/carlescere/scheduler v0.0.0-20170109141437-ee74d2f83d82
10 | github.com/cli/safeexec v1.0.1
11 | github.com/google/go-cmp v0.6.0
12 | github.com/google/go-github/v55 v55.0.0
13 | github.com/google/go-querystring v1.1.0
14 | github.com/gorilla/schema v1.2.0
15 | github.com/hashicorp/logutils v1.0.0
16 | github.com/jessevdk/go-flags v1.5.0
17 | github.com/k1LoW/go-github-client/v55 v55.0.11
18 | github.com/k1LoW/grpcstub v0.15.1
19 | github.com/k1LoW/remote v0.1.0
20 | github.com/lestrrat-go/server-starter v0.0.0-20210101230921-50cd1900b5bc
21 | github.com/lestrrat-go/slack v0.0.0-20190827134815-1aaae719550a
22 | github.com/mholt/archiver/v3 v3.5.1
23 | github.com/migueleliasweb/go-github-mock v0.0.19
24 | google.golang.org/grpc v1.67.1
25 | google.golang.org/protobuf v1.34.2
26 | )
27 |
28 | require (
29 | cloud.google.com/go v0.110.2 // indirect
30 | cloud.google.com/go/compute/metadata v0.5.0 // indirect
31 | cloud.google.com/go/iam v1.1.0 // indirect
32 | cloud.google.com/go/storage v1.31.0 // indirect
33 | github.com/ProtonMail/go-crypto v0.0.0-20230626094100-7e9e0395ebec // indirect
34 | github.com/andybalholm/brotli v1.0.1 // indirect
35 | github.com/aws/aws-sdk-go v1.44.305 // indirect
36 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect
37 | github.com/aws/aws-sdk-go-v2/credentials v1.17.42 // indirect
38 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.18 // indirect
39 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.22 // indirect
40 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.22 // indirect
41 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
42 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.22 // indirect
43 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect
44 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.3 // indirect
45 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.3 // indirect
46 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.3 // indirect
47 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.3 // indirect
48 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.3 // indirect
49 | github.com/aws/aws-sdk-go-v2/service/sts v1.32.3 // indirect
50 | github.com/aws/smithy-go v1.22.0 // indirect
51 | github.com/bmatcuk/doublestar/v4 v4.6.0 // indirect
52 | github.com/bradleyfalzon/ghinstallation/v2 v2.7.0 // indirect
53 | github.com/bufbuild/protocompile v0.6.0 // indirect
54 | github.com/cli/go-gh/v2 v2.3.0 // indirect
55 | github.com/cloudflare/circl v1.3.7 // indirect
56 | github.com/davecgh/go-spew v1.1.1 // indirect
57 | github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
58 | github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
59 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
60 | github.com/golang/protobuf v1.5.3 // indirect
61 | github.com/golang/snappy v0.0.4 // indirect
62 | github.com/google/go-github/v53 v53.2.0 // indirect
63 | github.com/google/s2a-go v0.1.4 // indirect
64 | github.com/google/uuid v1.6.0 // indirect
65 | github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
66 | github.com/googleapis/gax-go/v2 v2.11.0 // indirect
67 | github.com/gorilla/mux v1.8.0 // indirect
68 | github.com/jaswdr/faker v1.16.0 // indirect
69 | github.com/jmespath/go-jmespath v0.4.0 // indirect
70 | github.com/jszwec/s3fs v0.4.0 // indirect
71 | github.com/k1LoW/ghfs v1.1.0 // indirect
72 | github.com/k1LoW/go-github-client/v53 v53.2.11 // indirect
73 | github.com/klauspost/compress v1.11.4 // indirect
74 | github.com/klauspost/pgzip v1.2.5 // indirect
75 | github.com/lestrrat-go/pdebug v0.0.0-20210111095411-35b07dbf089b // indirect
76 | github.com/mauri870/gcsfs v0.0.0-20220203135357-0da01ba4e96d // indirect
77 | github.com/minio/pkg v1.6.5 // indirect
78 | github.com/nwaples/rardecode v1.1.0 // indirect
79 | github.com/pierrec/lz4/v4 v4.1.2 // indirect
80 | github.com/pkg/errors v0.9.1 // indirect
81 | github.com/ulikunitz/xz v0.5.10 // indirect
82 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
83 | go.opencensus.io v0.24.0 // indirect
84 | golang.org/x/crypto v0.26.0 // indirect
85 | golang.org/x/net v0.28.0 // indirect
86 | golang.org/x/oauth2 v0.22.0 // indirect
87 | golang.org/x/sync v0.8.0 // indirect
88 | golang.org/x/sys v0.24.0 // indirect
89 | golang.org/x/text v0.17.0 // indirect
90 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
91 | google.golang.org/api v0.126.0 // indirect
92 | google.golang.org/appengine v1.6.8 // indirect
93 | google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect
94 | google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 // indirect
95 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
96 | gopkg.in/yaml.v3 v3.0.1 // indirect
97 | )
98 |
--------------------------------------------------------------------------------
/kvs/consul.go:
--------------------------------------------------------------------------------
1 | package kvs
2 |
3 | // Consul struct.
4 | type Consul struct {
5 | items map[string]*item //nolint
6 | Host string
7 | Port int
8 | Password string
9 | }
10 |
11 | // Read data on Consul.
12 | func (c *Consul) Read(key string) {
13 | }
14 |
15 | // Write data to Consul.
16 | func (c *Consul) Write(data string) {
17 | }
18 |
19 | // Delete data on Consul.
20 | func (c *Consul) Delete(key string) {
21 | }
22 |
23 | // List returns key from Consul.
24 | func (c *Consul) List() {
25 | }
26 |
--------------------------------------------------------------------------------
/kvs/file.go:
--------------------------------------------------------------------------------
1 | package kvs
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 | "sync"
10 |
11 | "github.com/mholt/archiver/v3"
12 | )
13 |
14 | var (
15 | // DefaultTempDir creates temp dir.
16 | DefaultTempDir = createTempDir()
17 | // DefaultMaxSize for data size.
18 | DefaultMaxSize int64 = 64 * 1024 * 1024
19 | )
20 |
21 | func createTempDir() string {
22 | dir, _ := os.MkdirTemp("", "dewy-")
23 | return dir
24 | }
25 |
26 | // File struct.
27 | type File struct {
28 | items map[string]*item //nolint
29 | dir string
30 | mutex sync.Mutex //nolint
31 | MaxItems int
32 | MaxSize int64
33 | }
34 |
35 | // GetDir returns dir.
36 | func (f *File) GetDir() string {
37 | return f.dir
38 | }
39 |
40 | // Default sets to struct.
41 | func (f *File) Default() {
42 | f.dir = DefaultTempDir
43 | f.MaxSize = DefaultMaxSize
44 | }
45 |
46 | // Read data by key from file.
47 | func (f *File) Read(key string) ([]byte, error) {
48 | p := filepath.Join(f.dir, key)
49 | if !IsFileExist(p) {
50 | return nil, fmt.Errorf("File not found: %s", p)
51 | }
52 |
53 | content, err := os.ReadFile(p)
54 | if err != nil {
55 | return nil, err
56 | }
57 |
58 | return content, nil
59 | }
60 |
61 | // Write data to file.
62 | func (f *File) Write(key string, data []byte) error {
63 | dirstat, err := os.Stat(f.dir)
64 | if err != nil {
65 | return err
66 | }
67 |
68 | if !dirstat.Mode().IsDir() {
69 | return errors.New("File.dir is not dir")
70 | }
71 | if dirstat.Size() > f.MaxSize {
72 | return errors.New("Max size has been reached")
73 | }
74 |
75 | p := filepath.Join(f.dir, key)
76 | file, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
77 | if err != nil {
78 | return err
79 | }
80 |
81 | defer file.Close()
82 | _, err = file.Write(data)
83 | if err != nil {
84 | return err
85 | }
86 |
87 | log.Printf("[INFO] Write file to %s", p)
88 |
89 | return nil
90 | }
91 |
92 | // Delete data on file.
93 | func (f *File) Delete(key string) error {
94 | p := filepath.Join(f.dir, key)
95 | if !IsFileExist(p) {
96 | return fmt.Errorf("File not found: %s", p)
97 | }
98 |
99 | if err := os.Remove(p); err != nil {
100 | return err
101 | }
102 |
103 | return nil
104 | }
105 |
106 | // List returns keys from file.
107 | func (f *File) List() ([]string, error) {
108 | files, err := os.ReadDir(f.dir)
109 | if err != nil {
110 | return nil, err
111 | }
112 |
113 | var list []string
114 | for _, file := range files {
115 | list = append(list, file.Name())
116 | }
117 |
118 | return list, nil
119 | }
120 |
121 | // ExtractArchive extracts by archive.
122 | func ExtractArchive(src, dst string) error {
123 | if !IsFileExist(src) {
124 | return fmt.Errorf("File not found: %s", src)
125 | }
126 |
127 | return archiver.Unarchive(src, dst)
128 | }
129 |
130 | // IsFileExist checks file exists.
131 | func IsFileExist(p string) bool {
132 | _, err := os.Stat(p)
133 |
134 | return !os.IsNotExist(err)
135 | }
136 |
--------------------------------------------------------------------------------
/kvs/file_test.go:
--------------------------------------------------------------------------------
1 | package kvs
2 |
3 | import (
4 | "path/filepath"
5 | "reflect"
6 | "testing"
7 | )
8 |
9 | func TestIsFileExist(t *testing.T) {
10 | if IsFileExist("/tmp") != true {
11 | t.Error("expects return true")
12 | }
13 | if IsFileExist("/tmpfoo") != false {
14 | t.Error("expects return false")
15 | }
16 | }
17 |
18 | func TestFileDefault(t *testing.T) {
19 | f := &File{}
20 | f.Default()
21 | if IsFileExist(f.dir) != true {
22 | t.Error("file dir expects not setted")
23 | }
24 | }
25 |
26 | func TestFileRead(t *testing.T) {
27 | f := &File{}
28 | f.Default()
29 | data := []byte("this is data for test")
30 | err := f.Write("testread", data)
31 | if err != nil {
32 | t.Error(err.Error())
33 | }
34 | p := filepath.Join(f.dir, "testread")
35 | if IsFileExist(p) != true {
36 | t.Error("file not created")
37 | }
38 | content, err := f.Read("testread")
39 | if err != nil {
40 | t.Error(err.Error())
41 | }
42 | if !reflect.DeepEqual(content, data) {
43 | t.Error("return is not correct")
44 | }
45 | }
46 |
47 | func TestFileWrite(t *testing.T) {
48 | f := &File{}
49 | f.Default()
50 | data := []byte("this is data for test")
51 | err := f.Write("test", data)
52 | if err != nil {
53 | t.Error(err.Error())
54 | }
55 | if IsFileExist(filepath.Join(f.dir, "test")) != true {
56 | t.Error("file not found for cache")
57 | }
58 | content, err := f.Read("test")
59 | if !reflect.DeepEqual(content, data) {
60 | t.Errorf("writing is not correct: %s", err)
61 | }
62 |
63 | // Override
64 | data2 := []byte("hello gophers")
65 | err = f.Write("test", data2)
66 | if err != nil {
67 | t.Error(err.Error())
68 | }
69 | content2, err := f.Read("test")
70 | if !reflect.DeepEqual(content2, data2) {
71 | t.Errorf("writing is not correct when override: %s, %s", content2, err)
72 | }
73 | }
74 |
75 | func TestFileDelete(t *testing.T) {
76 | f := &File{}
77 | f.Default()
78 | data := []byte("this is data for test")
79 | err := f.Write("testdelete", data)
80 | if err != nil {
81 | t.Error(err.Error())
82 | }
83 | p := filepath.Join(f.dir, "testdelete")
84 | if IsFileExist(p) != true {
85 | t.Error("file not created")
86 | }
87 | err = f.Delete("testdelete")
88 | if err != nil {
89 | t.Error(err.Error())
90 | }
91 | if IsFileExist(p) != false {
92 | t.Error("file not deleted")
93 | }
94 | }
95 |
96 | func TestFileList(t *testing.T) {
97 | f := &File{}
98 | f.Default()
99 | data := []byte("this is data for test")
100 | err := f.Write("testlist", data)
101 | if err != nil {
102 | t.Error(err.Error())
103 | }
104 | list, err := f.List()
105 | if err != nil {
106 | t.Error(err.Error())
107 | }
108 |
109 | found := false
110 | for _, v := range list {
111 | if v == "testlist" {
112 | found = true
113 | }
114 | }
115 | if !found {
116 | t.Error("file not found in list")
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/kvs/kvs.go:
--------------------------------------------------------------------------------
1 | package kvs
2 |
3 | import (
4 | "errors"
5 | "sync"
6 | "time"
7 | )
8 |
9 | // KVS interface.
10 | type KVS interface {
11 | Read(key string) ([]byte, error)
12 | Write(key string, data []byte) error
13 | Delete(key string) error
14 | List() ([]string, error)
15 | GetDir() string
16 | }
17 |
18 | // Config struct.
19 | type Config struct {
20 | }
21 |
22 | // New returns KVS.
23 | func New(t string, c Config) (KVS, error) {
24 | switch t {
25 | case "file":
26 | return &File{}, nil
27 | default:
28 | return nil, errors.New("no provider")
29 | }
30 | }
31 |
32 | //nolint
33 | type item struct {
34 | content []byte
35 | lock sync.Mutex
36 | expiration time.Time
37 | size uint64
38 | }
39 |
--------------------------------------------------------------------------------
/kvs/kvs_test.go:
--------------------------------------------------------------------------------
1 | package kvs
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestKV(t *testing.T) {
8 | if false {
9 | t.Error("")
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/kvs/memory.go:
--------------------------------------------------------------------------------
1 | package kvs
2 |
3 | // Memory struct.
4 | type Memory struct {
5 | items map[string]*item //nolint
6 | }
7 |
8 | // Read data by key on memory.
9 | func (m *Memory) Read(key string) string {
10 | return ""
11 | }
12 |
13 | // Write data to memory.
14 | func (m *Memory) Write(data string) bool {
15 | return true
16 | }
17 |
18 | // Delete data by key on memory.
19 | func (m *Memory) Delete(key string) bool {
20 | return true
21 | }
22 |
23 | // List returns keys from memory.
24 | func (m *Memory) List() {
25 | }
26 |
--------------------------------------------------------------------------------
/kvs/redis.go:
--------------------------------------------------------------------------------
1 | package kvs
2 |
3 | // Redis struct.
4 | type Redis struct {
5 | items map[string]*item //nolint
6 | Host string
7 | Port int
8 | Password string
9 | TTL int
10 | }
11 |
12 | // Read data by key on redis.
13 | func (r *Redis) Read(key string) {
14 | }
15 |
16 | // Write data to redis.
17 | func (r *Redis) Write(data string) {
18 | }
19 |
20 | // Delete key on redis.
21 | func (r *Redis) Delete(key string) {
22 | }
23 |
24 | // List returns keys from redis.
25 | func (r *Redis) List() {
26 | }
27 |
--------------------------------------------------------------------------------
/misc/dewy-architecture.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linyows/dewy/56e532857ba95a1bde9725fe678bcd414320fc04/misc/dewy-architecture.afdesign
--------------------------------------------------------------------------------
/misc/dewy-architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linyows/dewy/56e532857ba95a1bde9725fe678bcd414320fc04/misc/dewy-architecture.png
--------------------------------------------------------------------------------
/misc/dewy-architecture.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Application
14 | or Fil es
15 |
16 |
17 | v1.2.2
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Application
26 | or Fil es
27 |
28 |
29 | v1.2.3
30 |
31 |
32 |
33 |
34 |
35 |
36 | Artifact
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | Registry
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | Notify
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | Cache
64 |
65 |
66 |
67 |
68 |
69 | ・
70 | Github Releases
71 | ・
72 | AWS S3
73 | ・
74 | Google Cloud Storage
75 | ・
76 |
77 |
78 | GRPC Server
79 |
80 |
81 | 1.
82 |
83 |
84 | Polling
85 |
86 |
87 | 5.
88 |
89 |
90 | Reporting
91 |
92 |
93 | 6.
94 |
95 |
96 | Send
97 |
98 |
99 | 2.
100 |
101 |
102 | Download
103 |
104 |
105 | 3.
106 |
107 |
108 | Storage
109 |
110 |
111 | 4.
112 |
113 |
114 | Deployment
115 |
116 |
117 | ・
118 | File system
119 | ・
120 | Memory
121 | ・
122 | Hashicorp Consul
123 | ・
124 |
125 |
126 | Redis
127 |
128 |
129 |
130 |
131 | ]
132 |
133 |
134 | ・
135 | Slack
136 | ・
137 |
138 |
139 | SMTP
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 | Dewy
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
--------------------------------------------------------------------------------
/misc/dewy-dark-bg.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/misc/dewy-icon.512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linyows/dewy/56e532857ba95a1bde9725fe678bcd414320fc04/misc/dewy-icon.512.png
--------------------------------------------------------------------------------
/misc/dewy-icon.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linyows/dewy/56e532857ba95a1bde9725fe678bcd414320fc04/misc/dewy-icon.afdesign
--------------------------------------------------------------------------------
/misc/dewy-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/misc/dewy-logo.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linyows/dewy/56e532857ba95a1bde9725fe678bcd414320fc04/misc/dewy-logo.afdesign
--------------------------------------------------------------------------------
/misc/dewy-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/misc/dewy.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/linyows/dewy/56e532857ba95a1bde9725fe678bcd414320fc04/misc/dewy.afdesign
--------------------------------------------------------------------------------
/misc/dewy.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/notify/notify.go:
--------------------------------------------------------------------------------
1 | package notify
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "os"
8 | "os/user"
9 | "strings"
10 | )
11 |
12 | // Notify interface.
13 | type Notify interface {
14 | Send(ctx context.Context, message string)
15 | }
16 |
17 | // New returns Notice.
18 | func New(ctx context.Context, url string) (Notify, error) {
19 | splitted := strings.SplitN(url, "://", 2)
20 |
21 | switch splitted[0] {
22 | case "":
23 | return &Null{}, nil
24 | case "slack":
25 | sl, err := NewSlack(splitted[1])
26 | if err != nil {
27 | log.Printf("[ERROR] %s", err)
28 | return &Null{}, nil
29 | }
30 | return sl, nil
31 | default:
32 | return nil, fmt.Errorf("unsupported notify: %s", url)
33 | }
34 | }
35 |
36 | func hostname() string {
37 | n, err := os.Hostname()
38 | if err != nil {
39 | return fmt.Sprintf("%#v", err)
40 | }
41 | return n
42 | }
43 |
44 | func cwd() string {
45 | c, err := os.Getwd()
46 | if err != nil {
47 | return fmt.Sprintf("%#v", err)
48 | }
49 | return c
50 | }
51 |
52 | func username() string {
53 | u, err := user.Current()
54 | if err != nil {
55 | return fmt.Sprintf("%#v", err)
56 | }
57 | return u.Name
58 | }
59 |
--------------------------------------------------------------------------------
/notify/null.go:
--------------------------------------------------------------------------------
1 | package notify
2 |
3 | import "context"
4 |
5 | type Null struct {
6 | }
7 |
8 | func (n *Null) Send(ctx context.Context, message string) {
9 | }
10 |
--------------------------------------------------------------------------------
/notify/slack.go:
--------------------------------------------------------------------------------
1 | package notify
2 |
3 | import (
4 | "context"
5 | "crypto/md5" //nolint:gosec
6 | "fmt"
7 | "log"
8 | "net/url"
9 | "os"
10 | "strings"
11 | "time"
12 |
13 | "github.com/gorilla/schema"
14 | "github.com/lestrrat-go/slack"
15 | "github.com/lestrrat-go/slack/objects"
16 | )
17 |
18 | var (
19 | defaultSlackChannel = "randam"
20 | // SlackUsername variable.
21 | SlackUsername = "Dewy"
22 | // SlackIconURL variable.
23 | SlackIconURL = "https://raw.githubusercontent.com/linyows/dewy/main/misc/dewy-icon.512.png"
24 | // SlackFooter variable.
25 | SlackFooter = "Dewy notify/slack"
26 | // SlackFooterIcon variable.
27 | SlackFooterIcon = SlackIconURL
28 |
29 | decoder = schema.NewDecoder()
30 | )
31 |
32 | // Slack struct.
33 | type Slack struct {
34 | Channel string `schema:"-"`
35 | Title string `schema:"title"`
36 | TitleURL string `schema:"url"`
37 | token string
38 | github *Github
39 | }
40 |
41 | func NewSlack(schema string) (*Slack, error) {
42 | u, err := url.Parse(schema)
43 | if err != nil {
44 | return nil, err
45 | }
46 |
47 | s := &Slack{Channel: u.Path}
48 | if err := decoder.Decode(s, u.Query()); err != nil {
49 | return nil, err
50 | }
51 |
52 | if s.Channel == "" {
53 | s.Channel = defaultSlackChannel
54 | }
55 | if t := os.Getenv("SLACK_TOKEN"); t != "" {
56 | s.token = t
57 | }
58 | if s.token == "" {
59 | return nil, fmt.Errorf("slack token is required")
60 | }
61 |
62 | return s, nil
63 | }
64 |
65 | // Send posts message to Slack channel.
66 | func (s *Slack) Send(ctx context.Context, message string) {
67 | cl := slack.New(s.token)
68 | at := s.BuildAttachment(message)
69 | _, err := cl.Chat().PostMessage(s.Channel).Username(SlackUsername).
70 | IconURL(SlackIconURL).Attachment(&at).Text("").Do(ctx)
71 | if err != nil {
72 | log.Printf("[ERROR] Slack postMessage failure: %#v", err)
73 | }
74 | }
75 |
76 | func (s *Slack) genColor() string {
77 | return strings.ToUpper(fmt.Sprintf("#%x", md5.Sum([]byte(hostname())))[0:7]) //nolint:gosec
78 | }
79 |
80 | // Github struct.
81 | type Github struct {
82 | // linyows
83 | Owner string
84 | // dewy
85 | Repo string
86 | // appname_linux_amd64.tar.gz
87 | Artifact string
88 | }
89 |
90 | // OwnerURL returns owner URL.
91 | func (g *Github) OwnerURL() string {
92 | return fmt.Sprintf("https://github.com/%s", g.Owner)
93 | }
94 |
95 | // OwnerIconURL returns owner icon URL.
96 | func (g *Github) OwnerIconURL() string {
97 | return fmt.Sprintf("%s.png?size=200", g.OwnerURL())
98 | }
99 |
100 | // URL returns repository URL.
101 | func (g *Github) RepoURL() string {
102 | return fmt.Sprintf("%s/%s", g.OwnerURL(), g.Repo)
103 | }
104 |
105 | // BuildAttachmentByGithubArgs returns attachment for slack.
106 | func (s *Slack) BuildAttachment(message string) objects.Attachment {
107 | var at objects.Attachment
108 | at.Color = s.genColor()
109 |
110 | if s.github != nil {
111 | at.Text = message
112 | at.Title = s.github.Repo
113 | at.TitleLink = s.github.RepoURL()
114 | at.AuthorName = s.github.Owner
115 | at.AuthorLink = s.github.OwnerURL()
116 | at.AuthorIcon = s.github.OwnerIconURL()
117 | at.Footer = SlackFooter
118 | at.FooterIcon = SlackFooterIcon
119 | at.Timestamp = objects.Timestamp(time.Now().Unix())
120 | at.Fields.
121 | Append(&objects.AttachmentField{Title: "Host", Value: hostname(), Short: true}).
122 | Append(&objects.AttachmentField{Title: "User", Value: username(), Short: true}).
123 | Append(&objects.AttachmentField{Title: "Source", Value: s.github.Artifact, Short: true}).
124 | Append(&objects.AttachmentField{Title: "Working directory", Value: cwd(), Short: false})
125 | } else if s.Title != "" && s.TitleURL != "" {
126 | at.Text = fmt.Sprintf("%s of <%s|%s> on %s", message, s.TitleURL, s.Title, hostname())
127 | } else if s.Title != "" {
128 | at.Text = fmt.Sprintf("%s of %s on %s", message, s.Title, hostname())
129 | } else {
130 | at.Text = fmt.Sprintf("%s on %s", message, hostname())
131 | }
132 |
133 | return at
134 | }
135 |
--------------------------------------------------------------------------------
/registry/buf.gen.yaml:
--------------------------------------------------------------------------------
1 | version: v1
2 | managed:
3 | enabled: true
4 | go_package_prefix:
5 | default: github.com/linyows/dewy/registry/gen/dewy
6 | plugins:
7 | - plugin: buf.build/protocolbuffers/go
8 | out: gen/dewy
9 | opt: paths=source_relative
10 | - plugin: buf.build/grpc/go
11 | out: gen/dewy
12 | opt: paths=source_relative
13 |
--------------------------------------------------------------------------------
/registry/buf.yaml:
--------------------------------------------------------------------------------
1 | version: v1
2 | breaking:
3 | use:
4 | - FILE
5 | lint:
6 | use:
7 | - DEFAULT
8 |
--------------------------------------------------------------------------------
/registry/dewy.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 |
3 | import "google/protobuf/empty.proto";
4 |
5 | package dewy;
6 |
7 | service RegistryService {
8 | // Current returns the current artifact.
9 | rpc Current (CurrentRequest) returns (CurrentResponse);
10 | // Report reports the result of deploying the artifact.
11 | rpc Report (ReportRequest) returns (google.protobuf.Empty);
12 | }
13 |
14 | // CurrentRequest is the request to get the current artifact.
15 | message CurrentRequest {
16 | string arch = 1; // arch is the CPU architecture of deployment environment.
17 | string os = 2; // os is the operating system of deployment environment.
18 | optional string arifact_name = 3; // artifact_name is the name of the artifact to fetch.
19 | }
20 |
21 | // CurrentResponse is the response to get the current artifact.
22 | message CurrentResponse {
23 | string id = 1; // id uniquely identifies the response.
24 | string tag = 2; // tag uniquely identifies the artifact concerned.
25 | string artifact_url = 3; // artifact_url is the URL to download the artifact.
26 | }
27 |
28 | // ReportRequest is the request to report the result of deploying the artifact.
29 | message ReportRequest {
30 | string id = 1; // id is the ID of the response.
31 | string tag = 2; // tag is the current tag of deployed artifact.
32 | optional string err = 3; // err is the error that occurred during deployment. If Err is nil, the deployment is considered successful.
33 | }
34 |
--------------------------------------------------------------------------------
/registry/gen/dewy/dewy.pb.go:
--------------------------------------------------------------------------------
1 | // Code generated by protoc-gen-go. DO NOT EDIT.
2 | // versions:
3 | // protoc-gen-go v1.35.1
4 | // protoc (unknown)
5 | // source: dewy.proto
6 |
7 | package dewy
8 |
9 | import (
10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect"
11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl"
12 | emptypb "google.golang.org/protobuf/types/known/emptypb"
13 | reflect "reflect"
14 | sync "sync"
15 | )
16 |
17 | const (
18 | // Verify that this generated code is sufficiently up-to-date.
19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
20 | // Verify that runtime/protoimpl is sufficiently up-to-date.
21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
22 | )
23 |
24 | // CurrentRequest is the request to get the current artifact.
25 | type CurrentRequest struct {
26 | state protoimpl.MessageState
27 | sizeCache protoimpl.SizeCache
28 | unknownFields protoimpl.UnknownFields
29 |
30 | Arch string `protobuf:"bytes,1,opt,name=arch,proto3" json:"arch,omitempty"` // arch is the CPU architecture of deployment environment.
31 | Os string `protobuf:"bytes,2,opt,name=os,proto3" json:"os,omitempty"` // os is the operating system of deployment environment.
32 | ArifactName *string `protobuf:"bytes,3,opt,name=arifact_name,json=arifactName,proto3,oneof" json:"arifact_name,omitempty"` // artifact_name is the name of the artifact to fetch.
33 | }
34 |
35 | func (x *CurrentRequest) Reset() {
36 | *x = CurrentRequest{}
37 | mi := &file_dewy_proto_msgTypes[0]
38 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
39 | ms.StoreMessageInfo(mi)
40 | }
41 |
42 | func (x *CurrentRequest) String() string {
43 | return protoimpl.X.MessageStringOf(x)
44 | }
45 |
46 | func (*CurrentRequest) ProtoMessage() {}
47 |
48 | func (x *CurrentRequest) ProtoReflect() protoreflect.Message {
49 | mi := &file_dewy_proto_msgTypes[0]
50 | if x != nil {
51 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
52 | if ms.LoadMessageInfo() == nil {
53 | ms.StoreMessageInfo(mi)
54 | }
55 | return ms
56 | }
57 | return mi.MessageOf(x)
58 | }
59 |
60 | // Deprecated: Use CurrentRequest.ProtoReflect.Descriptor instead.
61 | func (*CurrentRequest) Descriptor() ([]byte, []int) {
62 | return file_dewy_proto_rawDescGZIP(), []int{0}
63 | }
64 |
65 | func (x *CurrentRequest) GetArch() string {
66 | if x != nil {
67 | return x.Arch
68 | }
69 | return ""
70 | }
71 |
72 | func (x *CurrentRequest) GetOs() string {
73 | if x != nil {
74 | return x.Os
75 | }
76 | return ""
77 | }
78 |
79 | func (x *CurrentRequest) GetArifactName() string {
80 | if x != nil && x.ArifactName != nil {
81 | return *x.ArifactName
82 | }
83 | return ""
84 | }
85 |
86 | // CurrentResponse is the response to get the current artifact.
87 | type CurrentResponse struct {
88 | state protoimpl.MessageState
89 | sizeCache protoimpl.SizeCache
90 | unknownFields protoimpl.UnknownFields
91 |
92 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // id uniquely identifies the response.
93 | Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"` // tag uniquely identifies the artifact concerned.
94 | ArtifactUrl string `protobuf:"bytes,3,opt,name=artifact_url,json=artifactUrl,proto3" json:"artifact_url,omitempty"` // artifact_url is the URL to download the artifact.
95 | }
96 |
97 | func (x *CurrentResponse) Reset() {
98 | *x = CurrentResponse{}
99 | mi := &file_dewy_proto_msgTypes[1]
100 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
101 | ms.StoreMessageInfo(mi)
102 | }
103 |
104 | func (x *CurrentResponse) String() string {
105 | return protoimpl.X.MessageStringOf(x)
106 | }
107 |
108 | func (*CurrentResponse) ProtoMessage() {}
109 |
110 | func (x *CurrentResponse) ProtoReflect() protoreflect.Message {
111 | mi := &file_dewy_proto_msgTypes[1]
112 | if x != nil {
113 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
114 | if ms.LoadMessageInfo() == nil {
115 | ms.StoreMessageInfo(mi)
116 | }
117 | return ms
118 | }
119 | return mi.MessageOf(x)
120 | }
121 |
122 | // Deprecated: Use CurrentResponse.ProtoReflect.Descriptor instead.
123 | func (*CurrentResponse) Descriptor() ([]byte, []int) {
124 | return file_dewy_proto_rawDescGZIP(), []int{1}
125 | }
126 |
127 | func (x *CurrentResponse) GetId() string {
128 | if x != nil {
129 | return x.Id
130 | }
131 | return ""
132 | }
133 |
134 | func (x *CurrentResponse) GetTag() string {
135 | if x != nil {
136 | return x.Tag
137 | }
138 | return ""
139 | }
140 |
141 | func (x *CurrentResponse) GetArtifactUrl() string {
142 | if x != nil {
143 | return x.ArtifactUrl
144 | }
145 | return ""
146 | }
147 |
148 | // ReportRequest is the request to report the result of deploying the artifact.
149 | type ReportRequest struct {
150 | state protoimpl.MessageState
151 | sizeCache protoimpl.SizeCache
152 | unknownFields protoimpl.UnknownFields
153 |
154 | Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // id is the ID of the response.
155 | Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"` // tag is the current tag of deployed artifact.
156 | Err *string `protobuf:"bytes,3,opt,name=err,proto3,oneof" json:"err,omitempty"` // err is the error that occurred during deployment. If Err is nil, the deployment is considered successful.
157 | }
158 |
159 | func (x *ReportRequest) Reset() {
160 | *x = ReportRequest{}
161 | mi := &file_dewy_proto_msgTypes[2]
162 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
163 | ms.StoreMessageInfo(mi)
164 | }
165 |
166 | func (x *ReportRequest) String() string {
167 | return protoimpl.X.MessageStringOf(x)
168 | }
169 |
170 | func (*ReportRequest) ProtoMessage() {}
171 |
172 | func (x *ReportRequest) ProtoReflect() protoreflect.Message {
173 | mi := &file_dewy_proto_msgTypes[2]
174 | if x != nil {
175 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
176 | if ms.LoadMessageInfo() == nil {
177 | ms.StoreMessageInfo(mi)
178 | }
179 | return ms
180 | }
181 | return mi.MessageOf(x)
182 | }
183 |
184 | // Deprecated: Use ReportRequest.ProtoReflect.Descriptor instead.
185 | func (*ReportRequest) Descriptor() ([]byte, []int) {
186 | return file_dewy_proto_rawDescGZIP(), []int{2}
187 | }
188 |
189 | func (x *ReportRequest) GetId() string {
190 | if x != nil {
191 | return x.Id
192 | }
193 | return ""
194 | }
195 |
196 | func (x *ReportRequest) GetTag() string {
197 | if x != nil {
198 | return x.Tag
199 | }
200 | return ""
201 | }
202 |
203 | func (x *ReportRequest) GetErr() string {
204 | if x != nil && x.Err != nil {
205 | return *x.Err
206 | }
207 | return ""
208 | }
209 |
210 | var File_dewy_proto protoreflect.FileDescriptor
211 |
212 | var file_dewy_proto_rawDesc = []byte{
213 | 0x0a, 0x0a, 0x64, 0x65, 0x77, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x04, 0x64, 0x65,
214 | 0x77, 0x79, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
215 | 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22,
216 | 0x6d, 0x0a, 0x0e, 0x43, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
217 | 0x74, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x63, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
218 | 0x04, 0x61, 0x72, 0x63, 0x68, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28,
219 | 0x09, 0x52, 0x02, 0x6f, 0x73, 0x12, 0x26, 0x0a, 0x0c, 0x61, 0x72, 0x69, 0x66, 0x61, 0x63, 0x74,
220 | 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0b, 0x61,
221 | 0x72, 0x69, 0x66, 0x61, 0x63, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x42, 0x0f, 0x0a,
222 | 0x0d, 0x5f, 0x61, 0x72, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x56,
223 | 0x0a, 0x0f, 0x43, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
224 | 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
225 | 0x64, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
226 | 0x74, 0x61, 0x67, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f,
227 | 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66,
228 | 0x61, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x50, 0x0a, 0x0d, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74,
229 | 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
230 | 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x67, 0x18, 0x02,
231 | 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x74, 0x61, 0x67, 0x12, 0x15, 0x0a, 0x03, 0x65, 0x72, 0x72,
232 | 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x03, 0x65, 0x72, 0x72, 0x88, 0x01, 0x01,
233 | 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x65, 0x72, 0x72, 0x32, 0x80, 0x01, 0x0a, 0x0f, 0x52, 0x65, 0x67,
234 | 0x69, 0x73, 0x74, 0x72, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x36, 0x0a, 0x07,
235 | 0x43, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x2e, 0x64, 0x65, 0x77, 0x79, 0x2e, 0x43,
236 | 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e,
237 | 0x64, 0x65, 0x77, 0x79, 0x2e, 0x43, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70,
238 | 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x06, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x13,
239 | 0x2e, 0x64, 0x65, 0x77, 0x79, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75,
240 | 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
241 | 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x42, 0x70, 0x0a, 0x08, 0x63,
242 | 0x6f, 0x6d, 0x2e, 0x64, 0x65, 0x77, 0x79, 0x42, 0x09, 0x44, 0x65, 0x77, 0x79, 0x50, 0x72, 0x6f,
243 | 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x29, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
244 | 0x2f, 0x6c, 0x69, 0x6e, 0x79, 0x6f, 0x77, 0x73, 0x2f, 0x64, 0x65, 0x77, 0x79, 0x2f, 0x72, 0x65,
245 | 0x67, 0x69, 0x73, 0x74, 0x72, 0x79, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x64, 0x65, 0x77, 0x79, 0xa2,
246 | 0x02, 0x03, 0x44, 0x58, 0x58, 0xaa, 0x02, 0x04, 0x44, 0x65, 0x77, 0x79, 0xca, 0x02, 0x04, 0x44,
247 | 0x65, 0x77, 0x79, 0xe2, 0x02, 0x10, 0x44, 0x65, 0x77, 0x79, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65,
248 | 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x04, 0x44, 0x65, 0x77, 0x79, 0x62, 0x06, 0x70,
249 | 0x72, 0x6f, 0x74, 0x6f, 0x33,
250 | }
251 |
252 | var (
253 | file_dewy_proto_rawDescOnce sync.Once
254 | file_dewy_proto_rawDescData = file_dewy_proto_rawDesc
255 | )
256 |
257 | func file_dewy_proto_rawDescGZIP() []byte {
258 | file_dewy_proto_rawDescOnce.Do(func() {
259 | file_dewy_proto_rawDescData = protoimpl.X.CompressGZIP(file_dewy_proto_rawDescData)
260 | })
261 | return file_dewy_proto_rawDescData
262 | }
263 |
264 | var file_dewy_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
265 | var file_dewy_proto_goTypes = []any{
266 | (*CurrentRequest)(nil), // 0: dewy.CurrentRequest
267 | (*CurrentResponse)(nil), // 1: dewy.CurrentResponse
268 | (*ReportRequest)(nil), // 2: dewy.ReportRequest
269 | (*emptypb.Empty)(nil), // 3: google.protobuf.Empty
270 | }
271 | var file_dewy_proto_depIdxs = []int32{
272 | 0, // 0: dewy.RegistryService.Current:input_type -> dewy.CurrentRequest
273 | 2, // 1: dewy.RegistryService.Report:input_type -> dewy.ReportRequest
274 | 1, // 2: dewy.RegistryService.Current:output_type -> dewy.CurrentResponse
275 | 3, // 3: dewy.RegistryService.Report:output_type -> google.protobuf.Empty
276 | 2, // [2:4] is the sub-list for method output_type
277 | 0, // [0:2] is the sub-list for method input_type
278 | 0, // [0:0] is the sub-list for extension type_name
279 | 0, // [0:0] is the sub-list for extension extendee
280 | 0, // [0:0] is the sub-list for field type_name
281 | }
282 |
283 | func init() { file_dewy_proto_init() }
284 | func file_dewy_proto_init() {
285 | if File_dewy_proto != nil {
286 | return
287 | }
288 | file_dewy_proto_msgTypes[0].OneofWrappers = []any{}
289 | file_dewy_proto_msgTypes[2].OneofWrappers = []any{}
290 | type x struct{}
291 | out := protoimpl.TypeBuilder{
292 | File: protoimpl.DescBuilder{
293 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
294 | RawDescriptor: file_dewy_proto_rawDesc,
295 | NumEnums: 0,
296 | NumMessages: 3,
297 | NumExtensions: 0,
298 | NumServices: 1,
299 | },
300 | GoTypes: file_dewy_proto_goTypes,
301 | DependencyIndexes: file_dewy_proto_depIdxs,
302 | MessageInfos: file_dewy_proto_msgTypes,
303 | }.Build()
304 | File_dewy_proto = out.File
305 | file_dewy_proto_rawDesc = nil
306 | file_dewy_proto_goTypes = nil
307 | file_dewy_proto_depIdxs = nil
308 | }
309 |
--------------------------------------------------------------------------------
/registry/gen/dewy/dewy_grpc.pb.go:
--------------------------------------------------------------------------------
1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
2 | // versions:
3 | // - protoc-gen-go-grpc v1.5.1
4 | // - protoc (unknown)
5 | // source: dewy.proto
6 |
7 | package dewy
8 |
9 | import (
10 | context "context"
11 | grpc "google.golang.org/grpc"
12 | codes "google.golang.org/grpc/codes"
13 | status "google.golang.org/grpc/status"
14 | emptypb "google.golang.org/protobuf/types/known/emptypb"
15 | )
16 |
17 | // This is a compile-time assertion to ensure that this generated file
18 | // is compatible with the grpc package it is being compiled against.
19 | // Requires gRPC-Go v1.64.0 or later.
20 | const _ = grpc.SupportPackageIsVersion9
21 |
22 | const (
23 | RegistryService_Current_FullMethodName = "/dewy.RegistryService/Current"
24 | RegistryService_Report_FullMethodName = "/dewy.RegistryService/Report"
25 | )
26 |
27 | // RegistryServiceClient is the client API for RegistryService service.
28 | //
29 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
30 | type RegistryServiceClient interface {
31 | // Current returns the current artifact.
32 | Current(ctx context.Context, in *CurrentRequest, opts ...grpc.CallOption) (*CurrentResponse, error)
33 | // Report reports the result of deploying the artifact.
34 | Report(ctx context.Context, in *ReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
35 | }
36 |
37 | type registryServiceClient struct {
38 | cc grpc.ClientConnInterface
39 | }
40 |
41 | func NewRegistryServiceClient(cc grpc.ClientConnInterface) RegistryServiceClient {
42 | return ®istryServiceClient{cc}
43 | }
44 |
45 | func (c *registryServiceClient) Current(ctx context.Context, in *CurrentRequest, opts ...grpc.CallOption) (*CurrentResponse, error) {
46 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
47 | out := new(CurrentResponse)
48 | err := c.cc.Invoke(ctx, RegistryService_Current_FullMethodName, in, out, cOpts...)
49 | if err != nil {
50 | return nil, err
51 | }
52 | return out, nil
53 | }
54 |
55 | func (c *registryServiceClient) Report(ctx context.Context, in *ReportRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
56 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
57 | out := new(emptypb.Empty)
58 | err := c.cc.Invoke(ctx, RegistryService_Report_FullMethodName, in, out, cOpts...)
59 | if err != nil {
60 | return nil, err
61 | }
62 | return out, nil
63 | }
64 |
65 | // RegistryServiceServer is the server API for RegistryService service.
66 | // All implementations must embed UnimplementedRegistryServiceServer
67 | // for forward compatibility.
68 | type RegistryServiceServer interface {
69 | // Current returns the current artifact.
70 | Current(context.Context, *CurrentRequest) (*CurrentResponse, error)
71 | // Report reports the result of deploying the artifact.
72 | Report(context.Context, *ReportRequest) (*emptypb.Empty, error)
73 | mustEmbedUnimplementedRegistryServiceServer()
74 | }
75 |
76 | // UnimplementedRegistryServiceServer must be embedded to have
77 | // forward compatible implementations.
78 | //
79 | // NOTE: this should be embedded by value instead of pointer to avoid a nil
80 | // pointer dereference when methods are called.
81 | type UnimplementedRegistryServiceServer struct{}
82 |
83 | func (UnimplementedRegistryServiceServer) Current(context.Context, *CurrentRequest) (*CurrentResponse, error) {
84 | return nil, status.Errorf(codes.Unimplemented, "method Current not implemented")
85 | }
86 | func (UnimplementedRegistryServiceServer) Report(context.Context, *ReportRequest) (*emptypb.Empty, error) {
87 | return nil, status.Errorf(codes.Unimplemented, "method Report not implemented")
88 | }
89 | func (UnimplementedRegistryServiceServer) mustEmbedUnimplementedRegistryServiceServer() {}
90 | func (UnimplementedRegistryServiceServer) testEmbeddedByValue() {}
91 |
92 | // UnsafeRegistryServiceServer may be embedded to opt out of forward compatibility for this service.
93 | // Use of this interface is not recommended, as added methods to RegistryServiceServer will
94 | // result in compilation errors.
95 | type UnsafeRegistryServiceServer interface {
96 | mustEmbedUnimplementedRegistryServiceServer()
97 | }
98 |
99 | func RegisterRegistryServiceServer(s grpc.ServiceRegistrar, srv RegistryServiceServer) {
100 | // If the following call pancis, it indicates UnimplementedRegistryServiceServer was
101 | // embedded by pointer and is nil. This will cause panics if an
102 | // unimplemented method is ever invoked, so we test this at initialization
103 | // time to prevent it from happening at runtime later due to I/O.
104 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
105 | t.testEmbeddedByValue()
106 | }
107 | s.RegisterService(&RegistryService_ServiceDesc, srv)
108 | }
109 |
110 | func _RegistryService_Current_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
111 | in := new(CurrentRequest)
112 | if err := dec(in); err != nil {
113 | return nil, err
114 | }
115 | if interceptor == nil {
116 | return srv.(RegistryServiceServer).Current(ctx, in)
117 | }
118 | info := &grpc.UnaryServerInfo{
119 | Server: srv,
120 | FullMethod: RegistryService_Current_FullMethodName,
121 | }
122 | handler := func(ctx context.Context, req interface{}) (interface{}, error) {
123 | return srv.(RegistryServiceServer).Current(ctx, req.(*CurrentRequest))
124 | }
125 | return interceptor(ctx, in, info, handler)
126 | }
127 |
128 | func _RegistryService_Report_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
129 | in := new(ReportRequest)
130 | if err := dec(in); err != nil {
131 | return nil, err
132 | }
133 | if interceptor == nil {
134 | return srv.(RegistryServiceServer).Report(ctx, in)
135 | }
136 | info := &grpc.UnaryServerInfo{
137 | Server: srv,
138 | FullMethod: RegistryService_Report_FullMethodName,
139 | }
140 | handler := func(ctx context.Context, req interface{}) (interface{}, error) {
141 | return srv.(RegistryServiceServer).Report(ctx, req.(*ReportRequest))
142 | }
143 | return interceptor(ctx, in, info, handler)
144 | }
145 |
146 | // RegistryService_ServiceDesc is the grpc.ServiceDesc for RegistryService service.
147 | // It's only intended for direct use with grpc.RegisterService,
148 | // and not to be introspected or modified (even as a copy)
149 | var RegistryService_ServiceDesc = grpc.ServiceDesc{
150 | ServiceName: "dewy.RegistryService",
151 | HandlerType: (*RegistryServiceServer)(nil),
152 | Methods: []grpc.MethodDesc{
153 | {
154 | MethodName: "Current",
155 | Handler: _RegistryService_Current_Handler,
156 | },
157 | {
158 | MethodName: "Report",
159 | Handler: _RegistryService_Report_Handler,
160 | },
161 | },
162 | Streams: []grpc.StreamDesc{},
163 | Metadata: "dewy.proto",
164 | }
165 |
--------------------------------------------------------------------------------
/registry/ghr.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "log"
8 | "net/url"
9 | "os"
10 | "strings"
11 | "time"
12 |
13 | "github.com/google/go-github/v55/github"
14 | "github.com/google/go-querystring/query"
15 | "github.com/k1LoW/go-github-client/v55/factory"
16 | )
17 |
18 | const (
19 | // ISO8601 for time format.
20 | ISO8601 = "20060102T150405Z0700"
21 | )
22 |
23 | // GHR struct.
24 | type GHR struct {
25 | Owner string `schema:"-"`
26 | Repo string `schema:"-"`
27 | Artifact string `schema:"artifact"`
28 | PreRelease bool `schema:"pre-release"`
29 | DisableRecordShipping bool // FIXME: For testing. Remove this.
30 | cl *github.Client
31 | }
32 |
33 | // New returns GHR.
34 | func NewGHR(ctx context.Context, u string) (*GHR, error) {
35 | ur, err := url.Parse(u)
36 | if err != nil {
37 | return nil, err
38 | }
39 |
40 | ghr := &GHR{
41 | Owner: ur.Host,
42 | Repo: strings.TrimPrefix(removeTrailingSlash(ur.Path), "/"),
43 | }
44 |
45 | if err := decoder.Decode(ghr, ur.Query()); err != nil {
46 | return nil, err
47 | }
48 |
49 | ghr.cl, err = factory.NewGithubClient()
50 | if err != nil {
51 | return nil, err
52 | }
53 |
54 | return ghr, nil
55 | }
56 |
57 | // String to string.
58 | func (g *GHR) String() string {
59 | return g.host()
60 | }
61 |
62 | func (g *GHR) host() string {
63 | h := g.cl.BaseURL.Host
64 | if h != "api.github.com" {
65 | return h
66 | }
67 | return "github.com"
68 | }
69 |
70 | // Current returns current artifact.
71 | func (g *GHR) Current(ctx context.Context, req *CurrentRequest) (*CurrentResponse, error) {
72 | release, err := g.latest(ctx)
73 | if err != nil {
74 | return nil, err
75 | }
76 | var artifactName string
77 |
78 | if req.ArtifactName != "" {
79 | artifactName = req.ArtifactName
80 | found := false
81 | for _, v := range release.Assets {
82 | if v.GetName() == artifactName {
83 | found = true
84 | log.Printf("[DEBUG] Fetched: %+v", v)
85 | break
86 | }
87 | }
88 | if !found {
89 | return nil, fmt.Errorf("artifact not found: %s", artifactName)
90 | }
91 | } else {
92 | archMatchs := []string{req.Arch}
93 | if req.Arch == "amd64" {
94 | archMatchs = append(archMatchs, "x86_64")
95 | }
96 | osMatchs := []string{req.OS}
97 | if req.OS == "darwin" {
98 | osMatchs = append(osMatchs, "macos")
99 | }
100 | found := false
101 | for _, v := range release.Assets {
102 | n := strings.ToLower(v.GetName())
103 | for _, arch := range archMatchs {
104 | if strings.Contains(n, arch) {
105 | found = true
106 | break
107 | }
108 | }
109 | if !found {
110 | continue
111 | }
112 | found = false
113 | for _, os := range osMatchs {
114 | if strings.Contains(n, os) {
115 | found = true
116 | break
117 | }
118 | }
119 | if !found {
120 | continue
121 | }
122 | artifactName = v.GetName()
123 | log.Printf("[DEBUG] Fetched: %+v", v)
124 | break
125 | }
126 | if !found {
127 | return nil, fmt.Errorf("artifact not found: %s", artifactName)
128 | }
129 | }
130 |
131 | au := fmt.Sprintf("%s://%s/%s/tag/%s/%s", ghrScheme, g.Owner, g.Repo, release.GetTagName(), artifactName)
132 |
133 | return &CurrentResponse{
134 | ID: time.Now().Format(ISO8601),
135 | Tag: release.GetTagName(),
136 | ArtifactURL: au,
137 | }, nil
138 | }
139 |
140 | func (g *GHR) latest(ctx context.Context) (*github.RepositoryRelease, error) {
141 | var r *github.RepositoryRelease
142 | if g.PreRelease {
143 | opt := &github.ListOptions{Page: 1}
144 | rr, _, err := g.cl.Repositories.ListReleases(ctx, g.Owner, g.Repo, opt)
145 | if err != nil {
146 | return nil, err
147 | }
148 | for _, v := range rr {
149 | if *v.Draft {
150 | continue
151 | }
152 | return r, nil
153 | }
154 | }
155 | r, _, err := g.cl.Repositories.GetLatestRelease(ctx, g.Owner, g.Repo)
156 | if err != nil {
157 | return nil, err
158 | }
159 | return r, nil
160 | }
161 |
162 | // Report report shipping.
163 | func (g *GHR) Report(ctx context.Context, req *ReportRequest) error {
164 | if req.Err != nil {
165 | return req.Err
166 | }
167 | now := time.Now().UTC().Format(ISO8601)
168 | hostname, _ := os.Hostname()
169 | info := fmt.Sprintf("shipped to %s at %s", strings.ToLower(hostname), now)
170 |
171 | page := 1
172 | for {
173 | releases, res, err := g.cl.Repositories.ListReleases(ctx, g.Owner, g.Repo, &github.ListOptions{
174 | Page: page,
175 | PerPage: 100,
176 | })
177 | if err != nil {
178 | return err
179 | }
180 | for _, r := range releases {
181 | if r.GetTagName() == req.Tag {
182 | s := fmt.Sprintf("repos/%s/%s/releases/%d/assets", g.Owner, g.Repo, r.GetID())
183 | opt := &github.UploadOptions{Name: strings.Replace(info, " ", "_", -1) + ".txt"}
184 |
185 | u, err := url.Parse(s)
186 | if err != nil {
187 | return err
188 | }
189 | qs, err := query.Values(opt)
190 | if err != nil {
191 | return err
192 | }
193 | u.RawQuery = qs.Encode()
194 | b := []byte(info)
195 | r := bytes.NewReader(b)
196 | req, err := g.cl.NewUploadRequest(u.String(), r, int64(len(b)), "text/plain")
197 | if err != nil {
198 | return err
199 | }
200 |
201 | asset := new(github.ReleaseAsset)
202 | if _, err := g.cl.Do(ctx, req, asset); err != nil {
203 | return err
204 | }
205 | return nil
206 | }
207 | }
208 | if res.NextPage == 0 {
209 | break
210 | }
211 | page = res.NextPage
212 | }
213 |
214 | return fmt.Errorf("release not found: %s", req.Tag)
215 | }
216 |
--------------------------------------------------------------------------------
/registry/grpc.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "context"
5 | "net/url"
6 |
7 | pb "github.com/linyows/dewy/registry/gen/dewy"
8 | "google.golang.org/grpc"
9 | "google.golang.org/grpc/credentials/insecure"
10 | )
11 |
12 | type GRPC struct {
13 | Target string `schema:"-"`
14 | NoTLS bool `schema:"no-tls"`
15 | cl pb.RegistryServiceClient
16 | }
17 |
18 | func NewGRPC(ctx context.Context, u string) (*GRPC, error) {
19 | ur, err := url.Parse(u)
20 | if err != nil {
21 | return nil, err
22 | }
23 |
24 | var gr GRPC
25 | if err := decoder.Decode(&gr, ur.Query()); err != nil {
26 | return nil, err
27 | }
28 |
29 | if err := gr.Dial(ctx, ur.Host); err != nil {
30 | return nil, err
31 | }
32 |
33 | return &gr, nil
34 | }
35 |
36 | // Dial returns GRPC.
37 | func (c *GRPC) Dial(ctx context.Context, target string) error {
38 | c.Target = target
39 | opts := []grpc.DialOption{}
40 | if c.NoTLS {
41 | opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
42 | }
43 | // cc, err := grpc.NewClient("passthrough://"+c.Target, opts...)
44 | cc, err := grpc.NewClient(c.Target, opts...)
45 | if err != nil {
46 | return err
47 | }
48 |
49 | c.cl = pb.NewRegistryServiceClient(cc)
50 | return nil
51 | }
52 |
53 | // Current returns current artifact.
54 | func (c *GRPC) Current(ctx context.Context, req *CurrentRequest) (*CurrentResponse, error) {
55 | var an *string
56 | if req.ArtifactName != "" {
57 | an = &req.ArtifactName
58 | }
59 | creq := &pb.CurrentRequest{
60 | Arch: req.Arch,
61 | Os: req.OS,
62 | ArifactName: an,
63 | }
64 | cres, err := c.cl.Current(ctx, creq)
65 | if err != nil {
66 | return nil, err
67 | }
68 | res := &CurrentResponse{
69 | ID: cres.Id,
70 | Tag: cres.Tag,
71 | ArtifactURL: cres.ArtifactUrl,
72 | }
73 | return res, nil
74 | }
75 |
76 | // Report report shipping.
77 | func (c *GRPC) Report(ctx context.Context, req *ReportRequest) error {
78 | var perr *string
79 | if req.Err != nil {
80 | serr := req.Err.Error()
81 | perr = &serr
82 | }
83 | creq := &pb.ReportRequest{
84 | Id: req.ID,
85 | Tag: req.Tag,
86 | Err: perr,
87 | }
88 | if _, err := c.cl.Report(ctx, creq); err != nil {
89 | return err
90 | }
91 | return nil
92 | }
93 |
--------------------------------------------------------------------------------
/registry/grpc_test.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "testing"
7 |
8 | "github.com/google/go-cmp/cmp"
9 | "github.com/k1LoW/grpcstub"
10 | pb "github.com/linyows/dewy/registry/gen/dewy"
11 | emptypb "google.golang.org/protobuf/types/known/emptypb"
12 | )
13 |
14 | func TestCurrent(t *testing.T) {
15 | ctx := context.Background()
16 | ts := grpcstub.NewServer(t, "dewy.proto")
17 | t.Cleanup(func() {
18 | ts.Close()
19 | })
20 | ts.Method("Current").Response(&pb.CurrentResponse{
21 | Id: "1234567890",
22 | Tag: "v1.0.0",
23 | ArtifactUrl: "ghr://linyows/dewy",
24 | })
25 | g := &GRPC{NoTLS: true}
26 | if err := g.Dial(ctx, ts.Addr()); err != nil {
27 | t.Fatal(err)
28 | }
29 | req := &CurrentRequest{
30 | Arch: "amd64",
31 | OS: "linux",
32 | }
33 |
34 | t.Run("Response", func(t *testing.T) {
35 | got, err := g.Current(ctx, req)
36 | if err != nil {
37 | t.Fatal(err)
38 | }
39 | want := &CurrentResponse{
40 | ID: "1234567890",
41 | Tag: "v1.0.0",
42 | ArtifactURL: "ghr://linyows/dewy",
43 | }
44 | if diff := cmp.Diff(got, want); diff != "" {
45 | t.Error(diff)
46 | }
47 | })
48 |
49 | t.Run("Request", func(t *testing.T) {
50 | if want := 1; len(ts.Requests()) != want {
51 | t.Errorf("got %v, want %v", len(ts.Requests()), want)
52 | }
53 | want := grpcstub.Message{
54 | "arch": "amd64",
55 | "os": "linux",
56 | }
57 | if diff := cmp.Diff(ts.Requests()[0].Message, want); diff != "" {
58 | t.Error(diff)
59 | }
60 | })
61 | }
62 |
63 | func TestReport(t *testing.T) {
64 | ctx := context.Background()
65 | ts := grpcstub.NewServer(t, "dewy.proto")
66 | t.Cleanup(func() {
67 | ts.Close()
68 | })
69 | ts.Method("Report").Response(&emptypb.Empty{})
70 | g := &GRPC{NoTLS: true}
71 | if err := g.Dial(ctx, ts.Addr()); err != nil {
72 | t.Fatal(err)
73 | }
74 | req := &ReportRequest{
75 | ID: "1234567890",
76 | Tag: "v1.0.0",
77 | Err: errors.New("something error"),
78 | }
79 | if err := g.Report(ctx, req); err != nil {
80 | t.Fatal(err)
81 | }
82 |
83 | t.Run("Request", func(t *testing.T) {
84 | if want := 1; len(ts.Requests()) != want {
85 | t.Errorf("got %v, want %v", len(ts.Requests()), want)
86 | }
87 | want := grpcstub.Message{
88 | "id": "1234567890",
89 | "tag": "v1.0.0",
90 | "err": "something error",
91 | }
92 | if diff := cmp.Diff(ts.Requests()[0].Message, want); diff != "" {
93 | t.Error(diff)
94 | }
95 | })
96 | }
97 |
--------------------------------------------------------------------------------
/registry/registry.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/gorilla/schema"
9 | )
10 |
11 | var (
12 | decoder = schema.NewDecoder()
13 | s3Scheme = "s3"
14 | ghrScheme = "ghr"
15 | grpcScheme = "grpc"
16 | )
17 |
18 | type Registry interface {
19 | // Current returns the current artifact.
20 | Current(context.Context, *CurrentRequest) (*CurrentResponse, error)
21 | // Report reports the result of deploying the artifact.
22 | Report(context.Context, *ReportRequest) error
23 | }
24 |
25 | // CurrentRequest is the request to get the current artifact.
26 | type CurrentRequest struct {
27 | // Arch is the CPU architecture of deployment environment.
28 | Arch string
29 | // OS is the operating system of deployment environment.
30 | OS string
31 | // ArtifactName is the name of the artifact to fetch.
32 | // FIXME: If possible, ArtifactName should be optional.
33 | ArtifactName string
34 | }
35 |
36 | // CurrentResponse is the response to get the current artifact.
37 | type CurrentResponse struct {
38 | // ID uniquely identifies the response.
39 | ID string
40 | // Tag uniquely identifies the artifact concerned.
41 | Tag string
42 | // ArtifactURL is the URL to download the artifact.
43 | // The URL is not only "https://"
44 | ArtifactURL string
45 | }
46 |
47 | // ReportRequest is the request to report the result of deploying the artifact.
48 | type ReportRequest struct {
49 | // ID is the ID of the response.
50 | ID string
51 | // Tag is the current tag of deployed artifact.
52 | Tag string
53 | // Err is the error that occurred during deployment. If Err is nil, the deployment is considered successful.
54 | Err error
55 | }
56 |
57 | func New(ctx context.Context, url string) (Registry, error) {
58 | splitted := strings.SplitN(url, "://", 2)
59 |
60 | switch splitted[0] {
61 | case ghrScheme:
62 | return NewGHR(ctx, url)
63 |
64 | case s3Scheme:
65 | return NewS3(ctx, url)
66 |
67 | case grpcScheme:
68 | return NewGRPC(ctx, url)
69 | }
70 |
71 | return nil, fmt.Errorf("unsupported registry: %s", url)
72 | }
73 |
74 | func addTrailingSlash(path string) string {
75 | if strings.HasSuffix(path, "/") {
76 | return path
77 | }
78 | return path + "/"
79 | }
80 |
81 | func removeTrailingSlash(path string) string {
82 | return strings.TrimSuffix(path, "/")
83 | }
84 |
--------------------------------------------------------------------------------
/registry/registry_test.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 | "testing"
8 |
9 | "github.com/google/go-cmp/cmp"
10 | "github.com/google/go-cmp/cmp/cmpopts"
11 | "github.com/k1LoW/grpcstub"
12 | )
13 |
14 | func TestNew(t *testing.T) {
15 | if os.Getenv("GITHUB_TOKEN") == "" {
16 | t.Skip("GITHUB_TOKEN is not set")
17 | }
18 |
19 | ts := grpcstub.NewServer(t, "dewy.proto")
20 | t.Cleanup(func() {
21 | ts.Close()
22 | })
23 | tests := []struct {
24 | urlstr string
25 | want Registry
26 | wantErr bool
27 | }{
28 | {
29 | "ghr://linyows/dewy",
30 | func(t *testing.T) Registry {
31 | return &GHR{
32 | Owner: "linyows",
33 | Repo: "dewy",
34 | }
35 | }(t),
36 | false,
37 | },
38 | {
39 | "ghr://linyows/dewy?artifact=dewy_linux_amd64",
40 | func(t *testing.T) Registry {
41 | return &GHR{
42 | Owner: "linyows",
43 | Repo: "dewy",
44 | Artifact: "dewy_linux_amd64",
45 | }
46 | }(t),
47 | false,
48 | },
49 | {
50 | "ghr://linyows/dewy?artifact=dewy_linux_amd64&pre-release=true",
51 | func(t *testing.T) Registry {
52 | return &GHR{
53 | Owner: "linyows",
54 | Repo: "dewy",
55 | Artifact: "dewy_linux_amd64",
56 | PreRelease: true,
57 | }
58 | }(t),
59 | false,
60 | },
61 | {
62 | "s3://ap-northeast-3/dewy/foo/bar/baz?pre-release=true",
63 | func(t *testing.T) Registry {
64 | return &S3{
65 | Bucket: "dewy",
66 | Prefix: "foo/bar/baz/",
67 | Region: "ap-northeast-3",
68 | PreRelease: true,
69 | }
70 | }(t),
71 | false,
72 | },
73 | {
74 | "s3://ap-northeast-1/dewy/",
75 | func(t *testing.T) Registry {
76 | return &S3{
77 | Bucket: "dewy",
78 | Prefix: "",
79 | Region: "ap-northeast-1",
80 | PreRelease: false,
81 | }
82 | }(t),
83 | false,
84 | },
85 | {
86 | fmt.Sprintf("grpc://%s?no-tls=true", ts.Addr()),
87 | func(t *testing.T) Registry {
88 | return &GRPC{
89 | Target: ts.Addr(),
90 | NoTLS: true,
91 | }
92 | }(t),
93 | false,
94 | },
95 | {
96 | "invalid://linyows/dewy",
97 | nil,
98 | true,
99 | },
100 | }
101 | for _, tt := range tests {
102 | t.Run(tt.urlstr, func(t *testing.T) {
103 | ctx := context.Background()
104 | got, err := New(ctx, tt.urlstr)
105 | if (err != nil) != tt.wantErr {
106 | t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
107 | return
108 | }
109 | opts := []cmp.Option{
110 | cmp.AllowUnexported(GHR{}, GRPC{}, S3{}),
111 | cmpopts.IgnoreFields(GHR{}, "cl"),
112 | cmpopts.IgnoreFields(S3{}, "cl"),
113 | cmpopts.IgnoreFields(GRPC{}, "cl"),
114 | }
115 | if diff := cmp.Diff(got, tt.want, opts...); diff != "" {
116 | t.Error(diff)
117 | }
118 | })
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/registry/s3.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net/url"
8 | "os"
9 | "strings"
10 | "time"
11 |
12 | "github.com/aws/aws-sdk-go-v2/aws"
13 | "github.com/aws/aws-sdk-go-v2/config"
14 | "github.com/aws/aws-sdk-go-v2/service/s3"
15 | "github.com/aws/aws-sdk-go-v2/service/s3/types"
16 | )
17 |
18 | const (
19 | s3Format string = "s3:////"
20 | )
21 |
22 | // S3 struct.
23 | type S3 struct {
24 | Bucket string `schema:"-"`
25 | Prefix string `schema:"-"`
26 | Region string `schema:"region"`
27 | Endpoint string `schema:"endpoint"`
28 | Artifact string `schema:"artifact"`
29 | PreRelease bool `schema:"pre-release"`
30 | cl S3Client
31 | pager ListObjectsV2Pager
32 | }
33 |
34 | // NewS3 returns S3.
35 | func NewS3(ctx context.Context, u string) (*S3, error) {
36 | ur, err := url.Parse(u)
37 | if err != nil {
38 | return nil, err
39 | }
40 |
41 | after, _ := strings.CutPrefix(ur.Path, "/")
42 | splitted := strings.SplitN(after, "/", 2)
43 | bucket := ""
44 | prefix := ""
45 | if len(splitted) > 0 {
46 | bucket = splitted[0]
47 | }
48 | if len(splitted) > 1 {
49 | prefix = strings.TrimPrefix(addTrailingSlash(splitted[1]), "/")
50 | }
51 |
52 | s := &S3{
53 | Region: ur.Host,
54 | Bucket: bucket,
55 | Prefix: prefix,
56 | }
57 | if err = decoder.Decode(s, ur.Query()); err != nil {
58 | return nil, err
59 | }
60 |
61 | if s.Region == "" {
62 | return nil, fmt.Errorf("region is required: %s", s3Format)
63 | }
64 | if s.Bucket == "" {
65 | return nil, fmt.Errorf("bucket is required: %s", s3Format)
66 | }
67 |
68 | cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(s.Region))
69 | if err != nil {
70 | return nil, err
71 | }
72 |
73 | if s.Endpoint != "" {
74 | s.cl = s3.NewFromConfig(cfg, func(o *s3.Options) {
75 | // path-style: https://s3.region.amazonaws.com//
76 | o.UsePathStyle = true
77 | o.BaseEndpoint = aws.String(s.Endpoint)
78 | })
79 | } else if e := os.Getenv("AWS_ENDPOINT_URL"); e != "" {
80 | s.cl = s3.NewFromConfig(cfg, func(o *s3.Options) {
81 | o.UsePathStyle = true
82 | })
83 | } else {
84 | s.cl = s3.NewFromConfig(cfg)
85 | }
86 |
87 | return s, nil
88 | }
89 |
90 | // Current returns current artifact.
91 | func (s *S3) Current(ctx context.Context, req *CurrentRequest) (*CurrentResponse, error) {
92 | prefix, version, err := s.LatestVersion(ctx)
93 | if err != nil {
94 | return nil, err
95 | }
96 |
97 | objects, err := s.ListObjects(ctx, prefix)
98 | if err != nil {
99 | return nil, err
100 | }
101 |
102 | var artifactName string
103 | found := false
104 |
105 | if req.ArtifactName != "" {
106 | artifactName = req.ArtifactName
107 | for _, v := range objects {
108 | name := s.extractFilenameFromObjectKey(*v.Key, prefix)
109 | if name == artifactName {
110 | found = true
111 | log.Printf("[DEBUG] Fetched: %+v", v)
112 | break
113 | }
114 | }
115 |
116 | } else {
117 | archMatchs := []string{req.Arch}
118 | if req.Arch == "amd64" {
119 | archMatchs = append(archMatchs, "x86_64")
120 | }
121 | osMatchs := []string{req.OS}
122 | if req.OS == "darwin" {
123 | osMatchs = append(osMatchs, "macos")
124 | }
125 |
126 | for _, v := range objects {
127 | name := s.extractFilenameFromObjectKey(*v.Key, prefix)
128 | n := strings.ToLower(name)
129 | for _, arch := range archMatchs {
130 | if strings.Contains(n, arch) {
131 | found = true
132 | break
133 | }
134 | }
135 | if !found {
136 | continue
137 | }
138 | found = false
139 | for _, os := range osMatchs {
140 | if strings.Contains(n, os) {
141 | found = true
142 | break
143 | }
144 | }
145 | if !found {
146 | continue
147 | }
148 | artifactName = name
149 | log.Printf("[DEBUG] Fetched: %+v", v)
150 | break
151 | }
152 | }
153 |
154 | if !found {
155 | return nil, fmt.Errorf("artifact not found: %s%s", prefix, artifactName)
156 | }
157 |
158 | return &CurrentResponse{
159 | ID: time.Now().Format(ISO8601),
160 | Tag: version.String(),
161 | ArtifactURL: s.buildArtifactURL(prefix + artifactName),
162 | }, nil
163 | }
164 |
165 | func (s *S3) buildArtifactURL(key string) string {
166 | var q []string
167 | var qstr string
168 |
169 | if s.Endpoint != "" {
170 | q = append(q, "endpoint="+s.Endpoint)
171 | }
172 | if len(q) > 0 {
173 | qstr = "?" + strings.Join(q, "&")
174 | }
175 |
176 | return fmt.Sprintf("%s://%s/%s/%s%s", s3Scheme, s.Region, s.Bucket, key, qstr)
177 | }
178 |
179 | // Report report shipping.
180 | func (s *S3) Report(ctx context.Context, req *ReportRequest) error {
181 | if req.Err != nil {
182 | return req.Err
183 | }
184 |
185 | now := time.Now().UTC().Format(ISO8601)
186 | hostname, _ := os.Hostname()
187 | info := fmt.Sprintf("shipped to %s at %s", strings.ToLower(hostname), now)
188 | filename := fmt.Sprintf("%s.txt", strings.Replace(info, " ", "_", -1))
189 | key := fmt.Sprintf("%s%s/%s", s.Prefix, req.Tag, filename)
190 | err := s.PutTextObject(ctx, key, "")
191 |
192 | return err
193 | }
194 |
195 | type S3Client interface {
196 | PutObject(ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.Options)) (*s3.PutObjectOutput, error)
197 | ListObjectsV2(context.Context, *s3.ListObjectsV2Input, ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
198 | }
199 |
200 | func (s *S3) PutTextObject(ctx context.Context, key, content string) error {
201 | _, err := s.cl.PutObject(ctx, &s3.PutObjectInput{
202 | Bucket: aws.String(s.Bucket),
203 | Key: aws.String(key),
204 | Body: strings.NewReader(content),
205 | ContentType: aws.String("text/plain"),
206 | })
207 | if err != nil {
208 | return fmt.Errorf("failed to upload text to S3: %w", err)
209 | }
210 |
211 | return nil
212 | }
213 |
214 | type ListObjectsV2Pager interface {
215 | HasMorePages() bool
216 | NextPage(context.Context, ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
217 | }
218 |
219 | func (s *S3) ListObjects(ctx context.Context, prefix string) ([]types.Object, error) {
220 | pager := s.pager
221 | if pager == nil {
222 | pager = s3.NewListObjectsV2Paginator(s.cl, &s3.ListObjectsV2Input{
223 | Bucket: aws.String(s.Bucket),
224 | Prefix: aws.String(prefix),
225 | })
226 | }
227 |
228 | var objects []types.Object
229 |
230 | for pager.HasMorePages() {
231 | output, err := pager.NextPage(ctx)
232 | if err != nil {
233 | return nil, fmt.Errorf("failed to list objects: %w", err)
234 | }
235 | objects = append(objects, output.Contents...)
236 | }
237 |
238 | return objects, nil
239 | }
240 |
241 | func (s *S3) extractFilenameFromObjectKey(key, prefix string) string {
242 | return strings.TrimPrefix(removeTrailingSlash(key), prefix)
243 | }
244 |
245 | func (s *S3) LatestVersion(ctx context.Context) (string, *SemVer, error) {
246 | pager := s.pager
247 | if pager == nil {
248 | pager = s3.NewListObjectsV2Paginator(s.cl, &s3.ListObjectsV2Input{
249 | Bucket: aws.String(s.Bucket),
250 | Prefix: aws.String(s.Prefix),
251 | Delimiter: aws.String("/"),
252 | })
253 | }
254 |
255 | var latestObject *types.CommonPrefix
256 | var latestVersion *SemVer
257 |
258 | matched := func(str string, pre bool) bool {
259 | if pre {
260 | return SemVerRegex.MatchString(str)
261 | } else {
262 | return SemVerRegexWithoutPreRelease.MatchString(str)
263 | }
264 | }
265 |
266 | for pager.HasMorePages() {
267 | output, err := pager.NextPage(ctx)
268 | if err != nil {
269 | return "", nil, fmt.Errorf("failed to list objects: %w", err)
270 | }
271 | // Use output.CommonPrefixes instead of output.Contents to process only directories under prefix.
272 | for i, obj := range output.CommonPrefixes {
273 | name := s.extractFilenameFromObjectKey(*obj.Prefix, s.Prefix)
274 | if matched(name, s.PreRelease) {
275 | ver := ParseSemVer(name)
276 | if ver != nil {
277 | if latestVersion == nil || ver.Compare(latestVersion) > 0 {
278 | latestVersion = ver
279 | latestObject = &output.CommonPrefixes[i]
280 | }
281 | }
282 | }
283 | }
284 | }
285 |
286 | if latestObject == nil {
287 | return "", nil, fmt.Errorf("no valid versioned object found")
288 | }
289 |
290 | return *latestObject.Prefix, latestVersion, nil
291 | }
292 |
--------------------------------------------------------------------------------
/registry/s3_test.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "testing"
7 |
8 | "github.com/aws/aws-sdk-go-v2/aws"
9 | "github.com/aws/aws-sdk-go-v2/service/s3"
10 | "github.com/aws/aws-sdk-go-v2/service/s3/types"
11 | "github.com/google/go-cmp/cmp"
12 | "github.com/google/go-cmp/cmp/cmpopts"
13 | )
14 |
15 | func TestNewS3(t *testing.T) {
16 | tests := []struct {
17 | desc string
18 | url string
19 | expected *S3
20 | expectErr bool
21 | err error
22 | }{
23 | {
24 | "valid small structure is returned",
25 | "s3://ap-northeast-1/mybucket",
26 | &S3{
27 | Region: "ap-northeast-1",
28 | Bucket: "mybucket",
29 | Prefix: "",
30 | Endpoint: "",
31 | Artifact: "",
32 | },
33 | false,
34 | nil,
35 | },
36 | {
37 | "valid large structure is returned",
38 | "s3://ap-northeast-1/mybucket/myteam/myapp?endpoint=http://localhost:9999/foobar&artifact=myapp-linux-x86_64.zip",
39 | &S3{
40 | Region: "ap-northeast-1",
41 | Bucket: "mybucket",
42 | Prefix: "myteam/myapp/",
43 | Endpoint: "http://localhost:9999/foobar",
44 | Artifact: "myapp-linux-x86_64.zip",
45 | },
46 | false,
47 | nil,
48 | },
49 | {
50 | "error is returned",
51 | "s3://ap",
52 | nil,
53 | true,
54 | fmt.Errorf("bucket is required: %s", s3Format),
55 | },
56 | }
57 |
58 | for _, tt := range tests {
59 | t.Run(tt.desc, func(t *testing.T) {
60 | s3, err := NewS3(context.Background(), tt.url)
61 | if tt.expectErr {
62 | if err == nil || err.Error() != tt.err.Error() {
63 | t.Errorf("expected error %s, got %s", tt.err, err)
64 | }
65 | } else {
66 | opts := []cmp.Option{
67 | cmp.AllowUnexported(S3{}),
68 | cmpopts.IgnoreFields(S3{}, "cl"),
69 | }
70 | if diff := cmp.Diff(s3, tt.expected, opts...); diff != "" {
71 | t.Error(diff)
72 | }
73 | }
74 | })
75 | }
76 | }
77 |
78 | type MockListObjectsV2Pager struct {
79 | // Pages [][]types.Object
80 | // Pages []*s3.ListObjectsV2Output
81 | Pages [][]types.CommonPrefix
82 | PageIndex int
83 | }
84 |
85 | func (m *MockListObjectsV2Pager) HasMorePages() bool {
86 | return m.PageIndex < len(m.Pages)
87 | }
88 |
89 | func (m *MockListObjectsV2Pager) NextPage(ctx context.Context, opts ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
90 | if !m.HasMorePages() {
91 | return &s3.ListObjectsV2Output{}, nil
92 | }
93 | page := m.Pages[m.PageIndex]
94 | m.PageIndex++
95 | return &s3.ListObjectsV2Output{
96 | // Contents: page,
97 | CommonPrefixes: page,
98 | }, nil
99 | }
100 |
101 | func TestS3LatestVersion(t *testing.T) {
102 | data := [][]types.CommonPrefix{
103 | {
104 | {Prefix: aws.String("your/path/v1.0.0/")},
105 | {Prefix: aws.String("your/path/v1.2.0/")},
106 | {Prefix: aws.String("your/path/v3.2.1-rc.1/")},
107 | {Prefix: aws.String("your/path/v3.2.2-beta.10/")},
108 | {Prefix: aws.String("your/path/v0.0.1/")},
109 | },
110 | {
111 | {Prefix: aws.String("your/path/v1.2.3/")},
112 | {Prefix: aws.String("your/path/v1.1.0/")},
113 | {Prefix: aws.String("your/path/3.2.1/")},
114 | {Prefix: aws.String("your/path/foobar.tar.gz")},
115 | },
116 | }
117 |
118 | tests := []struct {
119 | desc string
120 | pre bool
121 | expectedPrefix string
122 | expectedVer string
123 | }{
124 | {"pre-release is enabled", true, "your/path/v3.2.2-beta.10/", "v3.2.2-beta.10"},
125 | {"pre-release is disabled", false, "your/path/3.2.1/", "3.2.1"},
126 | }
127 |
128 | for _, tt := range tests {
129 | t.Run(tt.desc, func(t *testing.T) {
130 |
131 | s3 := &S3{
132 | Bucket: "foobar",
133 | Prefix: "your/path/",
134 | PreRelease: tt.pre,
135 | // If you create a mocking object outside of iteration,
136 | // the pageindex will be updated and the page will become 0 from the second time onwards, so create it during iteration.
137 | pager: &MockListObjectsV2Pager{Pages: data},
138 | }
139 |
140 | gotPrefix, gotVer, err := s3.LatestVersion(context.Background())
141 | if err != nil {
142 | t.Fatalf("unexpected error: %v", err)
143 | }
144 | if gotPrefix != tt.expectedPrefix {
145 | t.Errorf("expected latest version key %s, got %s", tt.expectedPrefix, gotPrefix)
146 | }
147 | if gotVer.String() != tt.expectedVer {
148 | t.Errorf("expected latest version key %s, got %s", tt.expectedVer, gotVer)
149 | }
150 | })
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/registry/semver.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "fmt"
5 | "regexp"
6 | "strconv"
7 | "strings"
8 | )
9 |
10 | var (
11 | SemVerRegexWithoutPreRelease = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`)
12 | SemVerRegex = regexp.MustCompile(`^(v)?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$`)
13 | )
14 |
15 | func ParseSemVer(version string) *SemVer {
16 | match := SemVerRegex.FindStringSubmatch(version)
17 | if match == nil {
18 | return nil
19 | }
20 |
21 | v := match[1]
22 | major, _ := strconv.Atoi(match[2])
23 | minor, _ := strconv.Atoi(match[3])
24 | patch, _ := strconv.Atoi(match[4])
25 | preRelease := match[5]
26 |
27 | return &SemVer{
28 | V: v,
29 | Major: major,
30 | Minor: minor,
31 | Patch: patch,
32 | PreRelease: preRelease,
33 | }
34 | }
35 |
36 | type SemVer struct {
37 | V string
38 | Major int
39 | Minor int
40 | Patch int
41 | PreRelease string
42 | }
43 |
44 | func (v *SemVer) Compare(other *SemVer) int {
45 | if v.Major != other.Major {
46 | return v.Major - other.Major
47 | }
48 | if v.Minor != other.Minor {
49 | return v.Minor - other.Minor
50 | }
51 | if v.Patch != other.Patch {
52 | return v.Patch - other.Patch
53 | }
54 | if v.PreRelease == "" && other.PreRelease != "" {
55 | return 1
56 | }
57 | if v.PreRelease != "" && other.PreRelease == "" {
58 | return -1
59 | }
60 | return strings.Compare(v.PreRelease, other.PreRelease)
61 | }
62 |
63 | func (v *SemVer) String() string {
64 | var pre string
65 | if v.PreRelease != "" {
66 | pre = fmt.Sprintf("-%s", v.PreRelease)
67 | }
68 | return fmt.Sprintf("%s%d.%d.%d%s", v.V, v.Major, v.Minor, v.Patch, pre)
69 | }
70 |
--------------------------------------------------------------------------------
/registry/semver_test.go:
--------------------------------------------------------------------------------
1 | package registry
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestSemVerRegexWithoutPreRelease(t *testing.T) {
8 | tests := []struct {
9 | ver string
10 | expected bool
11 | }{
12 | {"1.2.3", true},
13 | {"v1.2.3", true},
14 | {"v1.2.300", true},
15 | {"v1.2.3-rc", false},
16 | {"v1.2.3-beta.1", false},
17 | {"v8", false},
18 | {"12.3", false},
19 | {"abcdefg-10", false},
20 | }
21 |
22 | for _, tt := range tests {
23 | t.Run(tt.ver, func(t *testing.T) {
24 | got := SemVerRegexWithoutPreRelease.MatchString(tt.ver)
25 | if got != tt.expected {
26 | t.Errorf("expected %t, got %t", tt.expected, got)
27 | }
28 | })
29 | }
30 | }
31 |
32 | func TestSemVerRegex(t *testing.T) {
33 | tests := []struct {
34 | ver string
35 | expected bool
36 | }{
37 | {"1.2.3", true},
38 | {"v1.2.3", true},
39 | {"v1.2.300", true},
40 | {"v1.2.3-rc", true},
41 | {"v1.2.3-beta.1", true},
42 | {"v8", false},
43 | {"12.3", false},
44 | {"abcdefg-10", false},
45 | }
46 |
47 | for _, tt := range tests {
48 | t.Run(tt.ver, func(t *testing.T) {
49 | got := SemVerRegex.MatchString(tt.ver)
50 | if got != tt.expected {
51 | t.Errorf("expected %t, got %t", tt.expected, got)
52 | }
53 | })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/starter.go:
--------------------------------------------------------------------------------
1 | package dewy
2 |
3 | import (
4 | "os"
5 | "time"
6 |
7 | starter "github.com/lestrrat-go/server-starter"
8 | )
9 |
10 | // StarterConfig struct.
11 | type StarterConfig struct {
12 | args []string
13 | command string
14 | dir string
15 | interval int
16 | pidfile string
17 | ports []string
18 | paths []string
19 | sigonhup string
20 | sigonterm string
21 | statusfile string
22 | }
23 |
24 | // Args for StarterConfig.
25 | func (c StarterConfig) Args() []string { return c.args }
26 |
27 | // Command for StarterConfig.
28 | func (c StarterConfig) Command() string { return c.command }
29 |
30 | // Dir for StarterConfig.
31 | func (c StarterConfig) Dir() string { return c.dir }
32 |
33 | // Interval for StarterConfig.
34 | func (c StarterConfig) Interval() time.Duration { return time.Duration(c.interval) * time.Second }
35 |
36 | // PidFile for StarterConfig.
37 | func (c StarterConfig) PidFile() string { return c.pidfile }
38 |
39 | // Ports for StarterConfig.
40 | func (c StarterConfig) Ports() []string { return c.ports }
41 |
42 | // Paths for StarterConfig.
43 | func (c StarterConfig) Paths() []string { return c.paths }
44 |
45 | // SignalOnHUP for StarterConfig.
46 | func (c StarterConfig) SignalOnHUP() os.Signal { return starter.SigFromName(c.sigonhup) }
47 |
48 | // SignalOnTERM for StarterConfig.
49 | func (c StarterConfig) SignalOnTERM() os.Signal { return starter.SigFromName(c.sigonterm) }
50 |
51 | // StatusFile for StarterConfig.
52 | func (c StarterConfig) StatusFile() string { return c.statusfile }
53 |
--------------------------------------------------------------------------------
/website/.env.local:
--------------------------------------------------------------------------------
1 | HOMEPAGE_ID=91f9290a-b373-4e00-9869-e8e3873048eb
2 | ROTION_INCREMENTAL_CACHE=true
3 |
--------------------------------------------------------------------------------
/website/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/website/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 |
--------------------------------------------------------------------------------
/website/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | ```
12 |
13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
14 |
15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
16 |
17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
18 |
19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/website/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/website/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | output: 'export',
5 | images: {
6 | unoptimized: true,
7 | },
8 | }
9 |
10 | export default nextConfig
11 |
12 |
--------------------------------------------------------------------------------
/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "next": "^14.2.4",
13 | "react": "^18.3.1",
14 | "react-dom": "^18.3.1",
15 | "rotion": "^1.5.1"
16 | },
17 | "devDependencies": {
18 | "@types/node": "^20.14.5",
19 | "@types/react": "^18.3.3",
20 | "@types/react-dom": "^18.3.0",
21 | "eslint": "^8",
22 | "eslint-config-next": "14.2.4",
23 | "typescript": "^5.4.5"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/website/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { Inter } from 'next/font/google'
2 | import 'rotion/style.css'
3 | import '@/styles/globals.css'
4 | import type { AppProps } from 'next/app'
5 |
6 | const inter = Inter({
7 | weight: ['400', '700'],
8 | subsets: ['latin'],
9 | display: 'swap',
10 | })
11 |
12 | export default function App({ Component, pageProps }: AppProps) {
13 | return (
14 | <>
15 |
16 |
22 | >
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/website/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from "next/document";
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/website/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import type { GetStaticProps, InferGetStaticPropsType } from 'next'
2 | import { useEffect, useState } from 'react'
3 | import Head from 'next/head'
4 | import Link from 'next/link'
5 | import {
6 | FetchBlocks,
7 | FetchPage,
8 | FetchBlocksRes,
9 | } from 'rotion'
10 | import { Page, Link as RotionLink } from 'rotion/ui'
11 | import styles from '@/styles/Home.module.css'
12 |
13 | type Props = {
14 | icon: string
15 | logo: string
16 | blocks: FetchBlocksRes
17 | }
18 |
19 | export const getStaticProps: GetStaticProps = async (context) => {
20 | const id = process.env.HOMEPAGE_ID as string
21 | const page = await FetchPage({ page_id: id, last_edited_time: 'force' })
22 | const logo = page.cover?.src || ''
23 | const icon = page.icon!.src
24 | const blocks = await FetchBlocks({ block_id: id, last_edited_time: page.last_edited_time })
25 |
26 | return {
27 | props: {
28 | icon,
29 | logo,
30 | blocks,
31 | }
32 | }
33 | }
34 |
35 | const InlineSVG = ({ src }: { src: string }) => {
36 | const [svgContent, setSvgContent] = useState('')
37 |
38 | useEffect(() => {
39 | fetch(src)
40 | .then(response => response.text())
41 | .then(data => setSvgContent(data))
42 | }, [src])
43 |
44 | return
45 | }
46 |
47 | export default function Home({ logo, icon, blocks }: InferGetStaticPropsType) {
48 | const y = new Date(Date.now()).getFullYear()
49 | return (
50 | <>
51 |
52 | Dewy
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
72 |
73 |
78 |
79 | >
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/website/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .layout {
2 | position: relative;
3 | width: 100%;
4 | margin: 0;
5 | padding: 0;
6 | }
7 |
8 | .nav {
9 | width: 20%;
10 | height: 100vh;
11 | position: fixed;
12 | top: 0;
13 | left: 0;
14 | margin: 0;
15 | padding: 0;
16 | background: rgba(100, 140, 220, 0.8);
17 | }
18 |
19 | .header {
20 | padding: 5rem 0 0;
21 | width: 200px;
22 | margin: 0 -100px 0 auto;
23 | }
24 |
25 | .footer {
26 | padding: 2rem;
27 | position: absolute;
28 | top: 0;
29 | right: 0;
30 | font-size: .85rem;
31 | font-weight: bold;
32 | }
33 |
34 | .icon {
35 | width: 160px;
36 | margin: 0 auto;
37 | }
38 |
39 | .logo {
40 | padding-top: 3rem;
41 | }
42 |
43 | .icon img,
44 | .logo img {
45 | width: 100%;
46 | }
47 |
48 | .header svg {
49 | fill: var(--foreground-color);
50 | }
51 |
52 | .box {
53 | width: 80%;
54 | margin: 0 0 0 20%;
55 | padding: 0;
56 | }
57 |
58 | .page {
59 | max-width: 1800px;
60 | margin: 0;
61 | padding: 12rem 2rem 12rem 12rem;
62 | }
63 |
64 | /* Small */
65 | @media (max-width: 1200px) {
66 | .nav {
67 | width: 10%;
68 | }
69 | .box {
70 | width: 90%;
71 | margin-left: 10%;
72 | }
73 | .header {
74 | width: 100px;
75 | margin-right: -50px;
76 | }
77 | .icon {
78 | width: 80px;
79 | }
80 | .logo {
81 | padding-top: 1rem;
82 | }
83 | .page {
84 | padding: 6rem 2rem 6rem 6rem;
85 | }
86 | }
87 |
88 | /* Mobile */
89 | @media (max-width: 400px) {
90 | .nav {
91 | width: 100%;
92 | height: 150px;
93 | margin-bottom: 70px;
94 | position: relative;
95 | }
96 | .footer {
97 | padding: 1rem;
98 | left: 0;
99 | text-align: center;
100 | color: var(--accent-color);
101 | }
102 | .box {
103 | width: 100%;
104 | margin-left: 0;
105 | }
106 | .header {
107 | margin-right: auto;
108 | }
109 | .page {
110 | padding: 6rem 2rem;
111 | }
112 | }
113 |
114 | @media (prefers-color-scheme: dark) {
115 | }
116 |
--------------------------------------------------------------------------------
/website/styles/globals.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --max-width: 1100px;
3 | --border-radius: 12px;
4 | --side-padding: 4rem;
5 | --font-size: 1.1rem;
6 |
7 | --background-color: rgb(255, 255, 255);
8 | --foreground-color: rgb(194, 73, 85);
9 | --primary-color: rgba(80, 140, 220, 0.8);
10 | --secondary-color: rgb(201, 157, 163);
11 | --accent-color: rgb(198, 221, 240);
12 | --rotion-primary-text: var(--foreground-color);
13 | }
14 |
15 | @media (prefers-color-scheme: dark) {
16 | :root {
17 | --background-color: rgb(42, 43, 42);
18 | --foreground-color: rgb(230, 100, 105);
19 | --primary-color: rgba(120, 180, 240, 0.8);
20 | --secondary-color: rgb(70, 140, 200);
21 | --rotion-dark-primary-text: var(--foreground-color);
22 | }
23 | }
24 |
25 | @media (max-width: 700px) {
26 | :root {
27 | --side-padding: 1rem;
28 | --font-size: 1rem;
29 | }
30 | }
31 |
32 | * {
33 | box-sizing: border-box;
34 | padding: 0;
35 | margin: 0;
36 | }
37 |
38 | html,
39 | body {
40 | max-width: 100vw;
41 | overflow-x: hidden;
42 | font-size: var(--font-size);
43 | }
44 |
45 | body {
46 | color: var(--foreground-color);
47 | background-color: var(--background-color);
48 | }
49 |
50 | a {
51 | color: inherit;
52 | text-decoration: none;
53 | }
54 |
55 | body .rotion-text-h1,
56 | body .rotion-text-h2,
57 | body .rotion-text-h3 {
58 | color: var(--primary-color);
59 | }
60 | body .rotion-richtext-link {
61 | background-color: var(--accent-color);
62 | }
63 |
64 | @media (prefers-color-scheme: dark) {
65 | html {
66 | color-scheme: dark;
67 | }
68 | body .rotion-richtext-link {
69 | color: var(--accent-color);
70 | border-color: var(--accent-color);
71 | background: none;
72 | }
73 | body .rotion-code-area {
74 | border: 1px solid rgba(100,100,100,0.4);
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "paths": {
18 | "@/*": ["./*"]
19 | }
20 | },
21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
22 | "exclude": ["node_modules"]
23 | }
24 |
--------------------------------------------------------------------------------