Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion src/dda/tools/go.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
20 changes: 18 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions tests/tools/go/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <[email protected]>
#
# SPDX-License-Identifier: MIT
12 changes: 12 additions & 0 deletions tests/tools/go/conftest.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions tests/tools/go/fixtures/small_go_project/debug.go
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 3 additions & 0 deletions tests/tools/go/fixtures/small_go_project/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/DataDog/datadog-agent-dev/tests/tools/go/fixtures/small_go_project

go 1.21.0
21 changes: 21 additions & 0 deletions tests/tools/go/fixtures/small_go_project/main.go
Original file line number Diff line number Diff line change
@@ -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())
}
9 changes: 9 additions & 0 deletions tests/tools/go/fixtures/small_go_project/prod.go
Original file line number Diff line number Diff line change
@@ -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"
}
113 changes: 113 additions & 0 deletions tests/tools/go/test_go.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <[email protected]>
#
# 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
29 changes: 0 additions & 29 deletions tests/tools/test_go.py

This file was deleted.