diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..9ed3b90 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,47 @@ +name: golangci-lint +on: + push: + tags: + - v* + branches: + - master + - main + - init + pull_request: +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + # pull-requests: read +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v3 + with: + go-version: ${{ secrets.GO_VERSION }} + - uses: actions/checkout@v3 + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version + version: latest + + # Optional: working directory, useful for monorepos + # working-directory: somedir + + # Optional: golangci-lint command line arguments. + # args: --issues-exit-code=0 + + # Optional: show only new issues if it's a pull request. The default value is `false`. + # only-new-issues: true + + # Optional: if set to true then the all caching functionality will be complete disabled, + # takes precedence over all other caching options. + # skip-cache: true + + # Optional: if set to true then the action don't cache or restore ~/go/pkg. + # skip-pkg-cache: true + + # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. + # skip-build-cache: true \ No newline at end of file diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..2cea23a --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,39 @@ +name: "Release" +on: + push: + tags: + - v* + +permissions: + contents: write + packages: write + +jobs: + release: + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v3 + - name: Install Go + uses: actions/setup-go@v3 + with: + # stable: 'false' # Keep this line to be able to use rc and beta version of Go (ex: 1.18.0-rc1). + go-version: 1.18 + - name: Unshallow + run: git fetch --prune --unshallow + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create release + uses: goreleaser/goreleaser-action@v3 + with: + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f5e3eba --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: ${{ secrets.GO_VERSION }} + + - name: Run coverage + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... + - name: Upload coverage to Codecov + run: bash <(curl -s https://codecov.io/bash) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18ec179 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/pong +/dist +/coverage.* + +*.retry diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..8cc4d34 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,65 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com +project_name: pong + +release: + footer: | + **Full Changelog**: https://github.com/worldline-go/pong/compare/{{ .PreviousTag }}...{{ .Tag }} +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + # - windows + # - darwin + main: ./cmd/pong + ldflags: + - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}} + flags: + - -trimpath +archives: + - replacements: + darwin: Darwin + linux: Linux + windows: Windows + 386: i386 + amd64: x86_64 + name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' + format_overrides: + - goos: windows + format: zip +checksum: + name_template: '{{ .ProjectName }}-{{ .Version }}-checksums.txt' +snapshot: + name_template: "{{ incpatch .Version }}-next" +changelog: + sort: asc + use: github + filters: + exclude: + - '^test:' + - '^chore' + - 'merge conflict' + - Merge pull request + - Merge remote-tracking branch + - Merge branch + - go mod tidy + groups: + - title: Dependency updates + regexp: "^.*feat\\(deps\\)*:+.*$" + order: 300 + - title: 'New Features' + regexp: "^.*feat[(\\w)]*:+.*$" + order: 100 + - title: 'Bug fixes' + regexp: "^.*fix[(\\w)]*:+.*$" + order: 200 + - title: 'Documentation updates' + regexp: "^.*docs[(\\w)]*:+.*$" + order: 400 + - title: Other work + order: 9999 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..adc4eba --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +BINARY := pong +ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +VERSION := $(or $(IMAGE_TAG),$(shell git describe --tags --first-parent --match "v*" 2> /dev/null || echo v0.0.0)) +LOCAL_BIN_DIR := $(ROOT_DIR)/bin + +.DEFAULT_GOAL := help + +.PHONY: $(BINARY) test coverage help html html-gen html-wsl + +build: ## Build the binary + go build -trimpath -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) cmd/$(BINARY)/main.go + +test: ## Run unit tests + @go test -race ./... + +coverage: ## Run unit tests with coverage + @go test -v -race -cover -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./... + @go tool cover -func=coverage.out + +html: ## Show html coverage result + @go tool cover -html=./coverage.out + +html-gen: ## Export html coverage result + @go tool cover -html=./coverage.out -o ./coverage.html + +html-wsl: html-gen ## Open html coverage result in wsl + @explorer.exe `wslpath -w ./coverage.html` || true + +help: ## Display this help screen + @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/README.md b/README.md new file mode 100644 index 0000000..87e794e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# PONG 🏓 + +[![Codecov](https://img.shields.io/codecov/c/github/worldline-go/pong?logo=codecov&style=flat-square)](https://app.codecov.io/gh/worldline-go/pong) +[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/worldline-go/pong/Test?logo=github&style=flat-square&label=ci)](https://github.com/worldline-go/pong/actions) +[![Go Reference](https://pkg.go.dev/badge/github.com/worldline-go/pong.svg)](https://pkg.go.dev/github.com/worldline-go/pong) + +Pong status checker. + +Currently support only REST. + +## Usage + +Give a json or yaml file with the following structure: + +```yaml +# LogLevel is the log level, default info +log_level: "debug" + +check: + rest: + - concurrent: 1 + check: + # URL could be multiple URLs, separated by space + - url: "https://api.punkapi.com/v2/beers/1 https://api.punkapi.com/v2/beers/2" + # Method is the HTTP method to use, default is GET + method: GET + # Timeout is in millisecond, default is 0 (no timeout) + timeout: 1000 + # Status to check, default 200 + status: 200 +``` + +```sh +pong test.yaml +``` + +## With Ansible + +Get pong binary in release page add the plugin modules area. + +```sh +make build +``` + +```sh +docker run -it --rm -v ${PWD}:/workspace williamyeh/ansible:debian9 /bin/bash +``` + +Inside the container + +```sh +echo localhost ansible_connection=local > /etc/ansible/hosts + +mkdir -p /usr/share/ansible/plugins/modules/ +cp /workspace/pong /usr/share/ansible/plugins/modules + +ansible-playbook /workspace/testdata/ansible/deploy_check.yml +``` + +Example of playbook + +```yaml +- name: Check image exists + pong: + check: + rest: + - concurrent: 1 + check: + - url: "{% for k,item in yaml_return.ansible_facts.compose.services.items() %} https://hub.docker.com/v2/repositories/{{ item.image.split(':')[0] }}/tags/{{ item.image.split(':')[1] }} {% endfor %}" + timeout: 1000 + register: http_result + failed_when: http_result.failed +``` + +## Development + +Run `make` command to see available commands. + +Local generate binary and docker image + +```sh +goreleaser release --snapshot --rm-dist +``` diff --git a/cmd/pong/main.go b/cmd/pong/main.go new file mode 100644 index 0000000..0b63c59 --- /dev/null +++ b/cmd/pong/main.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + + "github.com/rs/zerolog/log" + "github.com/worldline-go/logz" + + "github.com/worldline-go/pong/internal/load" + "github.com/worldline-go/pong/internal/model" + "github.com/worldline-go/pong/internal/route" +) + +var ( + // Populated by goreleaser during build + version = "v0.0.0" + commit = "?" + date = "" +) + +const helpText = `pong [OPTIONS] +version:[%s] commit:[%s] buildDate:[%s] + +Check server up and running + +Options: + -v, --version + Show version number + -h, --help + Show help + +Examples: + pong test.yml test2.json +` + +func usage() { + fmt.Printf(helpText, version, commit, date) + os.Exit(0) +} + +var ( + flagVersion bool +) + +func flagParse() []string { + flag.Usage = usage + + flag.BoolVar(&flagVersion, "v", false, "") + flag.BoolVar(&flagVersion, "version", false, "") + + flag.Parse() + + // Check Values + if flagVersion { + fmt.Println(version) + os.Exit(0) + } + + return flag.Args() +} + +func main() { + logz.InitializeLog(nil) + + files := flagParse() + + exitCode := 0 + wg := &sync.WaitGroup{} + + defer func() { + wg.Wait() + os.Exit(exitCode) + }() + + // check length of the arguments + if len(files) == 0 { + if err := load.Response(&model.ModuleResponse{ + Msg: "Missing argument file", + Failed: true, + }); err != nil { + log.Error().Err(err).Msg("error while responding") + } + + exitCode = 1 + + return + } + + // start operation + chNotify := make(chan os.Signal, 1) + + signal.Notify(chNotify, os.Interrupt, syscall.SIGTERM) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wg.Add(1) + + go func() { + defer wg.Done() + + select { + case <-ctx.Done(): + return + case <-chNotify: + log.Info().Msg("shutting down...") + log.Info().Msg("send signal again to exit force") + signal.Stop(chNotify) + close(chNotify) + cancel() + } + }() + + var errRequests []error + // read config + for _, file := range files { + args, err := load.ReadConfig(file) + if err != nil { + if err := load.Response(&model.ModuleResponse{ + Msg: err.Error(), + Failed: true, + }); err != nil { + log.Error().Err(err).Msg("error while responding") + } + + exitCode = 1 + + return + } + + errs := route.Request(ctx, args) + if len(errs) > 0 { + errRequests = append(errRequests, errs...) + } + } + + if len(errRequests) == 0 { + load.ResponseLog(&model.ModuleResponse{ + Msg: "OK", + Failed: false, + }) + + return + } + + var errStrings []string + for _, err := range errRequests { + log.Warn().Err(err).Msg("while doing request") + errStrings = append(errStrings, fmt.Sprintf("%v", err.Error())) + } + + load.ResponseLog(&model.ModuleResponse{ + Msg: strings.Join(errStrings, " "), + Failed: true, + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2c939b7 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/worldline-go/pong + +go 1.18 + +require ( + github.com/rs/zerolog v1.28.0 + github.com/worldline-go/logz v0.1.0 +) + +require ( + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e // indirect + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d986e35 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= +github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/worldline-go/logz v0.1.0 h1:F4e7xjS/T+wJnCfCY66zeg2Uj2/5ATnXnfTLzXXFa7U= +github.com/worldline-go/logz v0.1.0/go.mod h1:CT9jFCJUR8uNYZacUDV+VaNQWdxQLQ9g2UayR4QmMzg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e h1:CsOuNlbOuf0mzxJIefr6Q4uAUetRUwZE4qt7VfzP+xo= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/load/load.go b/internal/load/load.go new file mode 100644 index 0000000..e8313b5 --- /dev/null +++ b/internal/load/load.go @@ -0,0 +1,57 @@ +package load + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" + + "github.com/rs/zerolog/log" + "github.com/worldline-go/logz" + "github.com/worldline-go/pong/internal/model" +) + +func ReadConfig(file string) (*model.ModuleArgs, error) { + // read file + v, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + + // unmarshal with yaml + args := model.ModuleArgs{ + LogLevel: "info", + } + + err = yaml.Unmarshal(v, &args) + if err != nil { + return nil, fmt.Errorf("unmarshalling yaml: %w", err) + } + + // set default values + defaultArgs(&args) + + if err := logz.SetLogLevel(args.LogLevel); err != nil { + return nil, fmt.Errorf("setting log level: %w", err) + } + + log.Debug().Msgf("read file %s", file) + + return &args, nil +} + +func defaultArgs(args *model.ModuleArgs) { + for i := range args.Check.Rest { + if args.Check.Rest[i].Concurrent == 0 { + args.Check.Rest[i].Concurrent = model.DefaultRestClient.Concurrent + } + for j := range args.Check.Rest[i].Check { + if args.Check.Rest[i].Check[j].Method == "" { + args.Check.Rest[i].Check[j].Method = model.DefaultRestCheck.Method + } + if args.Check.Rest[i].Check[j].Status == 0 { + args.Check.Rest[i].Check[j].Status = model.DefaultRestCheck.Status + } + } + } +} diff --git a/internal/load/response.go b/internal/load/response.go new file mode 100644 index 0000000..04dcfaa --- /dev/null +++ b/internal/load/response.go @@ -0,0 +1,27 @@ +package load + +import ( + "encoding/json" + "fmt" + + "github.com/rs/zerolog/log" + + "github.com/worldline-go/pong/internal/model" +) + +func Response(response *model.ModuleResponse) error { + responseBytes, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("marshaling response: %w", err) + } + + fmt.Println(string(responseBytes)) + + return nil +} + +func ResponseLog(response *model.ModuleResponse) { + if err := Response(response); err != nil { + log.Error().Err(err).Msg("while responding") + } +} diff --git a/internal/model/default.go b/internal/model/default.go new file mode 100644 index 0000000..9648530 --- /dev/null +++ b/internal/model/default.go @@ -0,0 +1,10 @@ +package model + +var DefaultRestCheck = RestCheck{ + Method: "GET", + Status: 200, +} + +var DefaultRestClient = RestClient{ + Concurrent: 1, +} diff --git a/internal/model/module.go b/internal/model/module.go new file mode 100644 index 0000000..4ee4a1a --- /dev/null +++ b/internal/model/module.go @@ -0,0 +1,33 @@ +package model + +type RestCheck struct { + // URL could be multiple URLs, separated by space + URL string `json:"url" yaml:"url"` + // Method is the HTTP method to use, default is GET + Method string `json:"method" yaml:"method"` + // Status to check, default 200 + Status int `json:"status" yaml:"status"` + // Timeout is in seconds, default 5 + Timeout int `json:"timeout" yaml:"timeout"` +} + +type RestClient struct { + // Concurrent is the number of concurrent requests, default 1 + Concurrent int `json:"concurrent" yaml:"concurrent"` + Check []RestCheck `json:"check" yaml:"check"` +} + +type Check struct { + Rest []RestClient `json:"rest" yaml:"rest"` +} + +type ModuleArgs struct { + Check Check `json:"check" yaml:"check"` + // LogLevel is the log level, default info + LogLevel string `json:"log_level" yaml:"log_level"` +} + +type ModuleResponse struct { + Msg string `json:"msg"` + Failed bool `json:"failed"` +} diff --git a/internal/registry/error.go b/internal/registry/error.go new file mode 100644 index 0000000..35f5313 --- /dev/null +++ b/internal/registry/error.go @@ -0,0 +1,15 @@ +package registry + +import "sync" + +type Errors struct { + Errs []error + mutex sync.Mutex +} + +func (e *Errors) AddError(err error) { + e.mutex.Lock() + defer e.mutex.Unlock() + + e.Errs = append(e.Errs, err) +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..7baa895 --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,69 @@ +package registry + +import ( + "context" + "fmt" + "sync" +) + +type ClientReg struct { + msgChan chan interface{} + clients []interface{} + *Errors + // mutexErr sync.RWMutex +} + +func NewClientReg(errs *Errors) *ClientReg { + msgChan := make(chan interface{}) + return &ClientReg{ + msgChan: msgChan, + Errors: errs, + } +} + +type ClientHolder interface { + Work() +} + +type GetNewClientHolder func(ctx context.Context, ctxCancel context.CancelFunc, r *ClientReg) ClientHolder + +func (r *ClientReg) GetMsgChan() <-chan interface{} { + return r.msgChan +} + +func (r *ClientReg) CloseChan() { + close(r.msgChan) +} + +func (r *ClientReg) SetClient(ctx context.Context, wg *sync.WaitGroup, cancel context.CancelFunc, + i int, getNewClientHolder GetNewClientHolder, +) *ClientReg { + if i < 0 { + i = 0 + } + + if i+1 > len(r.clients) { + instance := getNewClientHolder(ctx, cancel, r) + + r.clients = append(r.clients, instance) + + wg.Add(1) + + go func(client ClientHolder) { + defer wg.Done() + + client.Work() + }(instance) + } + + return r +} + +func (r *ClientReg) SendMessage(ctx context.Context, msg interface{}) error { + select { + case r.msgChan <- msg: + return nil + case <-ctx.Done(): + return fmt.Errorf("context canceled") + } +} diff --git a/internal/route/rest/call.go b/internal/route/rest/call.go new file mode 100644 index 0000000..a080276 --- /dev/null +++ b/internal/route/rest/call.go @@ -0,0 +1,40 @@ +package rest + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/worldline-go/pong/internal/model" + "github.com/worldline-go/pong/internal/registry" +) + +func Request(ctx context.Context, cancel context.CancelFunc, wg *sync.WaitGroup, args *model.RestCheck, concurrent int, reg *registry.ClientReg) { + // remove trailing spaces and multiple spaces + urlX := strings.TrimSpace(args.URL) + urlX = strings.ReplaceAll(urlX, " ", " ") + + urls := strings.Split(urlX, " ") + timeout := time.Duration(args.Timeout) * time.Millisecond + + gData := GeneralData{} + + // create a new client holderFunc + newClientHolderFn := NewClientHolder(gData) + + for i, url := range urls { + selectedClient := i % concurrent + // open new client if not exist + reg.SetClient(ctx, wg, cancel, selectedClient, newClientHolderFn) + + if err := reg.SendMessage(ctx, &Msg{ + URL: url, + Method: args.Method, + Status: args.Status, + Timeout: timeout, + }); err != nil { + break + } + } +} diff --git a/internal/route/rest/call_test.go b/internal/route/rest/call_test.go new file mode 100644 index 0000000..a069760 --- /dev/null +++ b/internal/route/rest/call_test.go @@ -0,0 +1,114 @@ +package rest + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/worldline-go/pong/internal/model" + "github.com/worldline-go/pong/internal/registry" +) + +func TestRequest(t *testing.T) { + type args struct { + check *model.RestCheck + } + tests := []struct { + name string + args args + concurrent int + want string + wantIn bool + handler func(w http.ResponseWriter, r *http.Request) + }{ + { + name: "simple one test", + args: args{ + check: &model.RestCheck{ + Status: 200, + Timeout: 2, + }, + }, + concurrent: 1, + want: "[]", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + }, + { + name: "multi test", + args: args{ + check: &model.RestCheck{ + URL: "/abc /xyz", + Method: "GET", + Status: 200, + Timeout: 2, + }, + }, + concurrent: 1, + want: `status code: 502; want: 200`, + wantIn: true, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }, + }, + { + name: "multi test with concurrent", + args: args{ + check: &model.RestCheck{ + URL: "/abc /xyz", + Method: "GET", + Status: 200, + Timeout: 2, + }, + }, + concurrent: 2, + want: `status code: 502; want: 200`, + wantIn: true, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + }, + }, + } + + var handlerFunc = func(w http.ResponseWriter, r *http.Request) {} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if handlerFunc != nil { + handlerFunc(w, r) + } + })) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wg := &sync.WaitGroup{} + errs := ®istry.Errors{} + reg := registry.NewClientReg(errs) + + handlerFunc = tt.handler + urls := strings.Split(tt.args.check.URL, " ") + for i, url := range urls { + urls[i] = fmt.Sprintf("%s%s", srv.URL, url) + } + + tt.args.check.URL = strings.Join(urls, " ") + ctx, cancel := context.WithCancel(context.Background()) + Request(ctx, cancel, wg, tt.args.check, tt.concurrent, reg) + reg.CloseChan() + wg.Wait() + cancel() + + if !tt.wantIn && fmt.Sprintf("%v", reg.Errors.Errs) != tt.want { + t.Errorf("Request() = %v, want %v", reg.Errors.Errs, tt.want) + } + + if tt.wantIn && !strings.Contains(fmt.Sprintf("%v", reg.Errors.Errs), tt.want) { + t.Errorf("Request() = %v, want %v", reg.Errors, tt.want) + } + }) + } +} diff --git a/internal/route/rest/clear.go b/internal/route/rest/clear.go new file mode 100644 index 0000000..55557ce --- /dev/null +++ b/internal/route/rest/clear.go @@ -0,0 +1,13 @@ +package rest + +import "strings" + +func cleanMethod(method string) string { + method = strings.TrimSpace(method) + + if method == "" { + return "GET" + } + + return strings.TrimSpace(strings.ToUpper(method)) +} diff --git a/internal/route/rest/client.go b/internal/route/rest/client.go new file mode 100644 index 0000000..c0808df --- /dev/null +++ b/internal/route/rest/client.go @@ -0,0 +1,110 @@ +package rest + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/rs/zerolog/log" + "github.com/worldline-go/pong/internal/registry" +) + +type GeneralData struct{} + +type Msg struct { + URL string + Method string + Status int + Timeout time.Duration +} + +type ClientHolder struct { + Ctx context.Context + Client *http.Client + MsgChan <-chan interface{} + Reg *registry.ClientReg + CtxCancel context.CancelFunc + GeneralData GeneralData +} + +func NewClientHolder(gData GeneralData) func(ctx context.Context, ctxCancel context.CancelFunc, r *registry.ClientReg) registry.ClientHolder { + return func(ctx context.Context, ctxCancel context.CancelFunc, r *registry.ClientReg) registry.ClientHolder { + return &ClientHolder{ + Client: &http.Client{}, + MsgChan: r.GetMsgChan(), + GeneralData: gData, + Reg: r, + Ctx: ctx, + CtxCancel: ctxCancel, + } + } +} + +func (c *ClientHolder) DoRequest(ctx context.Context, timeout time.Duration, method, url string, status int) error { + method = cleanMethod(method) + + ctxT := ctx + if timeout != 0 { + var cancel context.CancelFunc + ctxT, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + req, err := http.NewRequestWithContext(ctxT, method, url, nil) + if err != nil { + return fmt.Errorf("%s, creating request: %w", url, err) + } + + resp, err := c.Client.Do(req) + if err != nil { + return fmt.Errorf("%s, doing request: %w", url, err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + log.Info().Msgf("%s", body) + + if resp.StatusCode != status { + return fmt.Errorf("%s, status code: %d; want: %d", url, resp.StatusCode, status) + } + + return nil +} + +func (c *ClientHolder) Work() { + for { + select { + case <-c.Ctx.Done(): + return + case msg := <-c.MsgChan: + // check channel is closed + if msg == nil { + return + } + + // check message type + m, ok := msg.(*Msg) + if !ok { + log.Error().Msgf("wrong message type: %T", msg) + continue + } + + log.Debug().Msgf("Sending request to %s", m.URL) + + if err := c.DoRequest(c.Ctx, m.Timeout, m.Method, m.URL, m.Status); err != nil { + c.close() + // record error + c.Reg.AddError(err) + + return + } + } + } +} + +func (c *ClientHolder) close() { + // stop to redirect new messages + c.CtxCancel() +} diff --git a/internal/route/route.go b/internal/route/route.go new file mode 100644 index 0000000..47c9cd5 --- /dev/null +++ b/internal/route/route.go @@ -0,0 +1,55 @@ +package route + +import ( + "context" + "sync" + + "github.com/worldline-go/pong/internal/model" + "github.com/worldline-go/pong/internal/registry" + "github.com/worldline-go/pong/internal/route/rest" +) + +func Request(ctx context.Context, args *model.ModuleArgs) []error { + wg := &sync.WaitGroup{} + + errs := ®istry.Errors{} + + // call rest requests + wg.Add(1) + go RestRequest(ctx, wg, errs, args.Check.Rest) + + wg.Wait() + + return errs.Errs +} + +func RestRequest(ctx context.Context, wg *sync.WaitGroup, errs *registry.Errors, args []model.RestClient) { + defer wg.Done() + + wgRest := &sync.WaitGroup{} + for _, client := range args { + // new area for each client + reg := registry.NewClientReg(errs) + wgRest.Add(1) + // fast pass + go RestCheck(ctx, wgRest, client.Check, client.Concurrent, reg) + } + + wgRest.Wait() +} + +func RestCheck(ctx context.Context, wg *sync.WaitGroup, args []model.RestCheck, concurrent int, reg *registry.ClientReg) { + defer wg.Done() + wgRest := &sync.WaitGroup{} + + ctxRest, cancel := context.WithCancel(ctx) + defer cancel() + + for i := range args { + rest.Request(ctxRest, cancel, wgRest, &args[i], concurrent, reg) + } + + // all messages sent close channel for registry + reg.CloseChan() + wgRest.Wait() +} diff --git a/testdata/ansible/deploy_check.yml b/testdata/ansible/deploy_check.yml new file mode 100644 index 0000000..a67713e --- /dev/null +++ b/testdata/ansible/deploy_check.yml @@ -0,0 +1,4 @@ +- hosts: localhost + gather_facts: no + roles: + - imagecheck diff --git a/testdata/ansible/roles/imagecheck/tasks/main.yml b/testdata/ansible/roles/imagecheck/tasks/main.yml new file mode 100644 index 0000000..afc9e06 --- /dev/null +++ b/testdata/ansible/roles/imagecheck/tasks/main.yml @@ -0,0 +1,28 @@ +--- +- name: Load yaml file + include_vars: + dir: /workspace/testdata/compose + ignore_unknown_extensions: true + extensions: + - yml + - yaml + name: compose + register: yaml_return + +# - name: Get URLS +# debug: +# # https://hub.docker.com/v2/repositories/rytsh/frontend-node/tags/v0.1.1 +# msg: "{% for k,item in yaml_return.ansible_facts.compose.services.items() %} https://hub.docker.com/v2/repositories/{{ item.image.split(':')[0] }}/tags/{{ item.image.split(':')[1] }} {% endfor %}" +# when: yaml_return.failed == false +# register: url_list + +- name: Check image exists + pong: + check: + rest: + - concurrent: 1 + check: + - url: "{% for k,item in yaml_return.ansible_facts.compose.services.items() %} https://hub.docker.com/v2/repositories/{{ item.image.split(':')[0] }}/tags/{{ item.image.split(':')[1] }} {% endfor %}" + timeout: 1000 + register: http_result + failed_when: http_result.failed diff --git a/testdata/compose/docker-compose.yaml b/testdata/compose/docker-compose.yaml new file mode 100644 index 0000000..e19f461 --- /dev/null +++ b/testdata/compose/docker-compose.yaml @@ -0,0 +1,5 @@ +services: + test-1: + image: rytsh/frontend-node:v0.1.0 + # test-2: + # image: myawesomeimage:test2 diff --git a/testdata/test.yaml b/testdata/test.yaml new file mode 100644 index 0000000..fd8e0a4 --- /dev/null +++ b/testdata/test.yaml @@ -0,0 +1,15 @@ +log_level: "warn" +check: + rest: + - concurrent: 5 + check: + - url: "https://api.punkapi.com/v2/beers/1 https://api.punkapi.com/v2/beers/2 https://api.punkapi.com/v2/beers/3 https://api.punkapi.com/v2/beers/4 https://api.punkapi.com/v2/beers/5 https://api.punkapi.com/v2/beers/6 https://api.punkapi.com/v2/beers/7 https://api.punkapi.com/v2/beers/8 https://api.punkapi.com/v2/beers/9 https://api.punkapi.com/v2/beers/10 https://api.punkapi.com/v2/beers/11 https://api.punkapi.com/v2/beers/12 https://api.punkapi.com/v2/beers/13 https://api.punkapi.com/v2/beers/14 https://api.punkapi.com/v2/beers/15 https://api.punkapi.com/v2/beers/16 https://api.punkapi.com/v2/beers/17 https://api.punkapi.com/v2/beers/18 https://api.punkapi.com/v2/beers/19 https://api.punkapi.com/v2/beers/20 https://api.punkapi.com/v2/beers/21 https://api.punkapi.com/v2/beers/22 https://api.punkapi.com/v2/beers/23 https://api.punkapi.com/v2/beers/24 https://api.punkapi.com/v2/beers/25 https://api.punkapi.com/v2/beers/26 https://api.punkapi.com/v2/beers/27 https://api.punkapi.com/v2/beers/28 https://api.punkapi.com/v2/beers/29 https://api.punkapi.com/v2/beers/30 https://api.punkapi.com/v2/beers/31 https://api.punkapi.com/v2/beers/32 https://api.punkapi.com/v2/beers/33 https://api.punkapi.com/v2/beers/34 https://api.punkapi.com/v2/beers/35 https://api.punkapi.com/v2/beers/36 https://api.punkapi.com/v2/beers/37 https://api.punkapi.com/v2/beers/38 https://api.punkapi.com/v2/beers/39 https://api.punkapi.com/v2/beers/40 https://api.punkapi.com/v2/beers/41 https://api.punkapi.com/v2/beers/42 https://api.punkapi.com/v2/beers/43 https://api.punkapi.com/v2/beers/44 https://api.punkapi.com/v2/beers/45 https://api.punkapi.com/v2/beers/46 https://api.punkapi.com/v2/beers/47 https://api.punkapi.com/v2/beers/48 https://api.punkapi.com/v2/beers/49 https://api.punkapi.com/v2/beers/50 https://api.punkapi.com/v2/beers/51 https://api.punkapi.com/v2/beers/52 https://api.punkapi.com/v2/beers/53 https://api.punkapi.com/v2/beers/54 https://api.punkapi.com/v2/beers/55 https://api.punkapi.com/v2/beers/56 https://api.punkapi.com/v2/beers/57 https://api.punkapi.com/v2/beers/58 https://api.punkapi.com/v2/beers/59 https://api.punkapi.com/v2/beers/60 https://api.punkapi.com/v2/beers/61 https://api.punkapi.com/v2/beers/62 https://api.punkapi.com/v2/beers/63 https://api.punkapi.com/v2/beers/64 https://api.punkapi.com/v2/beers/65 https://api.punkapi.com/v2/beers/66 https://api.punkapi.com/v2/beers/67 https://api.punkapi.com/v2/beers/68 https://api.punkapi.com/v2/beers/69 https://api.punkapi.com/v2/beers/70 https://api.punkapi.com/v2/beers/71 https://api.punkapi.com/v2/beers/72 https://api.punkapi.com/v2/beers/73 https://api.punkapi.com/v2/beers/74 https://api.punkapi.com/v2/beers/75 https://api.punkapi.com/v2/beers/76 https://api.punkapi.com/v2/beers/77 https://api.punkapi.com/v2/beers/78 https://api.punkapi.com/v2/beers/79 https://api.punkapi.com/v2/beers/80 https://api.punkapi.com/v2/beers/81 https://api.punkapi.com/v2/beers/82 https://api.punkapi.com/v2/beers/83 https://api.punkapi.com/v2/beers/84 https://api.punkapi.com/v2/beers/85 https://api.punkapi.com/v2/beers/86 https://api.punkapi.com/v2/beers/87 https://api.punkapi.com/v2/beers/88 https://api.punkapi.com/v2/beers/89 https://api.punkapi.com/v2/beers/90 https://api.punkapi.com/v2/beers/91 https://api.punkapi.com/v2/beers/92 https://api.punkapi.com/v2/beers/93 https://api.punkapi.com/v2/beers/94 https://api.punkapi.com/v2/beers/95 https://api.punkapi.com/v2/beers/96 https://api.punkapi.com/v2/beers/97 https://api.punkapi.com/v2/beers/98 https://api.punkapi.com/v2/beers/99" + # timeout: 1000 + status: 200 + - url: "https://api.punkapi.com/v2/beers/you-wont-find-me" + status: 400 + - concurrent: 2 + check: + - url: "https://api.punkapi.com/v2/beers/1boylebirseyyok https://api.punkapi.com/v2/beers/2yok" + timeout: 1000 + status: 400