Skip to content

Commit fd65f14

Browse files
authored
ACIX-1062: Create helper function for go build (#211)
* feat(go): Add a custom `Go.build` method for easier invocations of `go build` Also restructures a bit the tests directory for `go` * fix(go): Make the `-a` argument controllable This way we can use the build cache if needed, which is useful for example in the `test_build_project` test which rebuilds more or less the same project twice. * fix(build): Prevent `get_random_filename` from creating filenames starting with `-` This would cause a failure in `test_command_formation` if the entrypoint starts with `-`, as it would then be considered a flag instead of a parameter * feat(tests): Add `skip_${OS}` pytest markers for skipping tests on specific operating systems * fix(tests): Skip `TestBuild::test_build_project` on macOS as `go` is not installed in CI runners * fix(go): Tweak `Go.build` method interface after testing - Additionnal quoting around some arguments - Pass `tags` as a comma-separated list - Pass custom env vars to the build command - `-mod` argument has a different purpose than previously thought * refactor: Minor Copilot-suggested tweaks * refactor: Simplify `get_random_filename` test fixture * refactor(go): Pass `*packages` instead of a single `entrypoint` * refactor(tests): Use sets of build tags everywhere * feat(go): Allow passing 0 packages to `build` method * refactor(tests): `get_random_filename` returns a simple `str`
1 parent ce618c0 commit fd65f14

File tree

10 files changed

+256
-32
lines changed

10 files changed

+256
-32
lines changed

src/dda/tools/go.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
from dda.utils.fs import Path
1212

1313
if TYPE_CHECKING:
14-
from collections.abc import Generator
14+
from collections.abc import Generator, Iterable
15+
from os import PathLike
16+
from typing import Any
1517

1618

1719
class Go(Tool):
@@ -62,3 +64,68 @@ def version(self) -> str | None:
6264
return match.group(1)
6365

6466
return None
67+
68+
def _build(self, args: list[str], **kwargs: Any) -> str:
69+
"""Run a raw go build command."""
70+
return self.capture(["build", *args], check=True, **kwargs)
71+
72+
def build(
73+
self,
74+
*packages: str | PathLike,
75+
output: str | PathLike,
76+
build_tags: set[str] | None = None,
77+
gcflags: Iterable[str] | None = None,
78+
ldflags: Iterable[str] | None = None,
79+
env_vars: dict[str, str] | None = None,
80+
force_rebuild: bool = False,
81+
**kwargs: Any,
82+
) -> str:
83+
"""
84+
Run an instrumented Go build command.
85+
86+
Args:
87+
packages: The go packages to build, passed as a list of strings or Paths.
88+
Empty by default, which is equivalent to building the current directory.
89+
output: The path to the output binary.
90+
build_tags: Build tags to include when compiling. Empty by default.
91+
gcflags: The gcflags (go compiler flags) to use, passed as a list of strings. Empty by default.
92+
ldflags: The ldflags (go linker flags) to use, passed as a list of strings. Empty by default.
93+
env_vars: Extra environment variables to set for the build command. Empty by default.
94+
force_rebuild: Whether to force a rebuild of the package and bypass the build cache.
95+
**kwargs: Additional arguments to pass to the go build command.
96+
"""
97+
from platform import machine as architecture
98+
99+
from dda.config.constants import Verbosity
100+
from dda.utils.platform import PLATFORM_ID
101+
102+
command_parts = [
103+
"-trimpath", # Always use trimmed paths instead of absolute file system paths # NOTE: This might not work with delve
104+
"-mod=readonly", # Always use readonly mode, we never use anything else
105+
f"-o={output}",
106+
]
107+
108+
if force_rebuild:
109+
command_parts.append("-a")
110+
111+
# Enable data race detection on platforms that support it (all except windows arm64)
112+
if not (PLATFORM_ID == "windows" and architecture() == "arm64"):
113+
command_parts.append("-race")
114+
115+
if self.app.config.terminal.verbosity >= Verbosity.VERBOSE:
116+
command_parts.append("-v")
117+
if self.app.config.terminal.verbosity >= Verbosity.DEBUG:
118+
command_parts.append("-x")
119+
120+
if gcflags:
121+
command_parts.append(f"-gcflags={' '.join(gcflags)}")
122+
if ldflags:
123+
command_parts.append(f"-ldflags={' '.join(ldflags)}")
124+
125+
if build_tags:
126+
command_parts.extend(("-tags", f"{','.join(sorted(build_tags))}"))
127+
128+
command_parts.extend(str(package) for package in packages)
129+
130+
# TODO: Debug log the command parts ?
131+
return self._build(command_parts, env=env_vars, **kwargs)

tests/conftest.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,21 +232,37 @@ def pytest_runtest_setup(item):
232232
if marker.name == "requires_windows" and PLATFORM_ID != "windows":
233233
pytest.skip("Not running on Windows")
234234

235+
if marker.name == "skip_windows" and PLATFORM_ID == "windows":
236+
pytest.skip("Test should be skipped on Windows")
237+
235238
if marker.name == "requires_macos" and PLATFORM_ID != "macos":
236239
pytest.skip("Not running on macOS")
237240

241+
if marker.name == "skip_macos" and PLATFORM_ID == "macos":
242+
pytest.skip("Test should be skipped on macOS")
243+
238244
if marker.name == "requires_linux" and PLATFORM_ID != "linux":
239245
pytest.skip("Not running on Linux")
240246

241-
if marker.name == "requires_unix" and PLATFORM_ID == "windows":
242-
pytest.skip("Not running on a Linux-based platform")
247+
if marker.name == "skip_linux" and PLATFORM_ID == "linux":
248+
pytest.skip("Test should be skipped on Linux")
249+
250+
if marker.name == "requires_unix" and PLATFORM_ID not in {"linux", "macos"}:
251+
pytest.skip("Not running on a Unix-based platform")
252+
253+
if marker.name == "skip_unix" and PLATFORM_ID in {"linux", "macos"}:
254+
pytest.skip("Test should be skipped on Unix-based platforms")
243255

244256

245257
def pytest_configure(config):
246258
config.addinivalue_line("markers", "requires_ci: Tests intended for CI environments")
247259
config.addinivalue_line("markers", "requires_windows: Tests intended for Windows operating systems")
260+
config.addinivalue_line("markers", "skip_windows: Tests should be skipped on Windows operating systems")
248261
config.addinivalue_line("markers", "requires_macos: Tests intended for macOS operating systems")
262+
config.addinivalue_line("markers", "skip_macos: Tests should be skipped on macOS operating systems")
249263
config.addinivalue_line("markers", "requires_linux: Tests intended for Linux operating systems")
264+
config.addinivalue_line("markers", "skip_linux: Tests should be skipped on Linux operating systems")
250265
config.addinivalue_line("markers", "requires_unix: Tests intended for Linux-based operating systems")
266+
config.addinivalue_line("markers", "skip_unix: Tests should be skipped on Unix-based operating systems")
251267

252268
config.getini("norecursedirs").remove("build") # /tests/cli/build

tests/tools/go/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
2+
#
3+
# SPDX-License-Identifier: MIT

tests/tools/go/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from base64 import urlsafe_b64encode
2+
from os import urandom
3+
4+
import pytest
5+
6+
7+
@pytest.fixture
8+
def get_random_filename():
9+
def _get_random_filename(k: int = 10) -> str:
10+
return urlsafe_b64encode(urandom(k)).decode("utf-8")
11+
12+
return _get_random_filename
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build debug
2+
// +build debug
3+
4+
package main
5+
6+
// Also defined in prod.go to test build tags
7+
func TagInfo() string {
8+
return "DEBUG"
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/DataDog/datadog-agent-dev/tests/tools/go/fixtures/small_go_project
2+
3+
go 1.21.0
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"runtime"
7+
)
8+
9+
func main() {
10+
fmt.Printf("Hello from small go project!\n")
11+
fmt.Printf("Go version: %s\n", runtime.Version())
12+
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
13+
14+
// Test command line args
15+
if len(os.Args) > 1 {
16+
fmt.Printf("Arguments: %v\n", os.Args[1:])
17+
}
18+
19+
// Call build-specific function to test build tags
20+
fmt.Printf("Tag: %s\n", TagInfo())
21+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//go:build !debug
2+
// +build !debug
3+
4+
package main
5+
6+
// Also defined in debug.go to test build tags
7+
func TagInfo() string {
8+
return "PRODUCTION"
9+
}

tests/tools/go/test_go.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
from __future__ import annotations
5+
6+
import platform
7+
8+
import pytest
9+
10+
from dda.utils.fs import Path
11+
12+
13+
def test_default(app):
14+
with app.tools.go.execution_context([]) as context:
15+
assert context.env_vars == {}
16+
17+
18+
class TestPrecedence:
19+
def test_workspace_file(self, app, temp_dir):
20+
(temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff")
21+
with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context:
22+
assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Z"}
23+
24+
def test_module_file(self, app, temp_dir):
25+
(temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff")
26+
(temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff")
27+
with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context:
28+
assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc1"}
29+
30+
def test_version_file(self, app, temp_dir):
31+
(temp_dir / "go.work").write_text("stuff\ngo X.Y.Z\nstuff")
32+
(temp_dir / "go.mod").write_text("stuff\ngo X.Y.Zrc1\nstuff")
33+
(temp_dir / ".go-version").write_text("X.Y.Zrc2")
34+
with temp_dir.as_cwd(), app.tools.go.execution_context([]) as context:
35+
assert context.env_vars == {"GOTOOLCHAIN": "goX.Y.Zrc2"}
36+
37+
38+
class TestBuild:
39+
@pytest.mark.parametrize(
40+
"call_args",
41+
[
42+
{"n_packages": 2},
43+
{"n_packages": 1, "build_tags": {"debug"}},
44+
{"n_packages": 0, "build_tags": {"prod"}, "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]},
45+
{"n_packages": 2, "build_tags": {"prod"}, "gcflags": ["all=-N -l"], "ldflags": ["all=-s -w", "-dumpdep"]},
46+
{"n_packages": 0, "force_rebuild": True},
47+
],
48+
)
49+
def test_command_formation(self, app, mocker, call_args, get_random_filename):
50+
# Patch the raw _build method to avoid running anything
51+
mocker.patch("dda.tools.go.Go._build", return_value="output")
52+
53+
# Generate dummy package and output paths
54+
n_packages = call_args.pop("n_packages", 0)
55+
packages: tuple[str, ...] = tuple(get_random_filename() for _ in range(n_packages))
56+
output: Path = get_random_filename()
57+
app.tools.go.build(
58+
*packages,
59+
output=output,
60+
**call_args,
61+
)
62+
63+
flags = {
64+
("-trimpath",),
65+
("-mod=readonly",),
66+
(f"-o={output}",),
67+
# ("-v",), # By default verbosity is INFO
68+
# ("-x",),
69+
}
70+
71+
if not (platform.system() == "Windows" and platform.machine() == "arm64"):
72+
flags.add(("-race",))
73+
74+
if call_args.get("build_tags"):
75+
flags.add(("-tags", ",".join(sorted(call_args.get("build_tags", [])))))
76+
if call_args.get("gcflags"):
77+
flags.add((f"-gcflags={' '.join(call_args.get('gcflags'))}",))
78+
if call_args.get("ldflags"):
79+
flags.add((f"-ldflags={' '.join(call_args.get('ldflags'))}",))
80+
if call_args.get("force_rebuild"):
81+
flags.add(("-a",))
82+
83+
seen_command_parts = app.tools.go._build.call_args[0][0] # noqa: SLF001
84+
85+
flags_len = len(flags)
86+
seen_flags: list[str] = seen_command_parts[: flags_len + 1]
87+
for flag_tuple in flags:
88+
assert flag_tuple[0] in seen_flags
89+
90+
if len(flag_tuple) > 1:
91+
flag_index = seen_flags.index(flag_tuple[0])
92+
assert seen_flags[flag_index + 1] == flag_tuple[1]
93+
94+
if n_packages > 0:
95+
assert seen_command_parts[-len(packages) :] == [str(package) for package in packages]
96+
97+
# This test is quite slow, we'll only run it in CI
98+
@pytest.mark.requires_ci
99+
@pytest.mark.skip_macos # Go binary is not installed on macOS CI runners
100+
def test_build_project(self, app, temp_dir):
101+
for tag, output_mark in [("prod", "PRODUCTION"), ("debug", "DEBUG")]:
102+
with (Path(__file__).parent / "fixtures" / "small_go_project").as_cwd():
103+
app.tools.go.build(
104+
".",
105+
output=(temp_dir / "testbinary").absolute(),
106+
build_tags={tag},
107+
force_rebuild=True,
108+
)
109+
110+
assert (temp_dir / "testbinary").is_file()
111+
output = app.subprocess.capture(str(temp_dir / "testbinary"))
112+
assert output_mark in output
113+
# Note: doing both builds in the same test with the same name also allows us to test the force rebuild

tests/tools/test_go.py

Lines changed: 0 additions & 29 deletions
This file was deleted.

0 commit comments

Comments
 (0)