diff --git a/src/dda/build/artifacts/__init__.py b/src/dda/build/artifacts/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/build/artifacts/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/build/artifacts/base.py b/src/dda/build/artifacts/base.py new file mode 100644 index 00000000..44ef3bfc --- /dev/null +++ b/src/dda/build/artifacts/base.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from dda.build.metadata.metadata import BuildMetadata, analyze_context + +if TYPE_CHECKING: + from typing import Any + + from dda.build.metadata.digests import ArtifactDigest + from dda.build.metadata.formats import ArtifactFormat + from dda.cli.application import Application + + +# NOTE: Very much speculative - this API might be subject to signficant changes ! +class BuildArtifact(ABC): + """ + Base class for all build artifacts. + """ + + @abstractmethod + def build(self, app: Application, *args: Any, **kwargs: Any) -> None: + """ + Build the artifact. This function can have arbitrary side effects (creating files, running commands, etc.), and is not expected to return anything. + """ + + @abstractmethod + def get_build_components(self) -> tuple[set[str], ArtifactFormat]: + """ + Gets the build components and artifact format for this artifact. + + Returns: + A tuple containing the build components and artifact format for this artifact. + """ + + def compute_metadata(self, app: Application, artifact_digest: ArtifactDigest) -> BuildMetadata: + """ + Creates a BuildMetadata instance for this artifact. + """ + from dda.build.metadata.metadata import BuildMetadata + + return BuildMetadata.spawn_from_context(analyze_context(app), self.get_build_components(), artifact_digest) diff --git a/src/dda/build/artifacts/binaries/__init__.py b/src/dda/build/artifacts/binaries/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/build/artifacts/binaries/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/build/artifacts/binaries/base.py b/src/dda/build/artifacts/binaries/base.py new file mode 100644 index 00000000..ce14f358 --- /dev/null +++ b/src/dda/build/artifacts/binaries/base.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING, override + +from dda.build.artifacts.base import BuildArtifact + +if TYPE_CHECKING: + from dda.build.metadata.formats import ArtifactFormat + + +class BinaryArtifact(BuildArtifact): + """ + Base class for all binary build artifacts. + """ + + @property + @abstractmethod + def name(self) -> str: + """ + Get the name of the binary artifact this object represents. + """ + + @override + def get_build_components(self) -> tuple[set[str], ArtifactFormat]: + from dda.build.metadata.formats import ArtifactFormat + + return {self.name}, ArtifactFormat.BIN diff --git a/src/dda/build/artifacts/binaries/core_agent.py b/src/dda/build/artifacts/binaries/core_agent.py new file mode 100644 index 00000000..25eedcba --- /dev/null +++ b/src/dda/build/artifacts/binaries/core_agent.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from functools import cache +from typing import TYPE_CHECKING, Any, override + +from dda.build.artifacts.binaries.base import BinaryArtifact +from dda.build.languages.go import GoArtifact + +if TYPE_CHECKING: + from dda.cli.application import Application + from dda.utils.fs import Path + + +@cache +def get_repo_root(app: Application) -> Path: + return app.tools.git.get_repo_root() + + +class CoreAgent(BinaryArtifact, GoArtifact): + """ + Build artifact for the `core-agent` binary. + """ + + @override + def get_build_tags(self, *args: Any, **kwargs: Any) -> set[str]: + # TODO: Implement a properly dynamic function, matching the old invoke task + return { + "ec2", + "python", + "kubeapiserver", + "oracle", + "etcd", + "jmx", + "grpcnotrace", + "consul", + "systemprobechecks", + "ncm", + "otlp", + "zstd", + "orchestrator", + "zk", + "datadog.no_waf", + "trivy_no_javadb", + "zlib", + "bundle_agent", + "fargateprocess", + "kubelet", + "cel", + } + + @override + def get_gcflags(self, *args: Any, **kwargs: Any) -> list[str]: + return [] + + @override + def get_ldflags(self, app: Application, *args: Any, **kwargs: Any) -> list[str]: + from dda.build.versioning import parse_describe_result + + repo_root = get_repo_root(app) + with repo_root.as_cwd(): + commit = app.tools.git.get_commit().sha1 + agent_version = parse_describe_result(app.tools.git.capture(["describe", "--tags"]).strip()) + + return [ + "-X", + f"github.com/DataDog/datadog-agent/pkg/version.Commit={commit[:10]}", + "-X", + f"github.com/DataDog/datadog-agent/pkg/version.AgentVersion={agent_version}", + "-X", + # TODO: Make this dynamic + "github.com/DataDog/datadog-agent/pkg/version.AgentPayloadVersion=v5.0.174", + "-X", + f"github.com/DataDog/datadog-agent/pkg/version.AgentPackageVersion={agent_version}", + "-r", + f"{repo_root}/dev/lib", + "'-extldflags=-Wl,-bind_at_load,-no_warn_duplicate_libraries'", + ] + + @override + def get_build_env(self, app: Application, *args: Any, **kwargs: Any) -> dict[str, str]: + # TODO: Implement a properly dynamic function, matching the old invoke task + repo_root = get_repo_root(app) + return { + "GO111MODULE": "on", + "CGO_LDFLAGS_ALLOW": "-Wl,--wrap=.*", + "DYLD_LIBRARY_PATH": f"{repo_root}/dev/lib", + "LD_LIBRARY_PATH": f"{repo_root}/dev/lib", + "CGO_LDFLAGS": f" -L{repo_root}/dev/lib", + "CGO_CFLAGS": f" -Werror -Wno-deprecated-declarations -I{repo_root}/dev/include", + "CGO_ENABLED": "1", + "PATH": f"{repo_root}/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + } + + @property + @override + def name(self) -> str: + return "core-agent" + + @override + def build(self, app: Application, output: Path, *args: Any, **kwargs: Any) -> None: + # TODO: Build rtloader first if needed + # TODO: Make this build in a devenv ? Or at least add a flag + app.tools.go.build( + "github.com/DataDog/datadog-agent/cmd/agent", + output=output, + build_tags=self.get_build_tags(), + gcflags=self.get_gcflags(), + ldflags=self.get_ldflags(app), + env_vars=self.get_build_env(app), + ) diff --git a/src/dda/build/artifacts/distributions/__init__.py b/src/dda/build/artifacts/distributions/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/build/artifacts/distributions/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/build/artifacts/distributions/base.py b/src/dda/build/artifacts/distributions/base.py new file mode 100644 index 00000000..49720172 --- /dev/null +++ b/src/dda/build/artifacts/distributions/base.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from dda.build.artifacts.base import BuildArtifact + + +class DistributionArtifact(BuildArtifact): + """ + Base class for all distribution build artifacts. + """ diff --git a/src/dda/build/languages/__init__.py b/src/dda/build/languages/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/build/languages/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/build/languages/go.py b/src/dda/build/languages/go.py new file mode 100644 index 00000000..3b02a965 --- /dev/null +++ b/src/dda/build/languages/go.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from typing import Any + + +class GoArtifact(ABC): + """ + Base class for all Go artifacts. + Any artifact class for an artifact built with the Go compiler should inherit from this class and implement its methods. + """ + + @abstractmethod + def get_build_tags(self, *args: Any, **kwargs: Any) -> set[str]: + """ + Get the build tags to pass to the Go compiler for the artifact. + """ + + @abstractmethod + def get_gcflags(self, *args: Any, **kwargs: Any) -> list[str]: + """ + Get the gcflags to pass to the Go compiler for the artifact. + """ + + @abstractmethod + def get_ldflags(self, *args: Any, **kwargs: Any) -> list[str]: + """ + Get the ldflags to pass to the Go compiler for the artifact. + """ + return [] + + @abstractmethod + def get_build_env(self, *args: Any, **kwargs: Any) -> dict[str, str]: + """ + Get the build environment variables to pass to the Go compiler for the artifact. + """ diff --git a/src/dda/build/metadata/metadata.py b/src/dda/build/metadata/metadata.py index 27add46c..493f3b61 100644 --- a/src/dda/build/metadata/metadata.py +++ b/src/dda/build/metadata/metadata.py @@ -18,8 +18,6 @@ from dda.utils.git.commit import Commit # noqa: TC001 if TYPE_CHECKING: - from click import Context - from dda.cli.application import Application @@ -70,13 +68,17 @@ def artifact_type(self) -> ArtifactType: def spawn_from_context( cls, context_data: _MetadataRequiredContext, + artifact_components: _MetadataAgentComponents, artifact_digest: ArtifactDigest, ) -> BuildMetadata: """ Create a BuildMetadata instance for the current build. - Takes two arguments: + Takes three arguments: - context_data: A _MetadataRequiredContext instance containing the required data to generate build metadata. This can be created with the `analyze_context` function. + - artifact_components: A tuple containing the agent components and artifact format for the artifact. + The first element is a set of strings representing the agent components. + The second element is an ArtifactFormat instance representing the format of the artifact. - artifact_digest: An ArtifactDigest instance containing the digest of the artifact. This can be calculated with the `DigestType.calculate_digest` method. For example, starting from the _MetadataRequiredContext instance, you can do: @@ -98,6 +100,8 @@ def spawn_from_context( return cls( id=artifact_id, build_time=build_time, + agent_components=artifact_components[0], + artifact_format=artifact_components[1], digest=artifact_digest, **context_data.dump(), ) @@ -176,37 +180,6 @@ def get_canonical_filename(self) -> str: return f"{components}-{compatibility}-{source_info}-{short_uuid}{artifact_format_identifier}" -def get_build_components(command: str) -> tuple[set[str], ArtifactFormat]: - """ - Parse calling command to get the agent components and artifact format. - - Ex: - `dda build bin core-agent` -> (`core-agent`), `bin` and `bin` - `dda build dist deb -c core-agent -c process-agent` -> (`core-agent`, `process-agent`), `dist` and `deb` - """ - command_parts = command.split(" ") - # Remove the first two parts, which are `dda` and `build`, if they exist - if command_parts[:2] != ["dda", "build"]: - msg = f"Unexpected command, only build commands can be used to extract build components: {command}" - raise ValueError(msg) - - artifact_format: ArtifactFormat - artifact_type_str = command_parts[2] - match artifact_type_str: - case "dist": - artifact_format = ArtifactFormat[command_parts[3].upper()] - # TODO: Implement this in a more robust way, write a proper parser for the command line - agent_components = {part for part in command_parts[4:] if part != "-c"} - case "bin": - artifact_format = ArtifactFormat.BIN - agent_components = {command_parts[3]} - case _: - msg = f"Unsupported artifact type: {artifact_type_str}" - raise NotImplementedError(msg) - - return agent_components, artifact_format - - def generate_build_id() -> UUID: """ Generate a unique build ID. @@ -216,11 +189,14 @@ def generate_build_id() -> UUID: return uuid4() -def analyze_context(ctx: Context, app: Application) -> _MetadataRequiredContext: +def analyze_context(app: Application) -> _MetadataRequiredContext: """ Analyze the context to get the required data to generate build metadata. """ - return _MetadataRequiredContext.from_context(ctx, app) + return _MetadataRequiredContext.from_context(app) + + +_MetadataAgentComponents = tuple[set[str], ArtifactFormat] class _MetadataRequiredContext(Struct): @@ -229,9 +205,6 @@ class _MetadataRequiredContext(Struct): Having this as a separate struct allows for easier overriding - this struct is explicitely not frozen. """ - agent_components: set[str] - artifact_format: ArtifactFormat - # Source tree fields commit: Commit worktree_diff: ChangeSet @@ -243,7 +216,7 @@ class _MetadataRequiredContext(Struct): build_platform: Platform @classmethod - def from_context(cls, ctx: Context, app: Application) -> _MetadataRequiredContext: + def from_context(cls, app: Application) -> _MetadataRequiredContext: """ Create a _MetadataRequiredContext instance from the application and build context. Some values might not be correct for some artifacts, in which case they should be overridden afterwards. @@ -259,16 +232,10 @@ def from_context(cls, ctx: Context, app: Application) -> _MetadataRequiredContex import platform - # Build components - build_components = get_build_components(ctx.command_path) - agent_components, artifact_format = build_components - # Build platform build_platform = Platform.from_alias(platform.system().lower(), platform.machine()) return cls( - agent_components=agent_components, - artifact_format=artifact_format, commit=app.tools.git.get_commit(), worktree_diff=app.tools.git.get_changes("HEAD", start="HEAD", working_tree=True), compatible_platforms={build_platform}, diff --git a/src/dda/build/metadata/platforms.py b/src/dda/build/metadata/platforms.py index 3c970b0e..10e27fe2 100644 --- a/src/dda/build/metadata/platforms.py +++ b/src/dda/build/metadata/platforms.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2025-present Datadog, Inc. # # SPDX-License-Identifier: MIT +from __future__ import annotations from enum import StrEnum, auto from typing import ClassVar @@ -21,7 +22,7 @@ class OS(StrEnum): ANY = auto() @classmethod - def from_alias(cls, alias: str) -> "OS": + def from_alias(cls, alias: str) -> OS: """ Get the OS enum value from an alias. """ @@ -57,7 +58,7 @@ class Arch(StrEnum): ANY = auto() @classmethod - def from_alias(cls, alias: str) -> "Arch": + def from_alias(cls, alias: str) -> Arch: """ Get the Arch enum value from an alias. """ @@ -81,10 +82,10 @@ class Platform(Struct, frozen=True): os: OS arch: Arch - ANY: ClassVar["Platform"] + ANY: ClassVar[Platform] @classmethod - def from_alias(cls, os_alias: str, arch_alias: str) -> "Platform": + def from_alias(cls, os_alias: str, arch_alias: str) -> Platform: """ Get the Platform enum value from an alias. """ diff --git a/src/dda/build/versioning.py b/src/dda/build/versioning.py new file mode 100644 index 00000000..87f8d334 --- /dev/null +++ b/src/dda/build/versioning.py @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: MIT +# +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. + +from __future__ import annotations + +import re + +from msgspec import Struct + +# TODO: Add tests +# TODO: Add all the versioning logic from the old invoke task + + +class SemanticVersion(Struct): + major: int + minor: int + patch: int + pre: str # e.g. "devel" + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}{f'-{self.pre}' if self.pre else ''}" + + +class AgentVersion(Struct): + tag: SemanticVersion + commits_since_tag: int + commit_hash: str # Does not have to be the full commit hash, just the first 7 characters + + def __str__(self) -> str: + # Format: 7.74.0-devel+git.96.e927e2b + return f"{self.tag}+git.{self.commits_since_tag}.{self.commit_hash[:7]}" + + +def parse_describe_result(describe_result: str) -> AgentVersion: + """ + Parse the result of `git describe --tags` into an AgentVersion. + """ + match = re.match( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P
[\w\.]+))?-(?P\d+)-g(?P[0-9a-f]+)$",
+        describe_result.strip(),
+    )
+    if not match:
+        msg = f"Failed to parse describe result: {describe_result}"
+        raise RuntimeError(msg)
+    return AgentVersion(
+        tag=SemanticVersion(
+            major=int(match.group("major")),
+            minor=int(match.group("minor")),
+            patch=int(match.group("patch")),
+            pre=match.group("pre"),
+        ),
+        commits_since_tag=int(match.group("commits_since_tag")),
+        commit_hash=match.group("commit_hash"),
+    )
diff --git a/src/dda/cli/build/bin/__init__.py b/src/dda/cli/build/bin/__init__.py
new file mode 100644
index 00000000..b20a1bb0
--- /dev/null
+++ b/src/dda/cli/build/bin/__init__.py
@@ -0,0 +1,13 @@
+# SPDX-FileCopyrightText: 2025-present Datadog, Inc. 
+#
+# SPDX-License-Identifier: MIT
+from __future__ import annotations
+
+from dda.cli.base import dynamic_group
+
+
+@dynamic_group(
+    short_help="Build binary artifacts",
+)
+def cmd() -> None:
+    pass
diff --git a/src/dda/cli/build/bin/core_agent/__init__.py b/src/dda/cli/build/bin/core_agent/__init__.py
new file mode 100644
index 00000000..9d0e95eb
--- /dev/null
+++ b/src/dda/cli/build/bin/core_agent/__init__.py
@@ -0,0 +1,53 @@
+# SPDX-FileCopyrightText: 2025-present Datadog, Inc. 
+#
+# SPDX-License-Identifier: MIT
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import click
+
+from dda.build.metadata.digests import ArtifactDigest, DigestType
+from dda.cli.base import dynamic_command, pass_app
+from dda.utils.fs import Path
+
+if TYPE_CHECKING:
+    from dda.cli.application import Application
+
+DEFAULT_OUTPUT_PLACEHOLDER = Path("./bin/agent/canonical_filename")
+
+
+@dynamic_command(short_help="Build the `core-agent` binary.")
+@click.option(
+    "--output",
+    "-o",
+    type=click.Path(file_okay=True, dir_okay=False, exists=False, writable=True),
+    default=DEFAULT_OUTPUT_PLACEHOLDER,
+    help="""
+The path on which to create the binary.
+Defaults to bin/agent/canonical_filename - the canonical filename of the built artifact.
+This filename contains some metadata about the built artifact, e.g. commit hash, build timestamp, etc.
+    """,
+)
+@pass_app
+def cmd(app: Application, output: Path) -> None:
+    import shutil
+
+    from dda.build.artifacts.binaries.core_agent import CoreAgent
+    from dda.utils.fs import temp_file
+
+    artifact = CoreAgent()
+    app.display_waiting("Building the `core-agent` binary...")
+    with temp_file() as tf:
+        artifact.build(app, output=tf)
+        digest = ArtifactDigest(value=tf.hexdigest(), type=DigestType.FILE_SHA256)
+
+        metadata = artifact.compute_metadata(app, digest)
+
+        # Special case: if output is the default value, use the canonical filename from the metadata
+        if output == DEFAULT_OUTPUT_PLACEHOLDER:
+            output = Path("./bin/") / metadata.get_canonical_filename()
+
+        output.parent.mkdir(parents=True, exist_ok=True)
+        shutil.move(tf, output)
+    metadata.to_file(output.with_suffix(".json"))
diff --git a/src/dda/config/model/tools.py b/src/dda/config/model/tools.py
index e9268457..8f0058be 100644
--- a/src/dda/config/model/tools.py
+++ b/src/dda/config/model/tools.py
@@ -3,12 +3,15 @@
 # SPDX-License-Identifier: MIT
 from __future__ import annotations
 
-from typing import Literal
+from typing import TYPE_CHECKING, Literal
 
 from msgspec import Struct, field
 
 from dda.utils.git.constants import GitEnvVars
 
+if TYPE_CHECKING:
+    from dda.utils.fs import Path
+
 
 def _get_name_from_git() -> str:
     from os import environ
@@ -93,6 +96,65 @@ class GitConfig(Struct, frozen=True):
     author: GitAuthorConfig = field(default_factory=GitAuthorConfig)
 
 
+def _query_go_envvar(var_name: str) -> str | None:
+    from os import environ
+
+    if name := environ.get(GitEnvVars.AUTHOR_EMAIL):
+        return name
+
+    import subprocess
+
+    command = ["go", "env", var_name]
+
+    try:
+        return subprocess.run(
+            command,
+            encoding="utf-8",
+            capture_output=True,
+            check=True,
+        ).stdout.strip()
+    except Exception:  # noqa: BLE001
+        return None
+
+
+def _get_default_gopath() -> Path:
+    from dda.utils.fs import Path
+
+    result_raw = _query_go_envvar("GOPATH")
+    result = Path(result_raw) if result_raw else Path.home() / "go"
+    return result.expanduser().resolve()
+
+
+def _get_default_gocache() -> Path:
+    from dda.utils.fs import Path
+
+    result_raw = _query_go_envvar("GOCACHE")
+    if not result_raw:
+        from platform import system
+
+        result_raw = {
+            "linux": "~/.cache/go-build",
+            "darwin": "~/Library/Caches/go-build",
+            "windows": "~\\AppData\\Local\\go-build",
+        }.get(system().lower(), "~/.cache/go-build")
+    return Path(result_raw).expanduser().resolve()
+
+
+class GoConfig(Struct, frozen=True):
+    """
+    /// tab | :octicons-file-code-16: config.toml
+    ```toml
+    [tools.go]
+    gopath = "/home/user/go"
+    gocache = "~/.cache/go-build"
+    ```
+    ///
+    """
+
+    gopath: str = field(default_factory=lambda: str(_get_default_gopath()))
+    gocache: str = field(default_factory=lambda: str(_get_default_gocache()))
+
+
 class ToolsConfig(Struct, frozen=True):
     """
     /// tab | :octicons-file-code-16: config.toml
@@ -105,3 +167,4 @@ class ToolsConfig(Struct, frozen=True):
 
     bazel: BazelConfig = field(default_factory=BazelConfig)
     git: GitConfig = field(default_factory=GitConfig)
+    go: GoConfig = field(default_factory=GoConfig)
diff --git a/src/dda/tools/git.py b/src/dda/tools/git.py
index 1078a207..7fa3adf6 100644
--- a/src/dda/tools/git.py
+++ b/src/dda/tools/git.py
@@ -82,6 +82,20 @@ def author_email(self) -> str:
 
         return self.capture(["config", "--get", "user.email"]).strip()
 
+    def get_repo_root(self) -> Path:
+        """
+        Get the root directory of the Git repository in the current working directory.
+        Will raise RuntimeErrorif the current working directory is not a Git repository.
+        """
+        from dda.utils.fs import Path
+
+        # Use check=False because we don't want to SystemExit if the command fails, instead handling it manually.
+        result = self.capture(["rev-parse", "--show-toplevel"], check=False).strip()
+        if not result:
+            msg = "Failed to get repo root. Make sure the current working directory is part of a Git repository."
+            raise RuntimeError(msg)
+        return Path(result)
+
     def get_remote(self, remote_name: str = "origin") -> Remote:
         """
         Get the details of the given remote for the Git repository in the current working directory.
diff --git a/src/dda/tools/go.py b/src/dda/tools/go.py
index 168ee24d..767bd985 100644
--- a/src/dda/tools/go.py
+++ b/src/dda/tools/go.py
@@ -34,9 +34,21 @@ class Go(Tool):
 
     @contextmanager
     def execution_context(self, command: list[str]) -> Generator[ExecutionContext, None, None]:
+        gotoolchain = f"go{self.version}" if self.version else ""
+        gopath = self.app.config.tools.go.gopath.strip()
+        gocache = self.app.config.tools.go.gocache.strip()
+        env_vars = {}
+
+        if gotoolchain:
+            env_vars["GOTOOLCHAIN"] = gotoolchain
+        if gopath:
+            env_vars["GOPATH"] = gopath
+        if gocache:
+            env_vars["GOCACHE"] = gocache
+
         yield ExecutionContext(
             command=[self.path, *command],
-            env_vars={"GOTOOLCHAIN": f"go{self.version}"} if self.version else {},
+            env_vars=env_vars,
         )
 
     @cached_property
diff --git a/tests/build/test_metadata.py b/tests/build/test_metadata.py
index bbc156e6..968ebda9 100644
--- a/tests/build/test_metadata.py
+++ b/tests/build/test_metadata.py
@@ -95,36 +95,10 @@ def test_basic(self, example_commit: Commit) -> None:
         }
         assert_metadata_equal(metadata, expected)
 
-    @pytest.mark.parametrize(
-        ("command_path", "expected"),
-        [
-            (
-                "dda build bin core-agent",
-                ({"core-agent"}, ArtifactFormat.BIN),
-            ),
-            (
-                "dda build dist deb -c core-agent -c process-agent",
-                (
-                    {"core-agent", "process-agent"},
-                    ArtifactFormat.DEB,
-                ),
-            ),
-            (
-                "dda build dist oci -c core-agent -c process-agent",
-                (
-                    {"core-agent", "process-agent"},
-                    ArtifactFormat.OCI,
-                ),
-            ),
-        ],
-    )
-    def test_analyze_context(self, app, mocker, command_path, expected, example_commit):
+    def test_analyze_context(self, app, mocker, example_commit):
         # Expected values
-        expected_agent_components, expected_artifact_format = expected
         build_platform = Platform.from_alias(platform.system(), platform.machine())
         expected = {
-            "agent_components": expected_agent_components,
-            "artifact_format": expected_artifact_format,
             "commit": example_commit,
             "compatible_platforms": {build_platform},
             "build_platform": build_platform,
@@ -134,13 +108,11 @@ def test_analyze_context(self, app, mocker, command_path, expected, example_comm
         }
 
         # Setup mocks
-        ctx = mocker.MagicMock()
-        ctx.command_path = command_path
         mocker.patch("dda.tools.git.Git.get_commit", return_value=expected["commit"])
         mocker.patch("dda.tools.git.Git.get_changes", return_value=expected["worktree_diff"])
 
         # Test without special arguments
-        context_details = analyze_context(ctx, app)
+        context_details = analyze_context(app)
 
         for field in expected:
             assert getattr(context_details, field) == expected[field]
diff --git a/tests/cli/config/test_show.py b/tests/cli/config/test_show.py
index 0bf349ba..c9c100e5 100644
--- a/tests/cli/config/test_show.py
+++ b/tests/cli/config/test_show.py
@@ -6,14 +6,20 @@
 from dda.env.dev import DEFAULT_DEV_ENV
 
 
-def test_default_scrubbed(dda, config_file, helpers, default_cache_dir, default_data_dir, default_git_author):
+def test_default_scrubbed(
+    dda, config_file, helpers, default_cache_dir, default_data_dir, default_git_author, default_gopath, default_gocache
+):
     config_file.data["github"]["auth"] = {"user": "foo", "token": "bar"}
+    config_file.data["tools"]["go"]["gopath"] = str(default_gopath)
+    config_file.data["tools"]["go"]["gocache"] = str(default_gocache)
     config_file.save()
 
     result = dda("config", "show")
 
     default_cache_directory = str(default_cache_dir).replace("\\", "\\\\")
     default_data_directory = str(default_data_dir).replace("\\", "\\\\")
+    default_gopath = str(default_gopath).replace("\\", "\\\\")
+    default_gocache = str(default_gocache).replace("\\", "\\\\")
 
     result.check(
         exit_code=0,
@@ -34,6 +40,10 @@ def test_default_scrubbed(dda, config_file, helpers, default_cache_dir, default_
             name = "{default_git_author.name}"
             email = "{default_git_author.email}"
 
+            [tools.go]
+            gopath = "{default_gopath}"
+            gocache = "{default_gocache}"
+
             [storage]
             data = "{default_data_directory}"
             cache = "{default_cache_directory}"
@@ -71,14 +81,20 @@ def test_default_scrubbed(dda, config_file, helpers, default_cache_dir, default_
     )
 
 
-def test_reveal(dda, config_file, helpers, default_cache_dir, default_data_dir, default_git_author):
+def test_reveal(
+    dda, config_file, helpers, default_cache_dir, default_data_dir, default_git_author, default_gopath, default_gocache
+):
     config_file.data["github"]["auth"] = {"user": "foo", "token": "bar"}
+    config_file.data["tools"]["go"]["gopath"] = str(default_gopath)
+    config_file.data["tools"]["go"]["gocache"] = str(default_gocache)
     config_file.save()
 
     result = dda("config", "show", "-a")
 
     default_cache_directory = str(default_cache_dir).replace("\\", "\\\\")
     default_data_directory = str(default_data_dir).replace("\\", "\\\\")
+    default_gopath = str(default_gopath).replace("\\", "\\\\")
+    default_gocache = str(default_gocache).replace("\\", "\\\\")
 
     result.check(
         exit_code=0,
@@ -99,6 +115,10 @@ def test_reveal(dda, config_file, helpers, default_cache_dir, default_data_dir,
             name = "{default_git_author.name}"
             email = "{default_git_author.email}"
 
+            [tools.go]
+            gopath = "{default_gopath}"
+            gocache = "{default_gocache}"
+
             [storage]
             data = "{default_data_directory}"
             cache = "{default_cache_directory}"
diff --git a/tests/conftest.py b/tests/conftest.py
index 0ac1920b..14d27316 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -204,6 +204,16 @@ def default_git_author() -> GitAuthorConfig:
     return GitAuthorConfig(name="Foo Bar", email="foo@bar.baz")
 
 
+@pytest.fixture(scope="session")
+def default_gopath() -> Path:
+    return Path(Path.home() / "go")
+
+
+@pytest.fixture(scope="session")
+def default_gocache() -> Path:
+    return Path(Path.home() / ".cache/go-build")
+
+
 @pytest.fixture(scope="session")
 def uv_on_path() -> Path:
     return Path(shutil.which("uv"))
diff --git a/tests/tools/git/test_git.py b/tests/tools/git/test_git.py
index 201b3a61..eb3d259e 100644
--- a/tests/tools/git/test_git.py
+++ b/tests/tools/git/test_git.py
@@ -7,6 +7,8 @@
 import re
 from typing import TYPE_CHECKING
 
+import pytest
+
 from dda.utils.fs import Path
 from dda.utils.git.changeset import ChangedFile, ChangeSet, ChangeType
 from dda.utils.git.commit import GitPersonDetails
@@ -57,6 +59,21 @@ def test_author_details(app: Application, mocker, default_git_author: GitAuthorC
         assert app.tools.git.author_email == "foo@bar2.baz"
 
 
+def test_get_repo_root(app: Application, temp_repo: Path, temp_dir: Path) -> None:
+    with temp_repo.as_cwd():
+        # Case 1: Get the repo root from the current working directory
+        assert app.tools.git.get_repo_root() == temp_repo
+        # Case 2: Get the repo root from a subdirectory
+        temp_repo_subdir = temp_repo / "subdir"
+        temp_repo_subdir.mkdir()
+        with temp_repo_subdir.as_cwd():
+            assert app.tools.git.get_repo_root() == temp_repo
+
+    with temp_dir.as_cwd(), pytest.raises(RuntimeError):
+        # Case 3: Get the repo root from a directory that is not a Git repository
+        app.tools.git.get_repo_root()
+
+
 def test_get_remote(app: Application, temp_repo_with_remote: Path) -> None:
     with temp_repo_with_remote.as_cwd():
         assert app.tools.git.get_remote().url == "https://github.com/foo/bar"
diff --git a/tests/tools/go/test_go.py b/tests/tools/go/test_go.py
index fa643818..08399a56 100644
--- a/tests/tools/go/test_go.py
+++ b/tests/tools/go/test_go.py
@@ -10,29 +10,24 @@
 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"}
+            assert context.env_vars.get("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"}
+            assert context.env_vars.get("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"}
+            assert context.env_vars.get("GOTOOLCHAIN") == "goX.Y.Zrc2"
 
 
 class TestBuild: