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 "}"
+}