diff --git a/.github/workflows/go-basic-tests.yaml b/.github/workflows/go-basic-tests.yaml index f37eb2a..f23080d 100644 --- a/.github/workflows/go-basic-tests.yaml +++ b/.github/workflows/go-basic-tests.yaml @@ -21,6 +21,16 @@ on: required: false type: string default: "./..." + TEST_PARALLELISM: + description: Maximum parallel Go tests per package; 0 keeps the Go default + required: false + type: number + default: 0 + TEST_PACKAGE_PARALLELISM: + description: Maximum concurrent Go test/build packages; 0 keeps the Go default + required: false + type: number + default: 0 FAILEDTHRESHOLD: required: false type: number @@ -88,13 +98,135 @@ jobs: with: go-version: '${{ inputs.GO_VERSION }}' - - name: Test race conditions - if: ${{ env.CGO_ENABLED == 1 }} - run: go test -v -race $(go list ${{ inputs.UNIT_TESTS_PATH }} | grep -v /e2e) + - &go_tests + name: Test Go packages + id: unit-test + shell: bash + env: + UNIT_TESTS_PATH: ${{ inputs.UNIT_TESTS_PATH }} + TEST_PARALLELISM: ${{ inputs.TEST_PARALLELISM || 0 }} + TEST_PACKAGE_PARALLELISM: ${{ inputs.TEST_PACKAGE_PARALLELISM || 0 }} + run: | + set -euo pipefail + results_dir=$(mktemp -d "$RUNNER_TEMP/go-tests-${GITHUB_JOB}.XXXXXX") + printf 'results_dir=%s\nartifact_name=%s\n' "$results_dir" "${results_dir##*/}" >> "$GITHUB_OUTPUT" + + args=(-json -count=1) + for setting in TEST_PARALLELISM TEST_PACKAGE_PARALLELISM; do + value=${!setting} + if [[ ! $value =~ ^(0|[1-9][0-9]*)$ ]]; then + printf '%s must be a non-negative integer\n' "$setting" | tee "$results_dir/setup.stderr" >&2 + exit 1 + fi + done + if [[ $TEST_PARALLELISM != 0 ]]; then + args+=(-parallel "$TEST_PARALLELISM") + fi + if [[ $TEST_PACKAGE_PARALLELISM != 0 ]]; then + args+=(-p "$TEST_PACKAGE_PARALLELISM") + fi + if [[ ${TEST_COVERAGE:-false} == true ]]; then + args+=(-covermode=count -coverprofile=coverage.out) + elif [[ $CGO_ENABLED == 1 ]]; then + args+=(-race) + fi - - name: Test without race conditions - if: ${{ env.CGO_ENABLED != 1 }} - run: go test -v $(go list ${{ inputs.UNIT_TESTS_PATH }} | grep -v /e2e) + read -r -a patterns <<< "${UNIT_TESTS_PATH//$'\n'/ }" + go list "${patterns[@]}" > "$results_dir/packages.txt" 2> "$results_dir/list.stderr" + mapfile -t packages < <(grep -v /e2e "$results_dir/packages.txt") + if (( ${#packages[@]} == 0 )); then + printf 'No unit-test packages selected\n' | tee "$results_dir/setup.stderr" >&2 + exit 1 + fi + # pipefail retains the test exit status even when tee succeeds. + go test "${args[@]}" "${packages[@]}" 2> "$results_dir/test.stderr" | tee "$results_dir/tests.jsonl" + + - &go_test_summary + name: Summarize Go test results + if: ${{ always() && steps.unit-test.outputs.results_dir != '' }} + shell: bash + env: + TEST_RESULTS_DIR: ${{ steps.unit-test.outputs.results_dir }} + TEST_OUTCOME: ${{ steps.unit-test.outcome }} + run: | + python3 - <<'PYTHON' + import collections + import html + import json + import os + from pathlib import Path + + results = Path(os.environ["TEST_RESULTS_DIR"]) + failed = set() + output = collections.defaultdict(lambda: collections.deque(maxlen=40)) + malformed = 0 + with (results / "tests.log").open("w") as readable: + json_log = results / "tests.jsonl" + if json_log.exists(): + with json_log.open(errors="replace") as events: + for line in events: + try: + event = json.loads(line) + except json.JSONDecodeError: + malformed += 1 + readable.write(line) + continue + # Go interleaves BuildEvents (ImportPath) + # with TestEvents (Package) in the JSON stream. + package = event.get("Package") or event.get("ImportPath", "unknown package") + key = (package, event.get("Test", "")) + if event.get("Action") in ("fail", "build-fail"): + failed.add(key) + text = event.get("Output", "") + readable.write(text) + output[key].extend(text.splitlines()) + + status = os.environ["TEST_OUTCOME"] + parts = [f"## Go tests: {html.escape(status)}\n", + "Full JSON, readable output, and stderr are attached as a job artifact.\n"] + if failed: + parts.append("### Failed tests and packages\n") + for package, test in sorted(failed): + label = package + (" / " + test if test else " (package)") + parts.append(f"- {html.escape(label)}\n") + # Named failures already include package context. Show package-level + # output when a build, panic, or TestMain failure has no named test. + named_packages = {package for package, test in failed if test} + for key in sorted(failed): + package, test = key + if not test and package in named_packages: + continue + label = package + (" / " + test if test else " (package)") + excerpt = "\n".join(output[key])[-6000:] + parts.append(f"\n
{html.escape(label)}\n\n" + f"
{html.escape(excerpt)}
\n
\n") + elif status != "success": + parts.append("No named test failure was recorded. Check setup/build stderr and the artifact.\n") + if malformed: + parts.append(f"\n{malformed} non-JSON or incomplete lines retained in tests.log.\n") + for name in ("setup.stderr", "list.stderr", "test.stderr"): + path = results / name + if path.exists() and path.stat().st_size: + with path.open(errors="replace") as stream: + tail = "".join(collections.deque(stream, maxlen=40))[-6000:] + parts.append(f"\n### {name}\n
{html.escape(tail)}
\n") + # Leave ample room below GitHub's 1 MiB per-step summary limit. + summary = "".join(parts) + if len(summary) > 60000: + summary = summary[:60000] + "\n\nSummary truncated; consult the complete artifact.\n" + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as stream: + stream.write(summary) + PYTHON + + - &go_test_artifact + name: Upload Go test results + if: ${{ always() && steps.unit-test.outputs.results_dir != '' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.unit-test.outputs.artifact_name }} + path: ${{ steps.unit-test.outputs.results_dir }} + retention-days: 7 + if-no-files-found: error - name: Initialize CodeQL continue-on-error: true @@ -115,6 +247,7 @@ jobs: Basic-Test: env: + TEST_COVERAGE: true GITGUARDIAN_API_KEY: ${{ secrets.GITGUARDIAN_API_KEY }} SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} @@ -167,9 +300,9 @@ jobs: args: --timeout 10m only-new-issues: true - - name: Test coverage - id: unit-test - run: go test -v $(go list ${{ inputs.UNIT_TESTS_PATH }} | grep -v /e2e) -covermode=count -coverprofile=coverage.out + - *go_tests + - *go_test_summary + - *go_test_artifact - name: Comment results to PR uses: peter-evans/create-or-update-comment@v4 diff --git a/.github/workflows/incluster-comp-pr-created.yaml b/.github/workflows/incluster-comp-pr-created.yaml index 1e938a2..907d938 100644 --- a/.github/workflows/incluster-comp-pr-created.yaml +++ b/.github/workflows/incluster-comp-pr-created.yaml @@ -29,6 +29,16 @@ on: required: false type: string default: "./..." + TEST_PARALLELISM: + description: Maximum parallel Go tests per package; 0 keeps the Go default + required: false + type: number + default: 0 + TEST_PACKAGE_PARALLELISM: + description: Maximum concurrent Go test/build packages; 0 keeps the Go default + required: false + type: number + default: 0 # TEST_MULTI_ENVIRONMENTS: # required: false # type: boolean @@ -51,5 +61,7 @@ jobs: CGO_ENABLED: ${{ inputs.CGO_ENABLED }} UNIT_TESTS_PATH: ${{ inputs.UNIT_TESTS_PATH }} BUILD_PATH: ${{ inputs.BUILD_PATH }} + TEST_PARALLELISM: ${{ inputs.TEST_PARALLELISM || 0 }} + TEST_PACKAGE_PARALLELISM: ${{ inputs.TEST_PACKAGE_PARALLELISM || 0 }} # TEST_MULTI_ENVIRONMENTS: ${{ inputs.TEST_MULTI_ENVIRONMENTS }} - secrets: inherit \ No newline at end of file + secrets: inherit diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 8514beb..50dbb3c 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -7,6 +7,18 @@ on: - '**.md' ### Ignore running when .md files change jobs: + validate-go-test-reporting: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + - name: Exercise test failure reporting + run: python3 -m unittest discover -s tests -v + validate-go-basic-tests-file: permissions: pull-requests: write diff --git a/README.md b/README.md index 8fa42cd..3bf44be 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,49 @@ Here's the list of reusable workflows available from this repository: | **Name** | **Variables** | **Description** | | ------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | | `sanity-check.yaml` | - GH_RUNNER: define the runner to use on the workflow. Default: `ubuntu-latest`
- `SYSTEM_TESTS_BRANCH`: define alternative branch to clone for [system-tests](github.com/armosec/system-tests) repository. Default: `master`
- `BINARY_TESTS`: specify which tests are going to be executed by the workflow. | This workflow allow you to run **system-tests** in the **production** environment | + +## Go test diagnostics and concurrency + +`go-basic-tests.yaml` captures each unit-test and coverage run as a separate +`go-tests--` artifact, retained for seven days. It contains: + +- `tests.jsonl`: the complete `go test -json` event stream. +- `tests.log`: readable output reconstructed from those events. +- `test.stderr`, `list.stderr`, and `packages.txt`: compiler/tool diagnostics + and the selected package list. Setup errors also produce `setup.stderr`. + +The job summary lists failed tests and packages with bounded output excerpts. +Summary and artifact steps run after test failure. A nonzero `go test` or +`go list` exit still fails the job; there are no automatic retries. Tests use +`-count=1` so every run executes them. Existing race and coverage modes are +preserved, as is the exclusion of `/e2e` packages. + +Both `go-basic-tests.yaml` and `incluster-comp-pr-created.yaml` accept two +optional numeric inputs: + +| Input | Meaning | Default | +| --- | --- | --- | +| `TEST_PARALLELISM` | Go `-parallel`: simultaneous parallel tests within each package | `0` (Go default) | +| `TEST_PACKAGE_PARALLELISM` | Go `-p`: concurrent package test/build processes | `0` (Go default) | + +For example, a caller can reduce competition for CPU and disk: + +```yaml +jobs: + pr-created: + uses: kubescape/workflows/.github/workflows/incluster-comp-pr-created.yaml@main + with: + GO_VERSION: "1.25" + CGO_ENABLED: 0 + TEST_PARALLELISM: 4 + TEST_PACKAGE_PARALLELISM: 2 + secrets: inherit +``` + +Zero preserves Go's default; negative and fractional values fail validation. +These limits do not make wall-clock assertions deterministic. Tests of +concurrency correctness should synchronize on the events they need to observe; +strict latency checks need a separate performance test with controlled load. + +The reporting regression harness uses Python's standard library and local Go +fixtures: `python3 -m unittest discover -s tests -v`. diff --git a/tests/test_go_ci.py b/tests/test_go_ci.py new file mode 100644 index 0000000..c34347c --- /dev/null +++ b/tests/test_go_ci.py @@ -0,0 +1,222 @@ +"""Exercise the workflow's actual shell scripts without network dependencies. + +Run with: python3 -m unittest discover -s tests -v +YAML structure and GitHub expressions are checked separately with actionlint. +""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/go-basic-tests.yaml" + + +def run_block(anchor): + """Extract an anchored step's literal run block, enforcing its indentation.""" + lines = WORKFLOW.read_text().splitlines() + start = lines.index(" - &" + anchor) + for index in range(start + 1, len(lines)): + if lines[index] == " run: |": + break + if lines[index].startswith(" - "): + raise AssertionError(f"Missing run block for {anchor}") + else: + raise AssertionError(f"Missing run block for {anchor}") + script = [] + for line in lines[index + 1:]: + if line and not line.startswith(" "): + break + script.append(line[10:] if line else "") + if not script: + raise AssertionError(f"Empty run block for {anchor}") + return "\n".join(script) + "\n" + + +class GoWorkflowTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.module = self.root / "module" + self.module.mkdir() + (self.module / "go.mod").write_text("module example.com/ci\n\ngo 1.20\n") + self.env = dict(os.environ, RUNNER_TEMP=str(self.root), GITHUB_JOB="test", + GITHUB_OUTPUT=str(self.root / "outputs"), + GITHUB_STEP_SUMMARY=str(self.root / "summary"), + CGO_ENABLED="0", UNIT_TESTS_PATH="./...", + TEST_PARALLELISM="0", TEST_PACKAGE_PARALLELISM="0", + TEST_COVERAGE="false", GOPROXY="off", GOSUMDB="off", + GOTOOLCHAIN="local", GOFLAGS="", GOWORK="off") + + def shell(self, anchor): + return subprocess.run(["bash", "-e", "-o", "pipefail", "-c", run_block(anchor)], + cwd=self.module, env=self.env, text=True, + capture_output=True, timeout=120) + + def execute(self): + result = self.shell("go_tests") + outputs = dict(line.split("=", 1) for line in + Path(self.env["GITHUB_OUTPUT"]).read_text().splitlines()) + self.results = Path(outputs["results_dir"]) + self.assertEqual(outputs["artifact_name"], self.results.name) + self.assertEqual(self.results.parent, self.root) + summary = self.summarize("success" if result.returncode == 0 else "failure") + return result, summary + + def summarize(self, outcome): + self.env.update(TEST_RESULTS_DIR=str(self.results), TEST_OUTCOME=outcome) + result = self.shell("go_test_summary") + self.assertEqual(result.returncode, 0, result.stderr) + return Path(self.env["GITHUB_STEP_SUMMARY"]).read_text() + + @unittest.skipUnless(shutil.which("go"), "Go is required for integration tests") + def test_real_go_outcomes(self): + cases = { + "pass": ('package ci\nimport "testing"\n' + 'func TestPass(t *testing.T) { t.Log("passing output") }\n', + True, "passing output"), + "named_subtest": ('package ci\nimport "testing"\n' + 'func TestParent(t *testing.T) { t.Run("child", ' + 'func(t *testing.T) { t.Fatal("failure marker") }) }\n', + False, "TestParent/child"), + "testmain": ('package ci\nimport ("testing"; "os"; "fmt")\n' + 'func TestMain(m *testing.M) { fmt.Println("TestMain marker"); os.Exit(1) }\n', + False, "TestMain marker"), + "build": ('package ci\nvar broken = undefinedBuildMarker\n', + False, "undefinedBuildMarker"), + } + for name, (source, success, marker) in cases.items(): + with self.subTest(name=name): + Path(self.env["GITHUB_OUTPUT"]).write_text("") + Path(self.env["GITHUB_STEP_SUMMARY"]).write_text("") + (self.module / "ci_test.go").write_text(source) + result, summary = self.execute() + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + self.assertTrue((self.results / "tests.jsonl").exists()) + self.assertIn(marker, (self.results / "tests.log").read_text() + + (self.results / "test.stderr").read_text()) + self.assertIn("Go tests: " + ("success" if success else "failure"), summary) + if not success: + self.assertIn(marker, summary) + + @unittest.skipUnless(shutil.which("go"), "Go is required for integration tests") + def test_go_list_failure_retains_diagnostics(self): + self.env["UNIT_TESTS_PATH"] = "./missing-package" + result, summary = self.execute() + self.assertNotEqual(result.returncode, 0) + self.assertTrue((self.results / "list.stderr").read_text()) + self.assertIn("missing-package", summary) + self.assertIn("No named test failure", summary) + self.assertTrue((self.results / "tests.log").exists()) + + def fake_go(self, packages="example.com/ci\nexample.com/ci/e2e\n"): + binary = self.root / "bin" + binary.mkdir(exist_ok=True) + fake = binary / "go" + fake.write_text("#!/usr/bin/env python3\nimport json, os, sys\n" + "with open(os.environ['GO_CALLS'], 'a') as out:\n" + " out.write(json.dumps(sys.argv[1:]) + '\\n')\n" + f"if sys.argv[1] == 'list': print({packages!r}, end='')\n") + fake.chmod(0o755) + self.env.update(PATH=str(binary) + os.pathsep + os.environ["PATH"], + GO_CALLS=str(self.root / "calls")) + + def test_flags_and_package_filtering(self): + self.fake_go() + for coverage, cgo, parallel, package_parallel in ( + ("false", "1", "2", "3"), ("true", "1", "0", "0"), + ("false", "0", "0", "0")): + with self.subTest(coverage=coverage, cgo=cgo): + self.env.update(TEST_COVERAGE=coverage, CGO_ENABLED=cgo, + TEST_PARALLELISM=parallel, + TEST_PACKAGE_PARALLELISM=package_parallel, + UNIT_TESTS_PATH="./pkg/...\n./internal/... ./literal-$(touch-INJECTED)/*") + result, _ = self.execute() + self.assertEqual(result.returncode, 0, result.stderr) + calls = [json.loads(line) for line in (self.root / "calls").read_text().splitlines()] + self.assertEqual(calls[-2], ["list", "./pkg/...", "./internal/...", + "./literal-$(touch-INJECTED)/*"]) + expected = ["test", "-json", "-count=1"] + if parallel != "0": + expected += ["-parallel", parallel, "-p", package_parallel] + if coverage == "true": + expected += ["-covermode=count", "-coverprofile=coverage.out"] + elif cgo == "1": + expected += ["-race"] + self.assertEqual(calls[-1], expected + ["example.com/ci"]) + + def test_invalid_parallelism_fails_before_go(self): + self.fake_go() + for setting in ("TEST_PARALLELISM", "TEST_PACKAGE_PARALLELISM"): + for value in ("-1", "1.5", "abc"): + with self.subTest(setting=setting, value=value): + self.env.update(TEST_PARALLELISM="0", TEST_PACKAGE_PARALLELISM="0") + self.env[setting] = value + result, summary = self.execute() + self.assertNotEqual(result.returncode, 0) + self.assertIn(setting + " must be a non-negative integer", summary) + self.assertFalse((self.root / "calls").exists()) + + def test_empty_package_selection_fails(self): + self.fake_go(packages="example.com/ci/e2e\n") + result, summary = self.execute() + self.assertNotEqual(result.returncode, 0) + self.assertIn("No unit-test packages selected", summary) + + def test_summary_escapes_and_preserves_complete_artifact(self): + self.results = self.root / "results" + self.results.mkdir() + events = [dict(Action="output", Package="pkg", Test="TestPassing", + Output="NOISY_PASSING_TEST\n" * 500), + dict(Action="pass", Package="pkg", Test="TestPassing"), + dict(Action="output", Package="pkg", Test="TestFail