diff --git a/.github/workflows/job-test-in-container.yml b/.github/workflows/job-test-in-container.yml index 32a0088822d..7cb74fc5d89 100644 --- a/.github/workflows/job-test-in-container.yml +++ b/.github/workflows/job-test-in-container.yml @@ -35,6 +35,10 @@ on: required: false default: false type: boolean + outputs: + artifact: + description: "Artifact generated by this job" + value: ${{ jobs.test.outputs.artifact }} env: GOTOOLCHAIN: local @@ -55,6 +59,8 @@ jobs: defaults: run: shell: bash + outputs: + artifact: ${{ steps.artifact-upload.outputs.artifact-url }} env: # https://github.com/containerd/nerdctl/issues/622 @@ -161,9 +167,9 @@ jobs: && args=(test-integration ./hack/test-integration.sh -test.allow-modify-users=true) \ || args=(test-integration-${{ inputs.target }} /test-integration-rootless.sh ./hack/test-integration.sh) if [ "${{ inputs.ipv6 }}" == true ]; then - docker run --network host -t --rm --privileged -e GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" -v "$GITHUB_STEP_SUMMARY":"$GITHUB_STEP_SUMMARY" -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622:-} "${args[@]}" -test.only-flaky=false -test.only-ipv6 -test.target=${{ inputs.binary }} + docker run --name test-runner --network host -t --privileged -e GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" -v "$GITHUB_STEP_SUMMARY":"$GITHUB_STEP_SUMMARY" -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622:-} "${args[@]}" -test.only-flaky=false -test.only-ipv6 -test.target=${{ inputs.binary }} else - docker run -t --rm --privileged -e GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" -v "$GITHUB_STEP_SUMMARY":"$GITHUB_STEP_SUMMARY" -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622:-} "${args[@]}" -test.only-flaky=false -test.target=${{ inputs.binary }} + docker run --name test-runner -t --privileged -e GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" -v "$GITHUB_STEP_SUMMARY":"$GITHUB_STEP_SUMMARY" -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622:-} "${args[@]}" -test.only-flaky=false -test.target=${{ inputs.binary }} fi # FIXME: this NEEDS to go away - name: "Run: integration tests (flaky)" @@ -175,7 +181,42 @@ jobs: && args=(test-integration ./hack/test-integration.sh) \ || args=(test-integration-${{ inputs.target }} /test-integration-rootless.sh ./hack/test-integration.sh) if [ "${{ inputs.ipv6 }}" == true ]; then - docker run --network host -t --rm --privileged -e GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" -v "$GITHUB_STEP_SUMMARY":"$GITHUB_STEP_SUMMARY" -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622:-} "${args[@]}" -test.only-flaky=true -test.only-ipv6 -test.target=${{ inputs.binary }} + docker run --name test-runner-flaky --network host -t --privileged -e GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" -v "$GITHUB_STEP_SUMMARY":"$GITHUB_STEP_SUMMARY" -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622:-} "${args[@]}" -test.only-flaky=true -test.only-ipv6 -test.target=${{ inputs.binary }} else - docker run -t --rm --privileged -e GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" -v "$GITHUB_STEP_SUMMARY":"$GITHUB_STEP_SUMMARY" -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622:-} "${args[@]}" -test.only-flaky=true -test.target=${{ inputs.binary }} + docker run --name test-runner-flaky -t --privileged -e GITHUB_STEP_SUMMARY="$GITHUB_STEP_SUMMARY" -v "$GITHUB_STEP_SUMMARY":"$GITHUB_STEP_SUMMARY" -e WORKAROUND_ISSUE_622=${WORKAROUND_ISSUE_622:-} "${args[@]}" -test.only-flaky=true -test.target=${{ inputs.binary }} fi + + - name: "Wrap: collector" + if: ${{ failure() || success() }} + run: | + # Get the reports from inside the containers + [ "${{ inputs.target }}" == "rootful" ] && src=/root || src=/home/rootless + mkdir -p ~/report + docker cp test-runner:$src/nerdctl-test-report ~/report/main || true + # Flaky may not have run + docker cp test-runner-flaky:$src/nerdctl-test-report ~/report/flaky 2>/dev/null || true + # Add metadata info to the runs + . ./mod/wax/scripts/collector.sh + collect::metadata \ + "runner=${{ inputs.runner }}" \ + "binary=${{ inputs.binary }}" \ + "canary=${{ inputs.canary }}" \ + "target=${{ inputs.target }}" \ + "ipv6=${{ inputs.ipv6 }}" \ + "containerd-version=${{ inputs.containerd-version }}" \ + "rootlesskit-version=${{ inputs.rootlesskit-version }}" \ + "attempt=$GITHUB_RUN_ATTEMPT" \ + "sha=$GITHUB_SHA" \ + "id=$GITHUB_RUN_ID" \ + "number=$GITHUB_RUN_NUMBER" \ + > ~/report/main/metadata.json || true + [ ! -e ~/report/flaky ] || cp ~/report/main/metadata.json ~/report/flaky/metadata.json || true + + - name: "Wrap: upload" + id: artifact-upload + if: ${{ failure() || success() }} + uses: actions/upload-artifact@v4 + with: + path: ~/report/* + retention-days: 1 + name: wax-${{ inputs.runner }}-${{ inputs.binary }}-${{ inputs.canary }}-${{ inputs.target }}-${{ inputs.ipv6 }}-${{ inputs.containerd-version }}-${{ inputs.rootlesskit-version }} diff --git a/.github/workflows/job-test-in-host.yml b/.github/workflows/job-test-in-host.yml index f760c74780f..ab4b2a22d41 100644 --- a/.github/workflows/job-test-in-host.yml +++ b/.github/workflows/job-test-in-host.yml @@ -43,6 +43,10 @@ on: linux-cni-sha: required: true type: string + outputs: + artifact: + description: "Artifact generated by this job" + value: ${{ jobs.test.outputs.artifact }} env: GOTOOLCHAIN: local @@ -60,6 +64,8 @@ jobs: defaults: run: shell: bash + outputs: + artifact: ${{ steps.artifact-upload.outputs.artifact-url }} env: SHOULD_RUN: "yes" @@ -107,6 +113,7 @@ jobs: if [ "${{ contains(inputs.binary, 'docker') }}" == true ]; then echo "::group:: configure cdi for docker" sudo mkdir -p /etc/docker + sudo touch /etc/docker/daemon.json sudo jq '.features.cdi = true' /etc/docker/daemon.json | sudo tee /etc/docker/daemon.json.tmp && sudo mv /etc/docker/daemon.json.tmp /etc/docker/daemon.json echo "::endgroup::" echo "::group:: downgrade docker to the specific version we want to test (${{ inputs.docker-version }})" @@ -183,14 +190,22 @@ jobs: make install-dev-tools echo "::endgroup::" + + - if: ${{ env.SHOULD_RUN == 'yes' }} + name: "Init: prepare artifacts directories" + run: | + mkdir -p ~/report/main + mkdir -p ~/report/ipv6 + mkdir -p ~/report/flaky + # ipv6 is tested only on linux - - if: ${{ contains(inputs.runner, 'ubuntu') && env.SHOULD_RUN == 'yes' }} + - if: ${{ env.SHOULD_RUN == 'yes' && contains(inputs.runner, 'ubuntu' )}} name: "Run (linux): integration tests (IPv6)" run: | . ./hack/github/action-helpers.sh github::md::h2 "ipv6" >> "$GITHUB_STEP_SUMMARY" - ./hack/test-integration.sh -test.target=${{ inputs.binary }} -test.only-ipv6 + WAX_REPORT_LOCATION=$HOME/report/ipv6 ./hack/test-integration.sh -test.target=${{ inputs.binary }} -test.only-ipv6 - if: ${{ env.SHOULD_RUN == 'yes' }} name: "Run: integration tests" @@ -198,7 +213,7 @@ jobs: . ./hack/github/action-helpers.sh github::md::h2 "non-flaky" >> "$GITHUB_STEP_SUMMARY" - ./hack/test-integration.sh -test.target=${{ inputs.binary }} -test.only-flaky=false + WAX_REPORT_LOCATION=$HOME/report/main ./hack/test-integration.sh -test.target=${{ inputs.binary }} -test.only-flaky=false # FIXME: this must go - if: ${{ env.SHOULD_RUN == 'yes' }} @@ -207,4 +222,42 @@ jobs: . ./hack/github/action-helpers.sh github::md::h2 "flaky" >> "$GITHUB_STEP_SUMMARY" - ./hack/test-integration.sh -test.target=${{ inputs.binary }} -test.only-flaky=true + WAX_REPORT_LOCATION=$HOME/report/flaky ./hack/test-integration.sh -test.target=${{ inputs.binary }} -test.only-flaky=true + + - name: "Wrap: collector" + if: ${{ env.SHOULD_RUN == 'yes' && (failure() || success()) }} + run: | + # Add metadata info to the runs + . ./mod/wax/scripts/collector.sh + collect::metadata \ + "runner=${{ inputs.runner }}" \ + "binary=${{ inputs.binary }}" \ + "canary=${{ inputs.canary }}" \ + "ipv6=false" \ + "attempt=$GITHUB_RUN_ATTEMPT" \ + "sha=$GITHUB_SHA" \ + "id=$GITHUB_RUN_ID" \ + "number=$GITHUB_RUN_NUMBER" \ + > ~/report/main/metadata.json || true + + collect::metadata \ + "runner=${{ inputs.runner }}" \ + "binary=${{ inputs.binary }}" \ + "canary=${{ inputs.canary }}" \ + "ipv6=true" \ + "attempt=$GITHUB_RUN_ATTEMPT" \ + "sha=$GITHUB_SHA" \ + "id=$GITHUB_RUN_ID" \ + "number=$GITHUB_RUN_NUMBER" \ + > ~/report/ipv6/metadata.json || true + + cp ~/report/main/metadata.json ~/report/flaky/metadata.json || true + + - name: "Wrap: upload" + id: artifact-upload + if: ${{ env.SHOULD_RUN == 'yes' && (failure() || success()) }} + uses: actions/upload-artifact@v4 + with: + path: ~/report/* + retention-days: 1 + name: wax-${{ inputs.runner }}-${{ inputs.binary }}-${{ inputs.canary }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index c6d6f6a4e7a..968a162aaa4 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -78,3 +78,13 @@ jobs: go-version: ${{ matrix.go-version }} runner: ubuntu-24.04 canary: ${{ matrix.canary && true || false }} + + + reporter: + if: ${{ failure() || success() }} + name: "DEBUG" + runs-on: ubuntu-24.04 + steps: + - name: Process + run: | + printf '::error title=TESTTHIS,file=.github/workflows/job-test-in-container.yml,line=1::ONE

TWO %0A; %0A; %0A; `THREE`\n' diff --git a/.github/workflows/workflow-test.yml b/.github/workflows/workflow-test.yml index b11d4578ed7..84852c43587 100644 --- a/.github/workflows/workflow-test.yml +++ b/.github/workflows/workflow-test.yml @@ -147,3 +147,33 @@ jobs: containerd-service-sha: 1941362cbaa89dd591b99c32b050d82c583d3cd2e5fa63085d7017457ec5fca8 linux-cni-version: v1.7.1 linux-cni-sha: 1a28a0506bfe5bcdc981caf1a49eeab7e72da8321f1119b7be85f22621013098 + + reporter: + if: ${{ failure() || success() }} + name: "reporter${{ inputs.hack }}" + needs: + - test-integration-host + - test-integration-container + runs-on: ubuntu-24.04 + steps: + - name: Fetch Repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: "Init: install go" + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: 1.24 + check-latest: true + - name: Download all workflow run artifacts + uses: actions/download-artifact@v4 + with: + path: ~/report + - name: Process + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd ./mod/wax + go build -o ../../wax ./cmd/wax + cd - + ./wax ~/report diff --git a/cmd/nerdctl/main_test.go b/cmd/nerdctl/main_test.go index bcd84434556..f4139800139 100644 --- a/cmd/nerdctl/main_test.go +++ b/cmd/nerdctl/main_test.go @@ -33,6 +33,11 @@ func TestMain(m *testing.M) { testutil.M(m) } +func TestWax(t *testing.T) { + t.Log("This test is voluntarily failing") + t.FailNow() +} + // TestUnknownCommand tests https://github.com/containerd/nerdctl/issues/487 func TestUnknownCommand(t *testing.T) { testCase := nerdtest.Setup() diff --git a/hack/github/action-helpers.sh b/hack/github/action-helpers.sh index 62244ee6fbc..da25dcb69b8 100755 --- a/hack/github/action-helpers.sh +++ b/hack/github/action-helpers.sh @@ -117,3 +117,8 @@ github::timer::format() { [[ "$m" == 0 ]] || printf "%d minutes " "$m" printf '%d seconds' "$s" } + +# echo "::error title=ErrorReport::MEH .github/workflows/job-test-in-host.yml${{steps.artifact-upload.outputs.artifact-url}}" +# echo "::notice title=NoticeReport::SHEESH ${{steps.artifact-upload.outputs.artifact-url}}" +# echo "::error file=cmd/nerdctl/main_test_test.go,line=1,endLine=10,title=AgainErrorReport::FOO ${{steps.artifact-upload.outputs.artifact-url}}" +# echo "::error file=cmd/nerdctl/main_test.go,line=38,endLine=41,title=AgainErrorReport::BLA ${{steps.artifact-upload.outputs.artifact-url}}" diff --git a/hack/github/gotestsum-reporter.sh b/hack/github/gotestsum-reporter.sh index 872fc25f03d..5a33a909014 100755 --- a/hack/github/gotestsum-reporter.sh +++ b/hack/github/gotestsum-reporter.sh @@ -14,6 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +# DEPRECATED: favor post-processing inside integration.sh rather than using a post-run hook. +# Reasons are: +# - post-run hook do not work on windows +# - post-run hook is limited to a single run of gotestsum, while it is desirable to process multiple runs together + # shellcheck disable=SC2034,SC2015 set -o errexit -o errtrace -o functrace -o nounset -o pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]:-$PWD}")" 2>/dev/null 1>&2 && pwd)" diff --git a/hack/test-integration.sh b/hack/test-integration.sh index 3d1a21365a5..91df67d0e1d 100755 --- a/hack/test-integration.sh +++ b/hack/test-integration.sh @@ -19,38 +19,79 @@ set -o errexit -o errtrace -o functrace -o nounset -o pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]:-$PWD}")" 2>/dev/null 1>&2 && pwd)" readonly root -if [[ "$(id -u)" = "0" ]]; then - # Ensure securityfs is mounted for apparmor to work - if ! mountpoint -q /sys/kernel/security; then - mount -tsecurityfs securityfs /sys/kernel/security - fi +# If no argument is provided, run both flaky and not-flaky test suites. +if [ "$#" == 0 ]; then + "$root"/integration.sh -test.only-flaky=false + "$root"/integration.sh -test.only-flaky=true + exit fi +##### Import helper libraries +# shellcheck source=/dev/null +. "$root"/../mod/wax/scripts/collector.sh + +##### Configuration +# Where to store report files +readonly report_location="${WAX_REPORT_LOCATION:-$HOME/nerdctl-test-report}" +# Where to store gotestsum log file +readonly gotestsum_log_main="$report_location"/test-integration.log +readonly gotestsum_log_flaky="$report_location"/test-integration-flaky.log +# Total run timeout readonly timeout="60m" +# Number of retries for flaky tests readonly retries="2" -readonly needsudo="${WITH_SUDO:-}" - -# See https://github.com/containerd/nerdctl/blob/main/docs/testing/README.md#about-parallelization -args=(--format=testname --jsonfile /tmp/test-integration.log --packages="$root"/../cmd/nerdctl/...) -# FIXME: not working on windows. Need to change approach: move away from --post-run-command and -# just process the log file. This might also allow multi-steps/multi-target results aggregation. -[ "$(uname -s)" != "Linux" ] || args+=(--post-run-command "$root"/github/gotestsum-reporter.sh) - -if [ "$#" == 0 ]; then - "$root"/test-integration.sh -test.only-flaky=false - "$root"/test-integration.sh -test.only-flaky=true - exit -fi +readonly need_sudo="${WITH_SUDO:-}" +##### Prepare gotestsum arguments +mkdir -p "$report_location" +# Format and packages to test +args=(--format=testname --packages="$root"/../cmd/nerdctl/...) +# Log file +gotestsum_log="$gotestsum_log_main" for arg in "$@"; do if [ "$arg" == "-test.only-flaky=true" ] || [ "$arg" == "-test.only-flaky" ]; then args+=("--rerun-fails=$retries") + gotestsum_log="$gotestsum_log_flaky" break fi done +args+=(--jsonfile "$gotestsum_log" --) + +##### Append go test arguments +# Honor sudo +[ "$need_sudo" != true ] && [ "$need_sudo" != yes ] && [ "$need_sudo" != 1 ] || args+=(-exec sudo) +# About `-p 1`, see https://github.com/containerd/nerdctl/blob/main/docs/testing/README.md#about-parallelization +args+=(-timeout="$timeout" -p 1 -args -test.allow-kill-daemon "$@") -if [ "$needsudo" == "true" ] || [ "$needsudo" == "yes" ] || [ "$needsudo" == "1" ]; then - gotestsum "${args[@]}" -- -timeout="$timeout" -p 1 -exec sudo -args -test.allow-kill-daemon "$@" -else - gotestsum "${args[@]}" -- -timeout="$timeout" -p 1 -args -test.allow-kill-daemon "$@" +# FIXME: this should not be the responsibility of the test script +# Instead, it should be in the Dockerfile (or other stack provisioning script) - eg: /etc/systemd/system/securityfs.service +# [Unit] +# Description=Kernel Security File System +# DefaultDependencies=no +# Before=sysinit.target +# Before=apparmor.service +# ConditionSecurity=apparmor +# ConditionPathIsMountPoint=!/sys/kernel/security +# +# [Service] +# Type=oneshot +# ExecStart=/bin/mount -t securityfs -o nosuid,nodev,noexec securityfs /sys/kernel/security +# +# [Install] +# WantedBy=sysinit.target +if [[ "$(id -u)" = "0" ]]; then + # Ensure securityfs is mounted for apparmor to work + if ! mountpoint -q /sys/kernel/security; then + mount -tsecurityfs securityfs /sys/kernel/security + fi fi + +##### Run it +ex=0 +gotestsum "${args[@]}" || ex=$? + +##### Post: collect logs into the report location +collect::logs "$report_location" + +# Honor gotestsum exit code +exit "$ex" \ No newline at end of file diff --git a/mod/wax/Makefile b/mod/wax/Makefile new file mode 100644 index 00000000000..6c21581b962 --- /dev/null +++ b/mod/wax/Makefile @@ -0,0 +1,209 @@ +# Copyright The containerd Authors. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# project-checks is broken. +# See https://github.com/containerd/nerdctl/pull/3889 + +########################## +# Configuration +########################## +ORG_PREFIXES := "github.com/containerd" + +MAKEFILE_DIR := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +VERSION ?= $(shell git -C $(MAKEFILE_DIR) describe --match 'v[0-9]*' --dirty='.m' --always --tags) +VERSION_TRIMMED := $(VERSION:v%=%) +REVISION ?= $(shell git -C $(MAKEFILE_DIR) rev-parse HEAD)$(shell if ! git -C $(MAKEFILE_DIR) diff --no-ext-diff --quiet --exit-code; then echo .m; fi) +LINT_COMMIT_RANGE ?= main..HEAD + +########################## +# Helpers +########################## +ifdef VERBOSE + VERBOSE_FLAG := -v + VERBOSE_FLAG_LONG := --verbose +endif + +ifndef NO_COLOR + NC := \033[0m + GREEN := \033[1;32m + ORANGE := \033[1;33m +endif + +# Helpers +recursive_wildcard=$(wildcard $1$2) $(foreach e,$(wildcard $1*),$(call recursive_wildcard,$e/,$2)) + +define title + @printf "$(GREEN)____________________________________________________________________________________________________\n" + @printf "$(GREEN)%*s\n" $$(( ( $(shell echo "🐯$(1) 🐯" | wc -c ) + 100 ) / 2 )) "🐯$(1) 🐯" + @printf "$(GREEN)____________________________________________________________________________________________________\n$(ORANGE)" +endef + +define footer + @printf "$(GREEN)> %s: done!\n" "$(1)" + @printf "$(GREEN)____________________________________________________________________________________________________\n$(NC)" +endef + +########################## +# High-level tasks definitions +########################## +lint: lint-go-all lint-yaml lint-shell lint-commits lint-headers lint-mod lint-licenses-all +test: test-unit test-unit-race test-unit-bench +unit: test-unit test-unit-race test-unit-bench +fix: fix-mod fix-go-all + +########################## +# Linting tasks +########################## +lint-go: + $(call title, $@: $(GOOS)) + @cd $(MAKEFILE_DIR) \ + && golangci-lint run $(VERBOSE_FLAG_LONG) ./... + $(call footer, $@) + +lint-go-all: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && GOOS=darwin make lint-go \ + && GOOS=freebsd make lint-go \ + && GOOS=linux make lint-go \ + && GOOS=windows make lint-go + $(call footer, $@) + +lint-yaml: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && yamllint . + $(call footer, $@) + +lint-shell: $(call recursive_wildcard,$(MAKEFILE_DIR)/,*.sh) + $(call title, $@) + @shellcheck -a -x $^ + $(call footer, $@) + +lint-commits: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && git-validation $(VERBOSE_FLAG) -run DCO,short-subject,dangling-whitespace -range "$(LINT_COMMIT_RANGE)" + $(call footer, $@) + +lint-headers: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && ltag -t "./hack/headers" --check -v + $(call footer, $@) + +lint-mod: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && go mod tidy --diff + $(call footer, $@) + +# FIXME: go-licenses cannot find LICENSE from root of repo when submodule is imported: +# https://github.com/google/go-licenses/issues/186 +# This is impacting gotest.tools +lint-licenses: + $(call title, $@: $(GOOS)) + @cd $(MAKEFILE_DIR) \ + && go-licenses check --include_tests --allowed_licenses=Apache-2.0,BSD-2-Clause,BSD-3-Clause,MIT,MPL-2.0 \ + --ignore gotest.tools \ + ./... + $(call footer, $@) + +lint-licenses-all: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && GOOS=darwin make lint-licenses \ + && GOOS=freebsd make lint-licenses \ + && GOOS=linux make lint-licenses \ + && GOOS=windows make lint-licenses + $(call footer, $@) + +########################## +# Automated fixing tasks +########################## +fix-go: + $(call title, $@: $(GOOS)) + @cd $(MAKEFILE_DIR) \ + && golangci-lint run --fix + $(call footer, $@) + +fix-go-all: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && GOOS=darwin make fix-go \ + && GOOS=freebsd make fix-go \ + && GOOS=linux make fix-go \ + && GOOS=windows make fix-go + $(call footer, $@) + +fix-mod: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && go mod tidy + $(call footer, $@) + +up: + $(call title, $@) + @cd $(MAKEFILE_DIR) \ + && go get -u ./... + $(call footer, $@) + +########################## +# Development tools installation +########################## +install-dev-tools: + $(call title, $@) + # golangci: v2.0.2 (2024-03-26) + # git-validation: main (2025-02-25) + # ltag: main (2025-03-04) + # go-licenses: v2.0.0-alpha.1 (2024-06-27) + # stubbing go-licenses with dependency upgrade due to non-compatibility with golang 1.25rc1 + # Issue: https://github.com/google/go-licenses/issues/312 + @cd $(MAKEFILE_DIR) \ + && go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@2b224c2cf4c9f261c22a16af7f8ca6408467f338 \ + && go install github.com/vbatts/git-validation@7b60e35b055dd2eab5844202ffffad51d9c93922 \ + && go install github.com/containerd/ltag@66e6a514664ee2d11a470735519fa22b1a9eaabd \ + && go install github.com/Shubhranshu153/go-licenses/v2@f8c503d1357dffb6c97ed3b94e912ab294dde24a \ + && go install gotest.tools/gotestsum@0d9599e513d70e5792bb9334869f82f6e8b53d4d + @echo "Remember to add \$$HOME/go/bin to your path" + $(call footer, $@) + +########################## +# Testing tasks +########################## +test-unit: + $(call title, $@) + @HIGHK_EXPERIMENTAL_FD=true go test $(VERBOSE_FLAG) $(MAKEFILE_DIR)/... + $(call footer, $@) + +test-unit-bench: + $(call title, $@) + @go test $(VERBOSE_FLAG) $(MAKEFILE_DIR)/... -bench=. + $(call footer, $@) + +test-unit-race: + $(call title, $@) + @HIGHK_EXPERIMENTAL_FD=true CGO_ENABLED=1 go test $(VERBOSE_FLAG) $(MAKEFILE_DIR)/... -race + $(call footer, $@) + +.PHONY: \ + lint \ + fix \ + test \ + up \ + unit \ + install-dev-tools \ + lint-commits lint-go lint-go-all lint-headers lint-licenses lint-licenses-all lint-mod lint-shell lint-yaml \ + fix-go fix-go-all fix-mod \ + test-unit test-unit-race test-unit-bench \ No newline at end of file diff --git a/mod/wax/README.md b/mod/wax/README.md new file mode 100644 index 00000000000..d9a4eb10d62 --- /dev/null +++ b/mod/wax/README.md @@ -0,0 +1,9 @@ +# Wax + +> polish your CI + +A tool to annotate pull requests and generate reports from gotestsum logs. + +## TL;DR + +TBD. diff --git a/mod/wax/analyzer/analyzer.go b/mod/wax/analyzer/analyzer.go new file mode 100644 index 00000000000..afa388fcb6c --- /dev/null +++ b/mod/wax/analyzer/analyzer.go @@ -0,0 +1,52 @@ +package analyzer + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func FindLocation(testName string, packageName string, base string) (string, int, int) { + root := packageName[len(base)+1:] + topTestName := strings.Split(testName, "/")[0] + foundFile := "" + foundLine := 0 + foundEndLine := 0 + + err := filepath.WalkDir(root, func(path string, info os.DirEntry, err error) error { + if foundFile != "" { + return nil + } + + if err != nil { + return err + } + + l, _ := os.Stat(path) + if !l.IsDir() && strings.HasSuffix(filepath.Base(path), "_test.go") { + cc, _ := os.ReadFile(path) + for id, line := range strings.Split(string(cc), "\n") { + if line == fmt.Sprintf("func %s(t *testing.T) {", topTestName) { + foundFile = path + foundLine = id + 1 + } else if foundFile != "" && line == "}" { + foundEndLine = id + 1 + break + } + } + } + + return nil + }) + + if err != nil { + fmt.Println(err) + } + + return foundFile, foundLine, foundEndLine +} + +func LocationURL(commit, file string, start, end int, base string) string { + return fmt.Sprintf("https://%s/blob/%s/%s#L%d-L%d", base, commit, file, start, end) +} diff --git a/mod/wax/api/github.go b/mod/wax/api/github.go new file mode 100644 index 00000000000..3bd8cb17cba --- /dev/null +++ b/mod/wax/api/github.go @@ -0,0 +1,47 @@ +package api + +import ( + "context" + + "github.com/google/go-github/v73/github" +) + +type Issue = github.Issue + +type status string + +const Open status = "open" +const Closed status = "closed" +const All status = "all" + +type Github struct { + client *github.Client +} + +func New(token string) *Github { + return &Github{ + client: github.NewClient(nil).WithAuthToken(token), + } +} + +func (gh *Github) Issues(ctx context.Context, state status, owner, repo string) ([]*Issue, error) { + opts := &github.IssueListByRepoOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + State: string(state), + } + + var allIssues []*Issue + for { + issues, response, err := gh.client.Issues.ListByRepo(ctx, owner, repo, opts) + if err != nil { + return nil, err + } + allIssues = append(allIssues, issues...) + if response.NextPage == 0 { + break + } + opts.ListOptions.Page = response.NextPage + } + + return allIssues, nil +} diff --git a/mod/wax/cmd/wax/main.go b/mod/wax/cmd/wax/main.go new file mode 100644 index 00000000000..54764dee2b2 --- /dev/null +++ b/mod/wax/cmd/wax/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "github.com/containerd/nerdctl/mod/wax/format/action" + "os" + "os/exec" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/containerd/nerdctl/mod/wax/api" + "github.com/containerd/nerdctl/mod/wax/gotestsum" + "github.com/containerd/nerdctl/mod/wax/report" +) + +func main() { + //mainLine := "::error endLine=84,file=cmd/nerdctl/ipfs/ipfs_compose_linux_test.go,line=41,title=Test failed in a flaky way::%0A```%0A=== RUN TestIPFSCompNoBuild%0A=== PAUSE TestIPFSCompNoBuild%0A=== CONT\n" + ////rgx := "^::([^:]+) file=([^,:]+),line=(\\d+)(?:,endLine=(\\d+))?,title=([^,:]+)::WAX-MARK-(.*)-WAX-MARK$" + + //rgx := "(?m)^::([^:]+) (?:endLine=(\\d+),)?file=([^,:]+),line=(\\d+),title=([^,:]+)::(.*)" + //r := regexp.MustCompile(rgx) + //res := r.FindStringSubmatch(mainLine) + //fmt.Println("LLALA") + //fmt.Println(">>>", res) + //return + + tk := os.Getenv("GITHUB_TOKEN") + sha := os.Getenv("GITHUB_SHA") + rep := strings.Split(os.Getenv("GITHUB_REPOSITORY"), "/") + gh := api.New(tk) + issues, err := gh.Issues(context.Background(), api.Open, rep[0], rep[1]) + if err != nil { + log.Fatal(err) + } + + reportsRoot := os.Args[1] + + run, _ := gotestsum.FromLogs(gotestsum.FindLogs(reportsRoot, "test-integration")...) + ls, _ := exec.Command("git", "diff", "--name-only", "-r", "HEAD^1", "HEAD").CombinedOutput() + changedFiles := strings.Split(string(ls), "\n") + topfile := changedFiles[0] + + alwaysFailingTests := run.FailedAlways() + for _, failedTest := range alwaysFailingTests { + var found *api.Issue + // Look for the top-level test, not subtests + compare := strings.Split(string(failedTest.Test), "/")[0] + for _, issue := range issues { + if strings.Contains(*issue.Title, compare) { + found = issue + break + } + } + if found != nil { + failedTest.KnownIssue = *found.HTMLURL + } + } + + onceFailingTests := run.FailedAtLeastOnceButNotAlways() + for _, failedTest := range onceFailingTests { + var found *api.Issue + // Look for the top-level test, not subtests + compare := strings.Split(string(failedTest.Test), "/")[0] + for _, issue := range issues { + if strings.Contains(*issue.Title, compare) { + found = issue + break + } + } + if found != nil { + failedTest.KnownIssue = *found.HTMLURL + } + } + + testsThatNeverRan := run.NeverRan() + + // ::error file=.github/matchers/wax.json,line=1,endLine=1,title=Tests that have NOT run in any pipeline::WAX-MARK- + + //"regexp": "^([^:]+):(\\d+):(\\d+):\\s+(error|warning):\\s+(.*)$", + // + //action.AddMatcher(os.Stdout, "wax", `^(\w+):\s+(\d+)\s+(\d+)\s+(.+)$`, 1, 2, 3, 4, 5) + + action.AddMatcher(os.Stdout, "wax", "^::([^:]+) (?:endLine=(\\\\d+),)?file=([^,:]+),line=(\\\\d+),title=([^,:]+)::(.*)$", 1, 3, 4, 2, 5, 6) + report.Annotate("github.com/"+strings.Join(rep, "/"), "v2", sha, alwaysFailingTests, onceFailingTests, testsThatNeverRan, topfile) + action.RemoveMatcher(os.Stdout, "wax") +} diff --git a/mod/wax/format/action/action.go b/mod/wax/format/action/action.go new file mode 100644 index 00000000000..f07819ca4d1 --- /dev/null +++ b/mod/wax/format/action/action.go @@ -0,0 +1,29 @@ +package action + +import ( + "fmt" + "io" + "sort" + "strings" +) + +func command(out io.Writer, command string, params map[string]string, message string) { + par := []string{} + for k, v := range params { + par = append(par, fmt.Sprintf("%s=%s", k, v)) + } + sort.Strings(par) + _, _ = fmt.Fprintf(out, "::%s %s::%s\n", command, strings.Join(par, ","), strings.ReplaceAll(message, "\n", "
")) // "%0A")) +} + +func GroupStart(out io.Writer, title string) { + command(out, "group", nil, title) +} + +func GroupEnd(out io.Writer) { + command(out, "endgroup", nil, "") +} + +func Debug(out io.Writer, message string) { + command(out, "group", nil, message) +} diff --git a/mod/wax/format/action/annotation.go b/mod/wax/format/action/annotation.go new file mode 100644 index 00000000000..9702832efc8 --- /dev/null +++ b/mod/wax/format/action/annotation.go @@ -0,0 +1,60 @@ +package action + +import ( + "io" + "strconv" +) + +const ( + warningLevel = "warning" + noticeLevel = "notice" + errorLevel = "error" + fileKey = "file" + columnKey = "col" + endColumnKey = "endColumn" + lineKey = "line" + endLineKey = "endLine" + titleKey = "title" +) + +func annotation(out io.Writer, level, file string, col, endColumn, line, endLine int, title, message string) { + params := map[string]string{} + + if file != "" { + params[fileKey] = file + } + + if col != 0 { + params[columnKey] = strconv.Itoa(col) + } + + if line != 0 { + params[lineKey] = strconv.Itoa(line) + } + + if endColumn != 0 { + params[endColumnKey] = strconv.Itoa(endColumn) + } + + if endLine != 0 { + params[endLineKey] = strconv.Itoa(endLine) + } + + if title != "" { + params[titleKey] = title + } + + command(out, level, params, message) +} + +func Notice(out io.Writer, file string, line, endLine, col, endColumn int, title, message string) { + annotation(out, noticeLevel, file, col, endColumn, line, endLine, title, message) +} + +func Warning(out io.Writer, file string, line, endLine, col, endColumn int, title, message string) { + annotation(out, warningLevel, file, col, endColumn, line, endLine, title, message) +} + +func Error(out io.Writer, file string, line, endLine, col, endColumn int, title, message string) { + annotation(out, errorLevel, file, col, endColumn, line, endLine, title, message) +} diff --git a/mod/wax/format/action/matcher.go b/mod/wax/format/action/matcher.go new file mode 100644 index 00000000000..b8ec4d245df --- /dev/null +++ b/mod/wax/format/action/matcher.go @@ -0,0 +1,66 @@ +package action + +import ( + "encoding/json" + "io" + "os" + "path/filepath" +) + +const ( + addMatcherCommand = "add-matcher" + removeMatcherCommand = "remove-matcher" + permission = 0o600 +) + +type matcher struct { + ProblemMatcher *ProblemMatcher `json:"problemMatcher"` +} + +type ProblemMatcherPattern struct { + Regexp string `json:"regexp"` + Severity int `json:"severity"` + File int `json:"file"` + Line int `json:"line"` + EndLine int `json:"endLine"` + Column int `json:"column"` + EndColumn int `json:"endColumn"` + Message int `json:"message"` + Title int `json:"title"` +} + +type ProblemMatcher []struct { + Owner string `json:"owner"` + Pattern []*ProblemMatcherPattern `json:"pattern"` +} + +func AddMatcher(out io.Writer, owner, regexp string, severity, endLine, file, line, title, message int) { + mtc := &matcher{ + ProblemMatcher: &ProblemMatcher{ + { + Owner: owner, + Pattern: []*ProblemMatcherPattern{ + { + Regexp: regexp, + Severity: severity, + File: file, + Line: line, + EndLine: endLine, + Title: title, + Message: message, + }, + }, + }, + }, + } + matcherLocation := filepath.Join(os.TempDir(), owner+".json") + m, _ := json.MarshalIndent(mtc, "", " ") + _ = os.WriteFile(matcherLocation, m, permission) + command(out, addMatcherCommand, nil, matcherLocation) +} + +func RemoveMatcher(out io.Writer, owner string) { + command(out, removeMatcherCommand, map[string]string{ + "owner": owner, + }, "") +} diff --git a/mod/wax/format/markdown/markdown.go b/mod/wax/format/markdown/markdown.go new file mode 100644 index 00000000000..ed2cb056005 --- /dev/null +++ b/mod/wax/format/markdown/markdown.go @@ -0,0 +1,45 @@ +package markdown + +import ( + "fmt" + "io" + "strings" +) + +func markdown(out io.Writer, prefix, message, suffix string) { + _, _ = fmt.Fprintf(out, "%s %s %s\n", prefix, message, suffix) +} + +func H1(out io.Writer, message string) { + markdown(out, "#", message, "") +} + +func H2(out io.Writer, message string) { + markdown(out, "##", message, "") +} + +func H3(out io.Writer, message string) { + markdown(out, "###", message, "") +} + +func Blockquote(out io.Writer, message string) { + markdown(out, ">", message, "") +} + +func Table(out io.Writer, headers []string, rows [][]string) { + markdown(out, "|", strings.Join(headers, "|"), "|") + markdown(out, "|", strings.Repeat(" ----- |", len(headers)), "") + for _, row := range rows { + markdown(out, "|", strings.Join(row, "|"), "|") + } +} + +func Pie(out io.Writer, title string, values map[string]string) { + markdown(out, "```", "mermaid", "") + markdown(out, "", "pie", "") + markdown(out, "title", title, "") + for label, value := range values { + markdown(out, fmt.Sprintf("%q", label), value, "") + } + markdown(out, "", "", "```") +} diff --git a/mod/wax/go.mod b/mod/wax/go.mod new file mode 100644 index 00000000000..806b7ada404 --- /dev/null +++ b/mod/wax/go.mod @@ -0,0 +1,21 @@ +module github.com/containerd/nerdctl/mod/wax + +go 1.23.5 + +require ( + github.com/google/go-github/v73 v73.0.0 + github.com/sirupsen/logrus v1.9.3 + gotest.tools/gotestsum v1.12.3 +) + +require ( + github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.17.0 // indirect +) diff --git a/mod/wax/go.sum b/mod/wax/go.sum new file mode 100644 index 00000000000..a8093a3b8e8 --- /dev/null +++ b/mod/wax/go.sum @@ -0,0 +1,49 @@ +github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= +github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= +github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/gotestsum v1.12.3 h1:jFwenGJ0RnPkuKh2VzAYl1mDOJgbhobBDeL2W1iEycs= +gotest.tools/gotestsum v1.12.3/go.mod h1:Y1+e0Iig4xIRtdmYbEV7K7H6spnjc1fX4BOuUhWw2Wk= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/mod/wax/gotestsum/helpers.go b/mod/wax/gotestsum/helpers.go new file mode 100644 index 00000000000..ecdb2e5a3e0 --- /dev/null +++ b/mod/wax/gotestsum/helpers.go @@ -0,0 +1,113 @@ +package gotestsum + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func testIntersection(slice1, slice2 []*FailedTest) []*FailedTest { + seen := make(map[string]bool) + result := []*FailedTest{} + + for _, val1 := range slice1 { + tn1 := val1.Package + "/" + string(val1.Test) + for _, val2 := range slice2 { + tn2 := val2.Package + "/" + string(val2.Test) + if tn1 == tn2 { + if _, ok := seen[tn1]; !ok { + seen[tn1] = true + result = append(result, val1) + break + } + } + } + } + + return result +} + +func testTestIntersection(slice1, slice2 []TestCase) []TestCase { + seen := make(map[string]bool) + result := []TestCase{} + + for _, val1 := range slice1 { + tn1 := val1.Package + "/" + string(val1.Test) + for _, val2 := range slice2 { + tn2 := val2.Package + "/" + string(val2.Test) + if tn1 == tn2 { + if _, ok := seen[tn1]; !ok { + seen[tn1] = true + result = append(result, val1) + break + } + } + } + } + + return result +} + +func testUnion(slice1, slice2 []*FailedTest) []*FailedTest { + seen := make(map[string]bool) + result := []*FailedTest{} + + for _, val := range slice1 { + tn := val.Package + "/" + string(val.Test) + if _, ok := seen[tn]; !ok { + seen[tn] = true + result = append(result, val) + } + } + + for _, val := range slice2 { + tn := val.Package + "/" + string(val.Test) + if _, ok := seen[tn]; !ok { + seen[tn] = true + result = append(result, val) + } + } + + return result +} + +func testNotIn(slice1, slice2 []*FailedTest) []*FailedTest { + mb := make(map[string]bool, len(slice1)) + for _, val := range slice1 { + tn := val.Package + "/" + string(val.Test) + mb[tn] = true + } + var diff []*FailedTest + for _, val := range slice2 { + tn := val.Package + "/" + string(val.Test) + if _, found := mb[tn]; !found { + diff = append(diff, val) + } + } + return diff +} + +func findFiles(root, needle string) []string { + files := []string{} + err := filepath.WalkDir(root, func(path string, info os.DirEntry, err error) error { + if err != nil { + return err + } + + l, _ := os.Stat(path) + if l.IsDir() && path != root { + files = append(files, findFiles(path, needle)...) + } else if strings.HasPrefix(filepath.Base(path), needle) { + files = append(files, path) + } + + return nil + }) + + if err != nil { + fmt.Println(err) + } + + return files +} diff --git a/mod/wax/gotestsum/log.go b/mod/wax/gotestsum/log.go new file mode 100644 index 00000000000..9b3c7872843 --- /dev/null +++ b/mod/wax/gotestsum/log.go @@ -0,0 +1,55 @@ +package gotestsum + +import ( + "errors" + "fmt" + "io" + "os" + + "gotest.tools/gotestsum/testjson" +) + +func FromLogs(files ...string) (run *Run, err error) { + run = &Run{ + Pipelines: []*Pipeline{}, + } + + for _, file := range files { + in, err := jsonlogReader(file) + if err != nil { + return nil, fmt.Errorf("failed to read jsonfile: %v", err) + } + + defer func() { + err = errors.Join(in.Close(), err) + }() + + exec, err := testjson.ScanTestOutput(testjson.ScanConfig{Stdout: in}) + if err != nil { + return nil, fmt.Errorf("failed to scan testjson: %v", err) + } + + pipeline := &Pipeline{} + pkgs := exec.Packages() + for _, pkg := range pkgs { + pipeline.Packages = append(pipeline.Packages, exec.Package(pkg)) + } + + run.Pipelines = append(run.Pipelines, pipeline) + } + + return run, nil +} + +func FindLogs(root, needle string) []string { + return findFiles(root, needle) +} + +func jsonlogReader(v string) (io.ReadCloser, error) { + switch v { + case "", "-": + return io.NopCloser(os.Stdin), nil + default: + return os.Open(v) + } +} diff --git a/mod/wax/gotestsum/package.go b/mod/wax/gotestsum/package.go new file mode 100644 index 00000000000..d111562a150 --- /dev/null +++ b/mod/wax/gotestsum/package.go @@ -0,0 +1,5 @@ +package gotestsum + +import "gotest.tools/gotestsum/testjson" + +type Package = testjson.Package diff --git a/mod/wax/gotestsum/pipeline.go b/mod/wax/gotestsum/pipeline.go new file mode 100644 index 00000000000..7c155375c98 --- /dev/null +++ b/mod/wax/gotestsum/pipeline.go @@ -0,0 +1,6 @@ +package gotestsum + +type Pipeline struct { + Packages []*Package + Metadata map[string]string +} diff --git a/mod/wax/gotestsum/run.go b/mod/wax/gotestsum/run.go new file mode 100644 index 00000000000..121ff5f625a --- /dev/null +++ b/mod/wax/gotestsum/run.go @@ -0,0 +1,133 @@ +package gotestsum + +type Run struct { + Pipelines []*Pipeline +} + +func (r *Run) NeverRan() []TestCase { + var result []TestCase + for _, pipe := range r.Pipelines { + pipelineSkipped := []TestCase{} + for _, pkg := range pipe.Packages { + pipelineSkipped = append(pipelineSkipped, pkg.Skipped...) + } + + if result == nil { + result = append(result, pipelineSkipped...) + continue + } + + result = testTestIntersection(pipelineSkipped, result) + } + + return result +} + +func (r *Run) FailedAlways() []*FailedTest { + var result []*FailedTest + for _, pipe := range r.Pipelines { + pipelineFailed := []*FailedTest{} + for _, pkg := range pipe.Packages { + for _, testCase := range pkg.Failed { + ft := &FailedTest{ + TestCase: testCase, + //nolint:staticcheck + Output: pkg.Output(testCase.ID), + AlwaysFailed: true, + } + pipelineFailed = append(pipelineFailed, ft) + } + } + if result == nil { + result = append(result, pipelineFailed...) + continue + } + + result = testIntersection(pipelineFailed, result) + } + + return result +} + +func (r *Run) FailedAtLeastOnceButNotAlways() []*FailedTest { + var result []*FailedTest + for _, pipe := range r.Pipelines { + pipelineFailed := []*FailedTest{} + for _, pkg := range pipe.Packages { + for _, testCase := range pkg.Failed { + ft := &FailedTest{ + TestCase: testCase, + //nolint:staticcheck + Output: pkg.Output(testCase.ID), + } + pipelineFailed = append(pipelineFailed, ft) + } + } + if result == nil { + result = append(result, pipelineFailed...) + continue + } + + result = testUnion(pipelineFailed, result) + } + always := r.FailedAlways() + result = testNotIn(always, result) + + return result +} + +// func (r *Run) Slowest(num int) map[string]float64 { +// tests := map[string][]TestCase{} +// times := map[string]float64{} +// for _, pipe := range r.Pipelines { +// for _, pkg := range pipe.Packages { +// // Use only successful tests to calculate mean time +// for _, test := range pkg.Passed { +// fqn := test.Package + "/" + string(test.Test) +// val, ok := tests[fqn] +// if !ok { +// tests[fqn] = []TestCase{} +// } +// tests[fqn] = append(val, test) +// } +// } +// } +// +// for fqn, val := range tests { +// var tm time.Duration +// for _, testCase := range val { +// tm += testCase.Elapsed +// } +// times[fqn] = math.Round(float64(tm.Microseconds()) / float64(len(val))) +// } +// +// keys := make([]string, 0, len(times)) +// for k := range times { +// keys = append(keys, k) +// } +// +// sort.Slice(keys, func(i, j int) bool { +// return times[keys[i]] < times[keys[j]] +// }) +// +// if num <= len(keys) { +// keys = keys[:int(num)] +// } +// result := map[string]float64{} +// for _, key := range keys { +// result[key] = times[key] +// } +// +// return result +// } + +//func (r *Run) OutputForTest(testCase *TestCase) { +// for _, pipe := range r.Pipelines { +// for _, pkg := range pipe.Packages { +// if testCase.Package == pkg { +// +// } +// } +// } +// //pkg.OutputLines(pkg.Failed[0]) +//} diff --git a/mod/wax/gotestsum/test.go b/mod/wax/gotestsum/test.go new file mode 100644 index 00000000000..ad34f951b7a --- /dev/null +++ b/mod/wax/gotestsum/test.go @@ -0,0 +1,12 @@ +package gotestsum + +import "gotest.tools/gotestsum/testjson" + +type TestCase = testjson.TestCase + +type FailedTest struct { + TestCase + KnownIssue string + Output string + AlwaysFailed bool +} diff --git a/mod/wax/hack/dev-setup-linux.sh b/mod/wax/hack/dev-setup-linux.sh new file mode 100755 index 00000000000..652877dee43 --- /dev/null +++ b/mod/wax/hack/dev-setup-linux.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# Copyright The containerd Authors. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit -o errtrace -o functrace -o nounset -o pipefail + +sudo apt-get install -qq --no-install-recommends golang make yamllint shellcheck diff --git a/mod/wax/hack/dev-setup-macos.sh b/mod/wax/hack/dev-setup-macos.sh new file mode 100755 index 00000000000..ce941fd5522 --- /dev/null +++ b/mod/wax/hack/dev-setup-macos.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# Copyright The containerd Authors. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -o errexit -o errtrace -o functrace -o nounset -o pipefail + +brew install golang make yamllint shellcheck diff --git a/mod/wax/hack/headers/bash.txt b/mod/wax/hack/headers/bash.txt new file mode 100644 index 00000000000..191b8ced5c4 --- /dev/null +++ b/mod/wax/hack/headers/bash.txt @@ -0,0 +1,13 @@ +# Copyright The containerd Authors. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. \ No newline at end of file diff --git a/mod/wax/hack/headers/dockerfile.txt b/mod/wax/hack/headers/dockerfile.txt new file mode 100644 index 00000000000..191b8ced5c4 --- /dev/null +++ b/mod/wax/hack/headers/dockerfile.txt @@ -0,0 +1,13 @@ +# Copyright The containerd Authors. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. \ No newline at end of file diff --git a/mod/wax/hack/headers/go.txt b/mod/wax/hack/headers/go.txt new file mode 100644 index 00000000000..34c6611aadc --- /dev/null +++ b/mod/wax/hack/headers/go.txt @@ -0,0 +1,15 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ \ No newline at end of file diff --git a/mod/wax/hack/headers/makefile.txt b/mod/wax/hack/headers/makefile.txt new file mode 100644 index 00000000000..dced09e47a8 --- /dev/null +++ b/mod/wax/hack/headers/makefile.txt @@ -0,0 +1,16 @@ +# Copyright The containerd Authors. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# project-checks is broken. +# See https://github.com/containerd/nerdctl/pull/3889 \ No newline at end of file diff --git a/mod/wax/report/annotation.go b/mod/wax/report/annotation.go new file mode 100644 index 00000000000..dac86b4a417 --- /dev/null +++ b/mod/wax/report/annotation.go @@ -0,0 +1,110 @@ +package report + +import ( + "fmt" + "os" + "strings" + + "github.com/containerd/nerdctl/mod/wax/analyzer" + "github.com/containerd/nerdctl/mod/wax/format/action" + "github.com/containerd/nerdctl/mod/wax/gotestsum" +) + +const ( + testsNotRun = "Tests that have NOT run in any pipeline" + testsNotRunComment = "If you authored these tests, you MUST look into this now, as this will block merging." + + testsFailingKnown = "Tests that are known to be problematic" + testsFailingKnownComment = "These tests are known to be flaky.\n" + + "Failures on these are probably not related to your changeset and can usually be ignored or retried." + + testsFailingAll = "These tests failed on ALL pipelines, and are NOT known to be problematic" + testsFailingAllComment = "These tests are not known to be problematic.\n" + + "The fact that they are failing on ALL pipelines is usually indicative that you broke something with your changeset." + + "You should look into this." + + testsFailingSometimes = "These tests failed at least once, and are NOT known to be problematic" + testsFailingSometimesComment = "These tests are not known to be problematic.\n" + + "If you did create these tests, then they are definitely flaky and you must look into it.\n" + + "If you did not create these tests, and do strongly believe this failure is unrelated to your changeset, " + + "please open a new ticket mentioning the test name in its title." +) + +func Annotate( + root string, + version string, + commit string, + alwaysFailedTests []*gotestsum.FailedTest, + failedOnceTests []*gotestsum.FailedTest, + disabledTests []gotestsum.TestCase, + headfile string) { + + strip := root + if version != "" { + strip += "/" + version + } + + title := "" + message := "" + + list := "" + for _, t := range disabledTests { + locFile, locLine, locEndLine := analyzer.FindLocation(string(t.Test), t.Package, strip) + link := analyzer.LocationURL(commit, locFile, locLine, locEndLine, root) + list += fmt.Sprintf("- [%s](%s)\n", t.Test, link) + } + + if list != "" { + title = testsNotRun + message = fmt.Sprintf("> %s\n\n%s", testsNotRunComment, list) + action.Error(os.Stdout, headfile, 1, 1, 0, 0, title, message) + } + + known := "" + unknownFailedAll := "" + unknownFailedOnce := "" + for _, t := range alwaysFailedTests { + locFile, locLine, locEndLine := analyzer.FindLocation(string(t.Test), t.Package, strip) + link := analyzer.LocationURL(commit, locFile, locLine, locEndLine, root) + if t.KnownIssue != "" { + spl := strings.Split(t.KnownIssue, "/") + id := spl[len(spl)-1] + known += fmt.Sprintf("- [%s](%s): see issue #%s\n", t.Test, link, id) + } else { + unknownFailedAll += fmt.Sprintf("- [%s](%s)\n", t.Test, link) + action.Error(os.Stdout, locFile, locLine, locEndLine, 0, 0, "Test failed on all targets", "\n```\n"+strings.ReplaceAll(t.Output, "\n", "\n")+"\n```") + } + } + + for _, t := range failedOnceTests { + locFile, locLine, locEndLine := analyzer.FindLocation(string(t.Test), t.Package, strip) + link := analyzer.LocationURL(commit, locFile, locLine, locEndLine, root) + if t.KnownIssue != "" { + spl := strings.Split(t.KnownIssue, "/") + id := spl[len(spl)-1] + known += fmt.Sprintf("- [%s](%s): see issue #%s\n", t.Test, link, id) + } else { + unknownFailedOnce += fmt.Sprintf("- [%s](%s)\n", t.Test, link) + action.Error(os.Stdout, locFile, locLine, locEndLine, 0, 0, "Test failed in a flaky way", "\n```\n"+strings.ReplaceAll(t.Output, "\n", "\n")+"\n```") + } + } + + if known != "" { + title = testsFailingKnown + message = fmt.Sprintf("> %s\n\n%s\n", testsFailingKnownComment, known) + action.Warning(os.Stdout, headfile, 1, 1, 0, 0, title, message) + } + + if unknownFailedAll != "" { + title = testsFailingAll + message = fmt.Sprintf("> %s\n\n%s\n", testsFailingAllComment, unknownFailedAll) + action.Error(os.Stdout, headfile, 1, 1, 0, 0, title, message) + } + + if unknownFailedOnce != "" { + title = testsFailingSometimes + message = fmt.Sprintf("> %s\n\n%s\n", testsFailingSometimesComment, unknownFailedOnce) + action.Error(os.Stdout, headfile, 1, 1, 0, 0, title, message) + } + +} diff --git a/mod/wax/scripts/collector.sh b/mod/wax/scripts/collector.sh new file mode 100755 index 00000000000..24405d22bab --- /dev/null +++ b/mod/wax/scripts/collector.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# Copyright The containerd Authors. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# collector.sh provides methods to collect all system logs relevant for debugging. +# It is primarily intended to be used after a run of gotestsum has completed (successfully or not). + +# shellcheck disable=SC2034,SC2015 +set -o errexit -o errtrace -o functrace -o nounset -o pipefail + +# collect::logs::units retrieve all running units and gather their logs +collect::logs::systemctl(){ + local destination="$1" + local item + local args=(--no-pager) + if command -v systemctl >/dev/null; then + [ "$(id -u)" == 0 ] || args+=(--user) + for item in $(systemctl show '*' --state=running --property=Id --value "${args[@]}" | grep . | sort | uniq); do + journalctl "${args[@]}" -u "$item" > "$destination/systemd-$item.log" + done + fi +} + +collect::logs(){ + collect::logs::systemctl "$@" +} + +collect::metadata(){ + local item + local key + local value + local sep="" + + printf "{" + for item in "$@"; do + printf " %s\"%s\": \"%s\"" "$sep" "${item%=*}" "${item#*=}" + sep=","$'\n' + done + printf "}" +}