From c1ff038adb400fcfa12fecea9fcb8ec31ef48ff0 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 5 Nov 2025 15:37:09 +0100 Subject: [PATCH 01/11] feat(build): Add stubs for build commands and `dda build bin core-agent` in particular --- src/dda/build/artifacts/__init__.py | 3 +++ src/dda/build/artifacts/base.py | 25 +++++++++++++++++++ src/dda/build/artifacts/binaries/__init__.py | 3 +++ src/dda/build/artifacts/binaries/base.py | 12 +++++++++ .../build/artifacts/binaries/core_agent.py | 21 ++++++++++++++++ .../build/artifacts/distributions/__init__.py | 3 +++ src/dda/build/artifacts/distributions/base.py | 12 +++++++++ src/dda/cli/build/bin/__init__.py | 13 ++++++++++ src/dda/cli/build/bin/core_agent/__init__.py | 21 ++++++++++++++++ 9 files changed, 113 insertions(+) create mode 100644 src/dda/build/artifacts/__init__.py create mode 100644 src/dda/build/artifacts/base.py create mode 100644 src/dda/build/artifacts/binaries/__init__.py create mode 100644 src/dda/build/artifacts/binaries/base.py create mode 100644 src/dda/build/artifacts/binaries/core_agent.py create mode 100644 src/dda/build/artifacts/distributions/__init__.py create mode 100644 src/dda/build/artifacts/distributions/base.py create mode 100644 src/dda/cli/build/bin/__init__.py create mode 100644 src/dda/cli/build/bin/core_agent/__init__.py 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..b50fa89e --- /dev/null +++ b/src/dda/build/artifacts/base.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + + 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. + """ 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..e83e28fc --- /dev/null +++ b/src/dda/build/artifacts/binaries/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 BinaryArtifact(BuildArtifact): + """ + Base class for all binary build artifacts. + """ 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..20783933 --- /dev/null +++ b/src/dda/build/artifacts/binaries/core_agent.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from dda.build.artifacts.binaries.base import BinaryArtifact + +if TYPE_CHECKING: + from dda.cli.application import Application + + +class CoreAgent(BinaryArtifact): + """ + Build artifact for the `core-agent` binary. + """ + + def build(self, app: Application, *args: Any, **kwargs: Any) -> None: + msg = "Not implemented" + raise NotImplementedError(msg) 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/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..7692a92b --- /dev/null +++ b/src/dda/cli/build/bin/core_agent/__init__.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dda.cli.base import dynamic_command, pass_app + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Build the `core-agent` binary.") +@pass_app +def cmd(app: Application) -> None: + from dda.build.artifacts.binaries.core_agent import CoreAgent + + artifact = CoreAgent() + app.display_waiting("Building the `core-agent` binary...") + artifact.build(app) From 0d68e744b1897dd19244170da89559de1e9a1801 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Wed, 5 Nov 2025 17:33:02 +0100 Subject: [PATCH 02/11] refactor(build): Get artifact components from `BuildArtifact` class instance, instead of parsing from calling command --- src/dda/build/artifacts/base.py | 21 +++++++ src/dda/build/artifacts/binaries/base.py | 19 ++++++ .../build/artifacts/binaries/core_agent.py | 7 ++- src/dda/build/metadata/metadata.py | 59 ++++--------------- tests/build/test_metadata.py | 32 +--------- 5 files changed, 61 insertions(+), 77 deletions(-) diff --git a/src/dda/build/artifacts/base.py b/src/dda/build/artifacts/base.py index b50fa89e..44ef3bfc 100644 --- a/src/dda/build/artifacts/base.py +++ b/src/dda/build/artifacts/base.py @@ -6,9 +6,13 @@ 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 @@ -23,3 +27,20 @@ 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/base.py b/src/dda/build/artifacts/binaries/base.py index e83e28fc..ce14f358 100644 --- a/src/dda/build/artifacts/binaries/base.py +++ b/src/dda/build/artifacts/binaries/base.py @@ -3,10 +3,29 @@ # 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 index 20783933..790980fb 100644 --- a/src/dda/build/artifacts/binaries/core_agent.py +++ b/src/dda/build/artifacts/binaries/core_agent.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MIT from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, override from dda.build.artifacts.binaries.base import BinaryArtifact @@ -16,6 +16,11 @@ class CoreAgent(BinaryArtifact): Build artifact for the `core-agent` binary. """ + @property + @override + def name(self) -> str: + return "core-agent" + def build(self, app: Application, *args: Any, **kwargs: Any) -> None: msg = "Not implemented" raise NotImplementedError(msg) 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/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] From a4dccac358da0ebcf7901744710d54855ba23967 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 7 Nov 2025 12:58:42 +0100 Subject: [PATCH 03/11] feat(build): Create `GoArtifact` class This proposed mechanism should allow us to define required functions for every possible artifact language. In this example, Go artifacts need to define build tags, build flags etc. --- src/dda/build/languages/__init__.py | 3 +++ src/dda/build/languages/go.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 src/dda/build/languages/__init__.py create mode 100644 src/dda/build/languages/go.py 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..277d5126 --- /dev/null +++ b/src/dda/build/languages/go.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +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) -> set[str]: + """ + Get the build tags to pass to the Go compiler for the artifact. + """ + + @abstractmethod + def get_gcflags(self) -> list[str]: + """ + Get the gcflags to pass to the Go compiler for the artifact. + """ + + @abstractmethod + def get_ldflags(self) -> list[str]: + """ + Get the ldflags to pass to the Go compiler for the artifact. + """ + return [] + + @abstractmethod + def get_build_env(self) -> dict[str, str]: + """ + Get the build environment variables to pass to the Go compiler for the artifact. + """ From 3bff2c95f36f63bc76adb74a84d07070e0c3f0dd Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 7 Nov 2025 12:59:38 +0100 Subject: [PATCH 04/11] feat(build): Create first working version of `CoreAgent.build` For the moment all values are hardcoded. --- .../build/artifacts/binaries/core_agent.py | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/src/dda/build/artifacts/binaries/core_agent.py b/src/dda/build/artifacts/binaries/core_agent.py index 790980fb..f3cf0102 100644 --- a/src/dda/build/artifacts/binaries/core_agent.py +++ b/src/dda/build/artifacts/binaries/core_agent.py @@ -6,21 +6,98 @@ 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 -class CoreAgent(BinaryArtifact): +class CoreAgent(BinaryArtifact, GoArtifact): """ Build artifact for the `core-agent` binary. """ + @override + def get_build_tags(self) -> 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) -> list[str]: + return [] + + @override + def get_ldflags(self) -> list[str]: + # TODO: Implement a properly dynamic function, matching the old invoke task + return [ + "-X", + "github.com/DataDog/datadog-agent/pkg/version.Commit=e927e2bc6e", + "-X", + "github.com/DataDog/datadog-agent/pkg/version.AgentVersion=7.74.0-devel+git.96.e927e2b", + "-X", + "github.com/DataDog/datadog-agent/pkg/version.AgentPayloadVersion=v5.0.174", + "-X", + "github.com/DataDog/datadog-agent/pkg/version.AgentPackageVersion=7.74.0-devel+git.96.e927e2b", + "-r", + "/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", + "'-extldflags=-Wl,-bind_at_load,-no_warn_duplicate_libraries'", + ] + + @override + def get_build_env(self) -> dict[str, str]: + # TODO: Implement a properly dynamic function, matching the old invoke task + return { + # TODO: Move GOPATH a GOCACHE to a configurable thing probably ? Probably also set them in the general go context + "GOPATH": "/Users/pierrelouis.veyrenc/go", + "GOCACHE": "/Users/pierrelouis.veyrenc/go/cache", + "GO111MODULE": "on", + "CGO_LDFLAGS_ALLOW": "-Wl,--wrap=.*", + "DYLD_LIBRARY_PATH": ":/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", + "LD_LIBRARY_PATH": ":/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", + "CGO_LDFLAGS": " -L/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", + "CGO_CFLAGS": " -Werror -Wno-deprecated-declarations -I/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/include", + "CGO_ENABLED": "1", + "PATH": "/Users/pierrelouis.veyrenc/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, *args: Any, **kwargs: Any) -> None: - msg = "Not implemented" - raise NotImplementedError(msg) + from dda.utils.fs import Path + + # 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=Path("./bin/agent"), + build_tags=self.get_build_tags(), + gcflags=self.get_gcflags(), + ldflags=self.get_ldflags(), + env_vars=self.get_build_env(), + ) From 3de3d2010e8b6cdd4c2e56a15cd6823055f8bea8 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 7 Nov 2025 14:24:43 +0100 Subject: [PATCH 05/11] feat(go): Make `GOPATH` and `GOCACHE` user-configurable and handled via `dda` --- .../build/artifacts/binaries/core_agent.py | 2 - src/dda/config/model/tools.py | 65 ++++++++++++++++++- src/dda/tools/go.py | 14 +++- tests/cli/config/test_show.py | 20 +++++- tests/conftest.py | 10 +++ tests/tools/go/test_go.py | 11 +--- 6 files changed, 108 insertions(+), 14 deletions(-) diff --git a/src/dda/build/artifacts/binaries/core_agent.py b/src/dda/build/artifacts/binaries/core_agent.py index f3cf0102..a08921d4 100644 --- a/src/dda/build/artifacts/binaries/core_agent.py +++ b/src/dda/build/artifacts/binaries/core_agent.py @@ -70,8 +70,6 @@ def get_build_env(self) -> dict[str, str]: # TODO: Implement a properly dynamic function, matching the old invoke task return { # TODO: Move GOPATH a GOCACHE to a configurable thing probably ? Probably also set them in the general go context - "GOPATH": "/Users/pierrelouis.veyrenc/go", - "GOCACHE": "/Users/pierrelouis.veyrenc/go/cache", "GO111MODULE": "on", "CGO_LDFLAGS_ALLOW": "-Wl,--wrap=.*", "DYLD_LIBRARY_PATH": ":/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", 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/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/cli/config/test_show.py b/tests/cli/config/test_show.py index 0bf349ba..a355decc 100644 --- a/tests/cli/config/test_show.py +++ b/tests/cli/config/test_show.py @@ -6,8 +6,12 @@ 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") @@ -34,6 +38,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,8 +79,12 @@ 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") @@ -99,6 +111,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/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: From e12846db6218a3ab87135897e4ebc16387fcf7b0 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 7 Nov 2025 14:49:36 +0100 Subject: [PATCH 06/11] feat(git): Add `Git.get_repo_root` method --- src/dda/tools/git.py | 14 ++++++++++++++ tests/tools/git/test_git.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) 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/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" From 68413951c5783314ba04e6e01f4722d7221e8168 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 7 Nov 2025 14:54:44 +0100 Subject: [PATCH 07/11] refactor(build): Allow passing any `*args, **kwargs` to `GoArtifact` methods --- src/dda/build/artifacts/binaries/core_agent.py | 8 ++++---- src/dda/build/languages/go.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/dda/build/artifacts/binaries/core_agent.py b/src/dda/build/artifacts/binaries/core_agent.py index a08921d4..421c1867 100644 --- a/src/dda/build/artifacts/binaries/core_agent.py +++ b/src/dda/build/artifacts/binaries/core_agent.py @@ -18,7 +18,7 @@ class CoreAgent(BinaryArtifact, GoArtifact): """ @override - def get_build_tags(self) -> set[str]: + def get_build_tags(self, *args: Any, **kwargs: Any) -> set[str]: # TODO: Implement a properly dynamic function, matching the old invoke task return { "ec2", @@ -45,11 +45,11 @@ def get_build_tags(self) -> set[str]: } @override - def get_gcflags(self) -> list[str]: + def get_gcflags(self, *args: Any, **kwargs: Any) -> list[str]: return [] @override - def get_ldflags(self) -> list[str]: + def get_ldflags(self, *args: Any, **kwargs: Any) -> list[str]: # TODO: Implement a properly dynamic function, matching the old invoke task return [ "-X", @@ -66,7 +66,7 @@ def get_ldflags(self) -> list[str]: ] @override - def get_build_env(self) -> dict[str, str]: + def get_build_env(self, *args: Any, **kwargs: Any) -> dict[str, str]: # TODO: Implement a properly dynamic function, matching the old invoke task return { # TODO: Move GOPATH a GOCACHE to a configurable thing probably ? Probably also set them in the general go context diff --git a/src/dda/build/languages/go.py b/src/dda/build/languages/go.py index 277d5126..3b02a965 100644 --- a/src/dda/build/languages/go.py +++ b/src/dda/build/languages/go.py @@ -5,6 +5,10 @@ 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): @@ -14,26 +18,26 @@ class GoArtifact(ABC): """ @abstractmethod - def get_build_tags(self) -> set[str]: + 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) -> list[str]: + 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) -> list[str]: + 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) -> dict[str, str]: + 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. """ From d05146886fba15d2c18569af42fa20a0942906ac Mon Sep 17 00:00:00 2001 From: Pierre-Louis Veyrenc Date: Fri, 7 Nov 2025 16:26:26 +0100 Subject: [PATCH 08/11] feat(build): Add basic versioning logic for compiler flags --- .../build/artifacts/binaries/core_agent.py | 44 ++++++++++----- src/dda/build/versioning.py | 55 +++++++++++++++++++ 2 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 src/dda/build/versioning.py diff --git a/src/dda/build/artifacts/binaries/core_agent.py b/src/dda/build/artifacts/binaries/core_agent.py index 421c1867..a8b1c41c 100644 --- a/src/dda/build/artifacts/binaries/core_agent.py +++ b/src/dda/build/artifacts/binaries/core_agent.py @@ -3,6 +3,7 @@ # 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 @@ -10,6 +11,12 @@ 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): @@ -49,35 +56,42 @@ def get_gcflags(self, *args: Any, **kwargs: Any) -> list[str]: return [] @override - def get_ldflags(self, *args: Any, **kwargs: Any) -> list[str]: - # TODO: Implement a properly dynamic function, matching the old invoke task + 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", - "github.com/DataDog/datadog-agent/pkg/version.Commit=e927e2bc6e", + f"github.com/DataDog/datadog-agent/pkg/version.Commit={commit[:10]}", "-X", - "github.com/DataDog/datadog-agent/pkg/version.AgentVersion=7.74.0-devel+git.96.e927e2b", + 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", - "github.com/DataDog/datadog-agent/pkg/version.AgentPackageVersion=7.74.0-devel+git.96.e927e2b", + f"github.com/DataDog/datadog-agent/pkg/version.AgentPackageVersion={agent_version}", "-r", - "/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", + f"{repo_root}/dev/lib", "'-extldflags=-Wl,-bind_at_load,-no_warn_duplicate_libraries'", ] @override - def get_build_env(self, *args: Any, **kwargs: Any) -> dict[str, str]: + 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 { - # TODO: Move GOPATH a GOCACHE to a configurable thing probably ? Probably also set them in the general go context "GO111MODULE": "on", "CGO_LDFLAGS_ALLOW": "-Wl,--wrap=.*", - "DYLD_LIBRARY_PATH": ":/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", - "LD_LIBRARY_PATH": ":/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", - "CGO_LDFLAGS": " -L/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/lib", - "CGO_CFLAGS": " -Werror -Wno-deprecated-declarations -I/Users/pierrelouis.veyrenc/go/src/github.com/DataDog/datadog-agent/dev/include", + "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": "/Users/pierrelouis.veyrenc/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "PATH": f"{repo_root}/go/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", } @property @@ -96,6 +110,6 @@ def build(self, app: Application, *args: Any, **kwargs: Any) -> None: output=Path("./bin/agent"), build_tags=self.get_build_tags(), gcflags=self.get_gcflags(), - ldflags=self.get_ldflags(), - env_vars=self.get_build_env(), + ldflags=self.get_ldflags(app), + env_vars=self.get_build_env(app), ) 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"),
+    )

From 0c1462b570cdda1283263d371904c6618df215e8 Mon Sep 17 00:00:00 2001
From: Pierre-Louis Veyrenc 
Date: Fri, 7 Nov 2025 16:56:25 +0100
Subject: [PATCH 09/11] feat(build): Output metadata file when calling build
 command

---
 .../build/artifacts/binaries/core_agent.py    |  6 ++--
 src/dda/cli/build/bin/core_agent/__init__.py  | 36 +++++++++++++++++--
 2 files changed, 36 insertions(+), 6 deletions(-)

diff --git a/src/dda/build/artifacts/binaries/core_agent.py b/src/dda/build/artifacts/binaries/core_agent.py
index a8b1c41c..25eedcba 100644
--- a/src/dda/build/artifacts/binaries/core_agent.py
+++ b/src/dda/build/artifacts/binaries/core_agent.py
@@ -100,14 +100,12 @@ def name(self) -> str:
         return "core-agent"
 
     @override
-    def build(self, app: Application, *args: Any, **kwargs: Any) -> None:
-        from dda.utils.fs import Path
-
+    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=Path("./bin/agent"),
+            output=output,
             build_tags=self.get_build_tags(),
             gcflags=self.get_gcflags(),
             ldflags=self.get_ldflags(app),
diff --git a/src/dda/cli/build/bin/core_agent/__init__.py b/src/dda/cli/build/bin/core_agent/__init__.py
index 7692a92b..9d0e95eb 100644
--- a/src/dda/cli/build/bin/core_agent/__init__.py
+++ b/src/dda/cli/build/bin/core_agent/__init__.py
@@ -5,17 +5,49 @@
 
 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) -> None:
+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...")
-    artifact.build(app)
+    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"))

From 24ba8d5fc718743254e7eee70557c155d31d8d7f Mon Sep 17 00:00:00 2001
From: Pierre-Louis Veyrenc 
Date: Mon, 8 Dec 2025 14:49:23 +0100
Subject: [PATCH 10/11] fix(tests): Fix config display with Windows-style paths

---
 tests/cli/config/test_show.py | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/tests/cli/config/test_show.py b/tests/cli/config/test_show.py
index a355decc..c9c100e5 100644
--- a/tests/cli/config/test_show.py
+++ b/tests/cli/config/test_show.py
@@ -18,6 +18,8 @@ def test_default_scrubbed(
 
     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,
@@ -91,6 +93,8 @@ def test_reveal(
 
     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,

From 865c099465d59da0ebcdf67b5791f4e96b2a3ac8 Mon Sep 17 00:00:00 2001
From: Pierre-Louis Veyrenc 
Date: Mon, 5 Jan 2026 15:56:58 +0100
Subject: [PATCH 11/11] fix(metadata): Add `from __future__ import annotations`

Otherwise this breaks `msgspec`'s initialization of the `Platform` Struct
---
 src/dda/build/metadata/platforms.py | 9 +++++----
 1 file changed, 5 insertions(+), 4 deletions(-)

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.
         """