├── .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 | Dewy 9 | 10 |





11 |
12 |

13 | 14 |

15 | Dewy enables declarative deployment of applications in non-Kubernetes environments. 16 |

17 | 18 |

19 | 20 | GitHub Workflow Status 21 | 22 | 23 | GitHub Release 24 | 25 | 26 | Go Documentation 27 | 28 |

29 | 30 | Dewyは、主にGoで作られたアプリケーションを非コンテナ環境において宣言的にデプロイするソフトウェアです。 31 | Dewyは、アプリケーションのSupervisor的な役割をし、Dewyがメインプロセスとなり、子プロセスとしてアプリケーションを起動させます。 32 | Dewyのスケジューラーは、指定する「レジストリ」をポーリングし、セマンティックバージョニングで管理された最新のバージョンを検知すると、指定する「アーティファクト」ストアからデプロイを行います。 33 | Dewyは、いわゆるプル型のデプロイを実現します。Dewyは、レジストリ、アーティファクトストア、キャッシュストア、通知の4つのインターフェースから構成されています。 34 | 以下はDewyのデプロイプロセスと構成を図にしたものです。 35 | 36 |

37 | Dewyのデプロイプロセスとアーキテクチャ 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 | Dewy 9 | 10 |





11 |
12 |

13 | 14 |

15 | Dewy enables declarative deployment of applications in non-Kubernetes environments. 16 |

17 | 18 |

19 | 20 | GitHub Workflow Status 21 | 22 | 23 | GitHub Release 24 | 25 | 26 | Go Documentation 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 | Dewy Architecture 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 Files 15 | 16 | 17 | v1.2.2 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Application 26 | or Files 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 |
68 |
69 | 70 |
71 |
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 | --------------------------------------------------------------------------------