diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py index 311de4a8..17abdeb0 100644 --- a/src/dda/tools/go.py +++ b/src/dda/tools/go.py @@ -12,6 +12,8 @@ if TYPE_CHECKING: from collections.abc import Generator + from os import PathLike + from typing import Any class Go(Tool): @@ -62,3 +64,69 @@ def version(self) -> str | None: return match.group(1) return None + + def _build(self, args: list[str], **kwargs: Any) -> str: + """Run a raw go build command.""" + return self.capture(["build", *args], check=True, **kwargs) + + def build( + self, + entrypoint: str | PathLike, + output: str | PathLike, + *args: str, + build_tags: set[str] | None = None, + go_mod: str | PathLike | None = None, + gcflags: str | None = None, + ldflags: str | None = None, + force_rebuild: bool = False, + **kwargs: dict[str, Any], + ) -> str: + """ + Run an instrumented Go build command. + + Args: + entrypoint: The go file / directory to build. + output: The path to the output binary. + *args: Extra positional arguments to pass to the go build command. + go_mod: Path to a go.mod file to use. By default will not be specified to the build command. + gcflags: The gcflags (go compiler flags) to use. Empty by default. + ldflags: The ldflags (go linker flags) to use. Empty by default. + force_rebuild: Whether to force a rebuild of the package and bypass the build cache. + **kwargs: Additional arguments to pass to the go build command. + """ + from platform import machine as architecture + + from dda.config.constants import Verbosity + from dda.utils.platform import PLATFORM_ID + + command_parts = [ + "-trimpath", # Always use trimmed paths instead of absolute file system paths # NOTE: This might not work with delve + f"-o={output}", + ] + + if force_rebuild: + command_parts.append("-a") + + # Enable data race detection on platforms that support it (all execpt windows arm64) + if not (PLATFORM_ID == "windows" and architecture() == "arm64"): + command_parts.append("-race") + + if self.app.config.terminal.verbosity >= Verbosity.VERBOSE: + command_parts.append("-v") + if self.app.config.terminal.verbosity >= Verbosity.DEBUG: + command_parts.append("-x") + + if go_mod: + command_parts.append(f"-mod={go_mod}") + if gcflags: + command_parts.append(f"-gcflags={gcflags}") + if ldflags: + command_parts.append(f"-ldflags={ldflags}") + + if build_tags: + command_parts.append(f"-tags={' '.join(sorted(build_tags))}") + + command_parts.extend(args) + command_parts.append(str(entrypoint)) + + return self._build(command_parts, **kwargs) diff --git a/tests/conftest.py b/tests/conftest.py index 6864cc57..0ac1920b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -232,21 +232,37 @@ def pytest_runtest_setup(item): if marker.name == "requires_windows" and PLATFORM_ID != "windows": pytest.skip("Not running on Windows") + if marker.name == "skip_windows" and PLATFORM_ID == "windows": + pytest.skip("Test should be skipped on Windows") + if marker.name == "requires_macos" and PLATFORM_ID != "macos": pytest.skip("Not running on macOS") + if marker.name == "skip_macos" and PLATFORM_ID == "macos": + pytest.skip("Test should be skipped on macOS") + if marker.name == "requires_linux" and PLATFORM_ID != "linux": pytest.skip("Not running on Linux") - if marker.name == "requires_unix" and PLATFORM_ID == "windows": - pytest.skip("Not running on a Linux-based platform") + if marker.name == "skip_linux" and PLATFORM_ID == "linux": + pytest.skip("Test should be skipped on Linux") + + if marker.name == "requires_unix" and PLATFORM_ID not in {"linux", "macos"}: + pytest.skip("Not running on a Unix-based platform") + + if marker.name == "skip_unix" and PLATFORM_ID in {"linux", "macos"}: + pytest.skip("Test should be skipped on Unix-based platforms") def pytest_configure(config): config.addinivalue_line("markers", "requires_ci: Tests intended for CI environments") config.addinivalue_line("markers", "requires_windows: Tests intended for Windows operating systems") + config.addinivalue_line("markers", "skip_windows: Tests should be skipped on Windows operating systems") config.addinivalue_line("markers", "requires_macos: Tests intended for macOS operating systems") + config.addinivalue_line("markers", "skip_macos: Tests should be skipped on macOS operating systems") config.addinivalue_line("markers", "requires_linux: Tests intended for Linux operating systems") + config.addinivalue_line("markers", "skip_linux: Tests should be skipped on Linux operating systems") config.addinivalue_line("markers", "requires_unix: Tests intended for Linux-based operating systems") + config.addinivalue_line("markers", "skip_unix: Tests should be skipped on Unix-based operating systems") config.getini("norecursedirs").remove("build") # /tests/cli/build diff --git a/tests/tools/go/__init__.py b/tests/tools/go/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/tools/go/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/tools/go/conftest.py b/tests/tools/go/conftest.py new file mode 100644 index 00000000..235c46c7 --- /dev/null +++ b/tests/tools/go/conftest.py @@ -0,0 +1,21 @@ +import random +import string + +import pytest + +from dda.utils.fs import Path + +AVAILABLE_CHARS = string.ascii_letters + string.digits + "-_+.!@#$%^&*()[]{}:;,. " + + +@pytest.fixture +def get_random_filename(): + def _get_random_filename(k: int = 10, root: Path | None = None) -> Path: + name = "".join(random.choices(AVAILABLE_CHARS, k=k)) + # Remove the leading `-` to avoid considering it as a command flag + path = Path(name.removeprefix("-")) + if root: + return root / path + return path + + return _get_random_filename diff --git a/tests/tools/go/fixtures/small_go_project/debug.go b/tests/tools/go/fixtures/small_go_project/debug.go new file mode 100644 index 00000000..5fefc6ee --- /dev/null +++ b/tests/tools/go/fixtures/small_go_project/debug.go @@ -0,0 +1,9 @@ +//go:build debug +// +build debug + +package main + +// Also defined in prod.go to test build tags +func TagInfo() string { + return "DEBUG" +} diff --git a/tests/tools/go/fixtures/small_go_project/go.mod b/tests/tools/go/fixtures/small_go_project/go.mod new file mode 100644 index 00000000..94195dd5 --- /dev/null +++ b/tests/tools/go/fixtures/small_go_project/go.mod @@ -0,0 +1,3 @@ +module github.com/DataDog/datadog-agent-dev/tests/tools/go/fixtures/small_go_project + +go 1.21.0 diff --git a/tests/tools/go/fixtures/small_go_project/main.go b/tests/tools/go/fixtures/small_go_project/main.go new file mode 100644 index 00000000..df5140a4 --- /dev/null +++ b/tests/tools/go/fixtures/small_go_project/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" + "runtime" +) + +func main() { + fmt.Printf("Hello from small go project!\n") + fmt.Printf("Go version: %s\n", runtime.Version()) + fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) + + // Test command line args + if len(os.Args) > 1 { + fmt.Printf("Arguments: %v\n", os.Args[1:]) + } + + // Call build-specific function to test build tags + fmt.Printf("Tag: %s\n", TagInfo()) +} diff --git a/tests/tools/go/fixtures/small_go_project/prod.go b/tests/tools/go/fixtures/small_go_project/prod.go new file mode 100644 index 00000000..983d3457 --- /dev/null +++ b/tests/tools/go/fixtures/small_go_project/prod.go @@ -0,0 +1,9 @@ +//go:build !debug +// +build !debug + +package main + +// Also defined in debug.go to test build tags +func TagInfo() string { + return "PRODUCTION" +} diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py new file mode 100644 index 00000000..7d9a1b08 --- /dev/null +++ b/tests/tools/go/test_go.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import platform + +import pytest + +from dda.utils.fs import Path + + +def test_default(app): + with app.tools.go.execution_context([]) as context: + assert context.env_vars == {} + + +class TestPrecedence: + def test_workspace_file(self, app, temp_dir): + (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") + with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: + assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Z"} + + def test_module_file(self, app, temp_dir): + (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") + (temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff") + with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: + assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc1"} + + def test_version_file(self, app, temp_dir): + (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") + (temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff") + (temp_dir / ".go-version").write_text("X.Y.Zrc2") + with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: + assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc2"} + + +class TestBuild: + @pytest.mark.parametrize( + "call_args", + [ + {}, + {"build_tags": ["debug"]}, + {"build_tags": ["prod"], "gcflags": "-gcflags=all=-N -l", "ldflags": "-ldflags=all=-s -w"}, + { + "build_tags": ["prod"], + "gcflags": "-gcflags=all=-N -l", + "ldflags": "-ldflags=all=-s -w", + "go_mod": "../go.mod", + }, + {"force_rebuild": True}, + ], + ) + def test_command_formation(self, app, mocker, call_args, get_random_filename): + # Patch the raw _build method to avoid running anything + mocker.patch("dda.tools.go.Go._build", return_value="output") + + # Generate dummy entrypoint and output paths + entrypoint: Path = get_random_filename() + output: Path = get_random_filename() + app.tools.go.build( + entrypoint=entrypoint, + output=output, + **call_args, + ) + + # Assert the command is formed correctly + expected_command_flags = { + "-trimpath", + f"-o={output}", + # "-v", # By default verbosity is INFO + # "-x", + } + if not (platform.machine() == "windows" and platform.machine() == "arm64"): + expected_command_flags.add("-race") + + if call_args.get("build_tags"): + expected_command_flags.add(f"-tags={' '.join(sorted(call_args.get('build_tags', [])))}") + if call_args.get("gcflags"): + expected_command_flags.add(f"-gcflags={call_args.get('gcflags')}") + if call_args.get("ldflags"): + expected_command_flags.add(f"-ldflags={call_args.get('ldflags')}") + if call_args.get("go_mod"): + expected_command_flags.add(f"-mod={call_args.get('go_mod')}") + if call_args.get("force_rebuild"): + expected_command_flags.add("-a") + + seen_command = app.tools.go._build.call_args[0][0] # noqa: SLF001 + seen_command_flags = {x for x in seen_command if x.startswith("-")} + assert seen_command_flags == expected_command_flags + assert seen_command[len(seen_command_flags)] == str(entrypoint) + + # This tests is quite slow, we'll only run it in CI + @pytest.mark.requires_ci + @pytest.mark.skip_macos # Go binary is not installed on macOS CI runners + def test_build_project(self, app, temp_dir): + for tag, output_mark in [("prod", "PRODUCTION"), ("debug", "DEBUG")]: + with (Path(__file__).parent / "fixtures" / "small_go_project").as_cwd(): + app.tools.go.build( + entrypoint=".", + output=(temp_dir / "testbinary").absolute(), + build_tags=[tag], + ) + + assert (temp_dir / "testbinary").is_file() + output = app.subprocess.capture(str(temp_dir / "testbinary")) + assert output_mark in output + # Note: doing both builds in the same test with the same name also allows us to test the force rebuild diff --git a/tests/tools/test_go.py b/tests/tools/test_go.py deleted file mode 100644 index 49d68dfb..00000000 --- a/tests/tools/test_go.py +++ /dev/null @@ -1,29 +0,0 @@ -# SPDX-FileCopyrightText: 2025-present Datadog, Inc. -# -# SPDX-License-Identifier: MIT -from __future__ import annotations - - -def test_default(app): - with app.tools.go.execution_context([]) as context: - assert context.env_vars == {} - - -class TestPrecedence: - def test_workspace_file(self, app, temp_dir): - (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") - with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: - assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Z"} - - def test_module_file(self, app, temp_dir): - (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") - (temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff") - with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: - assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc1"} - - def test_version_file(self, app, temp_dir): - (temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff") - (temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff") - (temp_dir / ".go-version").write_text("X.Y.Zrc2") - with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context: - assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc2"}