diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py index 311de4a8..168ee24d 100644 --- a/src/dda/tools/go.py +++ b/src/dda/tools/go.py @@ -11,7 +11,9 @@ from dda.utils.fs import Path if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Iterable + from os import PathLike + from typing import Any class Go(Tool): @@ -62,3 +64,68 @@ 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, + *packages: str | PathLike, + output: str | PathLike, + build_tags: set[str] | None = None, + gcflags: Iterable[str] | None = None, + ldflags: Iterable[str] | None = None, + env_vars: dict[str, str] | None = None, + force_rebuild: bool = False, + **kwargs: Any, + ) -> str: + """ + Run an instrumented Go build command. + + Args: + packages: The go packages to build, passed as a list of strings or Paths. + Empty by default, which is equivalent to building the current directory. + output: The path to the output binary. + build_tags: Build tags to include when compiling. Empty by default. + gcflags: The gcflags (go compiler flags) to use, passed as a list of strings. Empty by default. + ldflags: The ldflags (go linker flags) to use, passed as a list of strings. Empty by default. + env_vars: Extra environment variables to set for the build command. 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 + "-mod=readonly", # Always use readonly mode, we never use anything else + f"-o={output}", + ] + + if force_rebuild: + command_parts.append("-a") + + # Enable data race detection on platforms that support it (all except 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 gcflags: + command_parts.append(f"-gcflags={' '.join(gcflags)}") + if ldflags: + command_parts.append(f"-ldflags={' '.join(ldflags)}") + + if build_tags: + command_parts.extend(("-tags", f"{','.join(sorted(build_tags))}")) + + command_parts.extend(str(package) for package in packages) + + # TODO: Debug log the command parts ? + return self._build(command_parts, env=env_vars, **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..4ffd83f5 --- /dev/null +++ b/tests/tools/go/conftest.py @@ -0,0 +1,12 @@ +from base64 import urlsafe_b64encode +from os import urandom + +import pytest + + +@pytest.fixture +def get_random_filename(): + def _get_random_filename(k: int = 10) -> str: + return urlsafe_b64encode(urandom(k)).decode("utf-8") + + 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..fa643818 --- /dev/null +++ b/tests/tools/go/test_go.py @@ -0,0 +1,113 @@ +# 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", + [ + {"n_packages": 2}, + {"n_packages": 1, "build_tags": {"debug"}}, + {"n_packages": 0, "build_tags": {"prod"}, "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]}, + {"n_packages": 2, "build_tags": {"prod"}, "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]}, + {"n_packages": 0, "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 package and output paths + n_packages = call_args.pop("n_packages", 0) + packages: tuple[str, ...] = tuple(get_random_filename() for _ in range(n_packages)) + output: Path = get_random_filename() + app.tools.go.build( + *packages, + output=output, + **call_args, + ) + + flags = { + ("-trimpath",), + ("-mod=readonly",), + (f"-o={output}",), + # ("-v",), # By default verbosity is INFO + # ("-x",), + } + + if not (platform.system() == "Windows" and platform.machine() == "arm64"): + flags.add(("-race",)) + + if call_args.get("build_tags"): + flags.add(("-tags", ",".join(sorted(call_args.get("build_tags", []))))) + if call_args.get("gcflags"): + flags.add((f"-gcflags={' '.join(call_args.get('gcflags'))}",)) + if call_args.get("ldflags"): + flags.add((f"-ldflags={' '.join(call_args.get('ldflags'))}",)) + if call_args.get("force_rebuild"): + flags.add(("-a",)) + + seen_command_parts = app.tools.go._build.call_args[0][0] # noqa: SLF001 + + flags_len = len(flags) + seen_flags: list[str] = seen_command_parts[: flags_len + 1] + for flag_tuple in flags: + assert flag_tuple[0] in seen_flags + + if len(flag_tuple) > 1: + flag_index = seen_flags.index(flag_tuple[0]) + assert seen_flags[flag_index + 1] == flag_tuple[1] + + if n_packages > 0: + assert seen_command_parts[-len(packages) :] == [str(package) for package in packages] + + # This test 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( + ".", + output=(temp_dir / "testbinary").absolute(), + build_tags={tag}, + force_rebuild=True, + ) + + 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"}