Skip to content
Open
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
68 changes: 68 additions & 0 deletions src/dda/tools/go.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

if TYPE_CHECKING:
from collections.abc import Generator
from os import PathLike
from typing import Any


class Go(Tool):
Expand Down Expand Up @@ -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)
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. <dev@datadoghq.com>
#
# SPDX-License-Identifier: MIT
21 changes: 21 additions & 0 deletions tests/tools/go/conftest.py
Original file line number Diff line number Diff line change
@@ -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
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"
}
108 changes: 108 additions & 0 deletions tests/tools/go/test_go.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com>
#
# 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
29 changes: 0 additions & 29 deletions tests/tools/test_go.py

This file was deleted.

Loading