From 9a2146541b60b4bd5d3e713248f3bfbe453f518a Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Sun, 24 Dec 2023 12:34:40 +0100 Subject: [PATCH] Change grep flag to pass/skip, update usage and tests (#11) --- .github/workflows/golangci-lint.yml | 2 +- .github/workflows/test-unit.yml | 136 ++++++++++++++++++++++ Makefile | 5 +- cmd/catp/README.md | 49 +++++--- cmd/catp/catp/app.go | 109 +++++++++++++---- cmd/catp/catp/app_test.go | 45 +++++++ cmd/catp/catp/cgo_zstd.go | 4 + cmd/catp/catp/testdata/.gitignore | 1 + cmd/catp/catp/testdata/release-assets.yml | 94 +++++++++++++++ cmd/catp/default.pgo | Bin 7035 -> 6287 bytes 10 files changed, 404 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/test-unit.yml create mode 100644 cmd/catp/catp/app_test.go create mode 100644 cmd/catp/catp/testdata/.gitignore create mode 100644 cmd/catp/catp/testdata/release-assets.yml diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index d2e4b61..5e985cc 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -27,7 +27,7 @@ jobs: uses: golangci/golangci-lint-action@v3.7.0 with: # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. - version: v1.54.1 + version: v1.55.2 # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml new file mode 100644 index 0000000..4214222 --- /dev/null +++ b/.github/workflows/test-unit.yml @@ -0,0 +1,136 @@ +# This script is provided by github.com/bool64/dev. +name: test-unit +on: + push: + branches: + - master + - main + pull_request: + +# Cancel the workflow in progress in newer build is about to start. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + GO111MODULE: "on" + RUN_BASE_COVERAGE: "on" # Runs test for PR base in case base test coverage is missing. + COV_GO_VERSION: 1.21.x # Version of Go to collect coverage + TARGET_DELTA_COV: 90 # Target coverage of changed lines, in percents +jobs: + test: + strategy: + matrix: + go-version: [ 1.19.x, 1.20.x, 1.21.x ] + runs-on: ubuntu-latest + steps: + - name: Install Go stable + if: matrix.go-version != 'tip' + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go-version }} + + - name: Install Go tip + if: matrix.go-version == 'tip' + run: | + curl -sL https://storage.googleapis.com/go-build-snap/go/linux-amd64/$(git ls-remote https://github.com/golang/go.git HEAD | awk '{print $1;}').tar.gz -o gotip.tar.gz + ls -lah gotip.tar.gz + mkdir -p ~/sdk/gotip + tar -C ~/sdk/gotip -xzf gotip.tar.gz + ~/sdk/gotip/bin/go version + echo "PATH=$HOME/go/bin:$HOME/sdk/gotip/bin/:$PATH" >> $GITHUB_ENV + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Go cache + uses: actions/cache@v3 + with: + # In order: + # * Module download cache + # * Build cache (Linux) + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-cache + + - name: Restore base test coverage + id: base-coverage + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' + uses: actions/cache@v2 + with: + path: | + unit-base.txt + # Use base sha for PR or new commit hash for master/main push in test result key. + key: ${{ runner.os }}-unit-test-coverage-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} + + - name: Run test for base code + if: matrix.go-version == env.COV_GO_VERSION && env.RUN_BASE_COVERAGE == 'on' && steps.base-coverage.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' + run: | + git fetch origin master ${{ github.event.pull_request.base.sha }} + HEAD=$(git rev-parse HEAD) + git reset --hard ${{ github.event.pull_request.base.sha }} + (make test-unit && go tool cover -func=./unit.coverprofile > unit-base.txt) || echo "No test-unit in base" + git reset --hard $HEAD + + - name: Test + id: test + run: | + make test-unit + go tool cover -func=./unit.coverprofile > unit.txt + TOTAL=$(grep 'total:' unit.txt) + echo "${TOTAL}" + echo "total=$TOTAL" >> $GITHUB_OUTPUT + + - name: Annotate missing test coverage + id: annotate + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' + run: | + curl -sLO https://github.com/vearutop/gocovdiff/releases/download/v1.4.2/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz && rm linux_amd64.tar.gz + gocovdiff_hash=$(git hash-object ./gocovdiff) + [ "$gocovdiff_hash" == "c37862c73a677e5a9c069470287823ab5bbf0244" ] || (echo "::error::unexpected hash for gocovdiff, possible tampering: $gocovdiff_hash" && exit 1) + git fetch origin master ${{ github.event.pull_request.base.sha }} + REP=$(./gocovdiff -mod github.com/$GITHUB_REPOSITORY -cov unit.coverprofile -gha-annotations gha-unit.txt -delta-cov-file delta-cov-unit.txt -target-delta-cov ${TARGET_DELTA_COV}) + echo "${REP}" + cat gha-unit.txt + DIFF=$(test -e unit-base.txt && ./gocovdiff -mod github.com/$GITHUB_REPOSITORY -func-cov unit.txt -func-base-cov unit-base.txt || echo "Missing base coverage file") + TOTAL=$(cat delta-cov-unit.txt) + echo "rep<> $GITHUB_OUTPUT && echo "$REP" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT + echo "diff<> $GITHUB_OUTPUT && echo "$DIFF" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT + echo "total<> $GITHUB_OUTPUT && echo "$TOTAL" >> $GITHUB_OUTPUT && echo "EOF" >> $GITHUB_OUTPUT + + - name: Comment test coverage + continue-on-error: true + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' + uses: marocchino/sticky-pull-request-comment@v2 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + header: unit-test + message: | + ### Unit Test Coverage + ${{ steps.test.outputs.total }} + ${{ steps.annotate.outputs.total }} +
Coverage of changed lines + + ${{ steps.annotate.outputs.rep }} + +
+ +
Coverage diff with base branch + + ${{ steps.annotate.outputs.diff }} + +
+ + - name: Store base coverage + if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + run: cp unit.txt unit-base.txt + + - name: Upload code coverage + if: matrix.go-version == env.COV_GO_VERSION + uses: codecov/codecov-action@v1 + with: + file: ./unit.coverprofile + flags: unittests diff --git a/Makefile b/Makefile index 650b826..4926457 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -#GOLANGCI_LINT_VERSION := "v1.54.1" # Optional configuration to pinpoint golangci-lint version. +#GOLANGCI_LINT_VERSION := "v1.55.2" # Optional configuration to pinpoint golangci-lint version. # The head of Makefile determines location of dev-go to include standard targets. GO ?= go @@ -33,9 +33,12 @@ BUILD_LDFLAGS=-s -w -include $(DEVGO_PATH)/makefiles/main.mk -include $(DEVGO_PATH)/makefiles/lint.mk +-include $(DEVGO_PATH)/makefiles/test-unit.mk -include $(DEVGO_PATH)/makefiles/build.mk -include $(DEVGO_PATH)/makefiles/release-assets.mk -include $(DEVGO_PATH)/makefiles/reset-ci.mk # Add your custom targets here. +## Run tests +test: test-unit diff --git a/cmd/catp/README.md b/cmd/catp/README.md index ceb209d..5bbbac9 100644 --- a/cmd/catp/README.md +++ b/cmd/catp/README.md @@ -18,36 +18,49 @@ wget https://github.com/bool64/progress/releases/latest/download/linux_amd64.tar ## Usage ``` +catp dev, go1.22rc1 CGO_ZSTD + +catp prints contents of files to STDOUT or dir/file output, +while printing current progress status to STDERR. +It can decompress data from .gz and .zst files. + Usage of catp: +catp [OPTIONS] PATH ... -dbg-cpu-prof string - write first 10 seconds of CPU profile to file + write first 10 seconds of CPU profile to file -dbg-mem-prof string - write heap profile to file after 10 seconds - -grep value - grep pattern, may contain multiple OR patterns separated by \|, - each -grep value is added with AND logic, akin to extra '| grep foo', - for example, you can use '-grep bar\|baz -grep foo' to only keep lines that have (bar OR baz) AND foo + write heap profile to file after 10 seconds -no-progress - disable progress printing + disable progress printing -out-dir string - output to directory instead of STDOUT - files will be written to out dir with original base names - disables output flag + output to directory instead of STDOUT + files will be written to out dir with original base names + disables output flag -output string - output to file instead of STDOUT + output to file instead of STDOUT -parallel int - number of parallel readers if multiple files are provided - lines from different files will go to output simultaneously - use 0 for multi-threaded zst decoder (slightly faster at cost of more CPU) (default 1) + number of parallel readers if multiple files are provided + lines from different files will go to output simultaneously (out of order of files, but in order of lines in each file) + use 0 for multi-threaded zst decoder (slightly faster at cost of more CPU) (default 1) + -pass value + filter matching, may contain multiple AND patterns separated by ^, + if filter matches, line is passed to the output (unless filtered out by -skip) + each -pass value is added with OR logic, + for example, you can use "-pass bar^baz -pass foo" to only keep lines that have (bar AND baz) OR foo -progress-json string - write current progress to a file + write current progress to a file + -skip value + filter matching, may contain multiple AND patterns separated by ^, + if filter matches, line is removed from the output (even if it passed -pass) + each -skip value is added with OR logic, + for example, you can use "-skip quux^baz -skip fooO" to skip lines that have (quux AND baz) OR fooO -version - print version and exit + print version and exit ``` ## Examples -Feed a file into `jq` field extractor. +Feed a file into `jq` field extractor with progress printing. ``` catp get-key.log | jq .context.callback.Data.Nonce > get-key.jq @@ -65,7 +78,7 @@ Run log filtering (lines containing `foo bar` or `baz`) on multiple files in bac new file. ``` -screen -dmS foo12 ./catp -output ~/foo-2023-07-12.log -grep "foo bar\|baz" /home/logs/server-2023-07-12* +screen -dmS foo12 ./catp -output ~/foo-2023-07-12.log -pass "foo bar" -pass "baz" /home/logs/server-2023-07-12* ``` ``` diff --git a/cmd/catp/catp/app.go b/cmd/catp/catp/app.go index db8f2fb..f69882f 100644 --- a/cmd/catp/catp/app.go +++ b/cmd/catp/catp/app.go @@ -23,6 +23,8 @@ import ( gzip "github.com/klauspost/pgzip" ) +var versionExtra []string + type runner struct { mu sync.Mutex output io.Writer @@ -39,8 +41,10 @@ type runner struct { currentBytes int64 currentLines int64 - // grep is a slice of AND items, that are slices of OR items. - grep [][][]byte + // pass is a slice of OR items, that are slices of AND items. + pass [][][]byte + // skip is a slice of OR items, that are slices of AND items. + skip [][][]byte currentFile *progress.CountingReader currentTotal int64 @@ -78,7 +82,7 @@ func (r *runner) st(s progress.Status) string { s.Elapsed.Round(10*time.Millisecond).String(), s.Remaining.String()) } - if r.grep != nil { + if len(r.pass) > 0 || len(r.skip) > 0 { m := atomic.LoadInt64(&r.matches) pr.Matches = &m res += fmt.Sprintf(", matches %d", m) @@ -159,20 +163,46 @@ func (r *runner) scanFile(rd io.Reader, out io.Writer) { } func (r *runner) shouldWrite(line []byte) bool { - shouldWrite := true + shouldWrite := false - for _, andGrep := range r.grep { - andPassed := false + if len(r.pass) == 0 { + shouldWrite = true + } else { + for _, orFilter := range r.pass { + orPassed := true - for _, orGrep := range andGrep { - if bytes.Contains(line, orGrep) { - andPassed = true + for _, andFilter := range orFilter { + if !bytes.Contains(line, andFilter) { + orPassed = false + + break + } + } + + if orPassed { + shouldWrite = true break } } + } - if !andPassed { + if !shouldWrite { + return shouldWrite + } + + for _, orFilter := range r.skip { + orPassed := true + + for _, andFilter := range orFilter { + if !bytes.Contains(line, andFilter) { + orPassed = false + + break + } + } + + if orPassed { shouldWrite = false break @@ -249,7 +279,7 @@ func (r *runner) cat(filename string) (err error) { }) } - if len(r.grep) > 0 { + if len(r.pass) > 0 || len(r.skip) > 0 { r.scanFile(rd, out) } else { r.readFile(rd, out) @@ -331,14 +361,23 @@ func (i *stringFlags) Set(value string) error { // Main is the entry point for catp CLI tool. func Main() error { //nolint:funlen,cyclop,gocognit,gocyclo - var grep stringFlags + var ( + pass stringFlags + skip stringFlags + ) + + flag.Var(&pass, "pass", "filter matching, may contain multiple AND patterns separated by ^,\n"+ + "if filter matches, line is passed to the output (unless filtered out by -skip)\n"+ + "each -pass value is added with OR logic,\n"+ + "for example, you can use \"-pass bar^baz -pass foo\" to only keep lines that have (bar AND baz) OR foo") - flag.Var(&grep, "grep", "grep pattern, may contain multiple OR patterns separated by \\|,\n"+ - "each -grep value is added with AND logic, akin to extra '| grep foo',\n"+ - "for example, you can use '-grep bar\\|baz -grep foo' to only keep lines that have (bar OR baz) AND foo") + flag.Var(&skip, "skip", "filter matching, may contain multiple AND patterns separated by ^,\n"+ + "if filter matches, line is removed from the output (even if it passed -pass)\n"+ + "each -skip value is added with OR logic,\n"+ + "for example, you can use \"-skip quux^baz -skip fooO\" to skip lines that have (quux AND baz) OR fooO") parallel := flag.Int("parallel", 1, "number of parallel readers if multiple files are provided\n"+ - "lines from different files will go to output simultaneously\n"+ + "lines from different files will go to output simultaneously (out of order of files, but in order of lines in each file)\n"+ "use 0 for multi-threaded zst decoder (slightly faster at cost of more CPU)") cpuProfile := flag.String("dbg-cpu-prof", "", "write first 10 seconds of CPU profile to file") @@ -351,6 +390,17 @@ func Main() error { //nolint:funlen,cyclop,gocognit,gocyclo progressJSON := flag.String("progress-json", "", "write current progress to a file") ver := flag.Bool("version", false, "print version and exit") + flag.Usage = func() { + fmt.Println("catp", version.Info().Version+",", version.Info().GoVersion, strings.Join(versionExtra, " ")) + fmt.Println() + fmt.Println("catp prints contents of files to STDOUT or dir/file output, \n" + + "while printing current progress status to STDERR. \n" + + "It can decompress data from .gz and .zst files.") + fmt.Println() + fmt.Println("Usage of catp:") + fmt.Println("catp [OPTIONS] PATH ...") + flag.PrintDefaults() + } flag.Parse() if *ver { @@ -359,6 +409,12 @@ func Main() error { //nolint:funlen,cyclop,gocognit,gocyclo return nil } + if flag.NArg() == 0 { + flag.Usage() + + return nil + } + if *cpuProfile != "" { startProfiling(*cpuProfile, *memProfile) @@ -399,14 +455,25 @@ func Main() error { //nolint:funlen,cyclop,gocognit,gocyclo }() } - if len(grep) > 0 { - for _, andGrep := range grep { + if len(pass) > 0 { + for _, orFilter := range pass { + var og [][]byte + for _, andFilter := range strings.Split(orFilter, "^") { + og = append(og, []byte(andFilter)) + } + + r.pass = append(r.pass, og) + } + } + + if len(skip) > 0 { + for _, orFilter := range skip { var og [][]byte - for _, orGrep := range strings.Split(andGrep, "\\|") { - og = append(og, []byte(orGrep)) + for _, andFilter := range strings.Split(orFilter, "^") { + og = append(og, []byte(andFilter)) } - r.grep = append(r.grep, og) + r.skip = append(r.skip, og) } } diff --git a/cmd/catp/catp/app_test.go b/cmd/catp/catp/app_test.go new file mode 100644 index 0000000..82b4daa --- /dev/null +++ b/cmd/catp/catp/app_test.go @@ -0,0 +1,45 @@ +package catp_test + +import ( + "os" + "testing" + + "github.com/bool64/progress/cmd/catp/catp" +) + +func Test_Main(t *testing.T) { + os.Args = []string{ + "catp", + "-pass", "linux^64", + "-pass", "windows", + "-skip", "dbg", + "-output", "testdata/filtered.log", + "testdata/release-assets.yml", + } + + if err := catp.Main(); err != nil { + t.Fatal(err) + } + + d, err := os.ReadFile("testdata/filtered.log") + if err != nil { + t.Fatal(err) + } + + if string(d) != ` curl -sL https://storage.googleapis.com/go-build-snap/go/linux-amd64/$(git ls-remote https://github.com/golang/go.git HEAD | awk '{print $1;}').tar.gz -o gotip.tar.gz + - name: Upload linux_amd64.tar.gz + if: hashFiles('linux_amd64.tar.gz') != '' + asset_path: ./linux_amd64.tar.gz + asset_name: linux_amd64.tar.gz + - name: Upload linux_arm64.tar.gz + if: hashFiles('linux_arm64.tar.gz') != '' + asset_path: ./linux_arm64.tar.gz + asset_name: linux_arm64.tar.gz + - name: Upload windows_amd64.zip + if: hashFiles('windows_amd64.zip') != '' + asset_path: ./windows_amd64.zip + asset_name: windows_amd64.zip +` { + t.Fatal("Unexpected output:\n", string(d)) + } +} diff --git a/cmd/catp/catp/cgo_zstd.go b/cmd/catp/catp/cgo_zstd.go index d00213e..6a126ae 100644 --- a/cmd/catp/catp/cgo_zstd.go +++ b/cmd/catp/catp/cgo_zstd.go @@ -8,6 +8,10 @@ import ( "github.com/DataDog/zstd" ) +func init() { + versionExtra = append(versionExtra, "CGO_ZSTD") +} + func zstdReader(rd io.Reader) (io.Reader, error) { return zstd.NewReader(rd), nil } diff --git a/cmd/catp/catp/testdata/.gitignore b/cmd/catp/catp/testdata/.gitignore new file mode 100644 index 0000000..5a579e1 --- /dev/null +++ b/cmd/catp/catp/testdata/.gitignore @@ -0,0 +1 @@ +filtered.log diff --git a/cmd/catp/catp/testdata/release-assets.yml b/cmd/catp/catp/testdata/release-assets.yml new file mode 100644 index 0000000..515af94 --- /dev/null +++ b/cmd/catp/catp/testdata/release-assets.yml @@ -0,0 +1,94 @@ +# This script is provided by github.com/bool64/dev. + +# This script uploads application binaries as GitHub release assets. +name: release-assets +on: + release: + types: + - created +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GO_VERSION: '1.22.0-rc.1' + LINUX_AMD64_BUILD_OPTIONS: '-tags cgo_zstd' + GOAMD64: v3 +jobs: + build: + name: Upload Release Assets + runs-on: ubuntu-20.04 + steps: + - name: Install Go stable + if: env.GO_VERSION != 'tip' + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + - name: Install Go tip + if: env.GO_VERSION == 'tip' + run: | + curl -sL https://storage.googleapis.com/go-build-snap/go/linux-amd64/$(git ls-remote https://github.com/golang/go.git HEAD | awk '{print $1;}').tar.gz -o gotip.tar.gz + ls -lah gotip.tar.gz + mkdir -p ~/sdk/gotip + tar -C ~/sdk/gotip -xzf gotip.tar.gz + ~/sdk/gotip/bin/go version + echo "PATH=$HOME/go/bin:$HOME/sdk/gotip/bin/:$PATH" >> $GITHUB_ENV + - name: Checkout code + uses: actions/checkout@v3 + - name: Build artifacts + run: | + make release-assets + - name: Upload linux_amd64.tar.gz + if: hashFiles('linux_amd64.tar.gz') != '' + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./linux_amd64.tar.gz + asset_name: linux_amd64.tar.gz + asset_content_type: application/tar+gzip + - name: Upload linux_amd64_dbg.tar.gz + if: hashFiles('linux_amd64_dbg.tar.gz') != '' + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./linux_amd64_dbg.tar.gz + asset_name: linux_amd64_dbg.tar.gz + asset_content_type: application/tar+gzip + - name: Upload linux_arm64.tar.gz + if: hashFiles('linux_arm64.tar.gz') != '' + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./linux_arm64.tar.gz + asset_name: linux_arm64.tar.gz + asset_content_type: application/tar+gzip + - name: Upload linux_arm.tar.gz + if: hashFiles('linux_arm.tar.gz') != '' + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./linux_arm.tar.gz + asset_name: linux_arm.tar.gz + asset_content_type: application/tar+gzip + - name: Upload darwin_amd64.tar.gz + if: hashFiles('darwin_amd64.tar.gz') != '' + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./darwin_amd64.tar.gz + asset_name: darwin_amd64.tar.gz + asset_content_type: application/tar+gzip + - name: Upload darwin_arm64.tar.gz + if: hashFiles('darwin_arm64.tar.gz') != '' + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./darwin_arm64.tar.gz + asset_name: darwin_arm64.tar.gz + asset_content_type: application/tar+gzip + - name: Upload windows_amd64.zip + if: hashFiles('windows_amd64.zip') != '' + uses: actions/upload-release-asset@v1 + with: + upload_url: ${{ github.event.release.upload_url }} + asset_path: ./windows_amd64.zip + asset_name: windows_amd64.zip + asset_content_type: application/zip + diff --git a/cmd/catp/default.pgo b/cmd/catp/default.pgo index d57301b947e83be3cba41453b57c8e9efda6b5e7..a176a8a836d40c9e4a3e4ccdfc282efff2dca4f2 100644 GIT binary patch literal 6287 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 F007egJ4^ro literal 7035 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