From 832f81a0b3a84f1db7a3f19088bae4f868cdd52e Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 28 Jul 2025 03:05:36 -0400 Subject: [PATCH] Add `env qa` command group --- CHANGELOG.md | 5 + docs/reference/interface/env/metadata.md | 11 + docs/reference/interface/env/types/qa.md | 15 + hatch.toml | 3 +- mkdocs.yml | 4 +- pyproject.toml | 2 +- src/dda/cli/env/qa/__init__.py | 13 + src/dda/cli/env/qa/config/__init__.py | 13 + src/dda/cli/env/qa/config/explore/__init__.py | 35 + src/dda/cli/env/qa/config/find/__init__.py | 35 + src/dda/cli/env/qa/config/show/__init__.py | 36 + src/dda/cli/env/qa/config/sync/__init__.py | 41 + .../cli/env/qa/config/templates/__init__.py | 13 + .../qa/config/templates/create/__init__.py | 31 + .../qa/config/templates/explore/__init__.py | 46 + .../env/qa/config/templates/find/__init__.py | 37 + .../qa/config/templates/remove/__init__.py | 31 + .../env/qa/config/templates/show/__init__.py | 45 + src/dda/cli/env/qa/config/utils.py | 51 + src/dda/cli/env/qa/gui/__init__.py | 44 + src/dda/cli/env/qa/info/__init__.py | 50 + src/dda/cli/env/qa/remove/__init__.py | 42 + src/dda/cli/env/qa/restart/__init__.py | 41 + src/dda/cli/env/qa/run/__init__.py | 50 + src/dda/cli/env/qa/shell/__init__.py | 41 + src/dda/cli/env/qa/show/__init__.py | 60 + src/dda/cli/env/qa/start/__init__.py | 101 ++ src/dda/cli/env/qa/status/__init__.py | 35 + src/dda/cli/env/qa/stop/__init__.py | 45 + src/dda/cli/env/qa/utils.py | 37 + src/dda/cli/terminal.py | 13 +- src/dda/config/model/env.py | 17 + src/dda/config/model/orgs.py | 4 +- src/dda/env/config/__init__.py | 3 + src/dda/env/config/agent.py | 131 ++ src/dda/env/dev/types/linux_container.py | 51 +- src/dda/env/docker.py | 44 + src/dda/env/models.py | 39 +- src/dda/env/qa/__init__.py | 57 + src/dda/env/qa/interface.py | 296 ++++ src/dda/env/qa/types/__init__.py | 3 + src/dda/env/qa/types/linux_container.py | 253 ++++ src/dda/utils/agent/__init__.py | 3 + src/dda/utils/agent/config/__init__.py | 3 + src/dda/utils/agent/config/format.py | 55 + src/dda/utils/network/hostname.py | 13 + tests/cli/config/test_show.py | 9 + tests/cli/env/__init__.py | 3 + tests/cli/env/qa/__init__.py | 3 + tests/cli/env/qa/config/__init__.py | 3 + tests/cli/env/qa/config/templates/__init__.py | 3 + .../env/qa/config/templates/test_create.py | 41 + .../env/qa/config/templates/test_explore.py | 80 ++ .../cli/env/qa/config/templates/test_find.py | 74 + .../env/qa/config/templates/test_remove.py | 39 + .../cli/env/qa/config/templates/test_show.py | 323 +++++ tests/cli/env/qa/config/test_explore.py | 49 + tests/cli/env/qa/config/test_find.py | 54 + tests/cli/env/qa/config/test_show.py | 117 ++ tests/cli/env/qa/test_show.py | 97 ++ tests/conftest.py | 9 +- tests/env/qa/__init__.py | 3 + tests/env/qa/test_interface.py | 69 + tests/env/qa/types/__init__.py | 3 + tests/env/qa/types/test_linux_container.py | 1229 +++++++++++++++++ tests/helpers/api.py | 11 + tests/utils/agent/__init__.py | 3 + tests/utils/agent/config/__init__.py | 3 + tests/utils/agent/config/test_format.py | 83 ++ uv.lock | 39 +- 70 files changed, 4285 insertions(+), 60 deletions(-) create mode 100644 docs/reference/interface/env/metadata.md create mode 100644 docs/reference/interface/env/types/qa.md create mode 100644 src/dda/cli/env/qa/__init__.py create mode 100644 src/dda/cli/env/qa/config/__init__.py create mode 100644 src/dda/cli/env/qa/config/explore/__init__.py create mode 100644 src/dda/cli/env/qa/config/find/__init__.py create mode 100644 src/dda/cli/env/qa/config/show/__init__.py create mode 100644 src/dda/cli/env/qa/config/sync/__init__.py create mode 100644 src/dda/cli/env/qa/config/templates/__init__.py create mode 100644 src/dda/cli/env/qa/config/templates/create/__init__.py create mode 100644 src/dda/cli/env/qa/config/templates/explore/__init__.py create mode 100644 src/dda/cli/env/qa/config/templates/find/__init__.py create mode 100644 src/dda/cli/env/qa/config/templates/remove/__init__.py create mode 100644 src/dda/cli/env/qa/config/templates/show/__init__.py create mode 100644 src/dda/cli/env/qa/config/utils.py create mode 100644 src/dda/cli/env/qa/gui/__init__.py create mode 100644 src/dda/cli/env/qa/info/__init__.py create mode 100644 src/dda/cli/env/qa/remove/__init__.py create mode 100644 src/dda/cli/env/qa/restart/__init__.py create mode 100644 src/dda/cli/env/qa/run/__init__.py create mode 100644 src/dda/cli/env/qa/shell/__init__.py create mode 100644 src/dda/cli/env/qa/show/__init__.py create mode 100644 src/dda/cli/env/qa/start/__init__.py create mode 100644 src/dda/cli/env/qa/status/__init__.py create mode 100644 src/dda/cli/env/qa/stop/__init__.py create mode 100644 src/dda/cli/env/qa/utils.py create mode 100644 src/dda/env/config/__init__.py create mode 100644 src/dda/env/config/agent.py create mode 100644 src/dda/env/docker.py create mode 100644 src/dda/env/qa/__init__.py create mode 100644 src/dda/env/qa/interface.py create mode 100644 src/dda/env/qa/types/__init__.py create mode 100644 src/dda/env/qa/types/linux_container.py create mode 100644 src/dda/utils/agent/__init__.py create mode 100644 src/dda/utils/agent/config/__init__.py create mode 100644 src/dda/utils/agent/config/format.py create mode 100644 src/dda/utils/network/hostname.py create mode 100644 tests/cli/env/__init__.py create mode 100644 tests/cli/env/qa/__init__.py create mode 100644 tests/cli/env/qa/config/__init__.py create mode 100644 tests/cli/env/qa/config/templates/__init__.py create mode 100644 tests/cli/env/qa/config/templates/test_create.py create mode 100644 tests/cli/env/qa/config/templates/test_explore.py create mode 100644 tests/cli/env/qa/config/templates/test_find.py create mode 100644 tests/cli/env/qa/config/templates/test_remove.py create mode 100644 tests/cli/env/qa/config/templates/test_show.py create mode 100644 tests/cli/env/qa/config/test_explore.py create mode 100644 tests/cli/env/qa/config/test_find.py create mode 100644 tests/cli/env/qa/config/test_show.py create mode 100644 tests/cli/env/qa/test_show.py create mode 100644 tests/env/qa/__init__.py create mode 100644 tests/env/qa/test_interface.py create mode 100644 tests/env/qa/types/__init__.py create mode 100644 tests/env/qa/types/test_linux_container.py create mode 100644 tests/utils/agent/__init__.py create mode 100644 tests/utils/agent/config/__init__.py create mode 100644 tests/utils/agent/config/test_format.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce18a66..a3ed4adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +***Added:*** + +- Add `env qa` command group for managing QA environments +- Add the `linux-container` QA environment type + ## 0.29.0 - 2025-10-09 ***Changed:*** diff --git a/docs/reference/interface/env/metadata.md b/docs/reference/interface/env/metadata.md new file mode 100644 index 00000000..b2eebb60 --- /dev/null +++ b/docs/reference/interface/env/metadata.md @@ -0,0 +1,11 @@ +# Environment metadata + +----- + +::: dda.env.models.EnvironmentMetadata + +::: dda.env.models.EnvironmentNetworkMetadata + +::: dda.env.models.EnvironmentPortMetadata + +::: dda.env.models.EnvironmentPort diff --git a/docs/reference/interface/env/types/qa.md b/docs/reference/interface/env/types/qa.md new file mode 100644 index 00000000..2cae1656 --- /dev/null +++ b/docs/reference/interface/env/types/qa.md @@ -0,0 +1,15 @@ +# QA environment interface + +----- + +Environment types implementing the [`QAEnvironmentInterface`][dda.env.qa.interface.QAEnvironmentInterface] interface may be managed by the [`env qa`](../../../cli/commands.md#dda-env-qa) command group. + +::: dda.env.qa.interface.QAEnvironmentConfig + options: + show_labels: true + unwrap_annotated: true + +::: dda.env.qa.interface.QAEnvironmentInterface + options: + show_labels: true + show_if_no_docstring: false diff --git a/hatch.toml b/hatch.toml index 1496b65d..d1e4ee52 100644 --- a/hatch.toml +++ b/hatch.toml @@ -15,9 +15,10 @@ extra-dependencies = [ extra-dependencies = [ "mypy", "pytest", + "types-pyyaml", ] [envs.types.scripts] -check = "mypy --install-types --non-interactive {args:src/dda tests}" +check = "mypy {args:src/dda tests}" [envs.docs] dependencies = [ diff --git a/mkdocs.yml b/mkdocs.yml index 83b1f306..114ef082 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,9 +86,11 @@ nav: - Interface: - Tool: reference/interface/tool.md - Environments: - - Status: reference/interface/env/status.md - Types: - Developer: reference/interface/env/types/dev.md + - QA: reference/interface/env/types/qa.md + - Status: reference/interface/env/status.md + - Metadata: reference/interface/env/metadata.md - Guidelines: - CLI: guidelines/cli.md - Documentation: guidelines/docs.md diff --git a/pyproject.toml b/pyproject.toml index c9796a4b..55042b46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "psutil~=7.0", "pyjson5~=1.6.9", "pywinpty~=2.0.15; sys_platform == 'win32'", + "pyyaml~=6.0.2", "rich~=14.0", "rich-click~=1.8.9", "tomlkit~=0.13", @@ -185,7 +186,6 @@ legacy-notifications = [ "codeowners==0.6.0", "invoke==2.2.0", "requests==2.32.3", - "pyyaml==6.0.1", "slack-sdk~=3.27.1", "tabulate[widechars]==0.9.0", ] diff --git a/src/dda/cli/env/qa/__init__.py b/src/dda/cli/env/qa/__init__.py new file mode 100644 index 00000000..2939add5 --- /dev/null +++ b/src/dda/cli/env/qa/__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="Work with QA environments", +) +def cmd() -> None: + pass diff --git a/src/dda/cli/env/qa/config/__init__.py b/src/dda/cli/env/qa/config/__init__.py new file mode 100644 index 00000000..66d4e3ed --- /dev/null +++ b/src/dda/cli/env/qa/config/__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="Manage Agent configuration", +) +def cmd() -> None: + pass diff --git a/src/dda/cli/env/qa/config/explore/__init__.py b/src/dda/cli/env/qa/config/explore/__init__.py new file mode 100644 index 00000000..8eb152e7 --- /dev/null +++ b/src/dda/cli/env/qa/config/explore/__init__.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Open the Agent config location in your file manager") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Open the Agent config location in your file manager. + """ + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + if not env.agent_config_dir.is_dir(): + app.abort(f"QA environment `{instance}` of type `{env_type}` does not exist") + + click.launch(str(env.agent_config.path), locate=True) diff --git a/src/dda/cli/env/qa/config/find/__init__.py b/src/dda/cli/env/qa/config/find/__init__.py new file mode 100644 index 00000000..d65fc4b1 --- /dev/null +++ b/src/dda/cli/env/qa/config/find/__init__.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Output the location of the Agent config") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Output the location of the Agent config. + """ + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + if not env.agent_config_dir.is_dir(): + app.abort(f"QA environment `{instance}` of type `{env_type}` does not exist") + + app.display(str(env.agent_config.root_dir)) diff --git a/src/dda/cli/env/qa/config/show/__init__.py b/src/dda/cli/env/qa/config/show/__init__.py new file mode 100644 index 00000000..b492fd16 --- /dev/null +++ b/src/dda/cli/env/qa/config/show/__init__.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Show the Agent configuration") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Show the Agent configuration. + """ + from dda.cli.env.qa.config.utils import get_agent_config_info + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + if not env.agent_config_dir.is_dir(): + app.abort(f"QA environment `{instance}` of type `{env_type}` does not exist") + + app.display_table(get_agent_config_info(env.agent_config)) diff --git a/src/dda/cli/env/qa/config/sync/__init__.py b/src/dda/cli/env/qa/config/sync/__init__.py new file mode 100644 index 00000000..fac09b79 --- /dev/null +++ b/src/dda/cli/env/qa/config/sync/__init__.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Sync the Agent configuration") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Sync the Agent configuration. + """ + from dda.env.models import EnvironmentState + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + expected_state = EnvironmentState.STARTED + if status.state != expected_state: + app.abort( + f"Cannot sync Agent configuration for QA environment `{instance}` of type `{env_type}` in state " + f"`{status.state}`, must be `{expected_state}`" + ) + + env.sync_agent_config() diff --git a/src/dda/cli/env/qa/config/templates/__init__.py b/src/dda/cli/env/qa/config/templates/__init__.py new file mode 100644 index 00000000..e815921f --- /dev/null +++ b/src/dda/cli/env/qa/config/templates/__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="Manage Agent config templates", +) +def cmd() -> None: + pass diff --git a/src/dda/cli/env/qa/config/templates/create/__init__.py b/src/dda/cli/env/qa/config/templates/create/__init__.py new file mode 100644 index 00000000..4e28332d --- /dev/null +++ b/src/dda/cli/env/qa/config/templates/create/__init__.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Create a new Agent config template") +@click.argument("name") +@pass_app +def cmd(app: Application, *, name: str) -> None: + """ + Create a new Agent config template. + """ + from dda.env.config.agent import AgentConfigTemplates + + templates = AgentConfigTemplates(app) + template = templates.get(name) + if template.exists(): + app.abort(f"Template already exists: {name}") + + template.restore_defaults() + app.display(f"Template created: {name}") diff --git a/src/dda/cli/env/qa/config/templates/explore/__init__.py b/src/dda/cli/env/qa/config/templates/explore/__init__.py new file mode 100644 index 00000000..6a6678c6 --- /dev/null +++ b/src/dda/cli/env/qa/config/templates/explore/__init__.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Open the Agent config templates location in your file manager") +@click.argument("name", required=False) +@pass_app +def cmd(app: Application, *, name: str | None) -> None: + """ + Open the Agent config templates location in your file manager. + """ + from dda.env.config.agent import AgentConfigTemplates + + templates = AgentConfigTemplates(app) + if not name: + default_template = templates.get("default") + if default_template.exists(): + location = default_template.root_dir + elif templates.root_dir.is_dir() and (entries := list(templates.root_dir.iterdir())): + location = entries[0] + else: + default_template.restore_defaults() + location = default_template.root_dir + + click.launch(str(location), locate=True) + return + + template = templates.get(name) + if not template.exists(): + app.abort(f"Template not found: {name}") + + click.launch(str(template.path), locate=True) diff --git a/src/dda/cli/env/qa/config/templates/find/__init__.py b/src/dda/cli/env/qa/config/templates/find/__init__.py new file mode 100644 index 00000000..83137fff --- /dev/null +++ b/src/dda/cli/env/qa/config/templates/find/__init__.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Output the location of Agent config templates") +@click.argument("name", required=False) +@pass_app +def cmd(app: Application, *, name: str | None) -> None: + """ + Output the location of Agent config templates. + """ + from dda.env.config.agent import AgentConfigTemplates + + templates = AgentConfigTemplates(app) + if not name: + if not any(templates): + templates.get("default").restore_defaults() + + app.display(str(templates.root_dir)) + return + + template = templates.get(name) + if not template.exists(): + app.abort(f"Template not found: {name}") + + app.display(str(template.root_dir)) diff --git a/src/dda/cli/env/qa/config/templates/remove/__init__.py b/src/dda/cli/env/qa/config/templates/remove/__init__.py new file mode 100644 index 00000000..919cbdbd --- /dev/null +++ b/src/dda/cli/env/qa/config/templates/remove/__init__.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Remove an Agent config template") +@click.argument("name") +@pass_app +def cmd(app: Application, *, name: str) -> None: + """ + Remove an Agent config template. + """ + from dda.env.config.agent import AgentConfigTemplates + + templates = AgentConfigTemplates(app) + template = templates.get(name) + if not template.exists(): + app.abort(f"Template not found: {name}") + + template.remove() + app.display(f"Template removed: {name}") diff --git a/src/dda/cli/env/qa/config/templates/show/__init__.py b/src/dda/cli/env/qa/config/templates/show/__init__.py new file mode 100644 index 00000000..e3bc7e7e --- /dev/null +++ b/src/dda/cli/env/qa/config/templates/show/__init__.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Show Agent config template details") +@click.argument("name", required=False) +@pass_app +def cmd(app: Application, *, name: str | None) -> None: + """ + Show Agent config template details. + """ + from dda.cli.env.qa.config.utils import get_agent_config_info + from dda.env.config.agent import AgentConfigTemplates + + templates = AgentConfigTemplates(app) + if not name: + existing_templates = list(templates) + if existing_templates: + app.display_table({template.name: get_agent_config_info(template) for template in existing_templates}) + return + + default_template = templates.get("default") + default_template.restore_defaults() + app.display_table({default_template.name: get_agent_config_info(default_template)}) + return + + template = templates.get(name) + if not template.exists(): + app.abort(f"Template not found: {name}") + + app.display_table(get_agent_config_info(template)) diff --git a/src/dda/cli/env/qa/config/utils.py b/src/dda/cli/env/qa/config/utils.py new file mode 100644 index 00000000..118067bc --- /dev/null +++ b/src/dda/cli/env/qa/config/utils.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from dda.env.config.agent import AgentConfig + + +def get_agent_config_info(agent_config: AgentConfig) -> dict[str, Any]: + info = {"Config": agent_config.load_scrubbed()} + integrations_info: dict[str, dict[str, Any]] = {} + for integration_name, integration_configs in sorted(agent_config.load_integrations().items()): + integration_ad_config: dict[str, Any] = {} + integration_config_files: dict[str, dict[str, Any] | str] = {} + for filename, file_config in integration_configs.items(): + file_info = {} + if instances := file_config.get("instances", []): + file_info["Instances"] = len(instances) + if logs := file_config.get("logs", []): + file_info["Logs"] = len(logs) + + if filename in {"auto_conf.yaml", "auto_conf.yml"}: + if ad_identifiers := file_config.get("ad_identifiers", []): + file_info["Identifiers"] = ad_identifiers + + integration_ad_config.update(file_info) + continue + + integration_config_files[str(len(integration_config_files) + 1)] = file_info or "" + + integration_info: dict[str, Any] = {} + if integration_ad_config: + integration_info["Autodiscovery"] = ( + dict(sorted(integration_ad_config.items())) if len(integration_ad_config) > 1 else "" + ) + + if integration_config_files: + if len(integration_config_files) == 1: + integration_info["Config"] = next(iter(integration_config_files.values())) + else: + integration_info["Config files"] = integration_config_files + + integrations_info[integration_name] = integration_info + + if integrations_info: + info["Integrations"] = integrations_info + + return info diff --git a/src/dda/cli/env/qa/gui/__init__.py b/src/dda/cli/env/qa/gui/__init__.py new file mode 100644 index 00000000..0e82b14b --- /dev/null +++ b/src/dda/cli/env/qa/gui/__init__.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Access a QA environment through a graphical interface") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Access a QA environment through a graphical interface. + """ + from dda.env.models import EnvironmentState + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + expected_state = EnvironmentState.STARTED + if status.state != expected_state: + app.abort( + f"Cannot stop QA environment `{instance}` of type `{env_type}` in state `{status.state}`, " + f"must be `{expected_state}`" + ) + + try: + env.launch_gui() + except NotImplementedError: + app.abort(f"QA environment type does not support GUI access: {env_type}") diff --git a/src/dda/cli/env/qa/info/__init__.py b/src/dda/cli/env/qa/info/__init__.py new file mode 100644 index 00000000..88e58c5d --- /dev/null +++ b/src/dda/cli/env/qa/info/__init__.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Show the metadata of a QA environment") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@click.option("--status", "-s", "show_status", is_flag=True, help="Include the status of the environment") +@click.option("--json", "as_json", is_flag=True, help="Output the metadata as JSON") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str, as_json: bool, show_status: bool) -> None: + """ + Show the metadata of a QA environment. + """ + import msgspec + + from dda.env.models import EnvironmentState + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + if status.state == EnvironmentState.NONEXISTENT: + app.abort(f"QA environment `{instance}` of type `{env_type}` does not exist") + + metadata = env.metadata() + info = msgspec.to_builtins(metadata) + if show_status: + info["status"] = msgspec.to_builtins(status) + + info = dict(sorted(info.items())) + if as_json: + app.display(msgspec.json.encode(info).decode()) + else: + app.display_table(info) diff --git a/src/dda/cli/env/qa/remove/__init__.py b/src/dda/cli/env/qa/remove/__init__.py new file mode 100644 index 00000000..5e58137b --- /dev/null +++ b/src/dda/cli/env/qa/remove/__init__.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Remove a QA environment") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Remove a QA environment. + """ + from dda.env.models import EnvironmentState + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + transition_states = {EnvironmentState.ERROR, EnvironmentState.STOPPED} + if status.state not in transition_states: + app.abort( + f"Cannot remove QA environment `{instance}` of type `{env_type}` in state `{status.state}`, " + f"must be one of: {', '.join(sorted(transition_states))}" + ) + + env.remove() + env.remove_state() diff --git a/src/dda/cli/env/qa/restart/__init__.py b/src/dda/cli/env/qa/restart/__init__.py new file mode 100644 index 00000000..118986a2 --- /dev/null +++ b/src/dda/cli/env/qa/restart/__init__.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Restart a QA environment") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Restart a QA environment. + """ + from dda.env.models import EnvironmentState + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + expected_state = EnvironmentState.STARTED + if status.state != expected_state: + app.abort( + f"Cannot restart QA environment `{instance}` of type `{env_type}` in state `{status.state}`, " + f"must be `{expected_state}`" + ) + + env.restart() diff --git a/src/dda/cli/env/qa/run/__init__.py b/src/dda/cli/env/qa/run/__init__.py new file mode 100644 index 00000000..9d98d37a --- /dev/null +++ b/src/dda/cli/env/qa/run/__init__.py @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command( + short_help="Run a command within a QA environment", + context_settings={"help_option_names": [], "ignore_unknown_options": True}, +) +@click.argument("args", required=True, nargs=-1) +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@click.pass_context +def cmd(ctx: click.Context, *, args: tuple[str, ...], env_type: str, instance: str) -> None: + """ + Run a command within a QA environment. + """ + app: Application = ctx.obj + first_arg = args[0] + if first_arg in {"-h", "--help"}: + app.display(ctx.get_help()) + app.abort(code=0) + + from dda.env.models import EnvironmentState + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + expected_state = EnvironmentState.STARTED + if status.state != expected_state: + app.abort( + f"QA environment `{instance}` of type `{env_type}` is in state `{status.state}`, must be `{expected_state}`" + ) + + env.run_command(list(args)) diff --git a/src/dda/cli/env/qa/shell/__init__.py b/src/dda/cli/env/qa/shell/__init__.py new file mode 100644 index 00000000..49f8fd5e --- /dev/null +++ b/src/dda/cli/env/qa/shell/__init__.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Spawn a shell within a QA environment") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Spawn a shell within a QA environment. + """ + from dda.env.models import EnvironmentState + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + expected_state = EnvironmentState.STARTED + if status.state != expected_state: + app.abort( + f"Cannot spawn shell in QA environment `{instance}` of type `{env_type}` in state `{status.state}`, " + f"must be `{expected_state}`" + ) + + env.launch_shell() diff --git a/src/dda/cli/env/qa/show/__init__.py b/src/dda/cli/env/qa/show/__init__.py new file mode 100644 index 00000000..311d74bb --- /dev/null +++ b/src/dda/cli/env/qa/show/__init__.py @@ -0,0 +1,60 @@ +# 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="Show the active QA environments") +@pass_app +def cmd(app: Application) -> None: + """ + Show the active QA environments. + """ + import json + + from dda.env.qa import get_qa_env + + env_data = {} + storage_dirs = app.config.storage.join("env", "qa") + if not storage_dirs.data.is_dir(): + app.display("No QA environments found") + return + + for env_type in sorted(storage_dirs.data.iterdir()): + type_name = env_type.name + instance_data = {} + for instance in sorted(env_type.iterdir()): + instance_name = instance.name + env = get_qa_env(type_name)( + app=app, + name=type_name, + instance=instance_name, + ) + if env.config_file.is_file(): + env_status = env.status() + config = { + key: value + for key, value in json.loads(env.config_file.read_text()).items() + # Filter out `None` and empty containers + if value or value is False + } + instance_data[instance_name] = { + "State": env_status.state, + "Config": config, + } + + if instance_data: + env_data[type_name] = instance_data + + if not env_data: + app.display("No QA environments found") + return + + app.display_table(env_data) diff --git a/src/dda/cli/env/qa/start/__init__.py b/src/dda/cli/env/qa/start/__init__.py new file mode 100644 index 00000000..267c150d --- /dev/null +++ b/src/dda/cli/env/qa/start/__init__.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command +from dda.cli.env.qa.utils import get_env_type, option_env_type +from dda.env.qa import get_qa_env + +if TYPE_CHECKING: + from dda.cli.application import Application + from dda.cli.base import DynamicContext + + +def resolve_environment(ctx: DynamicContext, param: click.Option, value: str) -> str: + from msgspec_click import generate_options + + env_type = get_env_type(ctx, param, value) + env_class = get_qa_env(env_type) + ctx.dynamic_params.extend(generate_options(env_class.config_class())) + return env_type + + +@dynamic_command( + short_help="Start a QA environment", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +@option_env_type(callback=resolve_environment) +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@click.option( + "-c", + "--config", + "config_template_name", + default="default", + help="The name of the Agent config template to use", +) +@click.pass_context +def cmd(ctx: click.Context, *, env_type: str, instance: str, config_template_name: str) -> None: + """ + Start a QA environment. + """ + import msgspec + + from dda.env.config.agent import AgentConfigTemplates + from dda.env.models import EnvironmentState + + app: Application = ctx.obj + + dynamic_context = ctx.get_dynamic_sibling() # type: ignore[attr-defined] + dynamic_options = { + param: value + for param, value in dynamic_context.params.items() + if dynamic_context.get_parameter_source(param).name != "DEFAULT" + } + user_config = dict(app.config.envs.get(env_type, {})) + user_config.update(dynamic_options) + if "e2e" not in user_config and app.config.env.qa.e2e: + user_config["e2e"] = True + + agent_config_templates = AgentConfigTemplates(app) + template = agent_config_templates.get(config_template_name) + if not template.exists(): + if config_template_name != "default": + app.abort(f"Agent config template not found: {config_template_name}") + + template.restore_defaults() + + env_class = get_qa_env(env_type) + config = msgspec.convert(user_config, env_class.config_class()) + env = env_class( + app=app, + name=env_type, + instance=instance, + config=config, + agent_config_template_path=template.root_dir, + ) + if dynamic_options and env.config_file.is_file(): + options = ", ".join(sorted(dynamic_options)) + app.abort( + f"Ignoring the following options as environments cannot be reconfigured from a stopped state: {options}\n" + f"To change the configuration, you must remove the environment after stopping it." + ) + + status = env.status() + transition_states = {EnvironmentState.NONEXISTENT, EnvironmentState.STOPPED} + if status.state not in transition_states: + app.abort( + f"Cannot start QA environment `{instance}` of type `{env_type}` in state `{status.state}`, " + f"must be one of: {', '.join(sorted(transition_states))}" + ) + + env.save_state() + try: + env.start() + except Exception: + env.remove_state() + raise diff --git a/src/dda/cli/env/qa/status/__init__.py b/src/dda/cli/env/qa/status/__init__.py new file mode 100644 index 00000000..943c0b9e --- /dev/null +++ b/src/dda/cli/env/qa/status/__init__.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Check the status of a QA environment") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str) -> None: + """ + Check the status of a QA environment. + """ + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + app.display(f"State: {status.state.value}") + if status.info: + app.display(status.info) diff --git a/src/dda/cli/env/qa/stop/__init__.py b/src/dda/cli/env/qa/stop/__init__.py new file mode 100644 index 00000000..d6b56a3c --- /dev/null +++ b/src/dda/cli/env/qa/stop/__init__.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.cli.env.qa.utils import option_env_type + +if TYPE_CHECKING: + from dda.cli.application import Application + + +@dynamic_command(short_help="Stop a QA environment") +@option_env_type() +@click.option("--id", "instance", default="default", help="Unique identifier for the environment") +@click.option("-r", "--remove", is_flag=True, help="Remove the environment after stopping") +@pass_app +def cmd(app: Application, *, env_type: str, instance: str, remove: bool) -> None: + """ + Stop a QA environment. + """ + from dda.env.models import EnvironmentState + from dda.env.qa import get_qa_env + + env = get_qa_env(env_type)( + app=app, + name=env_type, + instance=instance, + ) + status = env.status() + expected_state = EnvironmentState.STARTED + if status.state != expected_state: + app.abort( + f"Cannot stop QA environment `{instance}` of type `{env_type}` in state `{status.state}`, " + f"must be `{expected_state}`" + ) + + env.stop() + if remove: + env.remove() + env.remove_state() diff --git a/src/dda/cli/env/qa/utils.py b/src/dda/cli/env/qa/utils.py new file mode 100644 index 00000000..4faf1a7a --- /dev/null +++ b/src/dda/cli/env/qa/utils.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING + +import click + +from dda.env.qa import AVAILABLE_QA_ENVS, DEFAULT_QA_ENV + +if TYPE_CHECKING: + from dda.cli.application import Application + + +def get_env_type(ctx: click.Context, param: click.Option, value: str | None) -> str: + if value: + return value + + app: Application = ctx.obj + env_type = app.config.env.qa.default_type or DEFAULT_QA_ENV + param.default = env_type + return env_type + + +option_env_type = partial( + click.option, + "--type", + "-t", + "env_type", + type=click.Choice(AVAILABLE_QA_ENVS), + show_default=True, + is_eager=True, + callback=get_env_type, + help="The type of QA environment", +) diff --git a/src/dda/cli/terminal.py b/src/dda/cli/terminal.py index e62f5f80..218bb900 100644 --- a/src/dda/cli/terminal.py +++ b/src/dda/cli/terminal.py @@ -408,7 +408,18 @@ def _construct_table(data: dict[str, Any], *, key_style: Style) -> Table: for key, value in data.items(): if isinstance(value, dict): - table.add_row(key, _construct_table(value, key_style=key_style)) + if value: + table.add_row(key, _construct_table(value, key_style=key_style)) + else: + table.add_row(key, "{}") + elif isinstance(value, list): + table.add_row( + key, + _construct_table( + {f"{i}": v for i, v in enumerate(value, start=1)}, + key_style=key_style, + ), + ) else: table.add_row(key, str(value)) diff --git a/src/dda/config/model/env.py b/src/dda/config/model/env.py index 9c155fd8..4eff9262 100644 --- a/src/dda/config/model/env.py +++ b/src/dda/config/model/env.py @@ -6,6 +6,7 @@ from msgspec import Struct, field from dda.env.dev import DEFAULT_DEV_ENV +from dda.env.qa import DEFAULT_QA_ENV from dda.utils.editors import AVAILABLE_EDITORS, DEFAULT_EDITOR @@ -33,5 +34,21 @@ def __post_init__(self) -> None: raise ValueError(message) +class QAEnvConfig(Struct, frozen=True): + """ + /// tab | :octicons-file-code-16: config.toml + ```toml + [env.qa] + default-type = "linux-container" + e2e = false + ``` + /// + """ + + default_type: str = field(name="default-type", default=DEFAULT_QA_ENV) + e2e: bool = False + + class EnvConfig(Struct, frozen=True): dev: DevEnvConfig = field(default_factory=DevEnvConfig) + qa: QAEnvConfig = field(default_factory=QAEnvConfig) diff --git a/src/dda/config/model/orgs.py b/src/dda/config/model/orgs.py index 0d502692..909bcfdc 100644 --- a/src/dda/config/model/orgs.py +++ b/src/dda/config/model/orgs.py @@ -24,6 +24,6 @@ class OrgConfig(Struct, frozen=True, omit_defaults=True): api_key: str = environ.get("DD_API_KEY", "") app_key: str = environ.get("DD_APP_KEY", "") - site: str = environ.get("DD_SITE", "datadoghq.com") - dd_url: str = environ.get("DD_DD_URL", "https://app.datadoghq.com") + site: str = environ.get("DD_SITE", "") + dd_url: str = environ.get("DD_DD_URL", "") logs_url: str = environ.get("DD_LOGS_CONFIG_LOGS_DD_URL", "") diff --git a/src/dda/env/config/__init__.py b/src/dda/env/config/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/env/config/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/env/config/agent.py b/src/dda/env/config/agent.py new file mode 100644 index 00000000..a8bc9eab --- /dev/null +++ b/src/dda/env/config/agent.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import os +from functools import cached_property +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterator + + from dda.cli.application import Application + from dda.utils.fs import Path + + +class AgentConfigTemplates: + def __init__(self, app: Application) -> None: + self.__app = app + + @cached_property + def root_dir(self) -> Path: + return self.__app.config.storage.data / "env" / "config" / "templates" + + def get(self, name: str) -> AgentConfig: + return AgentConfig(app=self.__app, path=self.root_dir / name) + + def __iter__(self) -> Iterator[AgentConfig]: + if not self.root_dir.is_dir(): + return + + for path in self.root_dir.iterdir(): + template = AgentConfig(app=self.__app, path=path) + if template.exists(): + yield template + + +class AgentConfig: + def __init__(self, *, app: Application, path: Path) -> None: + self.__app = app + self.__root_dir = path + + @cached_property + def root_dir(self) -> Path: + return self.__root_dir + + @cached_property + def integrations_dir(self) -> Path: + return self.__root_dir / "integrations" + + @cached_property + def name(self) -> str: + return self.__root_dir.name + + @cached_property + def path(self) -> Path: + return self.__root_dir / "datadog.yaml" + + def exists(self) -> bool: + return self.path.is_file() + + def load(self) -> dict[str, Any]: + from dda.utils.agent.config.format import decode_agent_config_file + + config = decode_agent_config_file(self.path.read_text(encoding="utf-8")) + self.__inherit_org_config(config) + return dict(sorted(config.items())) + + def load_scrubbed(self) -> dict[str, Any]: + config = self.load() + + placeholder = "*****" + for key in ("api_key", "app_key"): + if key in config: + config[key] = placeholder + + return config + + def load_integrations(self) -> dict[str, dict[str, dict[str, Any]]]: + from dda.utils.agent.config.format import decode_agent_integration_config_file + + config: dict[str, dict[str, dict[str, Any]]] = {} + if not self.integrations_dir.is_dir(): + return config + + for path in sorted(self.integrations_dir.iterdir(), key=lambda p: p.name): + if not path.is_dir(): + continue + + configs: dict[str, dict[str, Any]] = { + entry.name: decode_agent_integration_config_file(entry.read_text(encoding="utf-8")) + for entry in path.iterdir() + if entry.name.endswith((".yaml", ".yml")) and entry.is_file() + } + if configs: + config[path.name] = dict(sorted(configs.items())) + + return config + + def remove(self) -> None: + import shutil + + if self.root_dir.is_dir(): + shutil.rmtree(self.root_dir) + + def restore_defaults(self) -> None: + from dda.utils.agent.config.format import encode_agent_config_file + + config: dict[str, Any] = {} + self.__inherit_org_config(config) + + self.remove() + self.root_dir.ensure_dir() + self.path.write_text(encode_agent_config_file(config), encoding="utf-8") + + def __inherit_org_config(self, config: dict[str, Any]) -> None: + org_name = config.pop("inherit_org", "default") + if not org_name: + return + + org = self.__app.config.orgs[org_name] + if api_key := (org.api_key or os.environ.get("DD_API_KEY", "")): + config.setdefault("api_key", api_key) + if app_key := (org.app_key or os.environ.get("DD_APP_KEY", "")): + config.setdefault("app_key", app_key) + if site := (org.site or os.environ.get("DD_SITE", "")): + config.setdefault("site", site) + if dd_url := (org.dd_url or os.environ.get("DD_DD_URL", "")): + config.setdefault("dd_url", dd_url) + if logs_url := (org.logs_url or os.environ.get("DD_LOGS_CONFIG_LOGS_DD_URL", "")): + config.setdefault("logs_config", {}).setdefault("logs_dd_url", logs_url) diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index 06d26edd..a68b8415 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -14,9 +14,9 @@ from dda.utils.git.constants import GitEnvVars if TYPE_CHECKING: + from dda.env.docker import Docker from dda.env.models import EnvironmentStatus from dda.env.shells.interface import Shell - from dda.tools.docker import Docker from dda.utils.container.model import Mount from dda.utils.editors.interface import EditorInterface @@ -74,14 +74,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: def config_class(cls) -> type[LinuxContainerConfig]: return LinuxContainerConfig - @cached_property - def docker(self) -> Docker: - from dda.tools.docker import Docker - - docker = Docker(self.app) - docker.path = self.config.cli - return docker - def start(self) -> None: from dda.env.models import EnvironmentState @@ -201,35 +193,12 @@ def remove(self) -> None: self.docker.wait(["rm", "-f", self.container_name], message=f"Removing container: {self.container_name}") def status(self) -> EnvironmentStatus: - import json - - from dda.env.models import EnvironmentState, EnvironmentStatus - - output = self.docker.capture(["inspect", self.container_name], check=False) - items = json.loads(output) - if not items: - return EnvironmentStatus(state=EnvironmentState.NONEXISTENT) - - inspection = items[0] - - # https://docs.docker.com/reference/api/engine/version/v1.47/#tag/Container/operation/ContainerList - # https://docs.podman.io/en/latest/_static/api.html?version=v5.0#tag/containers-(compat)/operation/ContainerList - state_data = inspection["State"] - status = state_data["Status"].lower() - if status == "running": - state = EnvironmentState.STARTED - elif status in {"created", "paused"}: - state = EnvironmentState.STOPPED - elif status == "exited": - state = EnvironmentState.ERROR if state_data["ExitCode"] == 1 else EnvironmentState.STOPPED - elif status == "restarting": - state = EnvironmentState.STARTING - elif status == "removing": - state = EnvironmentState.STOPPING - else: - state = EnvironmentState.UNKNOWN + from dda.env.models import EnvironmentState + + status = self.docker.get_status(self.container_name) + if status.state == EnvironmentState.NONEXISTENT: + return status - status = EnvironmentStatus(state=state) self.__latest_status = status return status @@ -328,6 +297,14 @@ def home_dir(self) -> str: def container_name(self) -> str: return f"dda-{self.name}-{self.instance}" + @cached_property + def docker(self) -> Docker: + from dda.env.docker import Docker + + docker = Docker(self.app) + docker.path = self.config.cli + return docker + @cached_property def shell(self) -> Shell: from dda.env.shells import get_shell diff --git a/src/dda/env/docker.py b/src/dda/env/docker.py new file mode 100644 index 00000000..8a5bfb5c --- /dev/null +++ b/src/dda/env/docker.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dda.tools.docker import Docker as DockerTool + +if TYPE_CHECKING: + from dda.env.models import EnvironmentStatus + + +class Docker(DockerTool): + def get_status(self, container_name: str) -> EnvironmentStatus: + import json + + from dda.env.models import EnvironmentState, EnvironmentStatus + + output = self.capture(["inspect", container_name], check=False) + items = json.loads(output) + if not items: + return EnvironmentStatus(state=EnvironmentState.NONEXISTENT) + + inspection = items[0] + + # https://docs.docker.com/reference/api/engine/version/v1.47/#tag/Container/operation/ContainerList + # https://docs.podman.io/en/latest/_static/api.html?version=v5.0#tag/containers-(compat)/operation/ContainerList + state_data = inspection["State"] + status = state_data["Status"].lower() + if status == "running": + state = EnvironmentState.STARTED + elif status in {"created", "paused"}: + state = EnvironmentState.STOPPED + elif status == "exited": + state = EnvironmentState.ERROR if state_data["ExitCode"] == 1 else EnvironmentState.STOPPED + elif status == "restarting": + state = EnvironmentState.STARTING + elif status == "removing": + state = EnvironmentState.STOPPING + else: + state = EnvironmentState.UNKNOWN + + return EnvironmentStatus(state=state) diff --git a/src/dda/env/models.py b/src/dda/env/models.py index 23ad8b65..df45b704 100644 --- a/src/dda/env/models.py +++ b/src/dda/env/models.py @@ -4,8 +4,9 @@ from __future__ import annotations from enum import StrEnum +from typing import Literal -from msgspec import Struct +from msgspec import Struct, field class EnvironmentState(StrEnum): @@ -25,3 +26,39 @@ class EnvironmentStatus(Struct, frozen=True, forbid_unknown_fields=True): state: EnvironmentState info: str = "" + + +class EnvironmentPort(Struct, frozen=True, forbid_unknown_fields=True): + """ + This class represents a port that an environment exposes. + """ + + port: int + protocol: str = "tcp" + + +class EnvironmentPortMetadata(Struct, frozen=True, forbid_unknown_fields=True): + """ + This class represents ports that an environment exposes. + """ + + # https://docs.datadoghq.com/agent/configuration/network/#inbound + agent: dict[Literal["apm", "dogstatsd", "expvar", "process_expvar"], EnvironmentPort] = {} + other: dict[str, EnvironmentPort] = {} + + +class EnvironmentNetworkMetadata(Struct, frozen=True, forbid_unknown_fields=True): + """ + This class represents network metadata that may be used to access an environment. + """ + + server: str + ports: EnvironmentPortMetadata = field(default_factory=EnvironmentPortMetadata) + + +class EnvironmentMetadata(Struct, frozen=True, forbid_unknown_fields=True): + """ + This class represents metadata about an environment. + """ + + network: EnvironmentNetworkMetadata diff --git a/src/dda/env/qa/__init__.py b/src/dda/env/qa/__init__.py new file mode 100644 index 00000000..539c98cd --- /dev/null +++ b/src/dda/env/qa/__init__.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + + from dda.env.qa.interface import QAEnvironmentInterface + from dda.env.qa.types.linux_container import LinuxContainer + + +DEFAULT_QA_ENV = "windows-container" if sys.platform == "win32" else "linux-container" + + +def get_qa_env(env_type: str) -> type[QAEnvironmentInterface]: + getter = __QA_ENVS.get(env_type) + if getter is None: + message = f"Unknown QA environment: {env_type}" + raise ValueError(message) + + return getter() + + +def __get_linux_container() -> type[LinuxContainer]: + from dda.env.qa.types.linux_container import LinuxContainer + + return LinuxContainer + + +def __get_windows_container() -> type[QAEnvironmentInterface]: + raise NotImplementedError + + +def __get_local_macos_vm() -> type[QAEnvironmentInterface]: + raise NotImplementedError + + +if sys.platform == "win32": + __QA_ENVS: dict[str, Callable[[], type[QAEnvironmentInterface[Any]]]] = { + "windows-container": __get_windows_container, + "linux-container": __get_linux_container, + } +elif sys.platform == "darwin": + __QA_ENVS: dict[str, Callable[[], type[QAEnvironmentInterface[Any]]]] = { + "linux-container": __get_linux_container, + "local-macos-vm": __get_local_macos_vm, + } +else: + __QA_ENVS: dict[str, Callable[[], type[QAEnvironmentInterface[Any]]]] = { + "linux-container": __get_linux_container, + } + +AVAILABLE_QA_ENVS: list[str] = sorted(__QA_ENVS) diff --git a/src/dda/env/qa/interface.py b/src/dda/env/qa/interface.py new file mode 100644 index 00000000..4fc288d6 --- /dev/null +++ b/src/dda/env/qa/interface.py @@ -0,0 +1,296 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from abc import ABC, abstractmethod +from functools import cached_property +from typing import TYPE_CHECKING, Annotated, Generic, NoReturn, cast + +import msgspec + +if TYPE_CHECKING: + from dda.env.config.agent import AgentConfig + + +class QAEnvironmentConfig(msgspec.Struct, kw_only=True): + env: Annotated[ + dict[str, str], + msgspec.Meta( + extra={ + "params": ["-e", "--env"], + "help": "Extra environment variables to expose at Agent startup", + } + ), + ] = {} + e2e: Annotated[ + bool, + msgspec.Meta( + extra={ + "help": "Whether to run the mock intake service for testing", + } + ), + ] = False + + +if TYPE_CHECKING: + from typing_extensions import TypeVar + + from dda.cli.application import Application + from dda.config.model.storage import StorageDirs + from dda.env.models import EnvironmentMetadata, EnvironmentStatus + from dda.utils.fs import Path + + ConfigT = TypeVar("ConfigT", bound=QAEnvironmentConfig, default=QAEnvironmentConfig) +else: + from typing import TypeVar + + ConfigT = TypeVar("ConfigT") + + +class QAEnvironmentInterface(ABC, Generic[ConfigT]): + """ + This interface defines the behavior of a QA environment. + """ + + def __init__( + self, + *, + app: Application, + name: str, + instance: str, + config: ConfigT | None = None, + agent_config_template_path: Path | None = None, + ) -> None: + self.__app = app + self.__name = name + self.__instance = instance + self.__config = config + self.__agent_config_template_path = agent_config_template_path + + @abstractmethod + def start(self) -> None: + """ + This method starts the QA environment. If this method returns early, the environment's + [status][dda.env.qa.interface.QAEnvironmentInterface.status] should contain information + about the startup progress. + + This method will only be called if the environment's + [status][dda.env.qa.interface.QAEnvironmentInterface.status] is + [stopped][dda.env.models.EnvironmentState.STOPPED] or + [nonexistent][dda.env.models.EnvironmentState.NONEXISTENT]. + + Users trigger this method by running the [`env qa start`](../../../cli/commands.md#dda-env-qa-start) command. + """ + + @abstractmethod + def stop(self) -> None: + """ + This method stops the QA environment. If this method returns early, the environment's + [status][dda.env.qa.interface.QAEnvironmentInterface.status] should contain information + about the shutdown progress. + + This method will only be called if the environment's + [status][dda.env.qa.interface.QAEnvironmentInterface.status] is + [started][dda.env.models.EnvironmentState.STARTED]. + + Users trigger this method by running the [`env qa stop`](../../../cli/commands.md#dda-env-qa-stop) command. + """ + + @abstractmethod + def restart(self) -> None: + """ + This method restarts the QA environment and must only return when the environment is fully restarted. + + This method will only be called if the environment's + [status][dda.env.qa.interface.QAEnvironmentInterface.status] is + [started][dda.env.models.EnvironmentState.STARTED]. + + Users trigger this method by running the [`env qa restart`](../../../cli/commands.md#dda-env-qa-restart) + command. + """ + + @abstractmethod + def remove(self) -> None: + """ + This method removes the QA environment and all associated state. + + This method will only be called if the environment's + [status][dda.env.qa.interface.QAEnvironmentInterface.status] is + [stopped][dda.env.models.EnvironmentState.STOPPED] or in an + [error][dda.env.models.EnvironmentState.ERROR] state. + + Users trigger this method by running the [`env qa remove`](../../../cli/commands.md#dda-env-qa-remove) + command or with the `-r`/`--remove` flag of the [`env qa stop`](../../../cli/commands.md#dda-env-qa-stop) + command. + """ + + @abstractmethod + def status(self) -> EnvironmentStatus: + """ + This method returns the current status of the QA environment. + """ + + @abstractmethod + def metadata(self) -> EnvironmentMetadata: + """ + This method returns metadata about the QA environment. + """ + + @abstractmethod + def run_command(self, command: list[str]) -> None: + """ + This method runs a command inside the QA environment. + + Users trigger this method by running the [`env qa run`](../../../cli/commands.md#dda-env-qa-run) command. + + Parameters: + command: The command to run inside the developer environment. + """ + + @abstractmethod + def sync_agent_config(self) -> None: + """ + This method ensures that the QA environment's Agent is configured with the current state of the host's + [Agent configuration directory][dda.env.qa.interface.QAEnvironmentInterface.agent_config_dir]. For + containerized environments, this usually requires restarting the container itself. + + Users trigger this method by running the [`env qa config sync`](../../../cli/commands.md#dda-env-qa-config-sync) + command. + """ + + def launch_shell(self) -> NoReturn: + """ + This method starts an interactive shell inside the QA environment. + + Users trigger this method by running the [`env qa shell`](../../../cli/commands.md#dda-env-qa-shell) command. + """ + raise NotImplementedError + + def launch_gui(self) -> NoReturn: + """ + This method starts an interactive GUI inside the QA environment using e.g. RDP or VNC. + + Users trigger this method by running the [`env qa gui`](../../../cli/commands.md#dda-env-qa-gui) command. + """ + raise NotImplementedError + + @property + def app(self) -> Application: + """ + The [`Application`][dda.cli.application.Application] instance. + """ + return self.__app + + @property + def name(self) -> str: + """ + The name of the environment type e.g. `linux-container`. + """ + return self.__name + + @property + def instance(self) -> str: + """ + The instance of the environment e.g. `default`. + """ + return self.__instance + + @cached_property + def storage_dirs(self) -> StorageDirs: + """ + The storage directories for the environment. + """ + return self.app.config.storage.join("env", "qa", self.name, self.instance) + + @cached_property + def state_dir(self) -> Path: + """ + The directory that is used to persist the environment's state. This directory will always be deleted after the + environment is removed with the [`env qa remove`](../../../cli/commands.md#dda-env-qa-remove) command or with + the [`env qa stop`](../../../cli/commands.md#dda-env-qa-stop) command when the `-r`/`--remove` flag is used. + """ + return self.storage_dirs.data / ".state" + + @cached_property + def config(self) -> ConfigT: + """ + The user-defined configuration as an instance of the + [`QAEnvironmentConfig`][dda.env.qa.interface.QAEnvironmentConfig] class, or subclass thereof. + """ + return self.__load_config() if self.__config is None else self.__config + + @classmethod + def config_class(cls) -> type[QAEnvironmentConfig]: + """ + The [`QAEnvironmentConfig`][dda.env.qa.interface.QAEnvironmentConfig] class, or subclass thereof, + that is used to configure the environment. + """ + return QAEnvironmentConfig + + @cached_property + def config_file(self) -> Path: + """ + The path to the JSON file that is used to persist the environment's configuration until the environment + is [removed][dda.env.qa.interface.QAEnvironmentInterface.remove]. + """ + return self.state_dir / "config.json" + + @cached_property + def metadata_file(self) -> Path: + """ + The path to the JSON file that is used to persist the environment's metadata until the environment + is [removed][dda.env.qa.interface.QAEnvironmentInterface.remove]. + """ + return self.state_dir / "metadata.json" + + @cached_property + def agent_config_dir(self) -> Path: + """ + The path to the directory that is used to persist the environment's Agent configuration. + + Users can find the location by running the + [`env qa config find`](../../../cli/commands.md#dda-env-qa-config-find) or + [`env qa config explore`](../../../cli/commands.md#dda-env-qa-config-explore) commands. + """ + return self.state_dir / "agent_config" + + @cached_property + def agent_config(self) -> AgentConfig: + from dda.env.config.agent import AgentConfig + + return AgentConfig(app=self.app, path=self.agent_config_dir) + + def save_metadata(self, metadata: EnvironmentMetadata) -> None: + self.metadata_file.write_bytes(msgspec.json.encode(metadata)) + + def load_metadata(self) -> EnvironmentMetadata: + from dda.env.models import EnvironmentMetadata + + return msgspec.json.decode(self.metadata_file.read_bytes(), type=EnvironmentMetadata) + + def save_state(self) -> None: + import shutil + + self.config_file.parent.ensure_dir() + self.config_file.write_bytes(msgspec.json.encode(self.config)) + + if self.agent_config_dir.is_dir(): + shutil.rmtree(self.agent_config_dir) + + if self.__agent_config_template_path: + shutil.copytree(self.__agent_config_template_path, self.agent_config_dir) + + def remove_state(self) -> None: + if self.state_dir.is_dir(): + import shutil + + shutil.rmtree(self.state_dir) + + def __load_config(self) -> ConfigT: + config = ( + msgspec.json.decode(self.config_file.read_bytes(), type=self.config_class()) + if self.config_file.is_file() + else self.config_class()() + ) + return cast(ConfigT, config) diff --git a/src/dda/env/qa/types/__init__.py b/src/dda/env/qa/types/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/env/qa/types/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/env/qa/types/linux_container.py b/src/dda/env/qa/types/linux_container.py new file mode 100644 index 00000000..86e58e81 --- /dev/null +++ b/src/dda/env/qa/types/linux_container.py @@ -0,0 +1,253 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from functools import cached_property +from typing import TYPE_CHECKING, Annotated, Any, NoReturn + +import msgspec + +from dda.env.qa.interface import QAEnvironmentConfig, QAEnvironmentInterface + +if TYPE_CHECKING: + from dda.env.docker import Docker + from dda.env.models import EnvironmentMetadata, EnvironmentStatus + + +class LinuxContainerConfig(QAEnvironmentConfig): + image: Annotated[ + str, + msgspec.Meta( + extra={ + "help": "The container image to use", + } + ), + ] = "datadog/agent" + pull: Annotated[ + bool, + msgspec.Meta( + extra={ + "help": "Whether to pull the image before every container creation", + } + ), + ] = False + network: Annotated[ + str, + msgspec.Meta( + extra={ + "help": ( + "The network to use for the container. Linux defaults to `host` while other platforms default " + "to only using port mappings" + ), + } + ), + ] = "" + cli: Annotated[ + str, + msgspec.Meta( + extra={ + "help": "The name or absolute path of the container manager e.g. `docker` or `podman`", + } + ), + ] = "docker" + arch: Annotated[ + str | None, + msgspec.Meta( + extra={ + "help": "The architecture to use e.g. `amd64` or `arm64`", + } + ), + ] = None + + +class LinuxContainer(QAEnvironmentInterface[LinuxContainerConfig]): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + self.__latest_status: EnvironmentStatus | None = None + + @classmethod + def config_class(cls) -> type[LinuxContainerConfig]: + return LinuxContainerConfig + + def start(self) -> None: + from dda.env.models import EnvironmentState + + status = self.__latest_status if self.__latest_status is not None else self.status() + if status.state == EnvironmentState.STOPPED: + self.__start_from_stopped() + return + + self.__start_anew() + + def __start_from_stopped(self) -> None: + self.docker.wait(["start", self.container_name], message=f"Starting container: {self.container_name}") + + def __start_anew(self) -> None: + from dda.env.models import ( + EnvironmentMetadata, + EnvironmentNetworkMetadata, + EnvironmentPort, + EnvironmentPortMetadata, + ) + + if self.config.e2e: + self.app.abort(f"The `{self.name}` QA environment does not support the `e2e` option") + + from dda.utils.agent.config.format import agent_config_to_env_vars + from dda.utils.container.model import Mount + from dda.utils.network.hostname import get_hostname + from dda.utils.platform import PLATFORM_ID + from dda.utils.process import EnvVars + from dda.utils.retry import wait_for + + if self.config.pull: + pull_command = ["pull", self.config.image] + if self.config.arch is not None: + pull_command.extend(("--platform", f"linux/{self.config.arch}")) + self.docker.wait(pull_command, message=f"Pulling image: {self.config.image}") + + command = [ + "run", + "-d", + "--name", + self.container_name, + ] + if self.config.arch is not None: + command.extend(("--platform", f"linux/{self.config.arch}")) + + # Mounts + mounts = [ + Mount( + type="bind", + path="/host/proc", + source="/proc", + ) + ] + mounts.extend( + Mount( + type="bind", + path=f"/etc/datadog-agent/conf.d/{integration}.d", + source=str(self.agent_config.integrations_dir / integration), + ) + for integration in self.agent_config.load_integrations() + ) + + for mount in mounts: + command.extend(("--mount", mount.as_csv())) + + ports = EnvironmentPortMetadata() + network_metadata = EnvironmentNetworkMetadata(server="localhost", ports=ports) + + # Environment variables + agent_config = self.agent_config.load() + if "api_key" not in agent_config: + self.app.display_warning("No API key set in the Agent config, using a placeholder") + agent_config["api_key"] = "a" * 32 + + agent_config["hostname"] = get_hostname() + cmd_port = self.derive_dynamic_port("cmd") + agent_config["cmd_port"] = str(cmd_port) + if agent_config.get("use_dogstatsd", True): + agent_config["dogstatsd_non_local_traffic"] = "true" + ports.agent["dogstatsd"] = EnvironmentPort(port=self.derive_dynamic_port("dogstatsd"), protocol="udp") + agent_config["dogstatsd_port"] = str(ports.agent["dogstatsd"].port) + if agent_config.get("apm_config", {}).get("enabled"): + ports.agent["apm"] = EnvironmentPort(port=self.derive_dynamic_port("apm")) + agent_config["receiver_port"] = str(ports.agent["apm"].port) + process_config = agent_config.get("process_config", {}) + if process_config.get("process_collection", {}).get("enabled") or process_config.get("enabled") == "true": + ports.agent["process_expvar"] = EnvironmentPort(port=self.derive_dynamic_port("process_expvar")) + agent_config["process_config"]["expvar_port"] = str(ports.agent["process_expvar"].port) + if agent_config.get("expvar_port"): + ports.agent["expvar"] = EnvironmentPort(port=self.derive_dynamic_port("expvar")) + agent_config["expvar_port"] = str(ports.agent["expvar"].port) + + env_vars = agent_config_to_env_vars(agent_config) + env_vars.update(self.config.env) + for env_var in env_vars: + command.extend(("-e", env_var)) + + # Network + if self.config.network: + command.extend(("--network", self.config.network)) + # Host mode is only enabled by default on Linux: + # https://docs.docker.com/engine/network/drivers/host/ + elif PLATFORM_ID == "linux": + command.extend(("--network", "host")) + else: + command.extend(("-p", f"{cmd_port}:{cmd_port}")) + for port in ports.agent.values(): + command.extend(("-p", f"{port.port}:{port.port}/{port.protocol}")) + + command.append(self.config.image) + self.docker.wait( + command, + message=f"Creating and starting container: {self.container_name}", + env=EnvVars(env_vars), + ) + + with self.app.status(f"Waiting for container: {self.container_name}"): + wait_for(self.check_readiness, timeout=30, interval=0.3) + + self.save_metadata(EnvironmentMetadata(network=network_metadata)) + + def stop(self) -> None: + self.docker.wait( + ["stop", self.container_name], + message=f"Stopping container: {self.container_name}", + ) + + def restart(self) -> None: + self.docker.wait(["restart", self.container_name], message=f"Restarting container: {self.container_name}") + + def remove(self) -> None: + self.docker.wait(["rm", "-f", self.container_name], message=f"Removing container: {self.container_name}") + + def sync_agent_config(self) -> None: + self.stop() + self.remove() + self.start() + + def status(self) -> EnvironmentStatus: + from dda.env.models import EnvironmentState + + status = self.docker.get_status(self.container_name) + if status.state == EnvironmentState.NONEXISTENT: + return status + + self.__latest_status = status + return status + + def metadata(self) -> EnvironmentMetadata: + return self.load_metadata() + + def run_command(self, command: list[str]) -> None: + self.docker.run(["exec", "-t", self.container_name, *command]) + + def launch_shell(self) -> NoReturn: + process = self.docker.attach(["exec", "-it", self.container_name, "bash"], check=False) + self.app.abort(code=process.returncode) + + @cached_property + def container_name(self) -> str: + return f"dda-qa-{self.name}-{self.instance}" + + @cached_property + def docker(self) -> Docker: + from dda.env.docker import Docker + + docker = Docker(self.app) + docker.path = self.config.cli + return docker + + def derive_dynamic_port(self, name: str) -> int: + from dda.utils.network.protocols import derive_dynamic_port + + return derive_dynamic_port(f"{self.container_name}-{name}") + + def check_readiness(self) -> None: + output = self.docker.capture(["logs", self.container_name]) + if "Starting Datadog Agent v" not in output: + raise RuntimeError diff --git a/src/dda/utils/agent/__init__.py b/src/dda/utils/agent/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/utils/agent/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/utils/agent/config/__init__.py b/src/dda/utils/agent/config/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/src/dda/utils/agent/config/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/src/dda/utils/agent/config/format.py b/src/dda/utils/agent/config/format.py new file mode 100644 index 00000000..7964df8d --- /dev/null +++ b/src/dda/utils/agent/config/format.py @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import Any + + +def decode_agent_config_file(text: str) -> dict[str, Any]: + from yaml import safe_load + + return safe_load(text) or {} + + +def encode_agent_config_file(config: dict[str, Any]) -> str: + from yaml import safe_dump + + return safe_dump(config, default_flow_style=False) + + +def decode_agent_integration_config_file(text: str) -> dict[str, Any]: + from yaml import safe_load + + return safe_load(text) or {} + + +def agent_config_to_env_vars(config: dict[str, Any]) -> dict[str, str]: + return dict(sorted(_flatten_config(config).items())) + + +def _flatten_config( + config: dict[str, Any], + prefix: str = "", + env_vars: dict[str, str] | None = None, +) -> dict[str, str]: + if env_vars is None: + env_vars = {} + + for key, value in config.items(): + if value is None: + continue + + env_key = key.upper().replace("-", "_") + env_key = f"{prefix}_{env_key}" if prefix else f"DD_{env_key}" + + if isinstance(value, dict): + _flatten_config(value, env_key, env_vars) + elif isinstance(value, bool): + env_vars[env_key] = str(value).lower() + elif isinstance(value, list | tuple): + env_vars[env_key] = " ".join(map(str, value)) + else: + env_vars[env_key] = str(value) + + return env_vars diff --git a/src/dda/utils/network/hostname.py b/src/dda/utils/network/hostname.py new file mode 100644 index 00000000..4363b27a --- /dev/null +++ b/src/dda/utils/network/hostname.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from functools import cache + + +@cache +def get_hostname() -> str: + import socket + + return socket.gethostname().lower() diff --git a/tests/cli/config/test_show.py b/tests/cli/config/test_show.py index 0bf349ba..06b1fdee 100644 --- a/tests/cli/config/test_show.py +++ b/tests/cli/config/test_show.py @@ -4,6 +4,7 @@ from __future__ import annotations from dda.env.dev import DEFAULT_DEV_ENV +from dda.env.qa import DEFAULT_QA_ENV def test_default_scrubbed(dda, config_file, helpers, default_cache_dir, default_data_dir, default_git_author): @@ -27,6 +28,10 @@ def test_default_scrubbed(dda, config_file, helpers, default_cache_dir, default_ universal-shell = false editor = "vscode" + [env.qa] + default-type = "{DEFAULT_QA_ENV}" + e2e = false + [tools.bazel] managed = "auto" @@ -92,6 +97,10 @@ def test_reveal(dda, config_file, helpers, default_cache_dir, default_data_dir, universal-shell = false editor = "vscode" + [env.qa] + default-type = "{DEFAULT_QA_ENV}" + e2e = false + [tools.bazel] managed = "auto" diff --git a/tests/cli/env/__init__.py b/tests/cli/env/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/cli/env/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/cli/env/qa/__init__.py b/tests/cli/env/qa/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/cli/env/qa/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/cli/env/qa/config/__init__.py b/tests/cli/env/qa/config/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/cli/env/qa/config/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/cli/env/qa/config/templates/__init__.py b/tests/cli/env/qa/config/templates/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/cli/env/qa/config/templates/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/cli/env/qa/config/templates/test_create.py b/tests/cli/env/qa/config/templates/test_create.py new file mode 100644 index 00000000..a7b8111c --- /dev/null +++ b/tests/cli/env/qa/config/templates/test_create.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +def test_create_new(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + assert not templates_dir.exists() + + result = dda("env", "qa", "config", "templates", "create", "foo") + result.check( + exit_code=0, + stdout=helpers.dedent( + """ + Template created: foo + """ + ), + ) + assert (templates_dir / "foo" / "datadog.yaml").is_file() + + +def test_create_existing(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "foo" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").touch() + + result = dda("env", "qa", "config", "templates", "create", "foo") + result.check( + exit_code=1, + output=helpers.dedent( + """ + Template already exists: foo + """ + ), + ) diff --git a/tests/cli/env/qa/config/templates/test_explore.py b/tests/cli/env/qa/config/templates/test_explore.py new file mode 100644 index 00000000..cf609fec --- /dev/null +++ b/tests/cli/env/qa/config/templates/test_explore.py @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +def test_root_no_templates(dda, temp_dir, mocker): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + assert not templates_dir.exists() + + mock = mocker.patch("click.launch") + result = dda("env", "qa", "config", "templates", "explore") + result.check(exit_code=0) + + mock.assert_called_once_with(str(templates_dir / "default"), locate=True) + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["default"] + + +def test_root_existing_default(dda, temp_dir, mocker): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "default" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").touch() + + mock = mocker.patch("click.launch") + result = dda("env", "qa", "config", "templates", "explore") + result.check(exit_code=0) + + mock.assert_called_once_with(str(template_dir), locate=True) + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["default"] + + +def test_root_existing_non_default(dda, temp_dir, mocker): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "foo" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").touch() + + mock = mocker.patch("click.launch") + result = dda("env", "qa", "config", "templates", "explore") + result.check(exit_code=0) + + mock.assert_called_once_with(str(template_dir), locate=True) + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["foo"] + + +def test_selection(dda, temp_dir, mocker): + mock = mocker.patch("click.launch") + template_dir = temp_dir / "data" / "env" / "config" / "templates" / "foo" + template_dir.ensure_dir() + config_file = template_dir / "datadog.yaml" + config_file.touch() + + result = dda("env", "qa", "config", "templates", "explore", "foo") + result.check(exit_code=0) + mock.assert_called_once_with(str(config_file), locate=True) + + +def test_selection_not_found(dda, helpers, mocker): + mock = mocker.patch("click.launch") + result = dda("env", "qa", "config", "templates", "explore", "foo") + result.check( + exit_code=1, + output=helpers.dedent( + """ + Template not found: foo + """ + ), + ) + mock.assert_not_called() diff --git a/tests/cli/env/qa/config/templates/test_find.py b/tests/cli/env/qa/config/templates/test_find.py new file mode 100644 index 00000000..0d4a35e7 --- /dev/null +++ b/tests/cli/env/qa/config/templates/test_find.py @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +def test_root_no_templates(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + assert not templates_dir.exists() + + result = dda("env", "qa", "config", "templates", "find") + result.check( + exit_code=0, + stdout=helpers.dedent( + f""" + {temp_dir / "data" / "env" / "config" / "templates"} + """ + ), + ) + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["default"] + + +def test_root_existing_non_default(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "foo" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").touch() + + result = dda("env", "qa", "config", "templates", "find") + result.check( + exit_code=0, + stdout=helpers.dedent( + f""" + {temp_dir / "data" / "env" / "config" / "templates"} + """ + ), + ) + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["foo"] + + +def test_selection(dda, helpers, temp_dir): + template_dir = temp_dir / "data" / "env" / "config" / "templates" / "foo" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").touch() + + result = dda("env", "qa", "config", "templates", "find", "foo") + result.check( + exit_code=0, + stdout=helpers.dedent( + f""" + {template_dir} + """ + ), + ) + + +def test_selection_not_found(dda, helpers): + result = dda("env", "qa", "config", "templates", "find", "foo") + result.check( + exit_code=1, + output=helpers.dedent( + """ + Template not found: foo + """ + ), + ) diff --git a/tests/cli/env/qa/config/templates/test_remove.py b/tests/cli/env/qa/config/templates/test_remove.py new file mode 100644 index 00000000..0eda6de8 --- /dev/null +++ b/tests/cli/env/qa/config/templates/test_remove.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +def test_remove_existing(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "foo" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").touch() + + result = dda("env", "qa", "config", "templates", "remove", "foo") + + result.check( + exit_code=0, + stdout=helpers.dedent( + """ + Template removed: foo + """ + ), + ) + + +def test_remove_not_found(dda, helpers): + result = dda("env", "qa", "config", "templates", "remove", "foo") + + result.check( + exit_code=1, + output=helpers.dedent( + """ + Template not found: foo + """ + ), + ) diff --git a/tests/cli/env/qa/config/templates/test_show.py b/tests/cli/env/qa/config/templates/test_show.py new file mode 100644 index 00000000..528040ed --- /dev/null +++ b/tests/cli/env/qa/config/templates/test_show.py @@ -0,0 +1,323 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import pytest + +from dda.utils.process import EnvVars + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +@pytest.fixture(scope="module", autouse=True) +def _terminal_width(): + with EnvVars({"COLUMNS": "200"}): + yield + + +def test_all_no_templates(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + assert not templates_dir.exists() + + with EnvVars({"DD_API_KEY": "foo"}): + result = dda("env", "qa", "config", "templates", "show") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌─────────┬──────────────────────────────────┐ + │ default │ ┌────────┬─────────────────────┐ │ + │ │ │ Config │ ┌─────────┬───────┐ │ │ + │ │ │ │ │ api_key │ ***** │ │ │ + │ │ │ │ └─────────┴───────┘ │ │ + │ │ └────────┴─────────────────────┘ │ + └─────────┴──────────────────────────────────┘ + """ + ), + ) + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["default"] + + +def test_all_existing_non_default(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "foo" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").write_text( + helpers.dedent( + """ + app_key: foo + bar: baz + """ + ) + ) + + with EnvVars({"DD_API_KEY": "foo"}): + result = dda("env", "qa", "config", "templates", "show") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌─────┬──────────────────────────────────┐ + │ foo │ ┌────────┬─────────────────────┐ │ + │ │ │ Config │ ┌─────────┬───────┐ │ │ + │ │ │ │ │ api_key │ ***** │ │ │ + │ │ │ │ │ app_key │ ***** │ │ │ + │ │ │ │ │ bar │ baz │ │ │ + │ │ │ │ └─────────┴───────┘ │ │ + │ │ └────────┴─────────────────────┘ │ + └─────┴──────────────────────────────────┘ + """ + ), + ) + + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["foo"] + + +def test_selection_not_found(dda, helpers): + result = dda("env", "qa", "config", "templates", "show", "foo") + result.check( + exit_code=1, + output=helpers.dedent( + """ + Template not found: foo + """ + ), + ) + + +def test_selection_empty(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "foo" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").touch() + + result = dda("env", "qa", "config", "templates", "show", "foo") + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌────────┬────┐ + │ Config │ {} │ + └────────┴────┘ + """ + ), + ) + + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["foo"] + + +def test_org_inheritance(dda, helpers, temp_dir, config_file): + config_file.data["orgs"]["foo"] = { + "api_key": "bar", + "app_key": "baz", + "site": "datadoghq.com", + "dd_url": "https://app.datadoghq.com", + "logs_url": "agent-intake.logs.datadoghq.com:10516", + } + config_file.save() + + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "foo" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").write_text( + helpers.dedent( + """ + inherit_org: foo + site: datadog.com + """ + ) + ) + + result = dda("env", "qa", "config", "templates", "show", "foo") + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌────────┬───────────────────────────────────────────────────────────────────────────┐ + │ Config │ ┌─────────────┬─────────────────────────────────────────────────────────┐ │ + │ │ │ api_key │ ***** │ │ + │ │ │ app_key │ ***** │ │ + │ │ │ dd_url │ https://app.datadoghq.com │ │ + │ │ │ logs_config │ ┌─────────────┬───────────────────────────────────────┐ │ │ + │ │ │ │ │ logs_dd_url │ agent-intake.logs.datadoghq.com:10516 │ │ │ + │ │ │ │ └─────────────┴───────────────────────────────────────┘ │ │ + │ │ │ site │ datadog.com │ │ + │ │ └─────────────┴─────────────────────────────────────────────────────────┘ │ + └────────┴───────────────────────────────────────────────────────────────────────────┘ + """ + ), + ) + + assert templates_dir.is_dir() + existing_templates = [p.name for p in templates_dir.iterdir()] + assert existing_templates == ["foo"] + + +def test_integrations(dda, helpers, temp_dir): + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "test" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").write_text( + helpers.dedent( + """ + api_key: foo + bar: baz + """ + ) + ) + integrations_dir = template_dir / "integrations" + integrations_dir.ensure_dir() + integration_no_files_dir = integrations_dir / "no_files" + integration_no_files_dir.ensure_dir() + (integration_no_files_dir / "config.yaml.example").touch() + integration_misconfigured_dir = integrations_dir / "misconfigured" + integration_misconfigured_dir.ensure_dir() + (integration_misconfigured_dir / "config.yaml").touch() + integration_only_instances_dir = integrations_dir / "only_instances" + integration_only_instances_dir.ensure_dir() + (integration_only_instances_dir / "config.yaml").write_text( + helpers.dedent( + """ + instances: + - name: foo + - name: bar + """ + ) + ) + integration_only_logs_dir = integrations_dir / "only_logs" + integration_only_logs_dir.ensure_dir() + (integration_only_logs_dir / "config.yaml").write_text( + helpers.dedent( + """ + logs: + - service: foo + source: foo + """ + ) + ) + integration_only_ad_dir = integrations_dir / "only_ad" + integration_only_ad_dir.ensure_dir() + (integration_only_ad_dir / "auto_conf.yaml").write_text( + helpers.dedent( + """ + ad_identifiers: + - foo + - bar + instances: + - name: foo + """ + ) + ) + integration_ad_misconfigured_dir = integrations_dir / "ad_misconfigured" + integration_ad_misconfigured_dir.ensure_dir() + (integration_ad_misconfigured_dir / "auto_conf.yaml").write_text( + helpers.dedent( + """ + ad_identifiers: + - foo + """ + ) + ) + integration_multiple_files_dir = integrations_dir / "multiple_files" + integration_multiple_files_dir.ensure_dir() + (integration_multiple_files_dir / "config1.yaml").write_text( + helpers.dedent( + """ + instances: + - name: foo + """ + ) + ) + (integration_multiple_files_dir / "config2.yaml").touch() + integration_config_with_ad_dir = integrations_dir / "config_with_ad" + integration_config_with_ad_dir.ensure_dir() + (integration_config_with_ad_dir / "config.yaml").write_text( + helpers.dedent( + """ + instances: + - name: foo + """ + ) + ) + (integration_config_with_ad_dir / "auto_conf.yaml").write_text( + helpers.dedent( + """ + ad_identifiers: + - foo + - bar + instances: + - name: foo + """ + ) + ) + + result = dda("env", "qa", "config", "templates", "show", "test") + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌──────────────┬──────────────────────────────────────────────────────────────────────────┐ + │ Config │ ┌─────────┬───────┐ │ + │ │ │ api_key │ ***** │ │ + │ │ │ bar │ baz │ │ + │ │ └─────────┴───────┘ │ + │ Integrations │ ┌──────────────────┬───────────────────────────────────────────────────┐ │ + │ │ │ ad_misconfigured │ ┌───────────────┬─────────────────┐ │ │ + │ │ │ │ │ Autodiscovery │ │ │ │ + │ │ │ │ └───────────────┴─────────────────┘ │ │ + │ │ │ config_with_ad │ ┌───────────────┬───────────────────────────────┐ │ │ + │ │ │ │ │ Autodiscovery │ ┌─────────────┬─────────────┐ │ │ │ + │ │ │ │ │ │ │ Identifiers │ ┌───┬─────┐ │ │ │ │ + │ │ │ │ │ │ │ │ │ 1 │ foo │ │ │ │ │ + │ │ │ │ │ │ │ │ │ 2 │ bar │ │ │ │ │ + │ │ │ │ │ │ │ │ └───┴─────┘ │ │ │ │ + │ │ │ │ │ │ │ Instances │ 1 │ │ │ │ + │ │ │ │ │ │ └─────────────┴─────────────┘ │ │ │ + │ │ │ │ │ Config │ ┌───────────┬───┐ │ │ │ + │ │ │ │ │ │ │ Instances │ 1 │ │ │ │ + │ │ │ │ │ │ └───────────┴───┘ │ │ │ + │ │ │ │ └───────────────┴───────────────────────────────┘ │ │ + │ │ │ misconfigured │ ┌────────┬─────────────────┐ │ │ + │ │ │ │ │ Config │ │ │ │ + │ │ │ │ └────────┴─────────────────┘ │ │ + │ │ │ multiple_files │ ┌──────────────┬───────────────────────────┐ │ │ + │ │ │ │ │ Config files │ ┌───┬───────────────────┐ │ │ │ + │ │ │ │ │ │ │ 1 │ ┌───────────┬───┐ │ │ │ │ + │ │ │ │ │ │ │ │ │ Instances │ 1 │ │ │ │ │ + │ │ │ │ │ │ │ │ └───────────┴───┘ │ │ │ │ + │ │ │ │ │ │ │ 2 │ │ │ │ │ + │ │ │ │ │ │ └───┴───────────────────┘ │ │ │ + │ │ │ │ └──────────────┴───────────────────────────┘ │ │ + │ │ │ only_ad │ ┌───────────────┬───────────────────────────────┐ │ │ + │ │ │ │ │ Autodiscovery │ ┌─────────────┬─────────────┐ │ │ │ + │ │ │ │ │ │ │ Identifiers │ ┌───┬─────┐ │ │ │ │ + │ │ │ │ │ │ │ │ │ 1 │ foo │ │ │ │ │ + │ │ │ │ │ │ │ │ │ 2 │ bar │ │ │ │ │ + │ │ │ │ │ │ │ │ └───┴─────┘ │ │ │ │ + │ │ │ │ │ │ │ Instances │ 1 │ │ │ │ + │ │ │ │ │ │ └─────────────┴─────────────┘ │ │ │ + │ │ │ │ └───────────────┴───────────────────────────────┘ │ │ + │ │ │ only_instances │ ┌────────┬───────────────────┐ │ │ + │ │ │ │ │ Config │ ┌───────────┬───┐ │ │ │ + │ │ │ │ │ │ │ Instances │ 2 │ │ │ │ + │ │ │ │ │ │ └───────────┴───┘ │ │ │ + │ │ │ │ └────────┴───────────────────┘ │ │ + │ │ │ only_logs │ ┌────────┬──────────────┐ │ │ + │ │ │ │ │ Config │ ┌──────┬───┐ │ │ │ + │ │ │ │ │ │ │ Logs │ 1 │ │ │ │ + │ │ │ │ │ │ └──────┴───┘ │ │ │ + │ │ │ │ └────────┴──────────────┘ │ │ + │ │ └──────────────────┴───────────────────────────────────────────────────┘ │ + └──────────────┴──────────────────────────────────────────────────────────────────────────┘ + """ + ), + ) diff --git a/tests/cli/env/qa/config/test_explore.py b/tests/cli/env/qa/config/test_explore.py new file mode 100644 index 00000000..042e98f0 --- /dev/null +++ b/tests/cli/env/qa/config/test_explore.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import sys + +import pytest + +from dda.env.models import EnvironmentState, EnvironmentStatus + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +@pytest.fixture(autouse=True) +def _updated_config(config_file): + # TODO: Remove once the default Windows QA environment is implemented + if sys.platform == "win32": + config_file.data["env"] = {"qa": {"default-type": "linux-container"}} + config_file.save() + + +def test_existing(dda, temp_dir, mocker): + mock = mocker.patch("click.launch") + mocker.patch("dda.env.qa.types.linux_container.LinuxContainer.start") + mocker.patch( + "dda.env.qa.types.linux_container.LinuxContainer.status", + return_value=EnvironmentStatus(state=EnvironmentState.STOPPED), + ) + agent_config_dir = temp_dir / "data" / "env" / "qa" / "linux-container" / "default" / ".state" / "agent_config" + + result = dda("env", "qa", "start") + result.check_exit_code(0) + + result = dda("env", "qa", "config", "explore") + result.check(exit_code=0) + mock.assert_called_once_with(str(agent_config_dir / "datadog.yaml"), locate=True) + + +def test_not_found(dda, helpers): + result = dda("env", "qa", "config", "explore") + result.check( + exit_code=1, + output=helpers.dedent( + """ + QA environment `default` of type `linux-container` does not exist + """ + ), + ) diff --git a/tests/cli/env/qa/config/test_find.py b/tests/cli/env/qa/config/test_find.py new file mode 100644 index 00000000..1553c524 --- /dev/null +++ b/tests/cli/env/qa/config/test_find.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import sys + +import pytest + +from dda.env.models import EnvironmentState, EnvironmentStatus + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +@pytest.fixture(autouse=True) +def _updated_config(config_file): + # TODO: Remove once the default Windows QA environment is implemented + if sys.platform == "win32": + config_file.data["env"] = {"qa": {"default-type": "linux-container"}} + config_file.save() + + +def test_existing(dda, helpers, temp_dir, mocker): + mocker.patch("dda.env.qa.types.linux_container.LinuxContainer.start") + mocker.patch( + "dda.env.qa.types.linux_container.LinuxContainer.status", + return_value=EnvironmentStatus(state=EnvironmentState.STOPPED), + ) + agent_config_dir = temp_dir / "data" / "env" / "qa" / "linux-container" / "default" / ".state" / "agent_config" + + result = dda("env", "qa", "start") + result.check_exit_code(0) + + result = dda("env", "qa", "config", "find") + result.check( + exit_code=0, + stdout=helpers.dedent( + f""" + {agent_config_dir} + """ + ), + ) + + +def test_not_found(dda, helpers): + result = dda("env", "qa", "config", "find") + result.check( + exit_code=1, + output=helpers.dedent( + """ + QA environment `default` of type `linux-container` does not exist + """ + ), + ) diff --git a/tests/cli/env/qa/config/test_show.py b/tests/cli/env/qa/config/test_show.py new file mode 100644 index 00000000..b90814d4 --- /dev/null +++ b/tests/cli/env/qa/config/test_show.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import sys + +import pytest + +from dda.env.models import EnvironmentState, EnvironmentStatus + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +@pytest.fixture(autouse=True) +def _updated_config(config_file): + # TODO: Remove once the default Windows QA environment is implemented + if sys.platform == "win32": + config_file.data["env"] = {"qa": {"default-type": "linux-container"}} + config_file.save() + + +def test_not_found(dda, helpers): + result = dda("env", "qa", "config", "show") + result.check( + exit_code=1, + output=helpers.dedent( + """ + QA environment `default` of type `linux-container` does not exist + """ + ), + ) + + +def test_default(dda, helpers, temp_dir, mocker): + mocker.patch("dda.env.qa.types.linux_container.LinuxContainer.start") + mocker.patch( + "dda.env.qa.types.linux_container.LinuxContainer.status", + return_value=EnvironmentStatus(state=EnvironmentState.STOPPED), + ) + + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "default" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").write_text( + helpers.dedent( + """ + api_key: foo + bar: baz + """ + ) + ) + + result = dda("env", "qa", "start") + result.check_exit_code(0) + + result = dda("env", "qa", "config", "show") + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌────────┬─────────────────────┐ + │ Config │ ┌─────────┬───────┐ │ + │ │ │ api_key │ ***** │ │ + │ │ │ bar │ baz │ │ + │ │ └─────────┴───────┘ │ + └────────┴─────────────────────┘ + """ + ), + ) + + +def test_modified(dda, helpers, temp_dir, mocker, config_file): + mocker.patch("dda.env.qa.types.linux_container.LinuxContainer.start") + mocker.patch( + "dda.env.qa.types.linux_container.LinuxContainer.status", + return_value=EnvironmentStatus(state=EnvironmentState.STOPPED), + ) + + config_file.data["orgs"]["foo"] = {"app_key": "bar", "site": "datadoghq.com"} + config_file.save() + + templates_dir = temp_dir / "data" / "env" / "config" / "templates" + template_dir = templates_dir / "default" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").write_text( + helpers.dedent( + """ + api_key: foo + bar: baz + """ + ) + ) + + result = dda("env", "qa", "start") + result.check_exit_code(0) + + agent_config_dir = temp_dir / "data" / "env" / "qa" / "linux-container" / "default" / ".state" / "agent_config" + agent_config_file = agent_config_dir / "datadog.yaml" + agent_config_file.write_text(f"{agent_config_file.read_text()}\ninherit_org: foo") + + result = dda("env", "qa", "config", "show") + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌────────┬─────────────────────────────┐ + │ Config │ ┌─────────┬───────────────┐ │ + │ │ │ api_key │ ***** │ │ + │ │ │ app_key │ ***** │ │ + │ │ │ bar │ baz │ │ + │ │ │ site │ datadoghq.com │ │ + │ │ └─────────┴───────────────┘ │ + └────────┴─────────────────────────────┘ + """ + ), + ) diff --git a/tests/cli/env/qa/test_show.py b/tests/cli/env/qa/test_show.py new file mode 100644 index 00000000..24aa0de8 --- /dev/null +++ b/tests/cli/env/qa/test_show.py @@ -0,0 +1,97 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import json +from subprocess import CompletedProcess + +import msgspec +import pytest + +from dda.env.qa.types.linux_container import LinuxContainerConfig + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +def test_empty_env_dir(dda, helpers): + result = dda("env", "qa", "show") + result.check( + exit_code=0, + stdout=helpers.dedent( + """ + No QA environments found + """ + ), + ) + + +def test_empty_instance_dirs(dda, helpers, temp_dir): + root_dir = temp_dir / "data" / "env" / "qa" / "linux-container" + instance_dir = root_dir / "foo" + instance_dir.ensure_dir() + + result = dda("env", "qa", "show") + result.check( + exit_code=0, + stdout=helpers.dedent( + """ + No QA environments found + """ + ), + ) + + +def test_default(dda, helpers, temp_dir): + root_dir = temp_dir / "data" / "env" / "qa" / "linux-container" + instance_foo_dir = root_dir / "foobar" / ".state" + instance_foo_dir.ensure_dir() + (instance_foo_dir / "config.json").write_bytes(msgspec.json.encode(LinuxContainerConfig())) + + instance_bar_dir = root_dir / "baz" / ".state" + instance_bar_dir.ensure_dir() + (instance_bar_dir / "config.json").write_bytes(msgspec.json.encode(LinuxContainerConfig(env={"FOO": "BAR"}))) + + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Show that the first instance is started + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + # Show that the second instance is stopped + 2: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "created"}}])), + }, + ): + result = dda("env", "qa", "show") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌─────────────────┬─────────────────────────────────────────────────────┐ + │ linux-container │ ┌────────┬────────────────────────────────────────┐ │ + │ │ │ baz │ ┌────────┬───────────────────────────┐ │ │ + │ │ │ │ │ State │ started │ │ │ + │ │ │ │ │ Config │ ┌───────┬───────────────┐ │ │ │ + │ │ │ │ │ │ │ image │ datadog/agent │ │ │ │ + │ │ │ │ │ │ │ pull │ False │ │ │ │ + │ │ │ │ │ │ │ cli │ docker │ │ │ │ + │ │ │ │ │ │ │ env │ ┌─────┬─────┐ │ │ │ │ + │ │ │ │ │ │ │ │ │ FOO │ BAR │ │ │ │ │ + │ │ │ │ │ │ │ │ └─────┴─────┘ │ │ │ │ + │ │ │ │ │ │ │ e2e │ False │ │ │ │ + │ │ │ │ │ │ └───────┴───────────────┘ │ │ │ + │ │ │ │ └────────┴───────────────────────────┘ │ │ + │ │ │ foobar │ ┌────────┬───────────────────────────┐ │ │ + │ │ │ │ │ State │ stopped │ │ │ + │ │ │ │ │ Config │ ┌───────┬───────────────┐ │ │ │ + │ │ │ │ │ │ │ image │ datadog/agent │ │ │ │ + │ │ │ │ │ │ │ pull │ False │ │ │ │ + │ │ │ │ │ │ │ cli │ docker │ │ │ │ + │ │ │ │ │ │ │ e2e │ False │ │ │ │ + │ │ │ │ │ │ └───────┴───────────────┘ │ │ │ + │ │ │ │ └────────┴───────────────────────────┘ │ │ + │ │ └────────┴────────────────────────────────────────┘ │ + └─────────────────┴─────────────────────────────────────────────────────┘ + """ + ), + ) diff --git a/tests/conftest.py b/tests/conftest.py index 6864cc57..7ceda412 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -136,7 +136,7 @@ def isolation(default_git_author: GitAuthorConfig) -> Generator[Path, None, None "COLUMNS": "80", "LINES": "24", } - with d.as_cwd(), EnvVars(default_env_vars): + with d.as_cwd(), EnvVars(default_env_vars, exclude=["DD_*"]): os.environ.pop(AppEnvVars.FORCE_COLOR, None) yield d @@ -224,6 +224,13 @@ def machine_id() -> Generator[UUID, None, None]: yield mock.return_value +@pytest.fixture(scope="session") +def hostname() -> str: + import socket + + return socket.gethostname().lower() + + def pytest_runtest_setup(item): for marker in item.iter_markers(): if marker.name == "requires_ci" and not running_in_ci(): # no cov diff --git a/tests/env/qa/__init__.py b/tests/env/qa/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/env/qa/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/env/qa/test_interface.py b/tests/env/qa/test_interface.py new file mode 100644 index 00000000..aeb4a963 --- /dev/null +++ b/tests/env/qa/test_interface.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import msgspec +import pytest + +from dda.env.models import EnvironmentMetadata, EnvironmentNetworkMetadata, EnvironmentState, EnvironmentStatus +from dda.env.qa.interface import QAEnvironmentConfig, QAEnvironmentInterface + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +class Container(QAEnvironmentInterface): + def start(self) -> None: ... + + def stop(self) -> None: ... + + def restart(self) -> None: ... + + def remove(self) -> None: ... + + def sync_agent_config(self) -> None: ... + + def status(self) -> EnvironmentStatus: + return EnvironmentStatus(state=EnvironmentState.UNKNOWN) + + def metadata(self) -> EnvironmentMetadata: + return EnvironmentMetadata(network=EnvironmentNetworkMetadata(server="localhost")) + + def run_command(self, command: list[str]) -> None: ... + + +def test_storage_dirs(app, temp_dir): + container = Container(app=app, name="test", instance="default") + + assert container.storage_dirs.cache == temp_dir / "cache" / "env" / "qa" / "test" / "default" + assert container.storage_dirs.data == temp_dir / "data" / "env" / "qa" / "test" / "default" + + +class TestConfig: + def test_default_config(self, app): + container = Container(app=app, name="test", instance="default") + + assert msgspec.to_builtins(container.config) == { + "env": {}, + "e2e": False, + } + + def test_save(self, app): + config = QAEnvironmentConfig(env={"foo": "bar"}) + container = Container(app=app, name="test", instance="default", config=config) + container.save_state() + + container = Container(app=app, name="test", instance="default") + assert container.config.env == {"foo": "bar"} + + def test_remove(self, app): + config = QAEnvironmentConfig(env={"foo": "bar"}) + container = Container(app=app, name="test", instance="default", config=config) + container.save_state() + + container = Container(app=app, name="test", instance="default") + assert container.config.env == {"foo": "bar"} + + container = Container(app=app, name="test", instance="default") + container.remove_state() + assert container.config.env == {} diff --git a/tests/env/qa/types/__init__.py b/tests/env/qa/types/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/env/qa/types/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/env/qa/types/test_linux_container.py b/tests/env/qa/types/test_linux_container.py new file mode 100644 index 00000000..257d4353 --- /dev/null +++ b/tests/env/qa/types/test_linux_container.py @@ -0,0 +1,1229 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import json +import subprocess +import sys +from subprocess import CompletedProcess + +import msgspec +import pytest + +from dda.env.qa.types.linux_container import LinuxContainer +from dda.utils.container.model import Mount +from dda.utils.process import PLATFORM_ID + +pytestmark = [pytest.mark.usefixtures("private_storage")] + + +@pytest.fixture(autouse=True) +def _updated_config(config_file): + # Allow Windows users to run these tests + if sys.platform == "win32": + config_file.data["env"] = {"qa": {"default-type": "linux-container"}} + config_file.save() + + +@pytest.fixture(scope="module") +def default_network_args(): + def _default_network_args(args): + return ["--network", "host"] if PLATFORM_ID == "linux" else args + + return _default_network_args + + +def test_default_config(app): + container = LinuxContainer(app=app, name="linux-container", instance="default") + + assert msgspec.to_builtins(container.config) == { + "arch": None, + "cli": "docker", + "env": {}, + "e2e": False, + "image": "datadog/agent", + "network": "", + "pull": False, + } + + +class TestStatus: + def test_default(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + result = dda("env", "qa", "status") + result.check( + exit_code=0, + stdout=helpers.dedent( + """ + State: nonexistent + """ + ), + ) + + @pytest.mark.parametrize( + ("state", "data"), + [ + pytest.param("started", {"Status": "running"}, id="running"), + pytest.param("stopped", {"Status": "created"}, id="created"), + pytest.param("stopped", {"Status": "paused"}, id="paused"), + pytest.param("stopped", {"Status": "exited", "ExitCode": 0}, id="exited without error"), + pytest.param("error", {"Status": "exited", "ExitCode": 1}, id="exited with error"), + pytest.param("starting", {"Status": "restarting"}, id="restarting"), + pytest.param("stopping", {"Status": "removing"}, id="removing"), + pytest.param("unknown", {"Status": "foo"}, id="unknown"), + ], + ) + def test_states(self, dda, helpers, mocker, state, data): + mocker.patch( + "subprocess.run", + return_value=CompletedProcess([], returncode=0, stdout=json.dumps([{"State": data}])), + ) + result = dda("env", "qa", "status") + result.check( + exit_code=0, + stdout=helpers.dedent( + f""" + State: {state} + """ + ), + ) + + +class TestStart: + def test_already_started(self, dda, helpers): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + }, + ): + result = dda("env", "qa", "start") + + result.check( + exit_code=1, + output=helpers.dedent( + """ + Cannot start QA environment `default` of type `linux-container` in state `started`, must be one of: nonexistent, stopped + """ + ), + ) + + def test_stopped(self, dda, helpers, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess( + [], + returncode=0, + stdout=json.dumps([{"State": {"Status": "exited", "ExitCode": 0}}]), + ), + }, + ) as calls: + result = dda("env", "qa", "start") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Starting container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ( + [ + helpers.locate("docker"), + "start", + "dda-qa-linux-container-default", + ], + ), + {"encoding": "utf-8", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": mocker.ANY}, + ), + ] + + def test_default(self, dda, helpers, hostname, default_network_args): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # 3: Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ) as calls: + result = dda("env", "qa", "start") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + No API key set in the Agent config, using a placeholder + Creating and starting container: dda-qa-linux-container-default + Waiting for container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ( + [ + helpers.locate("docker"), + "run", + "-d", + "--name", + "dda-qa-linux-container-default", + "--mount", + "type=bind,src=/proc,dst=/host/proc", + "-e", + "DD_API_KEY", + "-e", + "DD_CMD_PORT", + "-e", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC", + "-e", + "DD_DOGSTATSD_PORT", + "-e", + "DD_HOSTNAME", + *default_network_args([ + "-p", + "57680:57680", + "-p", + "65351:65351/udp", + ]), + "datadog/agent", + ], + ), + { + "encoding": "utf-8", + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": helpers.ExpectedEnvVars({ + "DD_API_KEY": "a" * 32, + "DD_CMD_PORT": "57680", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC": "true", + "DD_DOGSTATSD_PORT": "65351", + "DD_HOSTNAME": hostname, + }), + }, + ), + ] + + def test_api_key_configured(self, dda, helpers, config_file, hostname, default_network_args): + api_key = "test" * 8 + config_file.data["orgs"]["default"]["api_key"] = api_key + config_file.save() + + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ) as calls: + result = dda("env", "qa", "start") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Creating and starting container: dda-qa-linux-container-default + Waiting for container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ( + [ + helpers.locate("docker"), + "run", + "-d", + "--name", + "dda-qa-linux-container-default", + "--mount", + "type=bind,src=/proc,dst=/host/proc", + "-e", + "DD_API_KEY", + "-e", + "DD_CMD_PORT", + "-e", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC", + "-e", + "DD_DOGSTATSD_PORT", + "-e", + "DD_HOSTNAME", + *default_network_args([ + "-p", + "57680:57680", + "-p", + "65351:65351/udp", + ]), + "datadog/agent", + ], + ), + { + "encoding": "utf-8", + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": helpers.ExpectedEnvVars({ + "DD_API_KEY": api_key, + "DD_CMD_PORT": "57680", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC": "true", + "DD_DOGSTATSD_PORT": "65351", + "DD_HOSTNAME": hostname, + }), + }, + ), + ] + + def test_pull(self, dda, helpers, hostname, default_network_args, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # Capture image pull + # Capture container run + # Readiness check + 5: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ) as calls: + result = dda("env", "qa", "start", "--pull") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Pulling image: datadog/agent + No API key set in the Agent config, using a placeholder + Creating and starting container: dda-qa-linux-container-default + Waiting for container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ([helpers.locate("docker"), "pull", "datadog/agent"],), + {"encoding": "utf-8", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": mocker.ANY}, + ), + ( + ( + [ + helpers.locate("docker"), + "run", + "-d", + "--name", + "dda-qa-linux-container-default", + "--mount", + "type=bind,src=/proc,dst=/host/proc", + "-e", + "DD_API_KEY", + "-e", + "DD_CMD_PORT", + "-e", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC", + "-e", + "DD_DOGSTATSD_PORT", + "-e", + "DD_HOSTNAME", + *default_network_args([ + "-p", + "57680:57680", + "-p", + "65351:65351/udp", + ]), + "datadog/agent", + ], + ), + { + "encoding": "utf-8", + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": helpers.ExpectedEnvVars({ + "DD_API_KEY": "a" * 32, + "DD_CMD_PORT": "57680", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC": "true", + "DD_DOGSTATSD_PORT": "65351", + "DD_HOSTNAME": hostname, + }), + }, + ), + ] + + def test_arch(self, dda, helpers, hostname, default_network_args, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # Capture image pull + # Capture container run + # Readiness check + 5: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ) as calls: + result = dda("env", "qa", "start", "--pull", "--arch", "arm64") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Pulling image: datadog/agent + No API key set in the Agent config, using a placeholder + Creating and starting container: dda-qa-linux-container-default + Waiting for container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ([helpers.locate("docker"), "pull", "datadog/agent", "--platform", "linux/arm64"],), + {"encoding": "utf-8", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": mocker.ANY}, + ), + ( + ( + [ + helpers.locate("docker"), + "run", + "-d", + "--name", + "dda-qa-linux-container-default", + "--platform", + "linux/arm64", + "--mount", + "type=bind,src=/proc,dst=/host/proc", + "-e", + "DD_API_KEY", + "-e", + "DD_CMD_PORT", + "-e", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC", + "-e", + "DD_DOGSTATSD_PORT", + "-e", + "DD_HOSTNAME", + *default_network_args([ + "-p", + "57680:57680", + "-p", + "65351:65351/udp", + ]), + "datadog/agent", + ], + ), + { + "encoding": "utf-8", + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": helpers.ExpectedEnvVars({ + "DD_API_KEY": "a" * 32, + "DD_CMD_PORT": "57680", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC": "true", + "DD_DOGSTATSD_PORT": "65351", + "DD_HOSTNAME": hostname, + }), + }, + ), + ] + + def test_network(self, dda, helpers, hostname): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # 3: Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ) as calls: + result = dda("env", "qa", "start", "--network", "foo") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + No API key set in the Agent config, using a placeholder + Creating and starting container: dda-qa-linux-container-default + Waiting for container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ( + [ + helpers.locate("docker"), + "run", + "-d", + "--name", + "dda-qa-linux-container-default", + "--mount", + "type=bind,src=/proc,dst=/host/proc", + "-e", + "DD_API_KEY", + "-e", + "DD_CMD_PORT", + "-e", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC", + "-e", + "DD_DOGSTATSD_PORT", + "-e", + "DD_HOSTNAME", + "--network", + "foo", + "datadog/agent", + ], + ), + { + "encoding": "utf-8", + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": helpers.ExpectedEnvVars({ + "DD_API_KEY": "a" * 32, + "DD_CMD_PORT": "57680", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC": "true", + "DD_DOGSTATSD_PORT": "65351", + "DD_HOSTNAME": hostname, + }), + }, + ), + ] + + def test_extra_env_vars(self, dda, helpers, hostname, default_network_args): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # 3: Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ) as calls: + result = dda("env", "qa", "start", "--env", "EXTRA1", "foo", "--env", "EXTRA2", "bar") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + No API key set in the Agent config, using a placeholder + Creating and starting container: dda-qa-linux-container-default + Waiting for container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ( + [ + helpers.locate("docker"), + "run", + "-d", + "--name", + "dda-qa-linux-container-default", + "--mount", + "type=bind,src=/proc,dst=/host/proc", + "-e", + "DD_API_KEY", + "-e", + "DD_CMD_PORT", + "-e", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC", + "-e", + "DD_DOGSTATSD_PORT", + "-e", + "DD_HOSTNAME", + "-e", + "EXTRA1", + "-e", + "EXTRA2", + *default_network_args([ + "-p", + "57680:57680", + "-p", + "65351:65351/udp", + ]), + "datadog/agent", + ], + ), + { + "encoding": "utf-8", + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": helpers.ExpectedEnvVars({ + "DD_API_KEY": "a" * 32, + "DD_CMD_PORT": "57680", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC": "true", + "DD_DOGSTATSD_PORT": "65351", + "DD_HOSTNAME": hostname, + "EXTRA1": "foo", + "EXTRA2": "bar", + }), + }, + ), + ] + + def test_config_template(self, dda, helpers, hostname, temp_dir, default_network_args): + template_dir = temp_dir / "data" / "env" / "config" / "templates" / "default" + template_dir.ensure_dir() + (template_dir / "datadog.yaml").write_text( + helpers.dedent( + """ + api_key: foo + app_key: bar + use_dogstatsd: false + apm_config: + enabled: true + process_config: + process_collection: + enabled: true + expvar_port: 8126 + """ + ) + ) + integrations_dir = template_dir / "integrations" + integrations_dir.ensure_dir() + + config_dir = temp_dir / "data" / "env" / "qa" / "linux-container" / "default" / ".state" / "agent_config" + integration_mount_args = [] + for integration in ["bar", "foo"]: + integration_dir = integrations_dir / integration + integration_dir.ensure_dir() + (integration_dir / "config.yaml").write_text( + helpers.dedent( + """ + instances: + - name: foo + """ + ) + ) + mount = Mount( + type="bind", + path=f"/etc/datadog-agent/conf.d/{integration}.d", + source=str(config_dir / "integrations" / integration), + ) + integration_mount_args.extend(("--mount", mount.as_csv())) + + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # 3: Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ) as calls: + result = dda("env", "qa", "start") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Creating and starting container: dda-qa-linux-container-default + Waiting for container: dda-qa-linux-container-default + """ + ), + ) + + state_dir = temp_dir / "data" / "env" / "qa" / "linux-container" / "default" / ".state" + assert state_dir.is_dir() + assert (state_dir / "agent_config" / "datadog.yaml").exists() + assert (state_dir / "agent_config" / "integrations" / "foo" / "config.yaml").exists() + assert (state_dir / "agent_config" / "integrations" / "bar" / "config.yaml").exists() + + assert calls == [ + ( + ( + [ + helpers.locate("docker"), + "run", + "-d", + "--name", + "dda-qa-linux-container-default", + "--mount", + "type=bind,src=/proc,dst=/host/proc", + *integration_mount_args, + "-e", + "DD_API_KEY", + "-e", + "DD_APM_CONFIG_ENABLED", + "-e", + "DD_APP_KEY", + "-e", + "DD_CMD_PORT", + "-e", + "DD_EXPVAR_PORT", + "-e", + "DD_HOSTNAME", + "-e", + "DD_PROCESS_CONFIG_EXPVAR_PORT", + "-e", + "DD_PROCESS_CONFIG_PROCESS_COLLECTION_ENABLED", + "-e", + "DD_RECEIVER_PORT", + "-e", + "DD_USE_DOGSTATSD", + *default_network_args([ + "-p", + "57680:57680", + "-p", + "61712:61712/tcp", + "-p", + "52892:52892/tcp", + "-p", + "60495:60495/tcp", + ]), + "datadog/agent", + ], + ), + { + "encoding": "utf-8", + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": helpers.ExpectedEnvVars({ + "DD_API_KEY": "foo", + "DD_APM_CONFIG_ENABLED": "true", + "DD_APP_KEY": "bar", + "DD_CMD_PORT": "57680", + "DD_EXPVAR_PORT": "60495", + "DD_HOSTNAME": hostname, + "DD_PROCESS_CONFIG_EXPVAR_PORT": "52892", + "DD_PROCESS_CONFIG_PROCESS_COLLECTION_ENABLED": "true", + "DD_RECEIVER_PORT": "61712", + "DD_USE_DOGSTATSD": "false", + }), + }, + ), + ] + + +class TestStop: + def test_nonexistent(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + + result = dda("env", "qa", "stop") + + result.check( + exit_code=1, + output=helpers.dedent( + """ + Cannot stop QA environment `default` of type `linux-container` in state `nonexistent`, must be `started` + """ + ), + ) + + def test_default(self, dda, helpers, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Stop command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + # 2: Capture container stop + }, + ) as calls: + result = dda("env", "qa", "stop") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Stopping container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ([helpers.locate("docker"), "stop", "dda-qa-linux-container-default"],), + {"encoding": "utf-8", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": mocker.ANY}, + ), + ] + + +class TestRestart: + def test_nonexistent(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + + result = dda("env", "qa", "restart") + + result.check( + exit_code=1, + output=helpers.dedent( + """ + Cannot restart QA environment `default` of type `linux-container` in state `nonexistent`, must be `started` + """ + ), + ) + + def test_default(self, dda, helpers, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Restart command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + # 2: Capture container restart + }, + ) as calls: + result = dda("env", "qa", "restart") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Restarting container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ([helpers.locate("docker"), "restart", "dda-qa-linux-container-default"],), + {"encoding": "utf-8", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": mocker.ANY}, + ), + ] + + +class TestRemove: + def test_nonexistent(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + + result = dda("env", "qa", "remove") + + result.check( + exit_code=1, + output=helpers.dedent( + """ + Cannot remove QA environment `default` of type `linux-container` in state `nonexistent`, must be one of: error, stopped + """ + ), + ) + + def test_default(self, dda, helpers, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Remove command checks the status + 1: CompletedProcess( + [], returncode=0, stdout=json.dumps([{"State": {"Status": "exited", "ExitCode": 0}}]) + ), + # 2: Capture container removal + }, + ) as calls: + result = dda("env", "qa", "remove") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Removing container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ([helpers.locate("docker"), "rm", "-f", "dda-qa-linux-container-default"],), + {"encoding": "utf-8", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": mocker.ANY}, + ), + ] + + +class TestRun: + def test_nonexistent(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + + result = dda("env", "qa", "run", "echo", "foo") + result.check( + exit_code=1, + output=helpers.dedent( + """ + QA environment `default` of type `linux-container` is in state `nonexistent`, must be `started` + """ + ), + ) + + def test_default(self, dda, helpers, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Run command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + # 2: Capture container exec + }, + ) as calls: + result = dda("--interactive", "env", "qa", "run", "echo", "foo") + + result.check_exit_code(0) + + assert calls == [ + ( + ([helpers.locate("docker"), "exec", "-t", "dda-qa-linux-container-default", "echo", "foo"],), + {"cwd": None, "env": mocker.ANY}, + ), + ] + + +class TestSyncAgentConfig: + def test_nonexistent(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + + result = dda("env", "qa", "config", "sync") + result.check( + exit_code=1, + output=helpers.dedent( + """ + Cannot sync Agent configuration for QA environment `default` of type `linux-container` in state `nonexistent`, must be `started` + """ + ), + ) + + def test_default(self, dda, helpers, hostname, default_network_args, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # 3: Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ): + result = dda("env", "qa", "start") + result.check_exit_code(0) + + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Sync command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + # 2: Capture container stop + # 3: Capture container remove + # 4: Capture container start + # Readiness check + 5: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ) as calls: + result = dda("env", "qa", "config", "sync") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + Stopping container: dda-qa-linux-container-default + Removing container: dda-qa-linux-container-default + No API key set in the Agent config, using a placeholder + Creating and starting container: dda-qa-linux-container-default + Waiting for container: dda-qa-linux-container-default + """ + ), + ) + + assert calls == [ + ( + ([helpers.locate("docker"), "stop", "dda-qa-linux-container-default"],), + {"encoding": "utf-8", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": mocker.ANY}, + ), + ( + ([helpers.locate("docker"), "rm", "-f", "dda-qa-linux-container-default"],), + {"encoding": "utf-8", "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "env": mocker.ANY}, + ), + ( + ( + [ + helpers.locate("docker"), + "run", + "-d", + "--name", + "dda-qa-linux-container-default", + "--mount", + "type=bind,src=/proc,dst=/host/proc", + "-e", + "DD_API_KEY", + "-e", + "DD_CMD_PORT", + "-e", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC", + "-e", + "DD_DOGSTATSD_PORT", + "-e", + "DD_HOSTNAME", + *default_network_args([ + "-p", + "57680:57680", + "-p", + "65351:65351/udp", + ]), + "datadog/agent", + ], + ), + { + "encoding": "utf-8", + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "env": helpers.ExpectedEnvVars({ + "DD_API_KEY": "a" * 32, + "DD_CMD_PORT": "57680", + "DD_DOGSTATSD_NON_LOCAL_TRAFFIC": "true", + "DD_DOGSTATSD_PORT": "65351", + "DD_HOSTNAME": hostname, + }), + }, + ), + ] + + +class TestInfo: + def test_nonexistent(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + + result = dda("env", "qa", "info") + result.check( + exit_code=1, + output=helpers.dedent( + """ + QA environment `default` of type `linux-container` does not exist + """ + ), + ) + + def test_default(self, dda, helpers): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # 3: Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ): + result = dda("env", "qa", "start") + + result.check_exit_code(0) + + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Info command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + }, + ): + result = dda("env", "qa", "info") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌─────────┬───────────────────────────────────────────────────────────────┐ + │ network │ ┌────────┬──────────────────────────────────────────────────┐ │ + │ │ │ server │ localhost │ │ + │ │ │ ports │ ┌───────┬──────────────────────────────────────┐ │ │ + │ │ │ │ │ agent │ ┌───────────┬──────────────────────┐ │ │ │ + │ │ │ │ │ │ │ dogstatsd │ ┌──────────┬───────┐ │ │ │ │ + │ │ │ │ │ │ │ │ │ port │ 65351 │ │ │ │ │ + │ │ │ │ │ │ │ │ │ protocol │ udp │ │ │ │ │ + │ │ │ │ │ │ │ │ └──────────┴───────┘ │ │ │ │ + │ │ │ │ │ │ └───────────┴──────────────────────┘ │ │ │ + │ │ │ │ │ other │ {} │ │ │ + │ │ │ │ └───────┴──────────────────────────────────────┘ │ │ + │ │ └────────┴──────────────────────────────────────────────────┘ │ + └─────────┴───────────────────────────────────────────────────────────────┘ + """ + ), + ) + + def test_status(self, dda, helpers): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # 3: Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ): + result = dda("env", "qa", "start") + result.check_exit_code(0) + + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Info command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + }, + ): + result = dda("env", "qa", "info", "--status") + + result.check( + exit_code=0, + output=helpers.dedent( + """ + ┌─────────┬───────────────────────────────────────────────────────────────┐ + │ network │ ┌────────┬──────────────────────────────────────────────────┐ │ + │ │ │ server │ localhost │ │ + │ │ │ ports │ ┌───────┬──────────────────────────────────────┐ │ │ + │ │ │ │ │ agent │ ┌───────────┬──────────────────────┐ │ │ │ + │ │ │ │ │ │ │ dogstatsd │ ┌──────────┬───────┐ │ │ │ │ + │ │ │ │ │ │ │ │ │ port │ 65351 │ │ │ │ │ + │ │ │ │ │ │ │ │ │ protocol │ udp │ │ │ │ │ + │ │ │ │ │ │ │ │ └──────────┴───────┘ │ │ │ │ + │ │ │ │ │ │ └───────────┴──────────────────────┘ │ │ │ + │ │ │ │ │ other │ {} │ │ │ + │ │ │ │ └───────┴──────────────────────────────────────┘ │ │ + │ │ └────────┴──────────────────────────────────────────────────┘ │ + │ status │ ┌───────┬─────────┐ │ + │ │ │ state │ started │ │ + │ │ │ info │ │ │ + │ │ └───────┴─────────┘ │ + └─────────┴───────────────────────────────────────────────────────────────┘ + """ + ), + ) + + def test_json(self, dda, helpers): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Start command checks the status + 1: CompletedProcess([], returncode=0, stdout="{}"), + # Start method checks the status + 2: CompletedProcess([], returncode=0, stdout="{}"), + # 3: Capture container run + # Readiness check + 4: CompletedProcess([], returncode=0, stdout="Starting Datadog Agent v9000"), + }, + ): + result = dda("env", "qa", "start") + result.check_exit_code(0) + + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Info command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + }, + ): + result = dda("env", "qa", "info", "--status", "--json") + + result.check( + exit_code=0, + stdout_json={ + "network": { + "server": "localhost", + "ports": { + "agent": { + "dogstatsd": {"port": 65351, "protocol": "udp"}, + }, + "other": {}, + }, + }, + "status": { + "state": "started", + "info": "", + }, + }, + ) + + +class TestShell: + def test_nonexistent(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + + result = dda("env", "qa", "shell") + result.check( + exit_code=1, + output=helpers.dedent( + """ + Cannot spawn shell in QA environment `default` of type `linux-container` in state `nonexistent`, must be `started` + """ + ), + ) + + def test_default(self, dda, helpers, mocker): + with helpers.hybrid_patch( + "subprocess.run", + return_values={ + # Shell command checks the status + 1: CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + # 2: Capture container exec + }, + ) as calls: + result = dda("env", "qa", "shell") + + result.check_exit_code(0) + + assert calls == [ + ( + ([helpers.locate("docker"), "exec", "-it", "dda-qa-linux-container-default", "bash"],), + {"env": mocker.ANY}, + ), + ] + + +class TestGUI: + def test_nonexistent(self, dda, helpers, mocker): + mocker.patch("subprocess.run", return_value=CompletedProcess([], returncode=0, stdout="{}")) + + result = dda("env", "qa", "gui") + result.check( + exit_code=1, + output=helpers.dedent( + """ + Cannot stop QA environment `default` of type `linux-container` in state `nonexistent`, must be `started` + """ + ), + ) + + def test_not_supported(self, dda, helpers, mocker): + mocker.patch( + "subprocess.run", + return_value=CompletedProcess([], returncode=0, stdout=json.dumps([{"State": {"Status": "running"}}])), + ) + + result = dda("env", "qa", "gui") + result.check( + exit_code=1, + output=helpers.dedent( + """ + QA environment type does not support GUI access: linux-container + """ + ), + ) diff --git a/tests/helpers/api.py b/tests/helpers/api.py index 8da9bdf5..1721f50e 100644 --- a/tests/helpers/api.py +++ b/tests/helpers/api.py @@ -75,3 +75,14 @@ def __existing_binary() -> str: # Prefer the entry point because it's very small return shutil.which("dda", path=sysconfig.get_path("scripts")) or sys.executable + + +class ExpectedEnvVars: # noqa: PLW1641 + def __init__(self, env_vars: dict[str, str]) -> None: + self.env_vars = env_vars + + def __eq__(self, other: object) -> bool: + if not isinstance(other, dict): + return False + + return {k: v for k, v in other.items() if k in self.env_vars} == self.env_vars diff --git a/tests/utils/agent/__init__.py b/tests/utils/agent/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/utils/agent/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/utils/agent/config/__init__.py b/tests/utils/agent/config/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/utils/agent/config/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/utils/agent/config/test_format.py b/tests/utils/agent/config/test_format.py new file mode 100644 index 00000000..2d6e34c3 --- /dev/null +++ b/tests/utils/agent/config/test_format.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from dda.utils.agent.config.format import ( + agent_config_to_env_vars, + decode_agent_config_file, + encode_agent_config_file, +) + + +def test_encode_agent_config_file(helpers): + assert encode_agent_config_file({ + "api_key": "foobar", + "extra_tags": ["tag1:value1", "tag2:value2"], + "proxy": { + "http": "http://proxy.example.com:8080", + "https": "https://proxy.example.com:443", + }, + "hostname_fqdn": True, + "dogstatsd_port": 8125, + }) == helpers.dedent( + """ + api_key: foobar + dogstatsd_port: 8125 + extra_tags: + - tag1:value1 + - tag2:value2 + hostname_fqdn: true + proxy: + http: http://proxy.example.com:8080 + https: https://proxy.example.com:443 + """ + ) + + +def test_decode_agent_config_file(helpers): + assert decode_agent_config_file( + helpers.dedent( + """ + api_key: foobar + dogstatsd_port: 8125 + extra_tags: + - tag1:value1 + - tag2:value2 + hostname_fqdn: true + proxy: + http: http://proxy.example.com:8080 + https: https://proxy.example.com:443 + """ + ) + ) == { + "api_key": "foobar", + "dogstatsd_port": 8125, + "extra_tags": ["tag1:value1", "tag2:value2"], + "hostname_fqdn": True, + "proxy": { + "http": "http://proxy.example.com:8080", + "https": "https://proxy.example.com:443", + }, + } + + +def test_agent_config_to_env_vars(): + assert agent_config_to_env_vars({ + "api_key": "foobar", + "app_key": None, + "dogstatsd_port": 8125, + "extra_tags": ["tag1:value1", "tag2:value2"], + "hostname_fqdn": True, + "proxy": { + "http": "http://proxy.example.com:8080", + "https": "https://proxy.example.com:443", + }, + }) == { + "DD_API_KEY": "foobar", + "DD_DOGSTATSD_PORT": "8125", + "DD_EXTRA_TAGS": "tag1:value1 tag2:value2", + "DD_HOSTNAME_FQDN": "true", + "DD_PROXY_HTTP": "http://proxy.example.com:8080", + "DD_PROXY_HTTPS": "https://proxy.example.com:443", + } diff --git a/uv.lock b/uv.lock index 839aecbd..9efeeab0 100644 --- a/uv.lock +++ b/uv.lock @@ -474,6 +474,7 @@ dependencies = [ { name = "psutil" }, { name = "pyjson5" }, { name = "pywinpty", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, { name = "rich" }, { name = "rich-click" }, { name = "tomlkit" }, @@ -580,7 +581,6 @@ legacy-kernel-matrix-testing = [ legacy-notifications = [ { name = "codeowners" }, { name = "invoke" }, - { name = "pyyaml" }, { name = "requests" }, { name = "slack-sdk" }, { name = "tabulate", extra = ["widechars"] }, @@ -621,7 +621,6 @@ legacy-tasks = [ { name = "pymdown-extensions" }, { name = "pyright" }, { name = "python-gitlab" }, - { name = "pyyaml" }, { name = "reno" }, { name = "requests" }, { name = "rich" }, @@ -673,6 +672,7 @@ requires-dist = [ { name = "psutil", specifier = "~=7.0" }, { name = "pyjson5", specifier = "~=1.6.9" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = "~=2.0.15" }, + { name = "pyyaml", specifier = "~=6.0.2" }, { name = "rich", specifier = "~=14.0" }, { name = "rich-click", specifier = "~=1.8.9" }, { name = "tomlkit", specifier = "~=0.13" }, @@ -771,7 +771,6 @@ legacy-kernel-matrix-testing = [ legacy-notifications = [ { name = "codeowners", specifier = "==0.6.0" }, { name = "invoke", specifier = "==2.2.0" }, - { name = "pyyaml", specifier = "==6.0.1" }, { name = "requests", specifier = "==2.32.3" }, { name = "slack-sdk", specifier = "~=3.27.1" }, { name = "tabulate", extras = ["widechars"], specifier = "==0.9.0" }, @@ -812,7 +811,6 @@ legacy-tasks = [ { name = "pymdown-extensions", specifier = "~=10.5.0" }, { name = "pyright", specifier = "==1.1.391" }, { name = "python-gitlab", specifier = "==4.4.0" }, - { name = "pyyaml", specifier = "==6.0.1" }, { name = "reno", specifier = "==3.5.0" }, { name = "requests", specifier = "==2.32.3" }, { name = "rich", specifier = "==14.0.0" }, @@ -2509,17 +2507,28 @@ wheels = [ [[package]] name = "pyyaml" -version = "6.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201, upload-time = "2023-07-18T00:00:23.308Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/06/1b305bf6aa704343be85444c9d011f626c763abb40c0edc1cad13bfd7f86/PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", size = 178692, upload-time = "2023-08-28T18:43:24.924Z" }, - { url = "https://files.pythonhosted.org/packages/84/02/404de95ced348b73dd84f70e15a41843d817ff8c1744516bf78358f2ffd2/PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", size = 165622, upload-time = "2023-08-28T18:43:26.54Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4c/4a2908632fc980da6d918b9de9c1d9d7d7e70b2672b1ad5166ed27841ef7/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", size = 696937, upload-time = "2024-01-18T20:40:22.92Z" }, - { url = "https://files.pythonhosted.org/packages/b4/33/720548182ffa8344418126017aa1d4ab4aeec9a2275f04ce3f3573d8ace8/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", size = 724969, upload-time = "2023-08-28T18:43:28.56Z" }, - { url = "https://files.pythonhosted.org/packages/4f/78/77b40157b6cb5f2d3d31a3d9b2efd1ba3505371f76730d267e8b32cf4b7f/PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", size = 712604, upload-time = "2023-08-28T18:43:30.206Z" }, - { url = "https://files.pythonhosted.org/packages/2e/97/3e0e089ee85e840f4b15bfa00e4e63d84a3691ababbfea92d6f820ea6f21/PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", size = 126098, upload-time = "2023-08-28T18:43:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/fbade56564ad486809c27b322d0f7e6a89c01f6b4fe208402e90d4443a99/PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", size = 138675, upload-time = "2023-08-28T18:43:33.613Z" }, +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] [[package]]