├── .gitignore ├── docs ├── architecture-ja.md ├── images │ ├── init.jpg │ ├── push.jpg │ ├── sync.jpg │ ├── deploy.jpg │ ├── import.jpg │ ├── setup.jpg │ ├── profiling.jpg │ └── afterbench.jpg ├── index.md ├── user-guide-ja.md └── concept-ja.md ├── pkg ├── usecases │ ├── deploy │ │ ├── testdata │ │ │ ├── host01 │ │ │ │ ├── nginx_symlink │ │ │ │ ├── error │ │ │ │ │ └── unresolved_link │ │ │ │ └── nginx │ │ │ │ │ ├── nginx.conf │ │ │ │ │ ├── sites-available │ │ │ │ │ ├── default │ │ │ │ │ └── isucondition.conf │ │ │ │ │ └── sites-enabled │ │ │ │ │ └── isucondition.conf │ │ │ └── host02 │ │ │ │ ├── hosts_symlink │ │ │ │ └── nginx_symlink │ │ ├── deploy.go │ │ └── deploy_test.go │ ├── install │ │ ├── docker.go │ │ ├── install.go │ │ ├── alp.go │ │ └── docker_netdata.go │ ├── profiling │ │ └── profiling.go │ ├── afterbench │ │ └── afterbench.go │ └── imports │ │ └── imports.go ├── config │ ├── skelton.go │ ├── skelton.yaml │ └── types.go ├── slack │ ├── fake.go │ └── slack.go ├── shell │ ├── common.go │ ├── local.go │ ├── ssh.go │ └── mock │ │ └── mock.go ├── template │ └── template.go ├── cmd │ ├── init.go │ ├── sync.go │ ├── push.go │ ├── profiling.go │ ├── setup.go │ ├── common.go │ ├── import.go │ ├── afterbench.go │ └── deploy.go ├── errors │ └── errors.go └── localrepo │ └── localrepo.go ├── main.go ├── goreleaser.yml ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── README.md ├── cmd ├── setup.go ├── import.go ├── profiling.go ├── push.go ├── sync.go ├── afterbench.go ├── init.go ├── deploy.go └── root.go ├── go.mod ├── LICENSE └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | isucontinuous 2 | -------------------------------------------------------------------------------- /docs/architecture-ja.md: -------------------------------------------------------------------------------- 1 | ## Architecture 2 | 3 | TODO 4 | -------------------------------------------------------------------------------- /pkg/usecases/deploy/testdata/host01/nginx_symlink: -------------------------------------------------------------------------------- 1 | nginx -------------------------------------------------------------------------------- /pkg/usecases/deploy/testdata/host02/hosts_symlink: -------------------------------------------------------------------------------- 1 | /etc/hosts -------------------------------------------------------------------------------- /pkg/usecases/deploy/testdata/host02/nginx_symlink: -------------------------------------------------------------------------------- 1 | ../host01/nginx -------------------------------------------------------------------------------- /pkg/usecases/deploy/testdata/host01/error/unresolved_link: -------------------------------------------------------------------------------- 1 | /this_is_dummy_src -------------------------------------------------------------------------------- /pkg/usecases/deploy/testdata/host01/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | # For unittest (nginx.conf) 2 | -------------------------------------------------------------------------------- /pkg/usecases/deploy/testdata/host01/nginx/sites-available/default: -------------------------------------------------------------------------------- 1 | # For unittest (default) 2 | -------------------------------------------------------------------------------- /docs/images/init.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShotaKitazawa/isucontinuous/HEAD/docs/images/init.jpg -------------------------------------------------------------------------------- /docs/images/push.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShotaKitazawa/isucontinuous/HEAD/docs/images/push.jpg -------------------------------------------------------------------------------- /docs/images/sync.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShotaKitazawa/isucontinuous/HEAD/docs/images/sync.jpg -------------------------------------------------------------------------------- /docs/images/deploy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShotaKitazawa/isucontinuous/HEAD/docs/images/deploy.jpg -------------------------------------------------------------------------------- /docs/images/import.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShotaKitazawa/isucontinuous/HEAD/docs/images/import.jpg -------------------------------------------------------------------------------- /docs/images/setup.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShotaKitazawa/isucontinuous/HEAD/docs/images/setup.jpg -------------------------------------------------------------------------------- /docs/images/profiling.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShotaKitazawa/isucontinuous/HEAD/docs/images/profiling.jpg -------------------------------------------------------------------------------- /pkg/usecases/deploy/testdata/host01/nginx/sites-enabled/isucondition.conf: -------------------------------------------------------------------------------- 1 | ../sites-available/isucondition.conf -------------------------------------------------------------------------------- /docs/images/afterbench.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShotaKitazawa/isucontinuous/HEAD/docs/images/afterbench.jpg -------------------------------------------------------------------------------- /pkg/usecases/deploy/testdata/host01/nginx/sites-available/isucondition.conf: -------------------------------------------------------------------------------- 1 | # For unittest (isucondition.conf) 2 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/ShotaKitazawa/isucontinuous/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # isucontinuous 2 | 3 | ## 日本語 4 | 5 | - [コンセプト](https://github.com/ShotaKitazawa/isucontinuous/tree/main/docs/concept-ja.md) 6 | - [ユーザーガイド](https://github.com/ShotaKitazawa/isucontinuous/tree/main/docs/user-guide-ja.md) 7 | - [アーキテクチャ](https://github.com/ShotaKitazawa/isucontinuous/tree/main/docs/architecture-ja.md) 8 | -------------------------------------------------------------------------------- /goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: isucontinuous 3 | env: 4 | - GO111MODULE=on 5 | before: 6 | hooks: 7 | - go mod tidy 8 | builds: 9 | - main: . 10 | binary: isucontinuous 11 | ldflags: 12 | - -s -w 13 | env: 14 | - CGO_ENABLED=0 15 | goos: 16 | - linux 17 | - darwin 18 | goarch: 19 | - amd64 20 | release: 21 | prerelease: auto 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: push 3 | 4 | jobs: 5 | unit-test: 6 | name: Test 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v1 11 | with: 12 | fetch-depth: 1 13 | - name: Setup Go 14 | uses: actions/setup-go@v1 15 | with: 16 | go-version: 1.17 17 | - name: Go Test 18 | run: go test -v ./... 19 | 20 | -------------------------------------------------------------------------------- /pkg/config/skelton.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | //go:embed skelton.yaml 10 | var skelton []byte 11 | 12 | func Skelton() (Config, error) { 13 | f, err := SkeltonBytes() 14 | if err != nil { 15 | return Config{}, err 16 | } 17 | var conf Config 18 | if err := yaml.Unmarshal(f, &conf); err != nil { 19 | return Config{}, err 20 | } 21 | return conf, nil 22 | } 23 | 24 | func SkeltonBytes() ([]byte, error) { 25 | return skelton, nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/slack/fake.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "context" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | type FakeClient struct { 10 | log *zap.Logger 11 | } 12 | 13 | func NewFakeClient(logger *zap.Logger) ClientIface { 14 | return &FakeClient{logger} 15 | } 16 | 17 | func (c FakeClient) SendText(ctx context.Context, channel, text string) error { 18 | return nil 19 | } 20 | 21 | func (c FakeClient) SendFileContent(ctx context.Context, channel, filename, content, title string) error { 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /pkg/shell/common.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | ) 7 | 8 | type Iface interface { 9 | Host() string 10 | Exec(ctx context.Context, basedir string, command string) (bytes.Buffer, bytes.Buffer, error) 11 | Execf(ctx context.Context, basedir string, command string, a ...interface{}) (bytes.Buffer, bytes.Buffer, error) 12 | Deploy(ctx context.Context, src, dst string) error 13 | } 14 | 15 | func trimNewLine(buf bytes.Buffer) bytes.Buffer { 16 | b := buf.Bytes() 17 | b = bytes.TrimRight(b, "\n") 18 | return *bytes.NewBuffer(b) 19 | } 20 | -------------------------------------------------------------------------------- /docs/user-guide-ja.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ## Preparation to use isucontinuous 4 | 5 | * isucontinuous 管理対象サーバ上に事前に以下のコマンドがインストールされている必要があります。 6 | * git 7 | * curl 8 | * unzip 9 | 10 | * isucontinuous 実行サーバにある秘密鍵に対応する公開鍵が isucontinuous 管理対象サーバ上に配置されている必要があります。 11 | 12 | * isucontinuous 実行サーバにある秘密鍵に対応する公開鍵が、isucontinuous で管理するリポジトリに対応する GitHub Repository に登録されている必要があります。 13 | 14 | ## Setup Slack Bot 15 | 16 | isucontinuous からのデプロイ通知やプロファイルデータを Slack に送信したい場合、Slack にて Bot User OAuth Token を取得する必要があります。 17 | 18 | 注意点として、Bot User は以下の Scope を持っている必要があります。 19 | 20 | * `chat:write.public` 21 | * `files:write` 22 | * `channels:read` 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v1 12 | with: 13 | fetch-depth: 1 14 | - name: Setup Go 15 | uses: actions/setup-go@v1 16 | with: 17 | go-version: 1.17 18 | - name: Run GoReleaser 19 | uses: goreleaser/goreleaser-action@v1 20 | with: 21 | version: latest 22 | args: release --clean 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # isucontinuous 2 | 3 | **⚠ CAUTION: This software is experimental. Don't take responsible for any problems.** 4 | 5 | isucontinuous is tool to support Continuous Deployment, Benchmark, and Profiling for ISUCON! 6 | 7 | ### Documents 8 | 9 | - [docs](https://github.com/ShotaKitazawa/isucontinuous/tree/main/docs/index.md) 10 | 11 | ### References 12 | 13 | - [ISUCON で使えるツールを作った](https://speakerdeck.com/shotakitazawa/isucon-teshi-eruturuwozuo-tuta) - Japanese 14 | 15 | ### Licence 16 | 17 | [MIT](https://github.com/ShotaKitazawa/isucontinuous/tree/main/LICENCE) 18 | 19 | ### Note 20 | 21 | 「ISUCON」は、LINEヤフー株式会社の商標または登録商標です。 [link](https://isucon.net) 22 | -------------------------------------------------------------------------------- /cmd/setup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ShotaKitazawa/isucontinuous/pkg/cmd" 7 | ) 8 | 9 | // setupCmd represents the setup command 10 | var setupCmd = &cobra.Command{ 11 | Use: "setup", 12 | Short: "Install some softwares", 13 | PreRunE: func(cmd *cobra.Command, args []string) error { 14 | return checkRequiredFlags(cmd.Flags()) 15 | }, 16 | RunE: func(c *cobra.Command, args []string) error { 17 | executed = true 18 | conf := cmd.ConfigSetup{ 19 | ConfigCommon: cmd.ConfigCommon{ 20 | LogLevel: logLevel, 21 | LogFilename: logfile, 22 | LocalRepoPath: localRepo, 23 | }, 24 | } 25 | return cmd.RunSetup(conf) 26 | }, 27 | } 28 | 29 | func init() { 30 | rootCmd.AddCommand(setupCmd) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/import.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ShotaKitazawa/isucontinuous/pkg/cmd" 7 | ) 8 | 9 | // importCmd represents the import command 10 | var importCmd = &cobra.Command{ 11 | Use: "import", 12 | Short: "Import some files from hosts[].deploy.files[].target", 13 | PreRunE: func(cmd *cobra.Command, args []string) error { 14 | return checkRequiredFlags(cmd.Flags()) 15 | }, 16 | RunE: func(c *cobra.Command, args []string) error { 17 | executed = true 18 | conf := cmd.ConfigImport{ 19 | ConfigCommon: cmd.ConfigCommon{ 20 | LogLevel: logLevel, 21 | LogFilename: logfile, 22 | LocalRepoPath: localRepo, 23 | }, 24 | } 25 | return cmd.RunImport(conf) 26 | }, 27 | } 28 | 29 | func init() { 30 | rootCmd.AddCommand(importCmd) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/profiling.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ShotaKitazawa/isucontinuous/pkg/cmd" 7 | ) 8 | 9 | // profilingCmd represents the profiling command 10 | var profilingCmd = &cobra.Command{ 11 | Use: "profiling", 12 | Short: "Execute profiling command and wait to finish synchronously.", 13 | PreRunE: func(cmd *cobra.Command, args []string) error { 14 | return checkRequiredFlags(cmd.Flags()) 15 | }, 16 | RunE: func(c *cobra.Command, args []string) error { 17 | executed = true 18 | conf := cmd.ConfigProfiling{ 19 | ConfigCommon: cmd.ConfigCommon{ 20 | LogLevel: logLevel, 21 | LogFilename: logfile, 22 | LocalRepoPath: localRepo, 23 | }, 24 | } 25 | return cmd.RunProfiling(conf) 26 | }, 27 | } 28 | 29 | func init() { 30 | rootCmd.AddCommand(profilingCmd) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/usecases/install/docker.go: -------------------------------------------------------------------------------- 1 | package install 2 | 3 | import ( 4 | "context" 5 | 6 | "go.uber.org/zap" 7 | 8 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 9 | ) 10 | 11 | func (i *Installer) Docker(ctx context.Context) error { 12 | i.log.Info("### install Docker ###", zap.String("host", i.shell.Host())) 13 | 14 | // ealry return if Docker has already installed 15 | if stdout, _, _ := i.shell.Exec(ctx, "", `which -a docker`); len(stdout.Bytes()) != 0 { 16 | i.log.Info("... Docker has already been installed", zap.String("host", i.shell.Host())) 17 | return nil 18 | } 19 | 20 | stdout, stderr, err := i.shell.Exec(ctx, "", `curl -fsSL https://get.docker.com/ | sh`) 21 | if err != nil { 22 | return myerrors.NewErrorCommandExecutionFailed(stderr) 23 | } 24 | i.log.Debug(stdout.String(), zap.String("host", i.shell.Host())) 25 | 26 | i.log.Info("... installed Docker!", zap.String("host", i.shell.Host())) 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /cmd/push.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/ShotaKitazawa/isucontinuous/pkg/cmd" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | // pushCmd represents the push command 9 | var pushCmd = &cobra.Command{ 10 | Use: "push", 11 | Short: "Push local-repo to origin/${MAIN_BRANCH}", 12 | PreRunE: func(cmd *cobra.Command, args []string) error { 13 | return checkRequiredFlags(cmd.Flags()) 14 | }, 15 | RunE: func(c *cobra.Command, args []string) error { 16 | executed = true 17 | conf := cmd.ConfigPush{ 18 | ConfigCommon: cmd.ConfigCommon{ 19 | LogLevel: logLevel, 20 | LogFilename: logfile, 21 | LocalRepoPath: localRepo, 22 | }, 23 | GitBranch: pushGitBranch, 24 | } 25 | return cmd.RunPush(conf) 26 | }, 27 | } 28 | 29 | var ( 30 | pushGitBranch string 31 | ) 32 | 33 | func init() { 34 | rootCmd.AddCommand(pushCmd) 35 | pushCmd.PersistentFlags().StringVarP(&pushGitBranch, "branch", "b", "master", 36 | "branch-name to push to Git remote-repo") 37 | } 38 | -------------------------------------------------------------------------------- /cmd/sync.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ShotaKitazawa/isucontinuous/pkg/cmd" 7 | ) 8 | 9 | // syncCmd represents the sync command 10 | var syncCmd = &cobra.Command{ 11 | Use: "sync", 12 | Short: "synchronize local-repo with remote-repo", 13 | PreRunE: func(cmd *cobra.Command, args []string) error { 14 | return checkRequiredFlags(cmd.Flags()) 15 | }, 16 | RunE: func(c *cobra.Command, args []string) error { 17 | executed = true 18 | conf := cmd.ConfigSync{ 19 | ConfigCommon: cmd.ConfigCommon{ 20 | LogLevel: logLevel, 21 | LogFilename: logfile, 22 | LocalRepoPath: localRepo, 23 | }, 24 | GitBranch: syncGitBranch, 25 | } 26 | return cmd.RunSync(conf) 27 | }, 28 | } 29 | 30 | var ( 31 | syncGitBranch string 32 | ) 33 | 34 | func init() { 35 | rootCmd.AddCommand(syncCmd) 36 | syncCmd.PersistentFlags().StringVarP(&syncGitBranch, "branch", "b", "master", 37 | "branch-name to push to Git remote-repo") 38 | } 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ShotaKitazawa/isucontinuous 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/cheggaaa/pb v1.0.29 7 | github.com/golang/mock v1.6.0 8 | github.com/pkg/sftp v1.13.4 9 | github.com/slack-go/slack v0.15.0 10 | github.com/spf13/cobra v1.4.0 11 | github.com/spf13/pflag v1.0.5 12 | go.uber.org/zap v1.21.0 13 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b 14 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c 15 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b 16 | k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 17 | ) 18 | 19 | require ( 20 | github.com/benbjohnson/clock v1.1.0 // indirect 21 | github.com/gorilla/websocket v1.4.2 // indirect 22 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 23 | github.com/kr/fs v0.1.0 // indirect 24 | github.com/mattn/go-runewidth v0.0.4 // indirect 25 | github.com/pkg/errors v0.9.1 // indirect 26 | go.uber.org/atomic v1.7.0 // indirect 27 | go.uber.org/multierr v1.6.0 // indirect 28 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /pkg/usecases/install/install.go: -------------------------------------------------------------------------------- 1 | package install 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "k8s.io/utils/exec" 6 | 7 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 8 | "github.com/ShotaKitazawa/isucontinuous/pkg/shell" 9 | ) 10 | 11 | type Installer struct { 12 | log zap.Logger 13 | shell shell.Iface 14 | } 15 | 16 | type NewInstallersFunc func(logger *zap.Logger, hosts []config.Host) (map[string]*Installer, error) 17 | 18 | func NewInstallers(logger *zap.Logger, hosts []config.Host) (map[string]*Installer, error) { 19 | installers := make(map[string]*Installer) 20 | var err error 21 | for _, host := range hosts { 22 | var s shell.Iface 23 | if host.IsLocal() { 24 | s = shell.NewLocalClient(exec.New()) 25 | } else { 26 | s, err = shell.NewSshClient(host.Host, host.Port, host.User, host.Password, host.Key) 27 | if err != nil { 28 | return nil, err 29 | } 30 | } 31 | installers[host.Host] = new(logger, s) 32 | } 33 | return installers, nil 34 | } 35 | 36 | func new(l *zap.Logger, s shell.Iface) *Installer { 37 | return &Installer{*l, s} 38 | } 39 | -------------------------------------------------------------------------------- /pkg/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "text/template" 9 | ) 10 | 11 | type Templator struct { 12 | Git Git 13 | Env map[string]string 14 | } 15 | 16 | type Git struct { 17 | Revision string 18 | } 19 | 20 | func New(gitRevision string) *Templator { 21 | data := os.Environ() 22 | envMap := make(map[string]string) 23 | for _, val := range data { 24 | splits := strings.SplitN(val, "=", 2) 25 | key := splits[0] 26 | value := splits[1] 27 | envMap[key] = value 28 | } 29 | return &Templator{ 30 | Git: Git{strings.ReplaceAll(gitRevision, "/", "_")}, 31 | Env: envMap, 32 | } 33 | } 34 | 35 | func (t Templator) Exec(text string) (string, error) { 36 | if text == "" { 37 | return "", nil 38 | } 39 | engine := template.New("Templating").Option("missingkey=error") 40 | tmpl, err := engine.Parse(text) 41 | if err != nil { 42 | return "", fmt.Errorf("Error to parse template: %w", err) 43 | } 44 | val := bytes.Buffer{} 45 | if err := tmpl.Execute(&val, t); err != nil { 46 | return "", fmt.Errorf("Error to parse template: %w", err) 47 | } 48 | return val.String(), nil 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ShotaKitazawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/afterbench.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ShotaKitazawa/isucontinuous/pkg/cmd" 7 | ) 8 | 9 | // afterbenchCmd represents the afterbench command 10 | var afterbenchCmd = &cobra.Command{ 11 | Use: "afterbench", 12 | Short: "Collect and parse profile data & Send to Slack", 13 | PreRunE: func(cmd *cobra.Command, args []string) error { 14 | return checkRequiredFlags(cmd.Flags()) 15 | }, 16 | RunE: func(c *cobra.Command, args []string) error { 17 | executed = true 18 | conf := cmd.ConfigAfterBench{ 19 | ConfigCommon: cmd.ConfigCommon{ 20 | LogLevel: logLevel, 21 | LogFilename: logfile, 22 | LocalRepoPath: localRepo, 23 | }, 24 | SlackToken: deploySlackToken, 25 | } 26 | return cmd.RunAfterBench(conf) 27 | }, 28 | } 29 | 30 | var ( 31 | afterbenchSlackToken string 32 | ) 33 | 34 | func init() { 35 | rootCmd.AddCommand(afterbenchCmd) 36 | afterbenchCmd.PersistentFlags().StringVarP(&afterbenchSlackToken, "slack-token", "t", getenvDefault("SLACK_TOKEN", ""), 37 | "slack token of workspace where deployment notification will be sent") 38 | setRequired(afterbenchCmd, "slack-token") 39 | } 40 | -------------------------------------------------------------------------------- /pkg/cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | 8 | "go.uber.org/zap" 9 | "k8s.io/utils/exec" 10 | 11 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 12 | "github.com/ShotaKitazawa/isucontinuous/pkg/localrepo" 13 | ) 14 | 15 | type ConfigInit struct { 16 | ConfigCommon 17 | GitUsername string 18 | GitEmail string 19 | GitRemoteUrl string 20 | } 21 | 22 | func RunInit(conf ConfigInit) error { 23 | ctx := context.Background() 24 | logger, err := newLogger(conf.LogLevel, conf.LogFilename) 25 | if err != nil { 26 | return err 27 | } 28 | return runInit(conf, ctx, logger) 29 | } 30 | 31 | func runInit(conf ConfigInit, ctx context.Context, logger *zap.Logger) error { 32 | logger.Info("start init") 33 | defer func() { logger.Info("finish init") }() 34 | // Create local-repo directory if does not existed 35 | if _, err := os.Stat(conf.LocalRepoPath); err == nil { 36 | return myerrors.NewErrorFileAlreadyExisted(conf.LocalRepoPath) 37 | } 38 | if err := os.Mkdir(filepath.Clean(conf.LocalRepoPath), 0755); err != nil { 39 | return err 40 | } 41 | // Initialize local-repo 42 | _, err := localrepo.InitLocalRepo(logger, exec.New(), conf.LocalRepoPath, conf.GitUsername, conf.GitEmail, conf.GitRemoteUrl) 43 | if err != nil { 44 | return err 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/usecases/install/alp.go: -------------------------------------------------------------------------------- 1 | package install 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.uber.org/zap" 8 | 9 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 10 | ) 11 | 12 | func (i *Installer) Alp(ctx context.Context, version string) error { 13 | i.log.Info("### install alp ###", zap.String("host", i.shell.Host())) 14 | 15 | // ealry return if alp has already installed 16 | if stdout, _, _ := i.shell.Exec(ctx, "", `which -a alp`); len(stdout.Bytes()) != 0 { 17 | i.log.Info("... alp has already been installed", zap.String("host", i.shell.Host())) 18 | return nil 19 | } 20 | 21 | if version == "latest" { 22 | // TODO 23 | // get release 24 | // get latest tag 25 | } 26 | command := fmt.Sprintf( 27 | `curl -sL https://github.com/tkuchiki/alp/releases/download/%s/alp_linux_amd64.zip -o /tmp/alp.zip`, 28 | version) 29 | if _, stderr, err := i.shell.Exec(ctx, "", command); err != nil { 30 | return myerrors.NewErrorCommandExecutionFailed(stderr) 31 | } 32 | i.log.Debug("downloaded to /tmp/alp.zip", zap.String("host", i.shell.Host())) 33 | 34 | if _, stderr, err := i.shell.Exec(ctx, "", `unzip /tmp/alp.zip -d /usr/local/bin/`); err != nil { 35 | return myerrors.NewErrorCommandExecutionFailed(stderr) 36 | } 37 | 38 | i.log.Info("... installed alp!", zap.String("host", i.shell.Host())) 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/usecases/profiling/profiling.go: -------------------------------------------------------------------------------- 1 | package profiling 2 | 3 | import ( 4 | "context" 5 | 6 | "go.uber.org/zap" 7 | "k8s.io/utils/exec" 8 | 9 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 10 | myerrros "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 11 | "github.com/ShotaKitazawa/isucontinuous/pkg/shell" 12 | "github.com/ShotaKitazawa/isucontinuous/pkg/template" 13 | ) 14 | 15 | type Profilinger struct { 16 | log *zap.Logger 17 | shell shell.Iface 18 | template *template.Templator 19 | } 20 | 21 | type NewFunc func(*zap.Logger, *template.Templator, config.Host) (*Profilinger, error) 22 | 23 | func New(logger *zap.Logger, templator *template.Templator, host config.Host) (*Profilinger, error) { 24 | var err error 25 | var s shell.Iface 26 | if host.IsLocal() { 27 | s = shell.NewLocalClient(exec.New()) 28 | } else { 29 | s, err = shell.NewSshClient(host.Host, host.Port, host.User, host.Password, host.Key) 30 | if err != nil { 31 | return nil, err 32 | } 33 | } 34 | return &Profilinger{logger, s, templator}, nil 35 | } 36 | 37 | func (p Profilinger) Profiling(ctx context.Context, command string) error { 38 | command, err := p.template.Exec(command) 39 | if err != nil { 40 | return err 41 | } 42 | if _, stderr, err := p.shell.Exec(ctx, "", command); err != nil { 43 | return myerrros.NewErrorCommandExecutionFailed(stderr) 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/ShotaKitazawa/isucontinuous/pkg/cmd" 7 | ) 8 | 9 | // initCmd represents the init command 10 | var initCmd = &cobra.Command{ 11 | Use: "init", 12 | Short: "Initialize local repository", 13 | PreRunE: func(cmd *cobra.Command, args []string) error { 14 | return checkRequiredFlags(cmd.Flags()) 15 | }, 16 | RunE: func(c *cobra.Command, args []string) error { 17 | executed = true 18 | conf := cmd.ConfigInit{ 19 | ConfigCommon: cmd.ConfigCommon{ 20 | LogLevel: logLevel, 21 | LogFilename: logfile, 22 | LocalRepoPath: localRepo, 23 | }, 24 | GitUsername: gitUsername, 25 | GitEmail: gitEmail, 26 | GitRemoteUrl: gitRemoteUrl, 27 | } 28 | return cmd.RunInit(conf) 29 | }, 30 | } 31 | 32 | var ( 33 | gitUsername string 34 | gitEmail string 35 | gitRemoteUrl string 36 | ) 37 | 38 | func init() { 39 | rootCmd.AddCommand(initCmd) 40 | 41 | initCmd.PersistentFlags().StringVarP(&gitUsername, "username", "u", "isucontinuous", 42 | "username of GitHub Account") 43 | initCmd.PersistentFlags().StringVarP(&gitEmail, "email", "e", "isucontinuous@users.noreply.github.com", 44 | "email of GitHub Account") 45 | initCmd.PersistentFlags().StringVarP(&gitRemoteUrl, "remote-url", "r", getenvDefault("REMOTE_URL", ""), 46 | "URL of remote repository (requirement)") 47 | setRequired(initCmd, "remote-url") 48 | } 49 | -------------------------------------------------------------------------------- /pkg/shell/local.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | "k8s.io/utils/exec" 11 | ) 12 | 13 | type LocalClient struct { 14 | exec exec.Interface 15 | } 16 | 17 | func NewLocalClient(e exec.Interface) *LocalClient { 18 | return &LocalClient{e} 19 | } 20 | 21 | func (c *LocalClient) Host() string { 22 | return "localhost" 23 | } 24 | 25 | func (c *LocalClient) Exec(ctx context.Context, basedir string, command string) (bytes.Buffer, bytes.Buffer, error) { 26 | stdout := bytes.Buffer{} 27 | stderr := bytes.Buffer{} 28 | if command == "" { // early return 29 | return stdout, stderr, nil 30 | } 31 | cc := c.exec.CommandContext(ctx, "sh", "-c", command) 32 | if basedir != "" { 33 | cc.SetDir(basedir) 34 | } 35 | cc.SetStdout(&stdout) 36 | cc.SetStderr(&stderr) 37 | err := cc.Run() 38 | return trimNewLine(stdout), trimNewLine(stderr), err 39 | } 40 | 41 | func (c *LocalClient) Execf(ctx context.Context, basedir string, command string, a ...interface{}) (bytes.Buffer, bytes.Buffer, error) { 42 | return c.Exec(ctx, basedir, fmt.Sprintf(command, a...)) 43 | } 44 | 45 | func (c *LocalClient) Deploy(ctx context.Context, src, dst string) error { 46 | s, err := os.Open(src) 47 | if err != nil { 48 | return err 49 | } 50 | d, err := os.Create(dst) 51 | if err != nil { 52 | return err 53 | } 54 | defer d.Close() 55 | if _, err := io.Copy(d, s); err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /pkg/cmd/sync.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "go.uber.org/zap" 8 | "k8s.io/utils/exec" 9 | 10 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 11 | "github.com/ShotaKitazawa/isucontinuous/pkg/localrepo" 12 | ) 13 | 14 | type ConfigSync struct { 15 | ConfigCommon 16 | GitBranch string 17 | } 18 | 19 | func RunSync(conf ConfigSync) error { 20 | ctx := context.Background() 21 | logger, err := newLogger(conf.LogLevel, conf.LogFilename) 22 | if err != nil { 23 | return err 24 | } 25 | // Attach local-repo 26 | repo, err := localrepo.AttachLocalRepo(logger, exec.New(), conf.LocalRepoPath) 27 | if err != nil { 28 | return err 29 | } 30 | return runSync(conf, ctx, logger, repo) 31 | } 32 | 33 | func runSync( 34 | conf ConfigSync, ctx context.Context, logger *zap.Logger, 35 | repo localrepo.LocalRepoIface, 36 | ) error { 37 | logger.Info("start sync") 38 | defer func() { logger.Info("finish sync") }() 39 | // if current branch is detached, exec `git reset --hard`` 40 | if _, err := repo.CurrentBranch(ctx); err != nil && errors.As(err, &myerrors.GitBranchIsDetached{}) { 41 | if err := repo.Reset(ctx); err != nil { 42 | return err 43 | } 44 | } else if err != nil { 45 | return err 46 | } 47 | // Fetch remote-repo & switch to gitBranch 48 | if err := repo.Fetch(ctx); err != nil { 49 | return err 50 | } 51 | if err := repo.SwitchAndMerge(ctx, conf.GitBranch); err != nil { 52 | return err 53 | } 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /cmd/deploy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/ShotaKitazawa/isucontinuous/pkg/cmd" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | // deployCmd represents the deploy command 9 | var deployCmd = &cobra.Command{ 10 | Use: "deploy", 11 | Short: "Deploy files from specified revision", 12 | PreRunE: func(cmd *cobra.Command, args []string) error { 13 | return checkRequiredFlags(cmd.Flags()) 14 | }, 15 | RunE: func(c *cobra.Command, args []string) error { 16 | executed = true 17 | conf := cmd.ConfigDeploy{ 18 | ConfigCommon: cmd.ConfigCommon{ 19 | LogLevel: logLevel, 20 | LogFilename: logfile, 21 | LocalRepoPath: localRepo, 22 | }, 23 | GitRevision: deployGitRevision, 24 | Force: deployForce, 25 | SlackToken: deploySlackToken, 26 | } 27 | return cmd.RunDeploy(conf) 28 | }, 29 | } 30 | 31 | var ( 32 | deployGitRevision string 33 | deployForce bool 34 | deploySlackToken string 35 | ) 36 | 37 | func init() { 38 | rootCmd.AddCommand(deployCmd) 39 | deployCmd.PersistentFlags().StringVarP(&deployGitRevision, "revision", "b", "master", 40 | "branch-name, tag-name, or commit-hash of deployed from Git remote-repo") 41 | deployCmd.PersistentFlags().BoolVarP(&deployForce, "force", "f", false, 42 | "force deploy") 43 | deployCmd.PersistentFlags().StringVarP(&deploySlackToken, "slack-token", "t", getenvDefault("slack-token", ""), 44 | "slack token of workspace where deployment notification will be sent") 45 | setRequired(deployCmd, "slack-token") 46 | } 47 | -------------------------------------------------------------------------------- /pkg/usecases/install/docker_netdata.go: -------------------------------------------------------------------------------- 1 | package install 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.uber.org/zap" 8 | 9 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 10 | ) 11 | 12 | const ( 13 | containerName = "netdata" 14 | ) 15 | 16 | func (i *Installer) Netdata(ctx context.Context, version string, publicPort int) error { 17 | i.log.Info("### install Netdata ###", zap.String("host", i.shell.Host())) 18 | 19 | // ealry return if netdata has already installed 20 | command := fmt.Sprintf( 21 | `docker container ps -f name=%s --format {{.ID}}`, 22 | containerName) 23 | if stdout, _, _ := i.shell.Exec(ctx, "", command); len(stdout.Bytes()) != 0 { 24 | i.log.Info("... Netdata has already been installed", zap.String("host", i.shell.Host())) 25 | return nil 26 | } 27 | 28 | command = fmt.Sprintf(` 29 | docker run -itd -p %d:19999 \ 30 | -v netdataconfig:/etc/netdata \ 31 | -v netdatalib:/var/lib/netdata \ 32 | -v netdatacache:/var/cache/netdata \ 33 | -v /etc/passwd:/host/etc/passwd:ro \ 34 | -v /etc/group:/host/etc/group:ro \ 35 | -v /proc:/host/proc:ro \ 36 | -v /sys:/host/sys:ro \ 37 | -v /etc/os-release:/host/etc/os-release:ro \ 38 | --restart unless-stopped \ 39 | --cap-add SYS_PTRACE \ 40 | --security-opt apparmor=unconfined \ 41 | --name=%s \ 42 | netdata/netdata:%s`, publicPort, containerName, version) 43 | stdout, stderr, err := i.shell.Exec(ctx, "", command) 44 | if err != nil { 45 | return myerrors.NewErrorCommandExecutionFailed(stderr) 46 | } 47 | i.log.Debug(stdout.String(), zap.String("host", i.shell.Host())) 48 | 49 | i.log.Info("... installed Netdata!", zap.String("host", i.shell.Host())) 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /pkg/slack/slack.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/slack-go/slack" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type ClientIface interface { 12 | SendText(ctx context.Context, channel, text string) error 13 | SendFileContent(ctx context.Context, channel, filename, content, title string) error 14 | } 15 | 16 | type Client struct { 17 | log *zap.Logger 18 | client *slack.Client 19 | defaultChannelId string 20 | } 21 | 22 | func NewClient(logger *zap.Logger, token, channel string) ClientIface { 23 | api := slack.New(token) 24 | if _, err := api.AuthTest(); err != nil { 25 | // set fake client 26 | logger.Info(fmt.Sprintf("Slack client is not authorized. set fake client (nothing to notify): %v", err)) 27 | return &FakeClient{logger} 28 | } 29 | return &Client{logger, api, channel} 30 | } 31 | 32 | func (c Client) SendText(ctx context.Context, channel, text string) error { 33 | if channel == "" { 34 | channel = c.defaultChannelId 35 | } 36 | if _, _, err := c.client.PostMessageContext(ctx, channel, slack.MsgOptionText(text, true)); err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | func (c Client) SendFileContent(ctx context.Context, channel, filename, content, title string) error { 43 | if channel == "" { 44 | channel = c.defaultChannelId 45 | } 46 | params := slack.UploadFileV2Parameters{ 47 | Title: title, 48 | Filename: filename, 49 | Content: content, 50 | FileSize: len(content), 51 | Channel: channel, 52 | } 53 | file, err := c.client.UploadFileV2(params) 54 | if err != nil { 55 | return err 56 | } 57 | c.log.Debug(fmt.Sprintf("sent file %s to Slack", file.Title)) 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /pkg/config/skelton.yaml: -------------------------------------------------------------------------------- 1 | setup: 2 | docker: 3 | netdata: 4 | version: latest 5 | public_port: 19999 6 | alp: 7 | version: v1.0.8 8 | slack: 9 | default_channel_id: "" 10 | hosts: 11 | - host: localhost 12 | user: &user root 13 | key: &key /root/.ssh/id_rsa 14 | deploy: &deploy 15 | slack_channel_id: "" 16 | pre_command: >- 17 | rm -f "{{.Env.ACCESSLOG_PATH}}"; 18 | rm -f "{{.Env.SLOWLOG_PATH}}"; 19 | post_command: >- 20 | sudo systemctl restart mysql nginx && sudo systemctl restart isucondition.go.service 21 | targets: 22 | - src: nginx 23 | target: /etc/nginx 24 | - src: mysql 25 | target: /etc/mysql 26 | - src: go 27 | target: /home/isucon/webapp/go 28 | compile: /home/isucon/local/go/bin/go build . 29 | profiling: &profiling 30 | command: >- 31 | PPROF_TMPDIR=/pprof/{{.Git.Revision}} /home/isucon/local/go/bin/go tool pprof /home/isucon/webapp/go/isucondition http://localhost:6060/debug/pprof/profile?seconds=90 32 | #command: >- 33 | # PPROF_TMPDIR=/pprof/{{.Git.Revision}} /home/isucon/local/go/bin/go tool pprof /home/isucon/webapp/go/isucondition http://localhost:6060/debug/fgprof?seconds=90 34 | after_bench: &after_bench 35 | slack_channel_id: "" 36 | target: /profile/{{.Git.Revision}}/ 37 | command: >- 38 | mkdir -p /profile/{{.Git.Revision}}; 39 | export PPROF_FILENAME=$(ls /pprof/{{.Git.Revision}}/ -tr | tail -n1); 40 | if [ -f "/pprof/{{.Git.Revision}}/$PPROF_FILENAME" ]; then 41 | /home/isucon/local/go/bin/go tool pprof -top -cum /pprof/{{.Git.Revision}}/$PPROF_FILENAME > /profile/{{.Git.Revision}}/pprof_top_cum; 42 | fi; 43 | cat "{{.Env.ACCESSLOG_PATH}}" | alp ltsv -r --sort sum > /profile/{{.Git.Revision}}/accesslog; 44 | cat "{{.Env.SLOWLOG_PATH}}" | docker run -i --rm matsuu/pt-query-digest > /profile/{{.Git.Revision}}/slowlog; 45 | -------------------------------------------------------------------------------- /docs/concept-ja.md: -------------------------------------------------------------------------------- 1 | # Concept 2 | 3 | isucontinuous は複数台のサーバを用いた複数人での開発を支援するツールです。 4 | isucontinuous は複数台のサーバ上にあるソースコードを取り込み、継続的なデプロイメント及びプロファイリングを実現します。 5 | 6 | isucontinuous には以下のそれぞれのフェーズを意識したコマンド体系が用意されています。 7 | 8 | * `init`: Git ローカルリポジトリを初期化する 9 | 10 | 11 | 12 | * `setup`: 開発に必要なソフトウェアをインストールする 13 | 14 | 15 | 16 | * `import`: サーバ上の任意のファイルを取得し Git ローカルリポジトリの管理下にコピーする 17 | 18 | 19 | 20 | * `push`: Git ローカルリポジトリの更新を Git リモートリポジトリにプッシュする 21 | 22 | 23 | 24 | * `sync`: Git リモートリポジトリの内容を Git ローカルリポジトリに反映する 25 | 26 | 27 | 28 | * `deploy`: 任意のリビジョンにおける Git リモートリポジトリ管理下のファイルをサーバ上にデプロイする 29 | 30 | 31 | 32 | * `profile`: 各サーバにて任意のコマンドを実行する 33 | 34 | 35 | 36 | * `afterbench`: 各サーバ上にて任意のコマンドを実行しプロファイルデータを生成後、指定したディレクトリ以下のファイルを Slack に POST する 37 | 38 | 39 | 40 | ## Usecase 41 | 42 | ### 1. 初期セットアップ 43 | 44 | 1. `init` : ローカルリポジトリの新規作成 45 | 1. isucontinuous.yaml の編集 46 | 1. `setup` : 各種開発用ソフトウェアのインストール 47 | 1. `import` : 各種設定ファイルやソースコードを Git で管理 48 | 1. `push` : GitHub に push 49 | 50 | ### 2. 開発・デプロイ 51 | 52 | 1. GitHub に push したリポジトリを元に各メンバーが各ブランチで作業 53 | 1. `deploy` : 特定リビジョンの各ファイルを各サーバにデプロイ 54 | 1. `profiling` : 各サーバにてプロファイリング用コマンドを実行 55 | 1. `afterbench` : プロファイルデータを収集し Slack に送信 56 | 57 | ### 3. 開発中サーバ上の新たなファイルを Git で管理 58 | 59 | 1. redis 等、サーバーに新たなミドルウェアがインストールされる 60 | 1. isucontinuous.yaml に新たな import/deploy 対象を追記 61 | 1. `sync` : ローカルリポジトリを remotes/origin/master と同期 62 | 1. `import` : 各種設定ファイルやソースコードを Git で管理 63 | 1. `push` : GitHub に push 64 | 65 | -------------------------------------------------------------------------------- /pkg/cmd/push.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.uber.org/zap" 8 | "k8s.io/utils/exec" 9 | 10 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 11 | "github.com/ShotaKitazawa/isucontinuous/pkg/localrepo" 12 | ) 13 | 14 | type ConfigPush struct { 15 | ConfigCommon 16 | GitBranch string 17 | } 18 | 19 | func RunPush(conf ConfigPush) error { 20 | ctx := context.Background() 21 | logger, err := newLogger(conf.LogLevel, conf.LogFilename) 22 | if err != nil { 23 | return err 24 | } 25 | // Attach local-repo 26 | repo, err := localrepo.AttachLocalRepo(logger, exec.New(), conf.LocalRepoPath) 27 | if err != nil { 28 | return err 29 | } 30 | return runPush(conf, ctx, logger, repo) 31 | } 32 | 33 | func runPush( 34 | conf ConfigPush, ctx context.Context, logger *zap.Logger, 35 | repo localrepo.LocalRepoIface, 36 | ) error { 37 | logger.Info("start push") 38 | defer func() { logger.Info("finish push") }() 39 | // Check currentBranch 40 | currentBranch, err := repo.CurrentBranch(ctx) 41 | if err != nil { 42 | return err 43 | } else if currentBranch != conf.GitBranch { 44 | if currentBranch == "" { 45 | currentBranch = "" 46 | } 47 | return fmt.Errorf( 48 | "current branch name is %s. Please exec `sync` command first to checkout to %s.", 49 | currentBranch, conf.GitBranch, 50 | ) 51 | } 52 | // Fetch 53 | if err := repo.Fetch(ctx); err != nil { 54 | return err 55 | } 56 | // Validate whether ${BRANCH} == remotes/origin/${BRANCH} 57 | if ok, err := repo.DiffWithRemote(ctx); err != nil { 58 | switch err.(type) { 59 | case myerrors.GitBranchIsFirstCommit: 60 | // pass 61 | default: 62 | return err 63 | } 64 | } else if !ok { 65 | return fmt.Errorf("there are differences between %s and remotes/origin/%s", conf.GitBranch, conf.GitBranch) 66 | } 67 | // Execute add, commit, and push 68 | if err := repo.Push(ctx); err != nil { 69 | return err 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/cmd/profiling.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.uber.org/zap" 8 | "k8s.io/utils/exec" 9 | 10 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 11 | "github.com/ShotaKitazawa/isucontinuous/pkg/localrepo" 12 | "github.com/ShotaKitazawa/isucontinuous/pkg/template" 13 | "github.com/ShotaKitazawa/isucontinuous/pkg/usecases/profiling" 14 | ) 15 | 16 | type ConfigProfiling struct { 17 | ConfigCommon 18 | } 19 | 20 | func RunProfiling(conf ConfigProfiling) error { 21 | ctx := context.Background() 22 | logger, err := newLogger(conf.LogLevel, conf.LogFilename) 23 | if err != nil { 24 | return err 25 | } 26 | // Attach local-repo 27 | repo, err := localrepo.AttachLocalRepo(logger, exec.New(), conf.LocalRepoPath) 28 | if err != nil { 29 | return err 30 | } 31 | // Set newProfilingersFunc 32 | 33 | return runProfiling(conf, ctx, logger, repo, profiling.New) 34 | } 35 | 36 | func runProfiling( 37 | conf ConfigProfiling, ctx context.Context, logger *zap.Logger, 38 | repo localrepo.LocalRepoIface, newProfilingersFunc profiling.NewFunc, 39 | ) error { 40 | logger.Info("start profiling") 41 | defer func() { logger.Info("finish profiling") }() 42 | // Load isucontinus.yaml 43 | isucontinuous, err := repo.LoadConf() 44 | if err != nil { 45 | return err 46 | } 47 | // Get revision 48 | gitRevision, err := repo.GetRevision(ctx) 49 | if err != nil { 50 | return fmt.Errorf("%s/.revision is not found. exec `deploy` command first", conf.LocalRepoPath) 51 | } 52 | // Profiling files to per host 53 | return perHostExec(logger, ctx, isucontinuous.Hosts, []task{{ 54 | "Profiling", 55 | func(ctx context.Context, host config.Host) error { 56 | profilinger, err := newProfilingersFunc(logger, template.New(gitRevision), host) 57 | if err != nil { 58 | return err 59 | } 60 | if err := profilinger.Profiling(ctx, host.Profiling.Command); err != nil { 61 | return err 62 | } 63 | return nil 64 | }, 65 | }}) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | /* Unkouwn */ 9 | 10 | type Unkouwn struct{} 11 | 12 | func NewErrorUnkouwn() Unkouwn { 13 | return Unkouwn{} 14 | } 15 | 16 | func (e Unkouwn) Error() string { 17 | return fmt.Sprintf("unknown error is occurred.") 18 | } 19 | 20 | /* FileAlreadyExisted */ 21 | 22 | type FileAlreadyExisted struct { 23 | path string 24 | } 25 | 26 | func NewErrorFileAlreadyExisted(path string) FileAlreadyExisted { 27 | return FileAlreadyExisted{path} 28 | } 29 | 30 | func (e FileAlreadyExisted) Error() string { 31 | return fmt.Sprintf("%s already existed.", e.path) 32 | } 33 | 34 | /* IsNotFile */ 35 | 36 | type IsNotFile struct { 37 | path string 38 | } 39 | 40 | func NewErrorIsNotFile(path string) IsNotFile { 41 | return IsNotFile{path} 42 | } 43 | 44 | func (e IsNotFile) Error() string { 45 | return fmt.Sprintf("%s is not file.", e.path) 46 | } 47 | 48 | /* IsNotDirectory */ 49 | 50 | type IsNotDirectory struct { 51 | path string 52 | } 53 | 54 | func NewErrorIsNotDirectory(path string) IsNotDirectory { 55 | return IsNotDirectory{path} 56 | } 57 | 58 | func (e IsNotDirectory) Error() string { 59 | return fmt.Sprintf("%s is not directory.", e.path) 60 | } 61 | 62 | /* CommandExecutionFailed */ 63 | 64 | type CommandExecutionFailed struct { 65 | message string 66 | } 67 | 68 | func NewErrorCommandExecutionFailed(stderr bytes.Buffer) CommandExecutionFailed { 69 | return CommandExecutionFailed{stderr.String()} 70 | } 71 | 72 | func (e CommandExecutionFailed) Error() string { 73 | return fmt.Sprintf("command execution failed.\n%v", e.message) 74 | } 75 | 76 | /* GitBranchIsDetached */ 77 | 78 | type GitBranchIsDetached struct{} 79 | 80 | func NewErrorGitBranchIsDetached() GitBranchIsDetached { 81 | return GitBranchIsDetached{} 82 | } 83 | 84 | func (e GitBranchIsDetached) Error() string { 85 | return fmt.Sprintf("current branch name is . Please exec `sync` command first to checkout.") 86 | } 87 | 88 | /* GitBranchIsFirstCommit */ 89 | 90 | type GitBranchIsFirstCommit struct{} 91 | 92 | func NewErrorGitBranchIsFirstCommit() GitBranchIsFirstCommit { 93 | return GitBranchIsFirstCommit{} 94 | } 95 | 96 | func (e GitBranchIsFirstCommit) Error() string { 97 | return fmt.Sprintf("current branch name is . Please exec `sync` command first to checkout.") 98 | } 99 | -------------------------------------------------------------------------------- /pkg/cmd/setup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "go.uber.org/zap" 7 | "k8s.io/utils/exec" 8 | 9 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 10 | "github.com/ShotaKitazawa/isucontinuous/pkg/localrepo" 11 | "github.com/ShotaKitazawa/isucontinuous/pkg/usecases/install" 12 | ) 13 | 14 | type ConfigSetup struct { 15 | ConfigCommon 16 | } 17 | 18 | func RunSetup(conf ConfigSetup) error { 19 | ctx := context.Background() 20 | logger, err := newLogger(conf.LogLevel, conf.LogFilename) 21 | if err != nil { 22 | return err 23 | } 24 | // Attach local isucon-repo 25 | repo, err := localrepo.AttachLocalRepo(logger, exec.New(), conf.LocalRepoPath) 26 | if err != nil { 27 | return err 28 | } 29 | // set installers 30 | return runSetup(conf, ctx, logger, repo, install.NewInstallers) 31 | } 32 | 33 | func runSetup( 34 | conf ConfigSetup, ctx context.Context, logger *zap.Logger, 35 | repo localrepo.LocalRepoIface, newInstallers install.NewInstallersFunc, 36 | ) error { 37 | logger.Info("start setup") 38 | defer func() { logger.Info("finish setup") }() 39 | // load isucontinuous.yaml 40 | isucontinuous, err := repo.LoadConf() 41 | if err != nil { 42 | return err 43 | } 44 | // Set installers 45 | installers, err := newInstallers(logger, isucontinuous.Hosts) 46 | if err != nil { 47 | return err 48 | } 49 | return perHostExec(logger, ctx, isucontinuous.Hosts, []task{{ 50 | "Install Docker", 51 | func(ctx context.Context, host config.Host) error { 52 | installer := installers[host.Host] 53 | // install docker 54 | if isucontinuous.IsDockerEnabled() { 55 | if err := installer.Docker(ctx); err != nil { 56 | return err 57 | } 58 | } 59 | return nil 60 | }, 61 | }, { 62 | "Install netdata", 63 | func(ctx context.Context, host config.Host) error { 64 | installer := installers[host.Host] 65 | if ok, version, publicPort := isucontinuous.IsNetdataEnabled(); isucontinuous.IsDockerEnabled() && ok { 66 | if err := installer.Netdata(ctx, version, publicPort); err != nil { 67 | return err 68 | } 69 | } 70 | return nil 71 | }, 72 | }, { 73 | "Install alp", 74 | func(ctx context.Context, host config.Host) error { 75 | installer := installers[host.Host] 76 | if ok, version := isucontinuous.IsAlpEnabled(); ok { 77 | if err := installer.Alp(ctx, version); err != nil { 78 | return err 79 | } 80 | } 81 | return nil 82 | }, 83 | }}) 84 | } 85 | -------------------------------------------------------------------------------- /pkg/cmd/common.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "sync" 8 | 9 | "go.uber.org/zap" 10 | "go.uber.org/zap/zapcore" 11 | "golang.org/x/sync/errgroup" 12 | 13 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 14 | "github.com/cheggaaa/pb" 15 | ) 16 | 17 | type ConfigCommon struct { 18 | LogLevel string 19 | LogFilename string 20 | LocalRepoPath string 21 | } 22 | 23 | func newLogger(logLevel, logfile string) (*zap.Logger, error) { 24 | // setup encorder 25 | enc := zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) 26 | // setup syncer 27 | f, err := os.OpenFile(logfile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 28 | if err != nil { 29 | return nil, err 30 | } 31 | sink := zapcore.AddSync(f) 32 | lsink := zapcore.Lock(sink) 33 | // setup log-level 34 | failedParseFlag := false 35 | level, err := zap.ParseAtomicLevel(logLevel) 36 | if err != nil || logLevel == "" { 37 | failedParseFlag = true 38 | level = zap.NewAtomicLevelAt(zapcore.Level(0)) // INFO 39 | } 40 | // new 41 | logger := zap.New(zapcore.NewCore(enc, lsink, level)) 42 | if failedParseFlag { 43 | logger.Info("failed to parse log-level: set INFO") 44 | } 45 | return logger, nil 46 | } 47 | 48 | type taskFunc func(context.Context, config.Host) error 49 | 50 | type task struct { 51 | name string 52 | f taskFunc 53 | } 54 | 55 | func perHostExec(logger *zap.Logger, ctx context.Context, hosts []config.Host, tasks []task) error { 56 | eg, ctx := errgroup.WithContext(ctx) 57 | pbs := make([]*pb.ProgressBar, len(hosts)) 58 | var mu sync.RWMutex 59 | for idx, host := range hosts { 60 | idx := idx 61 | host := host 62 | pbs[idx] = pb.New(len(tasks)).SetMaxWidth(80) 63 | eg.Go(func() error { 64 | for _, task := range tasks { 65 | mu.Lock() 66 | pbs[idx] = pbs[idx].Prefix(fmt.Sprintf("[%s] %s", host.Host, task.name)) 67 | mu.Unlock() 68 | if err := task.f(ctx, host); err != nil { 69 | logger.Error(err.Error(), zap.String("host", host.Host)) 70 | return err 71 | } 72 | mu.Lock() 73 | pbs[idx].Increment() 74 | mu.Unlock() 75 | } 76 | mu.Lock() 77 | pbs[idx].Prefix(fmt.Sprintf("[%s] %s", host.Host, "Done!")) 78 | mu.Unlock() 79 | return nil 80 | }) 81 | } 82 | mu.RLock() 83 | pool, err := pb.StartPool(pbs...) 84 | mu.RUnlock() 85 | if err != nil { 86 | return err 87 | } 88 | defer func() { 89 | _ = pool.Stop() 90 | }() 91 | if err := eg.Wait(); err != nil { 92 | return err 93 | } 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/spf13/cobra" 13 | "github.com/spf13/pflag" 14 | ) 15 | 16 | // rootCmd represents the base command when called without any subcommands 17 | var rootCmd = &cobra.Command{ 18 | Use: "isucontinuous", 19 | SilenceUsage: true, 20 | Short: "isucontinuous is tool to support Continuous Deployment, Benchmark, and Profiling!", 21 | } 22 | 23 | var executed bool 24 | 25 | func Execute() { 26 | err := rootCmd.Execute() 27 | if executed { 28 | fmt.Printf("=> output log to %s\n", logfile) 29 | } 30 | if err != nil { 31 | os.Exit(1) 32 | } 33 | } 34 | 35 | var ( 36 | logLevel string 37 | logfile string 38 | localRepo string 39 | ) 40 | 41 | func init() { 42 | rootCmd.PersistentFlags().AddGoFlagSet(flag.CommandLine) 43 | rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "v", getenvDefault("LOG_LEVEL", "INFO"), 44 | "log-level (DEBUG, INFO, or ERROR)") 45 | defaultLogfile := filepath.Join(os.Getenv("HOME"), "isucontinuous.log") 46 | rootCmd.PersistentFlags().StringVarP(&logfile, "logfile", "o", getenvDefault("LOGFILE", defaultLogfile), 47 | "path of log file") 48 | defaultLocalRepo := filepath.Join(os.Getenv("HOME"), "local-repo") 49 | rootCmd.PersistentFlags().StringVarP(&localRepo, "local-repo", "l", getenvDefault("LOCAL_REPO", defaultLocalRepo), 50 | "local repository's path managed by isucontinuous") 51 | } 52 | 53 | // utils 54 | 55 | func getenvDefault(flagName, defaultV string) string { 56 | key := strings.ToUpper(strings.ReplaceAll(flagName, "-", "_")) 57 | result := os.Getenv(key) 58 | if result == "" { 59 | return defaultV 60 | } 61 | return result 62 | } 63 | 64 | const requiredFlagAnnotation = "isucontinuous/required" 65 | 66 | func setRequired(cmd *cobra.Command, flagNames ...string) { 67 | for _, flagName := range flagNames { 68 | if err := cmd.PersistentFlags().SetAnnotation(flagName, requiredFlagAnnotation, []string{"true"}); err != nil { 69 | log.Fatal(err) 70 | } 71 | } 72 | } 73 | 74 | func checkRequiredFlags(flags *pflag.FlagSet) error { 75 | requiredError := false 76 | flagName := "" 77 | flags.VisitAll(func(flag *pflag.Flag) { 78 | requiredAnnotation := flag.Annotations[requiredFlagAnnotation] 79 | if len(requiredAnnotation) == 0 { 80 | return 81 | } 82 | flagRequired := requiredAnnotation[0] == "true" 83 | if flagRequired && !flag.Changed && getenvDefault(flag.Name, "") == "" { 84 | requiredError = true 85 | flagName = flag.Name 86 | } 87 | }) 88 | if requiredError { 89 | return errors.New("Required flag `" + flagName + "` has not been set") 90 | } 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /pkg/cmd/import.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | 8 | "go.uber.org/zap" 9 | "k8s.io/utils/exec" 10 | 11 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 12 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 13 | "github.com/ShotaKitazawa/isucontinuous/pkg/localrepo" 14 | "github.com/ShotaKitazawa/isucontinuous/pkg/usecases/imports" 15 | ) 16 | 17 | type ConfigImport struct { 18 | ConfigCommon 19 | } 20 | 21 | func RunImport(conf ConfigImport) error { 22 | ctx := context.Background() 23 | logger, err := newLogger(conf.LogLevel, conf.LogFilename) 24 | if err != nil { 25 | return err 26 | } 27 | // Attach local isucon-repo 28 | repo, err := localrepo.AttachLocalRepo(logger, exec.New(), conf.LocalRepoPath) 29 | if err != nil { 30 | return err 31 | } 32 | return runImport(conf, ctx, logger, repo, imports.NewImporters) 33 | } 34 | 35 | func runImport( 36 | conf ConfigImport, ctx context.Context, logger *zap.Logger, 37 | repo localrepo.LocalRepoIface, newImporters imports.NewImportersFunc, 38 | ) error { 39 | logger.Info("start import") 40 | defer func() { logger.Info("finish import") }() 41 | // Check currentBranch 42 | if _, err := repo.CurrentBranch(ctx); err != nil { 43 | return err 44 | } 45 | // load isucontinuous.yaml 46 | isucontinuous, err := repo.LoadConf() 47 | if err != nil { 48 | return err 49 | } 50 | // Set importers 51 | importers, err := newImporters(logger, isucontinuous.Hosts) 52 | if err != nil { 53 | return err 54 | } 55 | // Import files from per host 56 | return perHostExec(logger, ctx, isucontinuous.Hosts, []task{{ 57 | "Import", 58 | func(ctx context.Context, host config.Host) error { 59 | importer := importers[host.Host] 60 | for _, target := range host.ListTarget() { 61 | switch importer.FileType(ctx, target.Target) { 62 | case imports.IsNotFound: 63 | logger.Info(fmt.Sprintf("%s is not found: skip", target.Target), zap.String("host", host.Host)) 64 | continue 65 | case imports.IsFile: 66 | content, mode, err := importer.GetFileContent(ctx, target.Target) 67 | if err != nil { 68 | return err 69 | } 70 | if err := repo.CreateFile(filepath.Join(host.Host, target.Src), content, mode); err != nil { 71 | return err 72 | } 73 | case imports.IsDirectory: 74 | files, err := importer.ListUntrackedFiles(ctx, target.Target) 75 | if err != nil { 76 | return err 77 | } 78 | files = importer.ExcludeSymlinkFiles(ctx, files) 79 | for _, file := range files { 80 | fileAbsPath := filepath.Join(target.Target, file) 81 | content, mode, err := importer.GetFileContent(ctx, fileAbsPath) 82 | if err != nil { 83 | return err 84 | } 85 | if err := repo.CreateFile(filepath.Join(host.Host, target.Src, file), content, mode); err != nil { 86 | return err 87 | } 88 | } 89 | default: 90 | return myerrors.NewErrorUnkouwn() 91 | } 92 | } 93 | return nil 94 | }, 95 | }}) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/config/types.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Config struct { 4 | Setup Setup `yaml:"setup,omitempty"` 5 | Slack Slack `yaml:"slack,omitempty"` 6 | Hosts []Host `yaml:"hosts,omitempty"` 7 | } 8 | 9 | func (c Config) IsDockerEnabled() bool { 10 | return c.Setup.Docker != nil 11 | } 12 | 13 | func (c Config) IsNetdataEnabled() (flag bool, version string, port int) { 14 | if !(c.Setup.Docker != nil && c.Setup.Docker.Netdata != nil) { 15 | return false, "", 0 16 | } 17 | if c.Setup.Docker.Netdata.Version == "" { 18 | version = "latest" 19 | } else { 20 | version = c.Setup.Docker.Netdata.Version 21 | } 22 | if c.Setup.Docker.Netdata.PublicPort == 0 { 23 | port = 19999 24 | } else { 25 | port = c.Setup.Docker.Netdata.PublicPort 26 | } 27 | return true, version, port 28 | } 29 | 30 | func (c Config) IsAlpEnabled() (flag bool, version string) { 31 | if !(c.Setup.Alp != nil) { 32 | return false, "" 33 | } 34 | if c.Setup.Alp.Version == "" { 35 | version = "latest" 36 | } else { 37 | version = c.Setup.Alp.Version 38 | } 39 | return true, version 40 | } 41 | 42 | type Setup struct { 43 | Docker *Docker `yaml:"docker,omitempty"` 44 | Alp *Alp `yaml:"alp,omitempty"` 45 | } 46 | 47 | type Docker struct { 48 | Netdata *Netdata `yaml:"netdata,omitempty"` 49 | } 50 | 51 | type Netdata struct { 52 | Version string `yaml:"version,omitempty"` 53 | PublicPort int `yaml:"public_port,omitempty"` 54 | } 55 | 56 | type Alp struct { 57 | Version string `yaml:"version,omitempty"` 58 | } 59 | 60 | type Slack struct { 61 | DefaultChannelId string `yaml:"default_channel_id,omitempty"` 62 | } 63 | 64 | type Host struct { 65 | Host string `yaml:"host,omitempty"` 66 | Port int `yaml:"int,omitempty"` 67 | User string `yaml:"user,omitempty"` 68 | Key string `yaml:"key,omitempty"` 69 | Password string `yaml:"password,omitempty"` 70 | Deploy Deploy `yaml:"deploy,omitempty"` 71 | Profiling Profiling `yaml:"profiling,omitempty"` 72 | AfterBench AfterBench `yaml:"after_bench,omitempty"` 73 | } 74 | 75 | func (c Host) IsLocal() bool { 76 | return c.Host == "localhost" || c.Host == "127.0.0.1" 77 | } 78 | 79 | func (c Host) ListTarget() []DeployTarget { 80 | return c.Deploy.Targets 81 | } 82 | 83 | type Deploy struct { 84 | SlackChannelId string `yaml:"slack_channel_id,omitempty"` 85 | PreCommand string `yaml:"pre_command,omitempty"` 86 | PostCommand string `yaml:"post_command,omitempty"` 87 | Targets []DeployTarget `yaml:"targets,omitempty"` 88 | } 89 | 90 | type DeployTarget struct { 91 | Src string `yaml:"src,omitempty"` 92 | Target string `yaml:"target,omitempty"` 93 | Compile string `yaml:"compile,omitempty"` 94 | } 95 | 96 | type Profiling struct { 97 | Command string `yaml:"command,omitempty"` 98 | } 99 | 100 | type AfterBench struct { 101 | SlackChannelId string `yaml:"slack_channel_id,omitempty"` 102 | Target string `yaml:"target,omitempty"` 103 | Command string `yaml:"command,omitempty"` 104 | } 105 | -------------------------------------------------------------------------------- /pkg/shell/ssh.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | 10 | "github.com/pkg/sftp" 11 | "golang.org/x/crypto/ssh" 12 | ) 13 | 14 | type SshClient struct { 15 | *ssh.Client 16 | host string 17 | target string 18 | } 19 | 20 | func (c *SshClient) Host() string { 21 | return c.host 22 | } 23 | 24 | func NewSshClient(host string, port int, user, password, keyfile string) (*SshClient, error) { 25 | var config ssh.ClientConfig 26 | switch { 27 | case keyfile != "": 28 | key, err := os.ReadFile(keyfile) 29 | if err != nil { 30 | return nil, fmt.Errorf("unable to read private key: %v", err) 31 | } 32 | signer, err := ssh.ParsePrivateKey(key) 33 | if err != nil { 34 | return nil, fmt.Errorf("unable to parse private key: %v", err) 35 | } 36 | config = ssh.ClientConfig{ 37 | User: user, 38 | Auth: []ssh.AuthMethod{ 39 | ssh.PublicKeys(signer), 40 | }, 41 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 42 | } 43 | case password != "": 44 | config = ssh.ClientConfig{ 45 | User: user, 46 | Auth: []ssh.AuthMethod{ 47 | ssh.Password(password), 48 | }, 49 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 50 | } 51 | default: 52 | return nil, fmt.Errorf("neither password nor publicKey was specified") 53 | } 54 | config.SetDefaults() 55 | // Connect to the remote server and perform the SSH handshake. 56 | target := fmt.Sprintf("%s:%d", host, port) 57 | if port == 0 { 58 | target = fmt.Sprintf("%s:22", host) 59 | } 60 | conn, err := ssh.Dial("tcp", target, &config) 61 | if err != nil { 62 | return nil, fmt.Errorf("unable to connect: %v", err) 63 | } 64 | return &SshClient{conn, host, target}, nil 65 | } 66 | 67 | func (c *SshClient) Exec(ctx context.Context, basedir string, command string) (bytes.Buffer, bytes.Buffer, error) { 68 | stdout := bytes.Buffer{} 69 | stderr := bytes.Buffer{} 70 | if command == "" { // early return 71 | return stdout, stderr, nil 72 | } 73 | 74 | session, err := c.NewSession() 75 | if err != nil { 76 | return stdout, stderr, fmt.Errorf("Failed to create session: %v", err) 77 | } 78 | defer session.Close() 79 | 80 | session.Stdout = &stdout 81 | session.Stderr = &stderr 82 | if basedir != "" { 83 | command = "cd " + basedir + "; " + command 84 | } 85 | err = session.Run(command) 86 | return trimNewLine(stdout), trimNewLine(stderr), err 87 | } 88 | 89 | func (c *SshClient) Execf(ctx context.Context, basedir string, cmd string, a ...interface{}) (bytes.Buffer, bytes.Buffer, error) { 90 | return c.Exec(ctx, basedir, fmt.Sprintf(cmd, a...)) 91 | } 92 | 93 | func (c *SshClient) Deploy(ctx context.Context, src, dst string) error { 94 | s, err := os.Open(src) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | client, err := sftp.NewClient(c.Client) 100 | if err != nil { 101 | return fmt.Errorf("Failed to create session: %v", err) 102 | } 103 | defer client.Close() 104 | 105 | d, err := client.Create(dst) 106 | if err != nil { 107 | return err 108 | } 109 | defer d.Close() 110 | 111 | if _, err := io.Copy(d, s); err != nil { 112 | return err 113 | } 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /pkg/cmd/afterbench.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | "k8s.io/utils/exec" 10 | 11 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 12 | "github.com/ShotaKitazawa/isucontinuous/pkg/localrepo" 13 | "github.com/ShotaKitazawa/isucontinuous/pkg/slack" 14 | "github.com/ShotaKitazawa/isucontinuous/pkg/template" 15 | "github.com/ShotaKitazawa/isucontinuous/pkg/usecases/afterbench" 16 | ) 17 | 18 | type ConfigAfterBench struct { 19 | ConfigCommon 20 | SlackToken string 21 | } 22 | 23 | func RunAfterBench(conf ConfigAfterBench) error { 24 | ctx := context.Background() 25 | logger, err := newLogger(conf.LogLevel, conf.LogFilename) 26 | if err != nil { 27 | return err 28 | } 29 | // Attach local-repo 30 | repo, err := localrepo.AttachLocalRepo(logger, exec.New(), conf.LocalRepoPath) 31 | if err != nil { 32 | return err 33 | } 34 | // load isucontinuous.yaml 35 | isucontinuous, err := repo.LoadConf() 36 | if err != nil { 37 | return err 38 | } 39 | slackClient := slack.NewClient(logger, conf.SlackToken, isucontinuous.Slack.DefaultChannelId) 40 | return runAfterBench(conf, ctx, logger, repo, slackClient, afterbench.New) 41 | } 42 | 43 | func runAfterBench( 44 | conf ConfigAfterBench, ctx context.Context, logger *zap.Logger, 45 | repo localrepo.LocalRepoIface, slackClient slack.ClientIface, 46 | newAfterBenchersFunc afterbench.NewFunc, 47 | ) error { 48 | logger.Info("start afterbench") 49 | defer func() { logger.Info("finish afterbench") }() 50 | // Load isucontinus.yaml 51 | isucontinuous, err := repo.LoadConf() 52 | if err != nil { 53 | return err 54 | } 55 | // Get revision 56 | gitRevision, err := repo.GetRevision(ctx) 57 | if err != nil { 58 | return fmt.Errorf("%s/.revision is not found. exec `deploy` command first", conf.LocalRepoPath) 59 | } 60 | // AfterBench files to per host 61 | if err := perHostExec(logger, ctx, isucontinuous.Hosts, []task{{ 62 | "AfterBench", 63 | func(ctx context.Context, host config.Host) error { 64 | if host.AfterBench.Target == "" { 65 | logger.Debug("skip bacause target is not specified", zap.String("host", host.Host)) 66 | return nil 67 | } 68 | afterbencher, err := newAfterBenchersFunc(logger, template.New(gitRevision), slackClient, host) 69 | if err != nil { 70 | return err 71 | } 72 | // mkdir location to deploy profile data 73 | if err := afterbencher.Prepare(ctx, host.AfterBench.Target); err != nil { 74 | return err 75 | } 76 | // cleanup location of profile data 77 | defer func() { 78 | suffix := fmt.Sprintf("%d", time.Now().Unix()) 79 | if err := afterbencher.CleanUp(ctx, host.AfterBench.Target, suffix); err != nil { 80 | logger.Error(fmt.Sprintf("failed to cleanup: %v", err), zap.String("host", host.Host)) 81 | } 82 | }() 83 | 84 | // execute to collect & parse profile data 85 | if err := afterbencher.RunCommand(ctx, host.AfterBench.Command); err != nil { 86 | return err 87 | } 88 | // post profile data to Slack 89 | if err := afterbencher.PostToSlack(ctx, host.AfterBench.Target, host.AfterBench.SlackChannelId); err != nil { 90 | return err 91 | } 92 | return nil 93 | }}}); err != nil { 94 | return err 95 | } 96 | // Clear revision-file 97 | return repo.ClearRevision(ctx) 98 | } 99 | -------------------------------------------------------------------------------- /pkg/usecases/afterbench/afterbench.go: -------------------------------------------------------------------------------- 1 | package afterbench 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "go.uber.org/zap" 10 | "k8s.io/utils/exec" 11 | 12 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 13 | myerrros "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 14 | "github.com/ShotaKitazawa/isucontinuous/pkg/shell" 15 | "github.com/ShotaKitazawa/isucontinuous/pkg/slack" 16 | "github.com/ShotaKitazawa/isucontinuous/pkg/template" 17 | ) 18 | 19 | type AfterBencher struct { 20 | log *zap.Logger 21 | shell shell.Iface 22 | template *template.Templator 23 | slack slack.ClientIface 24 | } 25 | 26 | type NewFunc func(*zap.Logger, *template.Templator, slack.ClientIface, config.Host) (*AfterBencher, error) 27 | 28 | func New(logger *zap.Logger, templator *template.Templator, slackClient slack.ClientIface, host config.Host) (*AfterBencher, error) { 29 | var err error 30 | var s shell.Iface 31 | if host.IsLocal() { 32 | s = shell.NewLocalClient(exec.New()) 33 | } else { 34 | s, err = shell.NewSshClient(host.Host, host.Port, host.User, host.Password, host.Key) 35 | if err != nil { 36 | return nil, err 37 | } 38 | } 39 | return &AfterBencher{logger, s, templator, slackClient}, nil 40 | } 41 | 42 | func (p AfterBencher) RunCommand(ctx context.Context, command string) error { 43 | command, err := p.template.Exec(command) 44 | if err != nil { 45 | return err 46 | } 47 | if _, stderr, err := p.shell.Exec(ctx, "", command); err != nil { 48 | return myerrros.NewErrorCommandExecutionFailed(stderr) 49 | } 50 | return nil 51 | } 52 | 53 | func (p AfterBencher) PostToSlack(ctx context.Context, dir, channel string) error { 54 | dir, err := p.template.Exec(dir) 55 | dir = filepath.Clean(dir) 56 | if err != nil { 57 | return err 58 | } 59 | stdout, stderr, err := p.shell.Execf(ctx, "", `find "%s" -type f`, dir) 60 | if err != nil { 61 | return myerrros.NewErrorCommandExecutionFailed(stderr) 62 | } 63 | for _, filename := range strings.Split(stdout.String(), "\n") { 64 | stdout, stderr, err := p.shell.Execf(ctx, "", "cat %s", filename) 65 | if err != nil { 66 | return myerrros.NewErrorCommandExecutionFailed(stderr) 67 | } 68 | title := fmt.Sprintf("%s at %s (%s)", filepath.Base(filename), p.shell.Host(), p.template.Git.Revision) 69 | if err := p.slack.SendFileContent(ctx, channel, filename, stdout.String(), title); err != nil { 70 | return err 71 | } 72 | } 73 | return nil 74 | } 75 | 76 | func (p AfterBencher) Prepare(ctx context.Context, dir string) error { 77 | dir, err := p.template.Exec(dir) 78 | dir = filepath.Clean(dir) 79 | if err != nil { 80 | return err 81 | } 82 | if _, stderr, err := p.shell.Execf(ctx, "", `mkdir -p "%s"`, dir); err != nil { 83 | return myerrros.NewErrorCommandExecutionFailed(stderr) 84 | } 85 | return nil 86 | } 87 | 88 | func (p AfterBencher) CleanUp(ctx context.Context, dir, suffix string) error { 89 | srcDir, err := p.template.Exec(dir) 90 | srcDir = filepath.Clean(srcDir) 91 | if err != nil { 92 | return err 93 | } 94 | dstDir := fmt.Sprintf("%s.%s", srcDir, suffix) 95 | if _, stderr, err := p.shell.Execf(ctx, "", `mv "%s" "%s"`, srcDir, dstDir); err != nil { 96 | return myerrros.NewErrorCommandExecutionFailed(stderr) 97 | } 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /pkg/shell/mock/mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./common.go 3 | 4 | // Package mock is a generated GoMock package. 5 | package mock 6 | 7 | import ( 8 | bytes "bytes" 9 | context "context" 10 | reflect "reflect" 11 | 12 | gomock "github.com/golang/mock/gomock" 13 | ) 14 | 15 | // MockIface is a mock of Iface interface. 16 | type MockIface struct { 17 | ctrl *gomock.Controller 18 | recorder *MockIfaceMockRecorder 19 | } 20 | 21 | // MockIfaceMockRecorder is the mock recorder for MockIface. 22 | type MockIfaceMockRecorder struct { 23 | mock *MockIface 24 | } 25 | 26 | // NewMockIface creates a new mock instance. 27 | func NewMockIface(ctrl *gomock.Controller) *MockIface { 28 | mock := &MockIface{ctrl: ctrl} 29 | mock.recorder = &MockIfaceMockRecorder{mock} 30 | return mock 31 | } 32 | 33 | // EXPECT returns an object that allows the caller to indicate expected use. 34 | func (m *MockIface) EXPECT() *MockIfaceMockRecorder { 35 | return m.recorder 36 | } 37 | 38 | // Deploy mocks base method. 39 | func (m *MockIface) Deploy(ctx context.Context, src, dst string) error { 40 | m.ctrl.T.Helper() 41 | ret := m.ctrl.Call(m, "Deploy", ctx, src, dst) 42 | ret0, _ := ret[0].(error) 43 | return ret0 44 | } 45 | 46 | // Deploy indicates an expected call of Deploy. 47 | func (mr *MockIfaceMockRecorder) Deploy(ctx, src, dst interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deploy", reflect.TypeOf((*MockIface)(nil).Deploy), ctx, src, dst) 50 | } 51 | 52 | // Exec mocks base method. 53 | func (m *MockIface) Exec(ctx context.Context, basedir, command string) (bytes.Buffer, bytes.Buffer, error) { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "Exec", ctx, basedir, command) 56 | ret0, _ := ret[0].(bytes.Buffer) 57 | ret1, _ := ret[1].(bytes.Buffer) 58 | ret2, _ := ret[2].(error) 59 | return ret0, ret1, ret2 60 | } 61 | 62 | // Exec indicates an expected call of Exec. 63 | func (mr *MockIfaceMockRecorder) Exec(ctx, basedir, command interface{}) *gomock.Call { 64 | mr.mock.ctrl.T.Helper() 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockIface)(nil).Exec), ctx, basedir, command) 66 | } 67 | 68 | // Execf mocks base method. 69 | func (m *MockIface) Execf(ctx context.Context, basedir, command string, a ...interface{}) (bytes.Buffer, bytes.Buffer, error) { 70 | m.ctrl.T.Helper() 71 | varargs := []interface{}{ctx, basedir, command} 72 | for _, a_2 := range a { 73 | varargs = append(varargs, a_2) 74 | } 75 | ret := m.ctrl.Call(m, "Execf", varargs...) 76 | ret0, _ := ret[0].(bytes.Buffer) 77 | ret1, _ := ret[1].(bytes.Buffer) 78 | ret2, _ := ret[2].(error) 79 | return ret0, ret1, ret2 80 | } 81 | 82 | // Execf indicates an expected call of Execf. 83 | func (mr *MockIfaceMockRecorder) Execf(ctx, basedir, command interface{}, a ...interface{}) *gomock.Call { 84 | mr.mock.ctrl.T.Helper() 85 | varargs := append([]interface{}{ctx, basedir, command}, a...) 86 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execf", reflect.TypeOf((*MockIface)(nil).Execf), varargs...) 87 | } 88 | 89 | // Host mocks base method. 90 | func (m *MockIface) Host() string { 91 | m.ctrl.T.Helper() 92 | ret := m.ctrl.Call(m, "Host") 93 | ret0, _ := ret[0].(string) 94 | return ret0 95 | } 96 | 97 | // Host indicates an expected call of Host. 98 | func (mr *MockIfaceMockRecorder) Host() *gomock.Call { 99 | mr.mock.ctrl.T.Helper() 100 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Host", reflect.TypeOf((*MockIface)(nil).Host)) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/usecases/imports/imports.go: -------------------------------------------------------------------------------- 1 | package imports 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | "strings" 9 | 10 | "go.uber.org/zap" 11 | "k8s.io/utils/exec" 12 | 13 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 14 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 15 | "github.com/ShotaKitazawa/isucontinuous/pkg/shell" 16 | ) 17 | 18 | type Importer struct { 19 | log zap.Logger 20 | shell shell.Iface 21 | } 22 | 23 | type NewImportersFunc func(logger *zap.Logger, hosts []config.Host) (map[string]*Importer, error) 24 | 25 | func NewImporters(logger *zap.Logger, hosts []config.Host) (map[string]*Importer, error) { 26 | importers := make(map[string]*Importer) 27 | var err error 28 | for _, host := range hosts { 29 | var s shell.Iface 30 | if host.IsLocal() { 31 | s = shell.NewLocalClient(exec.New()) 32 | } else { 33 | s, err = shell.NewSshClient(host.Host, host.Port, host.User, host.Password, host.Key) 34 | if err != nil { 35 | return nil, err 36 | } 37 | } 38 | importers[host.Host] = new(logger, s) 39 | } 40 | return importers, nil 41 | } 42 | 43 | func new(logger *zap.Logger, s shell.Iface) *Importer { 44 | return &Importer{*logger, s} 45 | } 46 | 47 | const ( 48 | IsNotFound = iota 49 | IsFile 50 | IsDirectory 51 | ) 52 | 53 | func (l *Importer) FileType(ctx context.Context, path string) int { 54 | if _, _, err := l.shell.Execf(ctx, "", `test ! -f "%s"`, path); err != nil { 55 | return IsFile 56 | } 57 | if _, _, err := l.shell.Execf(ctx, "", `test ! -d "%s"`, path); err != nil { 58 | return IsDirectory 59 | } 60 | return IsNotFound 61 | } 62 | 63 | func (l *Importer) GetFileContent(ctx context.Context, path string) ([]byte, os.FileMode, error) { 64 | if _, _, err := l.shell.Execf(ctx, "", `test -f "%s"`, path); err != nil { 65 | return nil, 0, myerrors.NewErrorIsNotFile(path) 66 | } 67 | stdout, stderr, err := l.shell.Execf(ctx, "", `cat "%s"`, path) 68 | if err != nil { 69 | return nil, 0, myerrors.NewErrorCommandExecutionFailed(stderr) 70 | } 71 | content := stdout.Bytes() 72 | stdout, stderr, err = l.shell.Exec(ctx, "", "stat "+path+" -c '%a'") 73 | if err != nil { 74 | return nil, 0, myerrors.NewErrorCommandExecutionFailed(stderr) 75 | } 76 | mode, err := strconv.Atoi(stdout.String()) 77 | if err != nil { 78 | return nil, 0, err 79 | } 80 | return content, os.FileMode(mode), nil 81 | } 82 | 83 | func (l *Importer) ListUntrackedFiles(ctx context.Context, path string) ([]string, error) { 84 | absPath, err := filepath.Abs(path) 85 | if err != nil { 86 | return nil, err 87 | } 88 | if _, _, err := l.shell.Execf(ctx, "", `test -d "%s"`, path); err != nil { 89 | return nil, myerrors.NewErrorIsNotDirectory(path) 90 | } 91 | 92 | if _, stderr, err := l.shell.Exec(ctx, absPath, `git init`); err != nil { 93 | return nil, myerrors.NewErrorCommandExecutionFailed(stderr) 94 | } 95 | defer func() { 96 | _, _, _ = l.shell.Execf(context.Background(), "", `rm -rf "%s"`, filepath.Join(absPath, ".git")) 97 | }() 98 | if _, stderr, err := l.shell.Execf(ctx, absPath, `git config --global --add safe.directory "%s"`, absPath); err != nil { 99 | return nil, myerrors.NewErrorCommandExecutionFailed(stderr) 100 | } 101 | 102 | stdout, stderr, err := l.shell.Exec(ctx, absPath, `git ls-files --others --exclude-standard`) 103 | if err != nil { 104 | return nil, myerrors.NewErrorCommandExecutionFailed(stderr) 105 | } 106 | return strings.Split(stdout.String(), "\n"), nil 107 | } 108 | 109 | func (l *Importer) ExcludeSymlinkFiles(ctx context.Context, files []string) []string { 110 | result := []string{} 111 | for _, f := range files { 112 | if _, _, err := l.shell.Execf(ctx, "", `test -L %s`, f); err != nil { 113 | result = append(result, f) 114 | } 115 | } 116 | return result 117 | } 118 | -------------------------------------------------------------------------------- /pkg/cmd/deploy.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "go.uber.org/zap" 8 | "k8s.io/utils/exec" 9 | 10 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 11 | "github.com/ShotaKitazawa/isucontinuous/pkg/localrepo" 12 | "github.com/ShotaKitazawa/isucontinuous/pkg/slack" 13 | "github.com/ShotaKitazawa/isucontinuous/pkg/template" 14 | "github.com/ShotaKitazawa/isucontinuous/pkg/usecases/deploy" 15 | ) 16 | 17 | type ConfigDeploy struct { 18 | ConfigCommon 19 | GitRevision string 20 | Force bool 21 | SlackToken string 22 | } 23 | 24 | func RunDeploy(conf ConfigDeploy) error { 25 | ctx := context.Background() 26 | logger, err := newLogger(conf.LogLevel, conf.LogFilename) 27 | if err != nil { 28 | return err 29 | } 30 | // Attach local-repo 31 | repo, err := localrepo.AttachLocalRepo(logger, exec.New(), conf.LocalRepoPath) 32 | if err != nil { 33 | return err 34 | } 35 | // load isucontinuous.yaml 36 | isucontinuous, err := repo.LoadConf() 37 | if err != nil { 38 | return err 39 | } 40 | slackClient := slack.NewClient(logger, conf.SlackToken, isucontinuous.Slack.DefaultChannelId) 41 | return runDeploy(conf, ctx, logger, repo, slackClient, deploy.NewDeployers) 42 | } 43 | 44 | func runDeploy( 45 | conf ConfigDeploy, ctx context.Context, logger *zap.Logger, 46 | repo localrepo.LocalRepoIface, slackClient slack.ClientIface, 47 | newDeployersFunc deploy.NewDeployersFunc, 48 | ) error { 49 | logger.Info("start deploy") 50 | defer func() { logger.Info("finish deploy") }() 51 | // Fetch remote-repo & switch to gitRevision 52 | if err := repo.Fetch(ctx); err != nil { 53 | return err 54 | } 55 | if err := repo.SwitchDetachedBranch(ctx, conf.GitRevision); err != nil { 56 | return err 57 | } 58 | // Load isucontinus.yaml 59 | isucontinuous, err := repo.LoadConf() 60 | if err != nil { 61 | return err 62 | } 63 | // Check to have already deployed 64 | if !conf.Force { 65 | if r, err := repo.GetRevision(ctx); err == nil { 66 | if r != conf.GitRevision { 67 | return fmt.Errorf( 68 | `"deploy" command has already been executed. Please execute "afterbench" or "deploy --force".`) 69 | } 70 | } 71 | } 72 | // Print CommitHash & CommitMessage 73 | hash, msg, err := repo.GetHeadInfo(ctx) 74 | if err != nil { 75 | return err 76 | } 77 | fmt.Printf("hash: %s\nmessage: %s\n\n", hash, msg) 78 | // Set deployers 79 | deployers, err := newDeployersFunc(logger, template.New(conf.GitRevision), conf.LocalRepoPath, isucontinuous.Hosts) 80 | if err != nil { 81 | return err 82 | } 83 | // Deploy files to per host 84 | if err := perHostExec(logger, ctx, isucontinuous.Hosts, []task{{ 85 | "Deploy", 86 | func(ctx context.Context, host config.Host) error { 87 | // Notify to Slack 88 | if err := slackClient.SendText(ctx, host.Deploy.SlackChannelId, 89 | fmt.Sprintf("*<%s> deploying to %s...*", conf.GitRevision, host.Host)); err != nil { 90 | return err 91 | } 92 | defer func() { 93 | if err != nil { 94 | _ = slackClient.SendText(ctx, host.Deploy.SlackChannelId, 95 | fmt.Sprintf("*<%s> deploy was failed to %s* :sob:", conf.GitRevision, host.Host)) 96 | } else { 97 | _ = slackClient.SendText(ctx, host.Deploy.SlackChannelId, 98 | fmt.Sprintf("*<%s> deploy was succeeded to %s* :laughing:", conf.GitRevision, host.Host)) 99 | } 100 | }() 101 | deployer := deployers[host.Host] 102 | // Execute preCommand 103 | if err = deployer.RunCommand(ctx, host.Deploy.PreCommand); err != nil { 104 | return err 105 | } 106 | // Deploy 107 | if err = deployer.Deploy(ctx, host.Host, host.Deploy.Targets); err != nil { 108 | return err 109 | } 110 | // Execute postCommand 111 | if err = deployer.RunCommand(ctx, host.Deploy.PostCommand); err != nil { 112 | return err 113 | } 114 | return nil 115 | }, 116 | }}); err != nil { 117 | return err 118 | } 119 | // Store revision to local-repo 120 | return repo.SetRevision(ctx, conf.GitRevision) 121 | } 122 | -------------------------------------------------------------------------------- /pkg/usecases/deploy/deploy.go: -------------------------------------------------------------------------------- 1 | package deploy 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "reflect" 10 | "strings" 11 | 12 | "go.uber.org/zap" 13 | "k8s.io/utils/exec" 14 | 15 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 16 | myerrros "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 17 | "github.com/ShotaKitazawa/isucontinuous/pkg/shell" 18 | "github.com/ShotaKitazawa/isucontinuous/pkg/template" 19 | ) 20 | 21 | type Deployer struct { 22 | log *zap.Logger 23 | shell shell.Iface 24 | template *template.Templator 25 | localRepoPath string 26 | } 27 | 28 | type NewDeployersFunc func(*zap.Logger, *template.Templator, string, []config.Host) (map[string]*Deployer, error) 29 | 30 | func NewDeployers( 31 | logger *zap.Logger, templator *template.Templator, localRepoPath string, 32 | hosts []config.Host, 33 | ) (map[string]*Deployer, error) { 34 | deployers := make(map[string]*Deployer) 35 | var err error 36 | for _, host := range hosts { 37 | var s shell.Iface 38 | if host.IsLocal() { 39 | s = shell.NewLocalClient(exec.New()) 40 | } else { 41 | s, err = shell.NewSshClient(host.Host, host.Port, host.User, host.Password, host.Key) 42 | if err != nil { 43 | return nil, err 44 | } 45 | } 46 | deployers[host.Host] = new(logger, s, templator, localRepoPath) 47 | } 48 | return deployers, nil 49 | } 50 | 51 | func new(logger *zap.Logger, s shell.Iface, templator *template.Templator, localRepoPath string) *Deployer { 52 | return &Deployer{logger, s, templator, localRepoPath} 53 | } 54 | 55 | func (d Deployer) Deploy(ctx context.Context, host string, targets []config.DeployTarget) error { 56 | realHost := d.shell.Host() 57 | for _, target := range targets { 58 | src := filepath.Join(d.localRepoPath, host, target.Src) 59 | if err := filepath.WalkDir(src, func(path string, info fs.DirEntry, err error) error { 60 | if info != nil && !reflect.ValueOf(info).IsNil() && !info.IsDir() { 61 | dst := filepath.Join(target.Target, strings.TrimPrefix(path, src)) 62 | dirname := filepath.Dir(dst) 63 | if _, _, err := d.shell.Execf(ctx, "", `test -d "%s"`, dirname); err != nil { 64 | d.log.Debug(fmt.Sprintf("%s does not exist, mkdir", dirname), zap.String("host", realHost)) 65 | if _, _, err := d.shell.Execf(ctx, "", `mkdir -p "%s"`, dirname); err != nil { 66 | return err 67 | } 68 | } 69 | finfo, err := info.Info() 70 | if err != nil { 71 | return err 72 | } 73 | if finfo.Mode()&os.ModeSymlink == os.ModeSymlink { // copy source is symlink 74 | origin, err := filepath.EvalSymlinks(path) 75 | if err != nil { 76 | return err 77 | } 78 | newHostAndSrc := strings.TrimPrefix(origin, d.localRepoPath+"/") 79 | if newHostAndSrc == origin { 80 | return fmt.Errorf("%s: cannot seek symlink bacause source of symlic isn't in localRepoPath", origin) 81 | } 82 | newHostAndSrcSlice := strings.Split(newHostAndSrc, "/") 83 | newHost := newHostAndSrcSlice[0] 84 | newSrc := strings.Join(newHostAndSrcSlice[1:], "/") 85 | if len(newHostAndSrcSlice) < 2 { 86 | return fmt.Errorf("%s: cannot seek symlink bacause this directory is hostdir", origin) 87 | } 88 | d.log.Debug(fmt.Sprintf("%s is symlink, seek to %s", path, origin), zap.String("host", realHost)) 89 | if err := d.Deploy(ctx, newHost, []config.DeployTarget{{Src: newSrc, Target: dst}}); err != nil { 90 | return err 91 | } 92 | } else { // copy source is file 93 | d.log.Debug(fmt.Sprintf("deploy %s to %s", path, dst), zap.String("host", realHost)) 94 | return d.shell.Deploy(ctx, path, dst) 95 | } 96 | } 97 | return nil 98 | }); err != nil { 99 | return err 100 | } 101 | if target.Compile != "" { 102 | d.log.Debug(fmt.Sprintf(`exec compile: "%s"`, target.Compile), zap.String("host", host)) 103 | if _, stderr, err := d.shell.Exec(ctx, target.Target, target.Compile); err != nil { 104 | return myerrros.NewErrorCommandExecutionFailed(stderr) 105 | } 106 | } 107 | } 108 | return nil 109 | } 110 | 111 | func (d Deployer) RunCommand(ctx context.Context, command string) error { 112 | if command == "" { 113 | return nil 114 | } 115 | var err error 116 | command, err = d.template.Exec(command) 117 | if err != nil { 118 | return err 119 | } 120 | d.log.Debug(fmt.Sprintf(`exec command: "%s"`, command), zap.String("host", d.shell.Host())) 121 | _, stderr, err := d.shell.Exec(ctx, "", command) 122 | if err != nil { 123 | return myerrros.NewErrorCommandExecutionFailed(stderr) 124 | } 125 | return nil 126 | } 127 | -------------------------------------------------------------------------------- /pkg/localrepo/localrepo.go: -------------------------------------------------------------------------------- 1 | package localrepo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | 9 | "go.uber.org/zap" 10 | "gopkg.in/yaml.v3" 11 | "k8s.io/utils/exec" 12 | 13 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 14 | myerrors "github.com/ShotaKitazawa/isucontinuous/pkg/errors" 15 | "github.com/ShotaKitazawa/isucontinuous/pkg/shell" 16 | ) 17 | 18 | const ( 19 | isucontinuousFilename = "isucontinuous.yaml" 20 | revisionStoreFilename = ".revision" 21 | ) 22 | 23 | type LocalRepoIface interface { 24 | LoadConf() (*config.Config, error) 25 | CreateFile(name string, data []byte, perm os.FileMode) error 26 | Fetch(ctx context.Context) error 27 | SwitchAndMerge(ctx context.Context, branch string) error 28 | SwitchDetachedBranch(ctx context.Context, revision string) error 29 | Push(ctx context.Context) error 30 | CurrentBranch(ctx context.Context) (string, error) 31 | IsFirstCommit(ctx context.Context) (bool, error) 32 | DiffWithRemote(ctx context.Context) (bool, error) 33 | Reset(ctx context.Context) error 34 | GetRevision(ctx context.Context) (string, error) 35 | SetRevision(ctx context.Context, revision string) error 36 | ClearRevision(ctx context.Context) error 37 | GetHeadInfo(ctx context.Context) (string, string, error) 38 | } 39 | 40 | type LocalRepo struct { 41 | log zap.Logger 42 | shell *shell.LocalClient 43 | 44 | absPath string 45 | } 46 | 47 | func InitLocalRepo(logger *zap.Logger, e exec.Interface, path, username, email, remoteUrl string) (*LocalRepo, error) { 48 | absPath, err := filepath.Abs(path) 49 | if err != nil { 50 | return nil, err 51 | } 52 | l := &LocalRepo{*logger, shell.NewLocalClient(e), absPath} 53 | ctx := context.Background() 54 | // Initialize local-repo 55 | if _, stderr, err := l.shell.Exec(ctx, l.absPath, "git init"); err != nil { 56 | return nil, myerrors.NewErrorCommandExecutionFailed(stderr) 57 | } 58 | if _, stderr, err := l.shell.Execf(ctx, l.absPath, `git config user.name "%s"`, username); err != nil { 59 | return nil, myerrors.NewErrorCommandExecutionFailed(stderr) 60 | } 61 | if _, stderr, err := l.shell.Execf(ctx, l.absPath, `git config user.email "%s"`, email); err != nil { 62 | return nil, myerrors.NewErrorCommandExecutionFailed(stderr) 63 | } 64 | if _, stderr, err := l.shell.Execf(ctx, l.absPath, `git remote add origin "%s"`, remoteUrl); err != nil { 65 | return nil, myerrors.NewErrorCommandExecutionFailed(stderr) 66 | } 67 | // Generate from skelton 68 | f, err := config.SkeltonBytes() 69 | if err != nil { 70 | return nil, err 71 | } 72 | // Create isucontinuous.yaml to local-repo. 73 | if err := l.CreateFile(isucontinuousFilename, f, 0644); err != nil { 74 | return nil, err 75 | } 76 | // Create .gitignore (.revision is written) to local-repo. 77 | if err := l.CreateFile(".gitignore", []byte(revisionStoreFilename), 0644); err != nil { 78 | return nil, err 79 | } 80 | return l, nil 81 | } 82 | 83 | func AttachLocalRepo(logger *zap.Logger, e exec.Interface, path string) (*LocalRepo, error) { 84 | absPath, err := filepath.Abs(path) 85 | if err != nil { 86 | return nil, err 87 | } 88 | if f, err := os.Stat(absPath); err != nil { 89 | return nil, myerrors.NewErrorFileAlreadyExisted(absPath) 90 | } else if !f.IsDir() { 91 | return nil, myerrors.NewErrorIsNotDirectory(absPath) 92 | } 93 | return &LocalRepo{*logger, shell.NewLocalClient(e), absPath}, nil 94 | } 95 | 96 | func (l *LocalRepo) LoadConf() (*config.Config, error) { 97 | f, err := os.ReadFile(filepath.Join(l.absPath, isucontinuousFilename)) 98 | if err != nil { 99 | return nil, err 100 | } 101 | conf := &config.Config{} 102 | if err := yaml.Unmarshal(f, conf); err != nil { 103 | return nil, err 104 | } 105 | return conf, nil 106 | 107 | } 108 | 109 | func (l *LocalRepo) CreateFile(name string, data []byte, perm os.FileMode) error { 110 | fileAbsPath := filepath.Join(l.absPath, name) 111 | if _, err := os.Stat(fileAbsPath); err != nil { 112 | if err := os.MkdirAll(filepath.Dir(fileAbsPath), 0755); err != nil { 113 | return err 114 | } 115 | } 116 | return os.WriteFile(filepath.Join(l.absPath, name), data, perm) 117 | } 118 | 119 | func (l *LocalRepo) Fetch(ctx context.Context) error { 120 | if _, stderr, err := l.shell.Exec(ctx, l.absPath, "git fetch"); err != nil { 121 | return myerrors.NewErrorCommandExecutionFailed(stderr) 122 | } 123 | return nil 124 | } 125 | 126 | func (l *LocalRepo) SwitchAndMerge(ctx context.Context, branch string) error { 127 | // get current branch name 128 | currentBranch, err := l.CurrentBranch(ctx) 129 | if err != nil && !errors.As(err, &myerrors.GitBranchIsDetached{}) { 130 | return err 131 | } 132 | // check to exist 133 | if _, _, err := l.shell.Exec(ctx, l.absPath, `git branch --format="%(refname:short)" | grep -e ^`+branch+`$`); err != nil { 134 | // checkout only 135 | if _, stderr, err := l.shell.Execf(ctx, l.absPath, `git checkout %s`, branch); err != nil { 136 | return myerrors.NewErrorCommandExecutionFailed(stderr) 137 | } 138 | } else { 139 | if currentBranch != branch { 140 | // checkout & merge 141 | if _, stderr, err := l.shell.Execf(ctx, l.absPath, `git checkout %s`, branch); err != nil { 142 | return myerrors.NewErrorCommandExecutionFailed(stderr) 143 | } 144 | if _, stderr, err := l.shell.Execf(ctx, l.absPath, `git merge origin/%s`, branch); err != nil { 145 | return myerrors.NewErrorCommandExecutionFailed(stderr) 146 | } 147 | return nil 148 | } 149 | } 150 | return nil 151 | } 152 | 153 | func (l *LocalRepo) SwitchDetachedBranch(ctx context.Context, revision string) error { 154 | if _, stderr, err := l.shell.Execf(ctx, l.absPath, "git checkout -d remotes/origin/%s || git checkout -d %s", revision, revision); err != nil { 155 | return myerrors.NewErrorCommandExecutionFailed(stderr) 156 | } 157 | return nil 158 | } 159 | 160 | func (l *LocalRepo) Push(ctx context.Context) error { 161 | if _, stderr, err := l.shell.Exec(ctx, l.absPath, `git add -A`); err != nil { 162 | return myerrors.NewErrorCommandExecutionFailed(stderr) 163 | } 164 | if _, _, err := l.shell.Exec(ctx, l.absPath, `git commit -m "commit by isucontinuous"`); err != nil { 165 | l.log.Info("failed `git commit`: no commit files") 166 | } 167 | if _, _, err := l.shell.Exec(ctx, l.absPath, `git push origin HEAD`); err != nil { 168 | l.log.Info("failed `git push`: no push commits") 169 | } 170 | return nil 171 | } 172 | 173 | func (l *LocalRepo) CurrentBranch(ctx context.Context) (string, error) { 174 | stdout, stderr, err := l.shell.Execf(ctx, l.absPath, "git branch --show-current") 175 | if err != nil { 176 | return "", myerrors.NewErrorCommandExecutionFailed(stderr) 177 | } else if stdout.String() == "" { 178 | return "", myerrors.NewErrorGitBranchIsDetached() 179 | } 180 | return stdout.String(), nil 181 | } 182 | 183 | func (l *LocalRepo) IsFirstCommit(ctx context.Context) (bool, error) { 184 | stdout, stderr, err := l.shell.Execf(ctx, l.absPath, "git branch -a") 185 | if err != nil { 186 | return false, myerrors.NewErrorCommandExecutionFailed(stderr) 187 | } else if stdout.String() != "" { 188 | return false, nil 189 | } 190 | return true, nil 191 | 192 | } 193 | 194 | func (l *LocalRepo) DiffWithRemote(ctx context.Context) (bool, error) { 195 | // get current branch name 196 | currentBranch, err := l.CurrentBranch(ctx) 197 | if err != nil { 198 | return false, err 199 | } 200 | if stdout, stderr, err := l.shell.Execf(ctx, l.absPath, "git diff origin/%s %s", currentBranch, currentBranch); err != nil { 201 | if isFirstCommit, _ := l.IsFirstCommit(ctx); isFirstCommit { 202 | return false, myerrors.NewErrorGitBranchIsFirstCommit() 203 | } 204 | return false, myerrors.NewErrorCommandExecutionFailed(stderr) 205 | } else if stdout.String() != "" { 206 | return false, nil 207 | } 208 | return true, nil 209 | } 210 | 211 | func (l *LocalRepo) Reset(ctx context.Context) error { 212 | if _, stderr, err := l.shell.Exec(ctx, l.absPath, "git reset --hard"); err != nil { 213 | return myerrors.NewErrorCommandExecutionFailed(stderr) 214 | } 215 | if _, stderr, err := l.shell.Exec(ctx, l.absPath, "git clean -df"); err != nil { 216 | return myerrors.NewErrorCommandExecutionFailed(stderr) 217 | } 218 | return nil 219 | } 220 | 221 | func (l *LocalRepo) GetRevision(ctx context.Context) (string, error) { 222 | b, err := os.ReadFile(filepath.Join(l.absPath, revisionStoreFilename)) 223 | return string(b), err 224 | } 225 | 226 | func (l *LocalRepo) SetRevision(ctx context.Context, revision string) error { 227 | return os.WriteFile(filepath.Join(l.absPath, revisionStoreFilename), []byte(revision), 0644) 228 | } 229 | 230 | func (l *LocalRepo) ClearRevision(ctx context.Context) error { 231 | return os.Remove(filepath.Join(l.absPath, revisionStoreFilename)) 232 | } 233 | 234 | func (l *LocalRepo) GetHeadInfo(ctx context.Context) (string, string, error) { 235 | stdout, stderr, err := l.shell.Exec(ctx, l.absPath, "git rev-parse HEAD") 236 | if err != nil { 237 | return "", "", myerrors.NewErrorCommandExecutionFailed(stderr) 238 | } 239 | hash := stdout.String() 240 | stdout, stderr, err = l.shell.Exec(ctx, l.absPath, "git log -1 --pretty=%B") 241 | if err != nil { 242 | return "", "", myerrors.NewErrorCommandExecutionFailed(stderr) 243 | } 244 | msg := stdout.String() 245 | return hash, msg, nil 246 | } 247 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= 2 | github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 3 | github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= 4 | github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= 10 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 11 | github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= 12 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 13 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 14 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 15 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 16 | github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= 17 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 18 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 19 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 20 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 21 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 22 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 23 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 24 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 25 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 26 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 27 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 28 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 29 | github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= 30 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 31 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 32 | github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= 33 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 34 | github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= 35 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 36 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 37 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 38 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 39 | github.com/pkg/sftp v1.13.4 h1:Lb0RYJCmgUcBgZosfoi9Y9sbl6+LJgOIgk/2Y4YjMFg= 40 | github.com/pkg/sftp v1.13.4/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8= 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 43 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 44 | github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0= 45 | github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= 46 | github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= 47 | github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= 48 | github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= 49 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 50 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 51 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 52 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 53 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 54 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 55 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 56 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 57 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 58 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 59 | go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= 60 | go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 61 | go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= 62 | go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 63 | go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= 64 | go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= 65 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 66 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 67 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= 68 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 69 | golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 70 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 71 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 72 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 73 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 74 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 75 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 76 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 77 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 78 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 79 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 80 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 81 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 82 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 83 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 84 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 85 | golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 86 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= 88 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 89 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= 90 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 91 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 92 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 93 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 94 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 95 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 96 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 97 | golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 98 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 99 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 100 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 101 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 102 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 103 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 104 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 106 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 107 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 108 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 110 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 111 | k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= 112 | k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 h1:HNSDgDCrr/6Ly3WEGKZftiE7IY19Vz2GdbOCyI4qqhc= 113 | k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= 114 | -------------------------------------------------------------------------------- /pkg/usecases/deploy/deploy_test.go: -------------------------------------------------------------------------------- 1 | package deploy 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "path/filepath" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/golang/mock/gomock" 12 | "go.uber.org/zap" 13 | "go.uber.org/zap/zaptest" 14 | 15 | "github.com/ShotaKitazawa/isucontinuous/pkg/config" 16 | "github.com/ShotaKitazawa/isucontinuous/pkg/shell" 17 | mock_shell "github.com/ShotaKitazawa/isucontinuous/pkg/shell/mock" 18 | "github.com/ShotaKitazawa/isucontinuous/pkg/template" 19 | ) 20 | 21 | func TestDeployer_Deploy(t *testing.T) { 22 | mockCtrl := gomock.NewController(t) 23 | defer mockCtrl.Finish() 24 | ctx := context.Background() 25 | _, testFilename, _, _ := runtime.Caller(0) 26 | testDir := filepath.Join(filepath.Dir(testFilename), "testdata") 27 | 28 | type fields struct { 29 | log *zap.Logger 30 | shell shell.Iface 31 | template *template.Templator 32 | localRepoPath string 33 | } 34 | type args struct { 35 | host string 36 | targets []config.DeployTarget 37 | } 38 | tests := []struct { 39 | name string 40 | fields fields 41 | args args 42 | wantErr bool 43 | }{ 44 | { 45 | name: "normal", 46 | fields: fields{ 47 | log: zaptest.NewLogger(t), 48 | shell: func() shell.Iface { 49 | m := mock_shell.NewMockIface(mockCtrl) 50 | m.EXPECT().Host().Return("host01") 51 | // /etc/nginx/nginx.conf (/etc/nginx is existed) 52 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx"). 53 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 54 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/nginx.conf"), "/etc/nginx/nginx.conf"). 55 | Return(nil) 56 | // /etc/nginx/sites-available/default (/etc/nginx/sites-available isn't existed) 57 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-available"). 58 | Return(bytes.Buffer{}, bytes.Buffer{}, fmt.Errorf("")) 59 | m.EXPECT().Execf(ctx, "", `mkdir -p "%s"`, "/etc/nginx/sites-available"). 60 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 61 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/default"), "/etc/nginx/sites-available/default"). 62 | Return(nil) 63 | // /etc/nginx/sites-available/default (/etc/nginx/sites-available is existed) 64 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-available"). 65 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 66 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/isucondition.conf"), "/etc/nginx/sites-available/isucondition.conf"). 67 | Return(nil) 68 | // /etc/nginx/sites-enabled/isucondition.conf (/etc/nginx/sites-available is existed) 69 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-enabled"). 70 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 71 | { // recursive due to resolve symlink 72 | m.EXPECT().Host().Return("host01") 73 | // /etc/nginx/sites-available/default (/etc/nginx/sites-available is existed) 74 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-enabled"). 75 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 76 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/isucondition.conf"), "/etc/nginx/sites-enabled/isucondition.conf"). 77 | Return(nil) 78 | } 79 | return m 80 | }(), 81 | }, 82 | args: args{ 83 | host: "host01", 84 | targets: []config.DeployTarget{ 85 | { 86 | Src: "nginx", 87 | Target: "/etc/nginx", 88 | }, 89 | }, 90 | }, 91 | }, 92 | { 93 | name: "normal_symlinkToSameHost", 94 | fields: fields{ 95 | log: zaptest.NewLogger(t), 96 | shell: func() shell.Iface { 97 | m := mock_shell.NewMockIface(mockCtrl) 98 | m.EXPECT().Host().Return("host01") 99 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc"). 100 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 101 | { // recursive due to resolve symlink 102 | m.EXPECT().Host().Return("host01") 103 | // /etc/nginx/nginx.conf (/etc/nginx is existed) 104 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx"). 105 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 106 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/nginx.conf"), "/etc/nginx/nginx.conf"). 107 | Return(nil) 108 | // /etc/nginx/sites-available/default (/etc/nginx/sites-available isn't existed) 109 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-available"). 110 | Return(bytes.Buffer{}, bytes.Buffer{}, fmt.Errorf("")) 111 | m.EXPECT().Execf(ctx, "", `mkdir -p "%s"`, "/etc/nginx/sites-available"). 112 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 113 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/default"), "/etc/nginx/sites-available/default"). 114 | Return(nil) 115 | // /etc/nginx/sites-available/isucondition.conf (/etc/nginx/sites-available is existed) 116 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-available"). 117 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 118 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/isucondition.conf"), "/etc/nginx/sites-available/isucondition.conf"). 119 | Return(nil) 120 | // /etc/nginx/sites-enabled/isucondition.conf (/etc/nginx/sites-available is existed) 121 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-enabled"). 122 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 123 | { // recursive due to resolve symlink 124 | m.EXPECT().Host().Return("host01") 125 | // /etc/nginx/sites-available/default (/etc/nginx/sites-available is existed) 126 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-enabled"). 127 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 128 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/isucondition.conf"), "/etc/nginx/sites-enabled/isucondition.conf"). 129 | Return(nil) 130 | } 131 | } 132 | return m 133 | }(), 134 | }, 135 | args: args{ 136 | host: "host01", 137 | targets: []config.DeployTarget{ 138 | { 139 | Src: "nginx_symlink", 140 | Target: "/etc/nginx", 141 | }, 142 | }, 143 | }, 144 | }, 145 | { 146 | name: "normal_symlinkToOtherHost", 147 | fields: fields{ 148 | log: zaptest.NewLogger(t), 149 | shell: func() shell.Iface { 150 | m := mock_shell.NewMockIface(mockCtrl) 151 | m.EXPECT().Host().Return("host02") 152 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc"). 153 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 154 | { // recursive due to resolve symlink 155 | m.EXPECT().Host().Return("host02") 156 | // /etc/nginx/nginx.conf (/etc/nginx is existed) 157 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx"). 158 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 159 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/nginx.conf"), "/etc/nginx/nginx.conf"). 160 | Return(nil) 161 | // /etc/nginx/sites-available/default (/etc/nginx/sites-available isn't existed) 162 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-available"). 163 | Return(bytes.Buffer{}, bytes.Buffer{}, fmt.Errorf("")) 164 | m.EXPECT().Execf(ctx, "", `mkdir -p "%s"`, "/etc/nginx/sites-available"). 165 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 166 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/default"), "/etc/nginx/sites-available/default"). 167 | Return(nil) 168 | // /etc/nginx/sites-available/isucondition.conf (/etc/nginx/sites-available is existed) 169 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-available"). 170 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 171 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/isucondition.conf"), "/etc/nginx/sites-available/isucondition.conf"). 172 | Return(nil) 173 | // /etc/nginx/sites-enabled/isucondition.conf (/etc/nginx/sites-enabled is existed) 174 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-enabled"). 175 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 176 | { // recursive due to resolve symlink 177 | m.EXPECT().Host().Return("host02") 178 | // /etc/nginx/sites-available/default (/etc/nginx/sites-available is existed) 179 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/nginx/sites-enabled"). 180 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 181 | m.EXPECT().Deploy(ctx, filepath.Join(testDir, "host01", "nginx/sites-available/isucondition.conf"), "/etc/nginx/sites-enabled/isucondition.conf"). 182 | Return(nil) 183 | } 184 | } 185 | return m 186 | }(), 187 | }, 188 | args: args{ 189 | host: "host02", 190 | targets: []config.DeployTarget{ 191 | { 192 | Src: "nginx_symlink", 193 | Target: "/etc/nginx", 194 | }, 195 | }, 196 | }, 197 | }, 198 | { 199 | name: "abnormal_symlinkCannotResolve", 200 | fields: fields{ 201 | log: zaptest.NewLogger(t), 202 | shell: func() shell.Iface { 203 | m := mock_shell.NewMockIface(mockCtrl) 204 | m.EXPECT().Host().Return("host01") 205 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc/error"). 206 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 207 | return m 208 | }(), 209 | }, 210 | args: args{ 211 | host: "host01", 212 | targets: []config.DeployTarget{ 213 | { 214 | Src: "error", 215 | Target: "/etc/error", 216 | }, 217 | }, 218 | }, 219 | wantErr: true, 220 | }, 221 | { 222 | name: "abnormal_symlinkToOutsideOfLocalRepo", 223 | fields: fields{ 224 | log: zaptest.NewLogger(t), 225 | shell: func() shell.Iface { 226 | m := mock_shell.NewMockIface(mockCtrl) 227 | m.EXPECT().Host().Return("host02") 228 | m.EXPECT().Execf(ctx, "", `test -d "%s"`, "/etc"). 229 | Return(bytes.Buffer{}, bytes.Buffer{}, nil) 230 | return m 231 | }(), 232 | }, 233 | args: args{ 234 | host: "host02", 235 | targets: []config.DeployTarget{ 236 | { 237 | Src: "hosts_symlink", 238 | Target: "/etc/hosts", 239 | }, 240 | }, 241 | }, 242 | wantErr: true, 243 | }, 244 | } 245 | for _, tt := range tests { 246 | t.Run(tt.name, func(t *testing.T) { 247 | d := Deployer{ 248 | log: tt.fields.log, 249 | shell: tt.fields.shell, 250 | template: tt.fields.template, 251 | localRepoPath: testDir, 252 | } 253 | if err := d.Deploy(ctx, tt.args.host, tt.args.targets); (err != nil) != tt.wantErr { 254 | t.Errorf("Deployer.Deploy() error = %v, wantErr %v", err, tt.wantErr) 255 | } 256 | }) 257 | } 258 | } 259 | --------------------------------------------------------------------------------