From 2f68343fd8f8454e79bb1704c5d7aa657d1aabf5 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 25 Sep 2023 00:17:15 -0400 Subject: [PATCH 1/3] Migrate E2E features --- datadog_checks_dev/CHANGELOG.md | 6 +- datadog_checks_dev/datadog_checks/dev/_env.py | 2 +- .../datadog_checks/dev/plugin/pytest.py | 38 +- datadog_checks_dev/tests/conftest.py | 2 +- ddev/CHANGELOG.md | 4 + ddev/pyproject.toml | 2 +- ddev/src/ddev/cli/application.py | 11 +- ddev/src/ddev/cli/config/__init__.py | 4 +- ddev/src/ddev/cli/env/__init__.py | 31 +- ddev/src/ddev/cli/env/agent.py | 80 ++ ddev/src/ddev/cli/env/check.py | 18 + ddev/src/ddev/cli/env/config.py | 95 ++ ddev/src/ddev/cli/env/reload.py | 39 + ddev/src/ddev/cli/env/shell.py | 41 + ddev/src/ddev/cli/env/show.py | 88 ++ ddev/src/ddev/cli/env/start.py | 194 +++ ddev/src/ddev/cli/env/stop.py | 59 + ddev/src/ddev/cli/env/test.py | 136 ++ ddev/src/ddev/cli/terminal.py | 96 +- ddev/src/ddev/cli/test/__init__.py | 15 +- ddev/src/ddev/config/constants.py | 2 + ddev/src/ddev/e2e/__init__.py | 3 + ddev/src/ddev/e2e/agent/__init__.py | 18 + ddev/src/ddev/e2e/agent/docker.py | 303 +++++ ddev/src/ddev/e2e/agent/interface.py | 79 ++ ddev/src/ddev/e2e/config.py | 89 ++ ddev/src/ddev/e2e/constants.py | 13 + ddev/src/ddev/e2e/run.py | 51 + ddev/src/ddev/testing/hatch.py | 12 + ddev/src/ddev/utils/platform.py | 10 + ddev/tests/cli/env/__init__.py | 3 + ddev/tests/cli/env/conftest.py | 38 + ddev/tests/cli/env/test_agent.py | 70 + ddev/tests/cli/env/test_reload.py | 38 + ddev/tests/cli/env/test_shell.py | 38 + ddev/tests/cli/env/test_start.py | 445 +++++++ ddev/tests/cli/env/test_stop.py | 46 + ddev/tests/conftest.py | 26 +- ddev/tests/e2e/__init__.py | 3 + ddev/tests/e2e/agent/__init__.py | 3 + ddev/tests/e2e/agent/test_docker.py | 1172 +++++++++++++++++ ddev/tests/e2e/test_config.py | 76 ++ ddev/tests/e2e/test_run.py | 91 ++ ddev/tests/testing/__init__.py | 3 + ddev/tests/testing/test_hatch.py | 15 + docs/developer/e2e.md | 169 ++- snmp/tests/conftest.py | 14 +- 47 files changed, 3656 insertions(+), 135 deletions(-) create mode 100644 ddev/src/ddev/cli/env/agent.py create mode 100644 ddev/src/ddev/cli/env/check.py create mode 100644 ddev/src/ddev/cli/env/config.py create mode 100644 ddev/src/ddev/cli/env/reload.py create mode 100644 ddev/src/ddev/cli/env/shell.py create mode 100644 ddev/src/ddev/cli/env/show.py create mode 100644 ddev/src/ddev/cli/env/start.py create mode 100644 ddev/src/ddev/cli/env/stop.py create mode 100644 ddev/src/ddev/cli/env/test.py create mode 100644 ddev/src/ddev/e2e/__init__.py create mode 100644 ddev/src/ddev/e2e/agent/__init__.py create mode 100644 ddev/src/ddev/e2e/agent/docker.py create mode 100644 ddev/src/ddev/e2e/agent/interface.py create mode 100644 ddev/src/ddev/e2e/config.py create mode 100644 ddev/src/ddev/e2e/constants.py create mode 100644 ddev/src/ddev/e2e/run.py create mode 100644 ddev/src/ddev/testing/hatch.py create mode 100644 ddev/tests/cli/env/__init__.py create mode 100644 ddev/tests/cli/env/conftest.py create mode 100644 ddev/tests/cli/env/test_agent.py create mode 100644 ddev/tests/cli/env/test_reload.py create mode 100644 ddev/tests/cli/env/test_shell.py create mode 100644 ddev/tests/cli/env/test_start.py create mode 100644 ddev/tests/cli/env/test_stop.py create mode 100644 ddev/tests/e2e/__init__.py create mode 100644 ddev/tests/e2e/agent/__init__.py create mode 100644 ddev/tests/e2e/agent/test_docker.py create mode 100644 ddev/tests/e2e/test_config.py create mode 100644 ddev/tests/e2e/test_run.py create mode 100644 ddev/tests/testing/__init__.py create mode 100644 ddev/tests/testing/test_hatch.py diff --git a/datadog_checks_dev/CHANGELOG.md b/datadog_checks_dev/CHANGELOG.md index 4dbf6c6b6424f..8c1143299ed83 100644 --- a/datadog_checks_dev/CHANGELOG.md +++ b/datadog_checks_dev/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +***Changed***: + +* Migrate E2E features ([#15931](https://github.com/DataDog/integrations-core/pull/15931)) + ## 26.0.1 / 2023-10-11 ***Fixed***: @@ -36,7 +40,7 @@ * Added overview examples to the readme file ([#15817](https://github.com/DataDog/integrations-core/pull/15817)) * Added required classifier tag examples to template ([#15828](https://github.com/DataDog/integrations-core/pull/15828)) * Prepare E2E tooling for better message passing ([#15843](https://github.com/DataDog/integrations-core/pull/15843)) - + ## 25.0.0 / 2023-09-13 ***Changed***: diff --git a/datadog_checks_dev/datadog_checks/dev/_env.py b/datadog_checks_dev/datadog_checks/dev/_env.py index 87b9c3059a725..1651eb1777a87 100644 --- a/datadog_checks_dev/datadog_checks/dev/_env.py +++ b/datadog_checks_dev/datadog_checks/dev/_env.py @@ -19,7 +19,7 @@ E2E_ENV_VAR_PREFIX = '{}_ENV_'.format(E2E_PREFIX) E2E_SET_UP = '{}_UP'.format(E2E_PREFIX) E2E_TEAR_DOWN = '{}_DOWN'.format(E2E_PREFIX) -E2E_PARENT_PYTHON = '{}_PYTHON_PATH'.format(E2E_PREFIX) +E2E_PARENT_PYTHON = '{}_PARENT_PYTHON'.format(E2E_PREFIX) E2E_RESULT_FILE = '{}_RESULT_FILE'.format(E2E_PREFIX) E2E_FIXTURE_NAME = 'dd_environment' diff --git a/datadog_checks_dev/datadog_checks/dev/plugin/pytest.py b/datadog_checks_dev/datadog_checks/dev/plugin/pytest.py index 1567a3f81e8ab..2345e6013cf57 100644 --- a/datadog_checks_dev/datadog_checks/dev/plugin/pytest.py +++ b/datadog_checks_dev/datadog_checks/dev/plugin/pytest.py @@ -9,10 +9,11 @@ import warnings from base64 import urlsafe_b64encode from collections import namedtuple # Not using dataclasses for Py2 compatibility +from io import open from typing import Dict, List, Optional, Tuple # noqa: F401 import pytest -from six import PY2 +from six import PY2, ensure_text from .._env import ( E2E_FIXTURE_NAME, @@ -104,31 +105,29 @@ def dd_environment_runner(request): if isinstance(config, tuple): config, possible_metadata = config - # Support only defining the env_type for ease-of-use + # Support only defining the agent_type for ease-of-use if isinstance(possible_metadata, str): - metadata['env_type'] = possible_metadata + metadata['agent_type'] = possible_metadata else: metadata.update(possible_metadata) - # Default to Docker as that is the most common - metadata.setdefault('env_type', 'docker') - - # Save any environment variables - metadata.setdefault('env_vars', {}) - metadata['env_vars'].update(get_env_vars(raw=True)) - # Inject any log configuration logs_config = get_state('logs_config', []) if logs_config: config = format_config(config) config['logs'] = logs_config + agent_type = metadata.get('agent_type') + # Mount any volumes for Docker - if metadata['env_type'] == 'docker': + if agent_type == 'docker': docker_volumes = get_state('docker_volumes', []) if docker_volumes: metadata.setdefault('docker_volumes', []).extend(docker_volumes) + # Save any environment variables + metadata['e2e_env_vars'] = get_env_vars(raw=True) + data = {'config': config, 'metadata': metadata} message_template = 'DDEV_E2E_START_MESSAGE {} DDEV_E2E_END_MESSAGE' @@ -139,7 +138,7 @@ def dd_environment_runner(request): # Exit testing and pass data back up to command if E2E_RESULT_FILE in os.environ: with open(os.environ[E2E_RESULT_FILE], 'w', encoding='utf-8') as f: - f.write(json.dumps(data)) + f.write(ensure_text(json.dumps(data))) # Rather than exiting we skip every test to avoid the following output: # !!!!!!!!!! _pytest.outcomes.Exit: !!!!!!!!!! @@ -160,7 +159,8 @@ def pytest_report_teststatus(report, config): """ # Skipping every test displays an `s` for each even when using # the minimum verbosity so we force zero output - return 'skipped', '', '' + if report.skipped: + return 'skipped', '', '' @pytest.fixture @@ -187,8 +187,7 @@ def run_check(config=None, **kwargs): python_path = os.environ[E2E_PARENT_PYTHON] env = os.environ['HATCH_ENV_ACTIVE'] - # TODO: switch to `ddev` when the old CLI is gone - check_command = [python_path, '-m', 'datadog_checks.dev', 'env', 'check', check, env, '--json'] + check_command = [python_path, '-m', 'ddev', 'env', 'agent', check, env, 'check', '--json'] if config: config = format_config(config) @@ -197,7 +196,14 @@ def run_check(config=None, **kwargs): with open(config_file, 'wb') as f: output = json.dumps(config).encode('utf-8') f.write(output) - check_command.extend(['--config', config_file]) + check_command.extend(['--config-file', config_file]) + + # TODO: remove these legacy flags when all usage of this fixture is migrated + if 'rate' in kwargs: + kwargs['check_rate'] = kwargs.pop('rate') + + if 'times' in kwargs: + kwargs['check_times'] = kwargs.pop('times') for key, value in kwargs.items(): if value is not False: diff --git a/datadog_checks_dev/tests/conftest.py b/datadog_checks_dev/tests/conftest.py index dc8102eaf1329..ae092827c3ff5 100644 --- a/datadog_checks_dev/tests/conftest.py +++ b/datadog_checks_dev/tests/conftest.py @@ -11,7 +11,7 @@ def mock_e2e_config(): @pytest.fixture(scope='session') def mock_e2e_metadata(): - return {'env_type': 'vagrant', 'future': 'now', 'env_vars': {}} + return {'agent_type': 'vagrant', 'e2e_env_vars': {}, 'future': 'now', 'env_vars': {}} @pytest.fixture(scope='session') diff --git a/ddev/CHANGELOG.md b/ddev/CHANGELOG.md index 0b00aa3425c9a..a4d192f7e831c 100644 --- a/ddev/CHANGELOG.md +++ b/ddev/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +***Added***: + +* Migrate E2E features ([#15931](https://github.com/DataDog/integrations-core/pull/15931)) + ## 5.1.1 / 2023-09-29 ***Fixed***: diff --git a/ddev/pyproject.toml b/ddev/pyproject.toml index 21d368e9de96d..b3871b3dbe0c2 100644 --- a/ddev/pyproject.toml +++ b/ddev/pyproject.toml @@ -28,7 +28,7 @@ classifiers = [ dependencies = [ "click~=8.1.6", "coverage", - "datadog-checks-dev[cli]~=25.0", + "datadog-checks-dev[cli]>=25.1.2,<26", "hatch>=1.6.3", "httpx", "jsonpointer", diff --git a/ddev/src/ddev/cli/application.py b/ddev/src/ddev/cli/application.py index 8e20095a27922..4293e1ad6a810 100644 --- a/ddev/src/ddev/cli/application.py +++ b/ddev/src/ddev/cli/application.py @@ -4,10 +4,11 @@ from __future__ import annotations import os +from functools import cached_property from typing import cast from ddev.cli.terminal import Terminal -from ddev.config.constants import AppEnvVars, VerbosityLevels +from ddev.config.constants import AppEnvVars, ConfigEnvVars, VerbosityLevels from ddev.config.file import ConfigFile, RootConfig from ddev.repo.core import Repository from ddev.utils.fs import Path @@ -18,7 +19,7 @@ class Application(Terminal): def __init__(self, exit_func, *args, **kwargs): super().__init__(*args, **kwargs) - self.platform = Platform(self.display_raw) + self.platform = Platform(self.output) self.__exit_func = exit_func self.config_file = ConfigFile() @@ -40,6 +41,12 @@ def config(self) -> RootConfig: def repo(self) -> Repository: return self.__repo + @cached_property + def data_dir(self) -> Path: + from platformdirs import user_data_dir + + return Path(os.getenv(ConfigEnvVars.DATA) or user_data_dir('ddev', appauthor=False)).expand() + @property def github(self) -> GitHubManager: return self.__github diff --git a/ddev/src/ddev/cli/config/__init__.py b/ddev/src/ddev/cli/config/__init__.py index 2426ed6dd94eb..28b854862802e 100644 --- a/ddev/src/ddev/cli/config/__init__.py +++ b/ddev/src/ddev/cli/config/__init__.py @@ -43,7 +43,7 @@ def show(app, all_keys): from rich.syntax import Syntax text = app.config_file.read() if all_keys else app.config_file.read_scrubbed() - app.display_raw(Syntax(text.rstrip(), 'toml', background_color='default')) + app.output(Syntax(text.rstrip(), 'toml', background_color='default')) @config.command(short_help='Update the config file with any new fields') @@ -146,4 +146,4 @@ def set_value(app, key, value): from rich.syntax import Syntax app.display_success('New setting:') - app.display_raw(Syntax(rendered_changed, 'toml', background_color='default')) + app.output(Syntax(rendered_changed, 'toml', background_color='default')) diff --git a/ddev/src/ddev/cli/env/__init__.py b/ddev/src/ddev/cli/env/__init__.py index b2858cdb14ff4..6f38d7bf9730c 100644 --- a/ddev/src/ddev/cli/env/__init__.py +++ b/ddev/src/ddev/cli/env/__init__.py @@ -2,15 +2,16 @@ # All rights reserved # Licensed under a 3-clause BSD style license (see LICENSE) import click -from datadog_checks.dev.tooling.commands.env.check import check_run -from datadog_checks.dev.tooling.commands.env.edit import edit -from datadog_checks.dev.tooling.commands.env.ls import ls -from datadog_checks.dev.tooling.commands.env.prune import prune -from datadog_checks.dev.tooling.commands.env.reload import reload_env -from datadog_checks.dev.tooling.commands.env.shell import shell -from datadog_checks.dev.tooling.commands.env.start import start -from datadog_checks.dev.tooling.commands.env.stop import stop -from datadog_checks.dev.tooling.commands.env.test import test + +from ddev.cli.env.agent import agent +from ddev.cli.env.check import check +from ddev.cli.env.config import config +from ddev.cli.env.reload import reload_command +from ddev.cli.env.shell import shell +from ddev.cli.env.show import show +from ddev.cli.env.start import start +from ddev.cli.env.stop import stop +from ddev.cli.env.test import test_command @click.group(short_help='Manage environments') @@ -20,12 +21,12 @@ def env(): """ -env.add_command(check_run) -env.add_command(edit) -env.add_command(ls) -env.add_command(prune) -env.add_command(reload_env) +env.add_command(agent) +env.add_command(check) +env.add_command(config) +env.add_command(reload_command) env.add_command(shell) +env.add_command(show) env.add_command(start) env.add_command(stop) -env.add_command(test) +env.add_command(test_command) diff --git a/ddev/src/ddev/cli/env/agent.py b/ddev/src/ddev/cli/env/agent.py new file mode 100644 index 0000000000000..f7614defc8dfc --- /dev/null +++ b/ddev/src/ddev/cli/env/agent.py @@ -0,0 +1,80 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command( + short_help='Invoke the Agent', context_settings={'help_option_names': [], 'ignore_unknown_options': True} +) +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.argument('args', required=True, nargs=-1) +@click.option('--config-file', hidden=True) +@click.pass_obj +def agent(app: Application, *, intg_name: str, environment: str, args: tuple[str, ...], config_file: str | None): + """ + Invoke the Agent. + """ + import subprocess + + from ddev.e2e.agent import get_agent_interface + from ddev.e2e.config import EnvDataStorage + from ddev.e2e.constants import DEFAULT_AGENT_TYPE + from ddev.utils.fs import Path + + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + + metadata = env_data.read_metadata() + agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) + + full_args = list(args) + trigger_run = False + if full_args[0] == 'check': + trigger_run = True + + # TODO: remove this when all invocations migrate to the actual command + if len(full_args) > 2 and full_args[1] == '--jmx-list': + full_args = ['jmx', 'list', full_args[2]] + # Automatically inject the integration name if not passed + elif len(full_args) == 1 or full_args[1].startswith('-'): + full_args.insert(1, intg_name) + + if config_file is None or not trigger_run: + try: + agent.invoke(full_args) + except subprocess.CalledProcessError as e: + app.abort(code=e.returncode) + + return + + import json + + config = json.loads(Path(config_file).read_text()) + + if not env_data.config_file.is_file(): + try: + env_data.write_config(config) + agent.invoke(full_args) + finally: + env_data.config_file.unlink() + else: + temp_config_file = env_data.config_file.parent / f'{env_data.config_file.name}.bak.example' + env_data.config_file.replace(temp_config_file) + try: + env_data.write_config(config) + agent.invoke(full_args) + finally: + temp_config_file.replace(env_data.config_file) diff --git a/ddev/src/ddev/cli/env/check.py b/ddev/src/ddev/cli/env/check.py new file mode 100644 index 0000000000000..57e032f5ba757 --- /dev/null +++ b/ddev/src/ddev/cli/env/check.py @@ -0,0 +1,18 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import click + + +@click.command(context_settings={'ignore_unknown_options': True}, hidden=True) +@click.argument('intg_name') +@click.argument('environment') +@click.argument('args', nargs=-1) +@click.option('--config') +@click.pass_context +def check(ctx: click.Context, *, intg_name: str, environment: str, args: tuple[str, ...], config: str | None): + from ddev.cli.env.agent import agent + + ctx.invoke(agent, intg_name=intg_name, environment=environment, args=('check', *args), config_file=config) diff --git a/ddev/src/ddev/cli/env/config.py b/ddev/src/ddev/cli/env/config.py new file mode 100644 index 0000000000000..d104bfc1d0911 --- /dev/null +++ b/ddev/src/ddev/cli/env/config.py @@ -0,0 +1,95 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.group(short_help='Manage the config file') +def config(): + pass + + +@config.command(short_help='Edit the config file with your default editor') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.pass_obj +def edit(app: Application, *, intg_name: str, environment: str): + """ + Edit the config file with your default editor. + """ + from ddev.e2e.config import EnvDataStorage + + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + + click.edit(filename=str(env_data.config_file)) + + +@config.command(short_help='Open the config location in your file manager') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.pass_obj +def explore(app: Application, *, intg_name: str, environment: str): + """ + Open the config location in your file manager. + """ + from ddev.e2e.config import EnvDataStorage + + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + + click.launch(str(env_data.config_file), locate=True) + + +@config.command(short_help='Show the location of the config file') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.pass_obj +def find(app: Application, *, intg_name: str, environment: str): + """ + Show the location of the config file. + """ + from ddev.e2e.config import EnvDataStorage + + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + + app.output(f'[link={env_data.config_file}]{env_data.config_file}[/]') + + +@config.command(short_help='Show the contents of the config file') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.pass_obj +def show(app: Application, *, intg_name: str, environment: str): + """ + Show the contents of the config file. + """ + from ddev.e2e.config import EnvDataStorage + + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + + from rich.syntax import Syntax + + text = env_data.config_file.read_text().rstrip() + app.output(Syntax(text, 'yaml', background_color='default')) diff --git a/ddev/src/ddev/cli/env/reload.py b/ddev/src/ddev/cli/env/reload.py new file mode 100644 index 0000000000000..ffb336161f2eb --- /dev/null +++ b/ddev/src/ddev/cli/env/reload.py @@ -0,0 +1,39 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command('reload', short_help='Restart the Agent to detect environment changes') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.pass_obj +def reload_command(app: Application, *, intg_name: str, environment: str): + """ + Restart the Agent to detect environment changes. + """ + from ddev.e2e.agent import get_agent_interface + from ddev.e2e.config import EnvDataStorage + from ddev.e2e.constants import DEFAULT_AGENT_TYPE + + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + + metadata = env_data.read_metadata() + agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) + + try: + agent.restart() + except Exception as e: + app.abort(str(e)) diff --git a/ddev/src/ddev/cli/env/shell.py b/ddev/src/ddev/cli/env/shell.py new file mode 100644 index 0000000000000..7d92aadc43300 --- /dev/null +++ b/ddev/src/ddev/cli/env/shell.py @@ -0,0 +1,41 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command('shell', short_help='Enter a shell alongside the Agent') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.pass_obj +def shell(app: Application, *, intg_name: str, environment: str): + """ + Enter a shell alongside the Agent. + """ + import subprocess + + from ddev.e2e.agent import get_agent_interface + from ddev.e2e.config import EnvDataStorage + from ddev.e2e.constants import DEFAULT_AGENT_TYPE + + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + + metadata = env_data.read_metadata() + agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) + + try: + agent.enter_shell() + except subprocess.CalledProcessError as e: + app.abort(code=e.returncode) diff --git a/ddev/src/ddev/cli/env/show.py b/ddev/src/ddev/cli/env/show.py new file mode 100644 index 0000000000000..4b8e3bb9b405d --- /dev/null +++ b/ddev/src/ddev/cli/env/show.py @@ -0,0 +1,88 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command('show', short_help='Show active or available environments') +@click.argument('intg_name', required=False, metavar='INTEGRATION') +@click.argument('environment', required=False) +@click.option('--ascii', 'force_ascii', is_flag=True, help='Whether or not to only use ASCII characters') +@click.pass_obj +def show(app: Application, *, intg_name: str | None, environment: str | None, force_ascii: bool): + """ + Show active or available environments. + """ + from ddev.e2e.config import EnvDataStorage + from ddev.e2e.constants import DEFAULT_AGENT_TYPE + + storage = EnvDataStorage(app.data_dir) + + # Display all active environments + if not intg_name: + for active_integration in storage.get_integrations(): + integration = app.repo.integrations.get(active_integration) + envs = storage.get_environments(integration.name) + + columns: dict[str, dict[int, str]] = {'Name': {}, 'Agent type': {}} + for i, name in enumerate(envs): + env_data = storage.get(integration.name, name) + metadata = env_data.read_metadata() + + columns['Name'][i] = name + columns['Agent type'][i] = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + + app.display_table(active_integration, columns, show_lines=True, force_ascii=force_ascii) + # Display active and available environments for a specific integration + elif not environment: + import json + import sys + + integration = app.repo.integrations.get(intg_name) + active_envs = storage.get_environments(integration.name) + + active_columns: dict[str, dict[int, str]] = {'Name': {}, 'Agent type': {}} + for i, name in enumerate(active_envs): + env_data = storage.get(integration.name, name) + metadata = env_data.read_metadata() + + active_columns['Name'][i] = name + active_columns['Agent type'][i] = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + + with integration.path.as_cwd(): + environments = json.loads( + app.platform.check_command_output([sys.executable, '-m', 'hatch', 'env', 'show', '--json']) + ) + + available_columns: dict[str, dict[int, str]] = {'Name': {}} + for i, (name, data) in enumerate(environments.items()): + if not data.get('e2e-env') or name in active_envs: + continue + + available_columns['Name'][i] = name + + app.display_table('Active', active_columns, show_lines=True, force_ascii=force_ascii) + app.display_table('Available', available_columns, show_lines=True, force_ascii=force_ascii) + # Display information about a specific environment + else: + from ddev.e2e.agent import get_agent_interface + + integration = app.repo.integrations.get(intg_name) + env_data = storage.get(integration.name, environment) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + + metadata = env_data.read_metadata() + agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) + + app.display_pair('Agent type', agent_type) + app.display_pair('Agent ID', agent.get_id()) diff --git a/ddev/src/ddev/cli/env/start.py b/ddev/src/ddev/cli/env/start.py new file mode 100644 index 0000000000000..56b16aef7c13d --- /dev/null +++ b/ddev/src/ddev/cli/env/start.py @@ -0,0 +1,194 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command(short_help='Start an environment') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.option('--dev', 'local_dev', is_flag=True, help='Install the local version of the integration') +@click.option( + '--base', + 'local_base', + is_flag=True, + help='Install the local version of the base package, implicitly enabling the `--dev` option', +) +@click.option( + '--agent', + '-a', + 'agent_build', + help=( + 'The Agent build to use e.g. a Docker image like `datadog/agent:latest`. You can ' + 'also use the name of an Agent defined in the `agents` configuration section.' + ), +) +@click.option( + '-e', + 'extra_env_vars', + multiple=True, + help='Environment variables to pass to the Agent e.g. -e DD_URL=app.datadoghq.com -e DD_API_KEY=foobar', +) +@click.option('--dogstatsd', is_flag=True, hidden=True) +@click.option('--hide-help', is_flag=True, hidden=True) +@click.option('--ignore-state', is_flag=True, hidden=True) +@click.pass_context +def start( + ctx: click.Context, + *, + intg_name: str, + environment: str, + local_dev: bool, + local_base: bool, + agent_build: str | None, + extra_env_vars: tuple[str, ...], + dogstatsd: bool, + hide_help: bool, + ignore_state: bool, +): + """ + Start an environment. + """ + import json + import os + from contextlib import suppress + + from ddev.e2e.agent import get_agent_interface + from ddev.e2e.config import EnvDataStorage + from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EEnvVars + from ddev.e2e.run import E2EEnvironmentRunner + from ddev.utils.fs import Path, temp_directory + + app: Application = ctx.obj + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + runner = E2EEnvironmentRunner(environment, app.verbosity) + + # Do as much error checking as possible before attempting to start the environment + if env_data.exists(): + if ignore_state: + return + + app.abort(f'Environment `{environment}` for integration `{integration.name}` is already running') + + local_packages: dict[Path, str] = {} + if local_base: + features = '[db,deps,http,json,kube]' + if (base_package_path := (app.repo.path / 'datadog_checks_base')).is_dir(): + local_packages[base_package_path] = features + else: + for repo_name, repo_path in app.config.repos.items(): + if repo_name == app.repo.name: + continue + elif (base_package_path := (Path(repo_path).expand() / 'datadog_checks_base')).is_dir(): + local_packages[base_package_path] = features + break + else: + app.abort('Unable to find a local version of the base package') + + # When using multiple namespaced packages it is required that they are all + # installed in the same way (normal versus editable) + local_dev = True + + # Install the integration after the base package for the following reasons: + # 1. We want the dependencies of the integration in question to take precedence + # 2. The rare, ephemeral situation where a new version of the base package is + # required but is not yet released + if local_dev: + local_packages[integration.path] = '[deps]' + + app.display_header(f'Starting: {environment}') + + with temp_directory() as temp_dir: + result_file = temp_dir / 'result.json' + env_vars = {E2EEnvVars.RESULT_FILE: str(result_file)} + + with integration.path.as_cwd(env_vars=env_vars), runner.start() as command: + process = app.platform.run_command(command) + if process.returncode: + app.abort(code=process.returncode) + + if not result_file.is_file(): # no cov + app.abort(f'No E2E result file found: {result_file}') + + result = json.loads(result_file.read_text()) + + metadata = result['metadata'] + env_data.write_metadata(metadata) + + config = result['config'] + env_data.write_config(config) + + agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) + + if not agent_build: + agent_build = ( + os.getenv(E2EEnvVars.AGENT_BUILD_PY2 if agent.python_version[0] == 2 else E2EEnvVars.AGENT_BUILD) + or app.config.agent.config.get(agent_type) + or '' + ) + + agent_env_vars = _get_agent_env_vars(app.config.org.config, metadata, extra_env_vars, dogstatsd) + + try: + agent.start(agent_build=agent_build, local_packages=local_packages, env_vars=agent_env_vars) + except Exception as e: + from ddev.cli.env.stop import stop + + app.display_critical(f'Unable to start the Agent: {e}') + with suppress(Exception): + ctx.invoke(stop, intg_name=intg_name, environment=environment) + + app.abort() + + if not hide_help: + app.output() + app.display_pair('Stop environment', app.style_info(f'ddev env stop {intg_name} {environment}')) + app.display_pair('Execute tests', app.style_info(f'ddev env test {intg_name} {environment}')) + app.display_pair('Check status', app.style_info(f'ddev env agent {intg_name} {environment} status')) + app.display_pair('Trigger run', app.style_info(f'ddev env agent {intg_name} {environment} check')) + app.display_pair('Reload config', app.style_info(f'ddev env reload {intg_name} {environment}')) + app.display_pair('Manage config', app.style_info('ddev env config')) + app.display_pair('Config file', f'[link={env_data.config_file}]{env_data.config_file}[/]') + + +def _get_agent_env_vars(org_config, metadata, extra_env_vars, dogstatsd): + from ddev.e2e.constants import DEFAULT_DOGSTATSD_PORT, E2EEnvVars + + # Use the environment variables defined by tests as defaults so tooling can override them + env_vars: dict[str, str] = metadata.get('env_vars', {}).copy() + env_vars.update(ev.split('=', maxsplit=1) for ev in extra_env_vars) + + if api_key := org_config.get('api_key'): + env_vars['DD_API_KEY'] = api_key + + if site := org_config.get('site'): + env_vars['DD_SITE'] = site + + # Custom core Agent intake + if dd_url := org_config.get('dd_url'): + env_vars['DD_DD_URL'] = dd_url + + # Custom logs Agent intake + if log_url := org_config.get('log_url'): + env_vars['DD_LOGS_CONFIG_DD_URL'] = log_url + + # TODO: remove the CLI flag and exclusively rely on the metadata flag + if metadata.get('dogstatsd') or dogstatsd: + env_vars['DD_DOGSTATSD_PORT'] = str(DEFAULT_DOGSTATSD_PORT) + env_vars['DD_DOGSTATSD_NON_LOCAL_TRAFFIC'] = 'true' + env_vars['DD_DOGSTATSD_METRICS_STATS_ENABLE'] = 'true' + + # Enable logs Agent by default if the environment is mounting logs + if any(ev.startswith(E2EEnvVars.LOGS_DIR_PREFIX) for ev in metadata.get('e2e_env_vars', {})): + env_vars.setdefault('DD_LOGS_ENABLED', 'true') + + return env_vars diff --git a/ddev/src/ddev/cli/env/stop.py b/ddev/src/ddev/cli/env/stop.py new file mode 100644 index 0000000000000..7a3e6e417fa92 --- /dev/null +++ b/ddev/src/ddev/cli/env/stop.py @@ -0,0 +1,59 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command(short_help='Stop an environment') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment') +@click.option('--ignore-state', is_flag=True, hidden=True) +@click.pass_obj +def stop(app: Application, *, intg_name: str, environment: str, ignore_state: bool): + """ + Stop an environment. + """ + from ddev.e2e.agent import get_agent_interface + from ddev.e2e.config import EnvDataStorage + from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EEnvVars + from ddev.e2e.run import E2EEnvironmentRunner + from ddev.utils.fs import temp_directory + + integration = app.repo.integrations.get(intg_name) + env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) + runner = E2EEnvironmentRunner(environment, app.verbosity) + + if not env_data.exists(): + app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') + elif ignore_state: + return + + app.display_header(f'Stopping: {environment}') + + # TODO: remove this required result file indicator once the E2E migration is complete + with temp_directory() as temp_dir: + result_file = temp_dir / 'result.json' + env_vars = {E2EEnvVars.RESULT_FILE: str(result_file)} + + metadata = env_data.read_metadata() + env_vars.update(metadata.get('e2e_env_vars', {})) + + agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) + + try: + agent.stop() + finally: + env_data.remove() + + with integration.path.as_cwd(env_vars=env_vars), runner.stop() as command: + process = app.platform.run_command(command) + if process.returncode: + app.abort(code=process.returncode) diff --git a/ddev/src/ddev/cli/env/test.py b/ddev/src/ddev/cli/env/test.py new file mode 100644 index 0000000000000..b5d4da028026d --- /dev/null +++ b/ddev/src/ddev/cli/env/test.py @@ -0,0 +1,136 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command('test', short_help='Test environments') +@click.argument('intg_name', metavar='INTEGRATION') +@click.argument('environment', required=False) +@click.argument('args', nargs=-1) +@click.option('--dev', 'local_dev', is_flag=True, help='Install the local version of the integration') +@click.option( + '--base', + 'local_base', + is_flag=True, + help='Install the local version of the base package, implicitly enabling the `--dev` option', +) +@click.option( + '--agent', + '-a', + 'agent_build', + help=( + 'The Agent build to use e.g. a Docker image like `datadog/agent:latest`. You can ' + 'also use the name of an Agent defined in the `agents` configuration section.' + ), +) +@click.option( + '-e', + 'extra_env_vars', + multiple=True, + help='Environment variables to pass to the Agent e.g. -e DD_URL=app.datadoghq.com -e DD_API_KEY=foobar', +) +@click.option('--junit', is_flag=True, hidden=True) +@click.option('--python-filter', envvar='PYTHON_FILTER', hidden=True) +@click.option('--new-env', is_flag=True, hidden=True) +@click.pass_context +def test_command( + ctx: click.Context, + *, + intg_name: str, + environment: str | None, + args: tuple[str, ...], + local_dev: bool, + local_base: bool, + agent_build: str | None, + extra_env_vars: tuple[str, ...], + junit: bool, + python_filter: str | None, + new_env: bool, +): + """ + Test environments. + + If no environment is specified, `active` is selected which will test all environments + that are currently running. You may choose `all` to test all environments whether or not + they are running. + + Testing active environments will not stop them after tests complete. Testing environments + that are not running will start and stop them automatically. + """ + from ddev.cli.env.start import start + from ddev.cli.env.stop import stop + from ddev.cli.test import test + from ddev.e2e.config import EnvDataStorage + from ddev.utils.ci import running_in_ci + from ddev.utils.structures import EnvVars + + app: Application = ctx.obj + storage = EnvDataStorage(app.data_dir) + integration = app.repo.integrations.get(intg_name) + active_envs = storage.get_environments(integration.name) + + if environment is None: + environment = 'all' if (not active_envs or running_in_ci()) else 'active' + + if environment == 'all': + import json + import sys + + with integration.path.as_cwd(): + env_data_output = app.platform.check_command_output( + [sys.executable, '-m', 'hatch', '--no-color', '--no-interactive', 'env', 'show', '--json'] + ) + try: + environments = json.loads(env_data_output) + except json.JSONDecodeError: + app.abort(f'Failed to parse environments for `{integration.name}`:\n{repr(env_data_output)}') + + env_names = [ + name + for name, data in environments.items() + if data.get('e2e-env') + and (not (platforms := data.get('python')) or app.platform.name in platforms) + and (python_filter is None or data.get('python') == python_filter) + ] + elif environment == 'active': + env_names = active_envs + else: + env_names = [environment] + + if not env_names: + return + + app.display_header(integration.display_name) + + active = set(active_envs) + for env_name in env_names: + env_active = env_name in active + ctx.invoke( + start, + intg_name=intg_name, + environment=env_name, + local_dev=local_dev, + local_base=local_base, + agent_build=agent_build, + extra_env_vars=extra_env_vars, + hide_help=True, + ignore_state=env_active, + ) + + env_data = storage.get(integration.name, env_name) + metadata = env_data.read_metadata() + try: + with EnvVars(metadata.get('e2e_env_vars', {})): + ctx.invoke( + test, target_spec=f'{intg_name}:{env_name}', args=args, junit=junit, hide_header=True, e2e=True + ) + finally: + ctx.invoke(stop, intg_name=intg_name, environment=env_name, ignore_state=env_active) diff --git a/ddev/src/ddev/cli/terminal.py b/ddev/src/ddev/cli/terminal.py index 20feaa064df6a..bc0b8557fb634 100644 --- a/ddev/src/ddev/cli/terminal.py +++ b/ddev/src/ddev/cli/terminal.py @@ -134,6 +134,7 @@ def __init__(self, verbosity: int, enable_color: bool, interactive: bool): self.console = Console( force_terminal=enable_color, no_color=enable_color is False, + highlight=False, # Force consistent output for test assertions legacy_windows=False if 'DDEV_SELF_TESTING' in os.environ else None, ) @@ -150,6 +151,28 @@ def __init__(self, verbosity: int, enable_color: bool, interactive: bool): # Chosen as the default since it's compatible everywhere and looks nice self._style_spinner = 'simpleDotsScrolling' + @cached_property + def kv_separator(self) -> Style: + return self.style_warning('->') + + def style_success(self, text: str) -> Text: + return Text(text, style=self._style_level_success) + + def style_error(self, text: str) -> Text: + return Text(text, style=self._style_level_error) + + def style_warning(self, text: str) -> Text: + return Text(text, style=self._style_level_warning) + + def style_waiting(self, text: str) -> Text: + return Text(text, style=self._style_level_waiting) + + def style_info(self, text: str) -> Text: + return Text(text, style=self._style_level_info) + + def style_debug(self, text: str) -> Text: + return Text(text, style=self._style_level_debug) + def initialize_styles(self, styles: dict): # no cov # Lazily display errors so that they use the correct style errors = [] @@ -187,37 +210,37 @@ def display_error(self, text='', stderr=True, indent=None, link=None, **kwargs): if self.verbosity < VerbosityLevels.ERROR: return - self.output(text, self._style_level_error, stderr=stderr, indent=indent, link=link, **kwargs) + self._output(text, self._style_level_error, stderr=stderr, indent=indent, link=link, **kwargs) def display_warning(self, text='', stderr=True, indent=None, link=None, **kwargs): if self.verbosity < VerbosityLevels.WARNING: return - self.output(text, self._style_level_warning, stderr=stderr, indent=indent, link=link, **kwargs) + self._output(text, self._style_level_warning, stderr=stderr, indent=indent, link=link, **kwargs) def display_info(self, text='', stderr=True, indent=None, link=None, **kwargs): if self.verbosity < VerbosityLevels.INFO: return - self.output(text, self._style_level_info, stderr=stderr, indent=indent, link=link, **kwargs) + self._output(text, self._style_level_info, stderr=stderr, indent=indent, link=link, **kwargs) def display_success(self, text='', stderr=True, indent=None, link=None, **kwargs): if self.verbosity < VerbosityLevels.INFO: return - self.output(text, self._style_level_success, stderr=stderr, indent=indent, link=link, **kwargs) + self._output(text, self._style_level_success, stderr=stderr, indent=indent, link=link, **kwargs) def display_waiting(self, text='', stderr=True, indent=None, link=None, **kwargs): if self.verbosity < VerbosityLevels.INFO: return - self.output(text, self._style_level_waiting, stderr=stderr, indent=indent, link=link, **kwargs) + self._output(text, self._style_level_waiting, stderr=stderr, indent=indent, link=link, **kwargs) def display_debug(self, text='', stderr=True, indent=None, link=None, **kwargs): if self.verbosity < VerbosityLevels.DEBUG: return - self.output(f'DEBUG: {text}', None, stderr=stderr, indent=indent, link=link, **kwargs) + self._output(f'DEBUG: {text}', None, stderr=stderr, indent=indent, link=link, **kwargs) def display_header(self, title=''): self.console.rule(Text(title, self._style_level_success)) @@ -225,7 +248,45 @@ def display_header(self, title=''): def display_markdown(self, text, stderr=False, **kwargs): from rich.markdown import Markdown - self.display_raw(Markdown(text), stderr=stderr, **kwargs) + self.output(Markdown(text), stderr=stderr, **kwargs) + + def display_pair(self, key, value): + self.output(self.style_success(key), self.kv_separator, value) + + def display_table(self, title, columns, *, show_lines=False, column_options=None, force_ascii=False, num_rows=0): + from rich.table import Table + + if column_options is None: + column_options = {} + + table_options = {} + if force_ascii: + from rich.box import ASCII_DOUBLE_HEAD + + table_options['box'] = ASCII_DOUBLE_HEAD + table_options['safe_box'] = True + + table = Table(title=title, show_lines=show_lines, title_style='', **table_options) + columns = dict(columns) + + for title, indices in list(columns.items()): + if indices: + table.add_column(title, style='bold', **column_options.get(title, {})) + else: + columns.pop(title) + + if not columns: + return + + for i in range(num_rows or max(map(max, columns.values())) + 1): + row = [] + for indices in columns.values(): + row.append(indices.get(i, '')) + + if any(row): + table.add_row(*row) + + self.output(table) def create_validation_tracker(self, label: str): from rich.tree import Tree @@ -253,30 +314,29 @@ def status(self): finalizer=lambda: setattr(self.platform, 'displaying_status', False), ) - def output(self, text='', style=None, *, stderr=False, indent=None, link=None, **kwargs): - kwargs.setdefault('overflow', 'ignore') - kwargs.setdefault('no_wrap', True) - kwargs.setdefault('crop', False) - + def _output(self, text='', style=None, *, stderr=False, indent=None, link=None, **kwargs): if indent: text = indent_text(text, indent) if link: style = style.update_link(self.platform.format_file_uri(link)) + self.output(text, stderr=stderr, style=style, **kwargs) + + def output(self, *args, stderr=False, **kwargs): + kwargs.setdefault('overflow', 'ignore') + kwargs.setdefault('no_wrap', True) + kwargs.setdefault('crop', False) + if not stderr: - self.console.print(text, style=style, **kwargs) + self.console.print(*args, **kwargs) else: self.console.stderr = True try: - self.console.print(text, style=style, **kwargs) + self.console.print(*args, **kwargs) finally: self.console.stderr = False - def display_raw(self, text, **kwargs): - # No styling - self.console.print(text, overflow='ignore', no_wrap=True, crop=False, **kwargs) - @staticmethod def prompt(text, **kwargs): return click.prompt(text, **kwargs) diff --git a/ddev/src/ddev/cli/test/__init__.py b/ddev/src/ddev/cli/test/__init__.py index f0c02c5a5e3bc..4945db12e61d4 100644 --- a/ddev/src/ddev/cli/test/__init__.py +++ b/ddev/src/ddev/cli/test/__init__.py @@ -38,6 +38,7 @@ def fix_coverage_report(report_file: Path): @click.option('--list', '-l', 'list_envs', is_flag=True, help='Show available test environments') @click.option('--python-filter', envvar='PYTHON_FILTER', hidden=True) @click.option('--junit', is_flag=True, hidden=True) +@click.option('--hide-header', is_flag=True, hidden=True) @click.option('--e2e', is_flag=True, hidden=True) @click.pass_obj def test( @@ -56,6 +57,7 @@ def test( list_envs: bool, python_filter: str | None, junit: bool, + hide_header: bool, e2e: bool, ): """ @@ -67,6 +69,7 @@ def test( from ddev.repo.constants import PYTHON_VERSION from ddev.testing.constants import EndToEndEnvVars, TestEnvVars + from ddev.testing.hatch import get_hatch_env_vars from ddev.utils.ci import running_in_ci if target_spec is None: @@ -110,13 +113,10 @@ def test( if compat: recreate = True - global_env_vars: dict[str, str] = {} + global_env_vars: dict[str, str] = get_hatch_env_vars(verbosity=app.verbosity + 1) - hatch_verbosity = app.verbosity + 1 - if hatch_verbosity > 0: - global_env_vars['HATCH_VERBOSE'] = str(hatch_verbosity) - elif hatch_verbosity < 0: - global_env_vars['HATCH_QUIET'] = str(abs(hatch_verbosity)) + # Disable unnecessary output from Docker + global_env_vars['DOCKER_CLI_HINTS'] = 'false' api_key = app.config.org.config.get('api_key') if api_key and not (lint or fmt): @@ -173,7 +173,8 @@ def test( app.display_debug(f'Targets: {", ".join(targets)}') for target in targets.values(): - app.display_header(target.display_name) + if not hide_header: + app.display_header(target.display_name) command = base_command.copy() env_vars = global_env_vars.copy() diff --git a/ddev/src/ddev/config/constants.py b/ddev/src/ddev/config/constants.py index 556bede831fcb..f6cabe6d57a53 100644 --- a/ddev/src/ddev/config/constants.py +++ b/ddev/src/ddev/config/constants.py @@ -12,6 +12,8 @@ class AppEnvVars: class ConfigEnvVars: + DATA = 'DDEV_DATA_DIR' + CACHE = 'DDEV_CACHE_DIR' CONFIG = 'DDEV_CONFIG' diff --git a/ddev/src/ddev/e2e/__init__.py b/ddev/src/ddev/e2e/__init__.py new file mode 100644 index 0000000000000..e0cc3d0a7662c --- /dev/null +++ b/ddev/src/ddev/e2e/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) diff --git a/ddev/src/ddev/e2e/agent/__init__.py b/ddev/src/ddev/e2e/agent/__init__.py new file mode 100644 index 0000000000000..531b78f4b6347 --- /dev/null +++ b/ddev/src/ddev/e2e/agent/__init__.py @@ -0,0 +1,18 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ddev.e2e.agent.interface import AgentInterface + + +def get_agent_interface(agent_type: str) -> type[AgentInterface]: + if agent_type == 'docker': + from ddev.e2e.agent.docker import DockerAgent + + return DockerAgent + + raise NotImplementedError(f'Unsupported Agent type: {agent_type}') diff --git a/ddev/src/ddev/e2e/agent/docker.py b/ddev/src/ddev/e2e/agent/docker.py new file mode 100644 index 0000000000000..5c72bfc38f1c6 --- /dev/null +++ b/ddev/src/ddev/e2e/agent/docker.py @@ -0,0 +1,303 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import os +import sys +from functools import cache, cached_property +from typing import TYPE_CHECKING, Callable + +from ddev.e2e.agent.interface import AgentInterface +from ddev.utils.structures import EnvVars + +if TYPE_CHECKING: + import subprocess + + from ddev.utils.fs import Path + + +class DockerAgent(AgentInterface): + @cached_property + def _isatty(self) -> bool: + isatty: Callable[[], bool] | None = getattr(sys.stdout, 'isatty', None) + if isatty is not None: + try: + return isatty() + except ValueError: + pass + + return False + + @cached_property + def _container_name(self) -> str: + return f'dd_{super().get_id()}' + + @cached_property + def _is_windows_container(self) -> bool: + return self.metadata.get('docker_platform') == 'windows' + + @cached_property + def _package_mount_dir(self) -> str: + return 'C:\\Users\\ContainerAdministrator\\' if self._is_windows_container else '/home/' + + @cached_property + def _config_mount_dir(self) -> str: + return ( + f'C:\\ProgramData\\Datadog\\conf.d\\{self.integration.name}.d' + if self._is_windows_container + else f'/etc/datadog-agent/conf.d/{self.integration.name}.d' + ) + + @cached_property + def _python_path(self) -> str: + return ( + f'C:\\Program Files\\Datadog\\Datadog Agent\\embedded{self.python_version[0]}\\python.exe' + if self._is_windows_container + else f'/opt/datadog-agent/embedded/bin/python{self.python_version[0]}' + ) + + def _format_command(self, command: list[str]) -> list[str]: + cmd = ['docker', 'exec'] + if self._isatty: + cmd.append('-it') + cmd.append(self._container_name) + + if command[0] == 'pip': + command = command[1:] + cmd.extend([self._python_path, '-m', 'pip']) + + cmd.extend(command) + return cmd + + def _captured_process(self, command: list[str]) -> subprocess.CompletedProcess: + return self._run_command( + command, stdout=self.platform.modules.subprocess.PIPE, stderr=self.platform.modules.subprocess.STDOUT + ) + + def _run_command(self, command: list[str], **kwargs) -> subprocess.CompletedProcess: + with EnvVars({'DOCKER_CLI_HINTS': 'false'}): + return self.platform.run_command(command, **kwargs) + + def get_id(self) -> str: + return self._container_name + + def start(self, *, agent_build: str, local_packages: dict[Path, str], env_vars: dict[str, str]) -> None: + if not agent_build: + agent_build = 'datadog/agent-dev:master' + + # Add a potentially missing `py` suffix for default non-RC builds + if ( + 'rc' not in agent_build + and 'py' not in agent_build + and agent_build != 'datadog/agent:6' + and agent_build != 'datadog/agent:7' + ): + agent_build = f'{agent_build}-py{self.python_version[0]}' + + if self.metadata.get('use_jmx') and not agent_build.endswith('-jmx'): + agent_build += '-jmx' + + env_vars = env_vars.copy() + + # Containerized agents require an API key to start + if 'DD_API_KEY' not in env_vars: + # This fake key must be the proper length + env_vars['DD_API_KEY'] = 'a' * 32 + + # Set Agent hostname for CI + env_vars['DD_HOSTNAME'] = _get_hostname() + + # Run API on a random free port + env_vars['DD_CMD_PORT'] = str(_find_free_port()) + + # Disable trace Agent + env_vars['DD_APM_ENABLED'] = 'false' + + # Set up telemetry + env_vars['DD_TELEMETRY_ENABLED'] = '1' + env_vars['DD_EXPVAR_PORT'] = '5000' + + # TODO: Remove this when Python 2 support is removed + # + # Don't write .pyc, needed to fix this issue (only Python 2): + # More info: https://github.com/DataDog/integrations-core/pull/5454 + # When reinstalling a package, .pyc are not cleaned correctly. The issue is fixed by not writing them + # in the first place. + env_vars['PYTHONDONTWRITEBYTECODE'] = '1' + + if (proxy_data := self.metadata.get('proxy')) is not None: + if (http_proxy := proxy_data.get('http')) is not None: + env_vars['DD_PROXY_HTTP'] = http_proxy + if (https_proxy := proxy_data.get('https')) is not None: + env_vars['DD_PROXY_HTTPS'] = https_proxy + + volumes = [] + + if not self._is_windows_container: + volumes.append('/proc:/host/proc') + + # Only mount the volume if the initial configuration is not set to `None`. + # As an example, the way SNMP does autodiscovery is that the Agent writes what its listener detects + # in `auto_conf.yaml`. The issue is we mount the entire config directory so changes are always in + # sync and, unlike other integrations that support autodiscovery, that file doesn't already exist. + # For that setup it seems the Agent cannot write to a file that does not already exist inside a + # directory that is mounted. + if self.config_file.is_file(): + volumes.append(f'{self.config_file.parent}:{self._config_mount_dir}') + + # It is safe to assume that the directory name is unique across all repos + for local_package in local_packages: + volumes.append(f'{local_package}:{self._package_mount_dir}{local_package.name}') + + volumes.extend(self.metadata.get('docker_volumes', [])) + + if self.platform.windows and not self._is_windows_container: + for i, volume in enumerate(volumes): + parts = volume.split(':') + possible_file = ':'.join(parts[:2]) + if os.path.isfile(possible_file): + # Workaround for https://github.com/moby/moby/issues/30555 + vm_file = possible_file.replace(':', '', 1).replace('\\', '/') + remaining = ':'.join(parts[2:]) + volumes[i] = f'/{vm_file}:{remaining}' + + process = self._run_command(['docker', 'pull', agent_build]) + if process.returncode: + raise RuntimeError(f'Could not pull image {agent_build}') + + command = [ + 'docker', + 'run', + # Keep it up + '-d', + # Ensure consistent naming + '--name', + self._container_name, + ] + + # Ensure access to host network + # + # Windows containers accessing the host network must use `docker.for.win.localhost` or `host.docker.internal`: + # https://docs.docker.com/docker-for-windows/networking/#use-cases-and-workarounds + if not self._is_windows_container: + command.extend(['--network', 'host']) + + for volume in volumes: + command.extend(['-v', volume]) + + # Any environment variables passed to the start command in addition to the default ones + for key, value in sorted(env_vars.items()): + command.extend(['-e', f'{key}={value}']) + + # The docker `--add-host` command will reliably create entries in the `/etc/hosts` file, + # otherwise, edits to that file will be overwritten on container restarts + for host, ip in self.metadata.get('custom_hosts', []): + command.extend(['--add-host', f'{host}:{ip}']) + + if dogstatsd_port := env_vars.get('DD_DOGSTATSD_PORT'): + command.extend(['-p', f'{dogstatsd_port}:{dogstatsd_port}/udp']) + + # The chosen tag + command.append(agent_build) + + process = self._captured_process(command) + if process.returncode: + raise RuntimeError( + f'Unable to start Agent container `{self._container_name}`: {process.stdout.decode("utf-8")}' + ) + + start_commands = self.metadata.get('start_commands', []) + if start_commands: + for start_command in start_commands: + formatted_command = self._format_command(self.platform.modules.shlex.split(start_command)) + process = self._run_command(formatted_command) + if process.returncode: + raise RuntimeError( + f'Unable to run start-up command in Agent container `{self._container_name}`: ' + f'{process.stdout.decode("utf-8")}' + ) + + if local_packages: + base_pip_command = self._format_command( + [self._python_path, '-m', 'pip', 'install', '--disable-pip-version-check', '-e'] + ) + for local_package, features in local_packages.items(): + package_mount = f'{self._package_mount_dir}{local_package.name}{features}' + process = self._run_command([*base_pip_command, package_mount]) + if process.returncode: + raise RuntimeError( + f'Unable to install package `{local_package.name}` in Agent container ' + f'`{self._container_name}`: {process.stdout.decode("utf-8")}' + ) + + post_install_commands = self.metadata.get('post_install_commands', []) + if post_install_commands: + for post_install_command in post_install_commands: + formatted_command = self._format_command(self.platform.modules.shlex.split(post_install_command)) + process = self._run_command(formatted_command) + if process.returncode: + raise RuntimeError( + f'Unable to run post-install command in Agent container `{self._container_name}`: ' + f'{process.stdout.decode("utf-8")}' + ) + + if local_packages or start_commands or post_install_commands: + self.restart() + + def stop(self) -> None: + stop_commands = self.metadata.get('stop_commands', []) + if stop_commands: + for stop_command in stop_commands: + formatted_command = self._format_command(self.platform.modules.shlex.split(stop_command)) + process = self._run_command(formatted_command) + if process.returncode: + raise RuntimeError( + f'Unable to run stop command in Agent container `{self._container_name}`: ' + f'{process.stdout.decode("utf-8")}' + ) + + for command in ( + ['docker', 'stop', '-t', '0', self._container_name], + ['docker', 'rm', self._container_name], + ): + process = self._captured_process(command) + if process.returncode: + raise RuntimeError( + f'Unable to stop Agent container `{self._container_name}`: {process.stdout.decode("utf-8")}' + ) + + def restart(self) -> None: + process = self._captured_process(['docker', 'restart', self._container_name]) + if process.returncode: + raise RuntimeError( + f'Unable to restart Agent container `{self._container_name}`: {process.stdout.decode("utf-8")}' + ) + + def invoke(self, args: list[str]) -> None: + self._run_command(self._format_command(['agent', *args]), check=True) + + def enter_shell(self) -> None: + self._run_command(self._format_command(['cmd' if self._is_windows_container else 'bash']), check=True) + + +@cache +def _get_hostname(): + import socket + + return socket.gethostname().lower() + + +def _find_free_port(): + import socket + from contextlib import closing + + with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s: + # doesn't have to be reachable + s.connect(('10.255.255.255', 1)) + ip = s.getsockname()[0] + + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind((ip, 0)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + return s.getsockname()[1] diff --git a/ddev/src/ddev/e2e/agent/interface.py b/ddev/src/ddev/e2e/agent/interface.py new file mode 100644 index 0000000000000..5aca0fbd74c1e --- /dev/null +++ b/ddev/src/ddev/e2e/agent/interface.py @@ -0,0 +1,79 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from abc import ABC, abstractmethod +from functools import cached_property +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ddev.integration.core import Integration + from ddev.utils.fs import Path + from ddev.utils.platform import Platform + + +class AgentInterface(ABC): + def __init__( + self, platform: Platform, integration: Integration, env: str, metadata: dict[str, Any], config_file: Path + ) -> None: + self.__platform = platform + self.__integration = integration + self.__env = env + self.__metadata = metadata + self.__config_file = config_file + + @property + def platform(self) -> Platform: + return self.__platform + + @property + def integration(self) -> Integration: + return self.__integration + + @property + def env(self) -> str: + return self.__env + + @property + def metadata(self) -> dict[str, Any]: + return self.__metadata + + @property + def config_file(self) -> Path: + return self.__config_file + + @cached_property + def python_version(self) -> tuple[int, int]: + import re + + if match := re.search(r'^py(\d)\.(\d+)', self.env): + return int(match.group(1)), int(match.group(2)) + + from ddev.repo.constants import PYTHON_VERSION + + major, minor = PYTHON_VERSION.split('.') + return int(major), int(minor) + + def get_id(self) -> str: + return f'{self.integration.name}_{self.env}' + + @abstractmethod + def start(self, *, agent_build: str, local_packages: dict[Path, str], env_vars: dict[str, str]) -> None: + ... + + @abstractmethod + def stop(self) -> None: + ... + + @abstractmethod + def restart(self) -> None: + ... + + @abstractmethod + def invoke(self, args: list[str]) -> None: + ... + + @abstractmethod + def enter_shell(self) -> None: + ... diff --git a/ddev/src/ddev/e2e/config.py b/ddev/src/ddev/e2e/config.py new file mode 100644 index 0000000000000..a59317cd780a7 --- /dev/null +++ b/ddev/src/ddev/e2e/config.py @@ -0,0 +1,89 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ddev.utils.fs import Path + + +class EnvData: + def __init__(self, storage_dir: Path): + self.__storage_dir = storage_dir + + @property + def storage_dir(self) -> Path: + return self.__storage_dir + + @property + def metadata_file(self) -> Path: + return self.storage_dir / 'metadata.json' + + @property + def config_dir(self) -> Path: + return self.storage_dir / 'config' + + @property + def config_file(self) -> Path: + return self.config_dir / f'{self.storage_dir.parent.name}.yaml' + + def read_config(self) -> dict[str, Any]: + if not self.config_file.is_file(): + return {} + + import yaml + + return yaml.safe_load(self.config_file.read_text()) + + def write_config(self, config: dict[str, Any] | None) -> None: + # Allow passing `None` to prevent the creation of the file, which is required for some integrations. + if config is None: + return + + import yaml + + if 'instances' not in config: + config = {'instances': [config]} + + self.config_file.parent.ensure_dir_exists() + self.config_file.write_text(yaml.safe_dump(config, default_flow_style=False)) + + def read_metadata(self) -> dict[str, Any]: + import json + + return json.loads(self.metadata_file.read_text()) + + def write_metadata(self, metadata: dict[str, Any]) -> None: + import json + + self.metadata_file.parent.ensure_dir_exists() + self.metadata_file.write_text(json.dumps(metadata, indent=2)) + + def exists(self) -> bool: + return self.storage_dir.is_dir() + + def remove(self) -> None: + self.storage_dir.remove() + + +class EnvDataStorage: + def __init__(self, data_dir: Path): + self.__path = data_dir / 'env' + + def get_integrations(self) -> list[str]: + if not self.__path.is_dir(): + return [] + + return sorted(path.name for path in self.__path.iterdir() if path.is_dir()) + + def get_environments(self, integration: str) -> list[str]: + intg_path = self.__path / integration + if not intg_path.is_dir(): + return [] + + return sorted(path.name for path in intg_path.iterdir() if path.is_dir()) + + def get(self, integration: str, env: str) -> EnvData: + return EnvData(self.__path / integration / env) diff --git a/ddev/src/ddev/e2e/constants.py b/ddev/src/ddev/e2e/constants.py new file mode 100644 index 0000000000000..8036a472fa0c4 --- /dev/null +++ b/ddev/src/ddev/e2e/constants.py @@ -0,0 +1,13 @@ +DEFAULT_AGENT_TYPE = 'docker' +DEFAULT_DOGSTATSD_PORT = 8125 +ENV_VAR_PREFIX = 'DDEV_E2E_ENV_' + + +class E2EEnvVars: + AGENT_BUILD = 'DDEV_E2E_AGENT' + AGENT_BUILD_PY2 = 'DDEV_E2E_AGENT_PY2' + SET_UP = 'DDEV_E2E_UP' + TEAR_DOWN = 'DDEV_E2E_DOWN' + PARENT_PYTHON = 'DDEV_E2E_PYTHON_PATH' + RESULT_FILE = 'DDEV_E2E_RESULT_FILE' + LOGS_DIR_PREFIX = 'DDEV_E2E_ENV_TEMP_DIR_DD_LOG_' diff --git a/ddev/src/ddev/e2e/run.py b/ddev/src/ddev/e2e/run.py new file mode 100644 index 0000000000000..36ac9522b7480 --- /dev/null +++ b/ddev/src/ddev/e2e/run.py @@ -0,0 +1,51 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import sys +from contextlib import contextmanager +from typing import Generator + +from ddev.e2e.constants import E2EEnvVars +from ddev.testing.hatch import get_hatch_env_vars +from ddev.utils.structures import EnvVars + + +class E2EEnvironmentRunner: + def __init__(self, env: str, verbosity: int): + self.__env = env + self.__verbosity = verbosity + + @contextmanager + def start(self) -> Generator[list[str], None, None]: + with EnvVars({E2EEnvVars.TEAR_DOWN: 'false', **get_hatch_env_vars(verbosity=self.__verbosity)}): + yield self._base_command() + + @contextmanager + def stop(self) -> Generator[list[str], None, None]: + with EnvVars({E2EEnvVars.SET_UP: 'false', **get_hatch_env_vars(verbosity=self.__verbosity)}): + yield self._base_command() + + def _base_command(self) -> list[str]: + command = [ + sys.executable, + '-m', + 'hatch', + 'env', + 'run', + '--env', + self.__env, + '--', + 'test', + '--capture=no', + '--disable-warnings', + '--exitfirst', + # We need -2 verbosity and by default the test command sets the verbosity to +2 + '-qqqq', + ] + # TODO: always use this flag when we drop support for Python 2 + if not self.__env.startswith('py2'): + command.append('--no-header') + + return command diff --git a/ddev/src/ddev/testing/hatch.py b/ddev/src/ddev/testing/hatch.py new file mode 100644 index 0000000000000..c5cebc53ae9a4 --- /dev/null +++ b/ddev/src/ddev/testing/hatch.py @@ -0,0 +1,12 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +def get_hatch_env_vars(*, verbosity: int) -> dict[str, str]: + env_vars = {} + + if verbosity > 0: + env_vars['HATCH_VERBOSE'] = str(verbosity) + elif verbosity < 0: + env_vars['HATCH_QUIET'] = str(abs(verbosity)) + + return env_vars diff --git a/ddev/src/ddev/utils/platform.py b/ddev/src/ddev/utils/platform.py index a20ad558961f7..4f0dd0a896a3f 100644 --- a/ddev/src/ddev/utils/platform.py +++ b/ddev/src/ddev/utils/platform.py @@ -129,6 +129,16 @@ def stream_process_output(process): for line in iter(process.stdout.readline, b''): yield line.decode('utf-8') + @property + def format_file_uri(self): + if self.__format_file_uri is None: + if self.windows: + self.__format_file_uri = lambda p: f'file:///{p}'.replace('\\', '/') + else: + self.__format_file_uri = lambda p: f'file://{p}' + + return self.__format_file_uri + @property def windows(self): """ diff --git a/ddev/tests/cli/env/__init__.py b/ddev/tests/cli/env/__init__.py new file mode 100644 index 0000000000000..e0cc3d0a7662c --- /dev/null +++ b/ddev/tests/cli/env/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) diff --git a/ddev/tests/cli/env/conftest.py b/ddev/tests/cli/env/conftest.py new file mode 100644 index 0000000000000..8bd9369b9a4f1 --- /dev/null +++ b/ddev/tests/cli/env/conftest.py @@ -0,0 +1,38 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import json +import os + +import pytest + +from ddev.config.constants import ConfigEnvVars +from ddev.e2e.constants import E2EEnvVars +from ddev.utils.fs import Path +from ddev.utils.structures import EnvVars + + +@pytest.fixture(autouse=True) +def data_dir(temp_dir): + d = temp_dir / 'data' + d.mkdir() + with EnvVars({ConfigEnvVars.DATA: str(d)}): + yield d + + +@pytest.fixture +def write_result_file(mocker): + def _write_result_file(result): + written = False + + def _write(*args, **kwargs): + nonlocal written + if not written: + Path(os.environ[E2EEnvVars.RESULT_FILE]).write_text(json.dumps(result)) + written = True + + return mocker.MagicMock(returncode=0) + + return _write + + return _write_result_file diff --git a/ddev/tests/cli/env/test_agent.py b/ddev/tests/cli/env/test_agent.py new file mode 100644 index 0000000000000..92d951f0a6914 --- /dev/null +++ b/ddev/tests/cli/env/test_agent.py @@ -0,0 +1,70 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from ddev.e2e.config import EnvDataStorage + + +def test_nonexistent(ddev, helpers, mocker): + invoke = mocker.patch('ddev.e2e.agent.docker.DockerAgent.invoke') + + integration = 'postgres' + environment = 'py3.12' + + result = ddev('env', 'agent', integration, environment, 'status') + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + Environment `{environment}` for integration `{integration}` is not running + """ + ) + + invoke.assert_not_called() + + +def test_not_trigger_run(ddev, data_dir, mocker): + invoke = mocker.patch('ddev.e2e.agent.docker.DockerAgent.invoke') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + env_data.write_metadata({}) + + result = ddev('env', 'agent', integration, environment, 'status') + + assert result.exit_code == 0, result.output + assert not result.output + + invoke.assert_called_once_with(['status']) + + +def test_trigger_run(ddev, data_dir, mocker): + invoke = mocker.patch('ddev.e2e.agent.docker.DockerAgent.invoke') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + env_data.write_metadata({}) + + result = ddev('env', 'agent', integration, environment, 'check', integration, '-l', 'debug') + + assert result.exit_code == 0, result.output + assert not result.output + + invoke.assert_called_once_with(['check', integration, '-l', 'debug']) + + +def test_trigger_run_inject_integration(ddev, data_dir, mocker): + invoke = mocker.patch('ddev.e2e.agent.docker.DockerAgent.invoke') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + env_data.write_metadata({}) + + result = ddev('env', 'agent', integration, environment, 'check', '-l', 'debug') + + assert result.exit_code == 0, result.output + assert not result.output + + invoke.assert_called_once_with(['check', integration, '-l', 'debug']) diff --git a/ddev/tests/cli/env/test_reload.py b/ddev/tests/cli/env/test_reload.py new file mode 100644 index 0000000000000..0ad6c560ef6f1 --- /dev/null +++ b/ddev/tests/cli/env/test_reload.py @@ -0,0 +1,38 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from ddev.e2e.config import EnvDataStorage + + +def test_nonexistent(ddev, helpers, mocker): + restart = mocker.patch('ddev.e2e.agent.docker.DockerAgent.restart') + + integration = 'postgres' + environment = 'py3.12' + + result = ddev('env', 'reload', integration, environment) + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + Environment `{environment}` for integration `{integration}` is not running + """ + ) + + restart.assert_not_called() + + +def test_basic(ddev, data_dir, mocker): + restart = mocker.patch('ddev.e2e.agent.docker.DockerAgent.restart') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + env_data.write_metadata({}) + + result = ddev('env', 'reload', integration, environment) + + assert result.exit_code == 0, result.output + assert not result.output + + restart.assert_called_once() diff --git a/ddev/tests/cli/env/test_shell.py b/ddev/tests/cli/env/test_shell.py new file mode 100644 index 0000000000000..47c748084c716 --- /dev/null +++ b/ddev/tests/cli/env/test_shell.py @@ -0,0 +1,38 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from ddev.e2e.config import EnvDataStorage + + +def test_nonexistent(ddev, helpers, mocker): + enter_shell = mocker.patch('ddev.e2e.agent.docker.DockerAgent.enter_shell') + + integration = 'postgres' + environment = 'py3.12' + + result = ddev('env', 'shell', integration, environment) + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + Environment `{environment}` for integration `{integration}` is not running + """ + ) + + enter_shell.assert_not_called() + + +def test_basic(ddev, data_dir, mocker): + enter_shell = mocker.patch('ddev.e2e.agent.docker.DockerAgent.enter_shell') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + env_data.write_metadata({}) + + result = ddev('env', 'shell', integration, environment) + + assert result.exit_code == 0, result.output + assert not result.output + + enter_shell.assert_called_once() diff --git a/ddev/tests/cli/env/test_start.py b/ddev/tests/cli/env/test_start.py new file mode 100644 index 0000000000000..64a1775b3a18c --- /dev/null +++ b/ddev/tests/cli/env/test_start.py @@ -0,0 +1,445 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import os + +import pytest + +from ddev.e2e.config import EnvDataStorage +from ddev.e2e.constants import DEFAULT_DOGSTATSD_PORT, E2EEnvVars +from ddev.utils.fs import Path +from ddev.utils.structures import EnvVars + + +@pytest.fixture(autouse=True) +def free_port(mocker): + port = 9000 + mocker.patch('ddev.e2e.agent.docker._find_free_port', return_value=port) + return port + + +class TestValidations: + def test_no_result_file(self, ddev, helpers, mocker): + result_file = Path() + + def _save_result_file(*args, **kwargs): + nonlocal result_file + result_file = Path(os.environ[E2EEnvVars.RESULT_FILE]) + return mocker.MagicMock(returncode=0) + + mocker.patch('subprocess.run', side_effect=_save_result_file) + + integration = 'postgres' + environment = 'py3.12' + + result = ddev('env', 'start', integration, environment) + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + No E2E result file found: {result_file} + """ + ) + + def test_already_exists(self, ddev, helpers, data_dir, mocker): + mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + env_data.write_metadata({}) + + result = ddev('env', 'start', integration, environment) + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + Environment `{environment}` for integration `{integration}` is already running + """ + ) + + +def test_stop_on_error(ddev, helpers, data_dir, write_result_file, mocker): + metadata = {} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start', side_effect=Exception('foo')) + stop = mocker.patch('ddev.e2e.agent.docker.DockerAgent.stop') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + result = ddev('env', 'start', integration, environment) + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + """ + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + Unable to start the Agent: foo + ─────────────────────────────── Stopping: py3.12 ─────────────────────────────── + """ + ) + + assert not env_data.exists() + + start.assert_called_once_with( + agent_build='datadog/agent-dev:master', + local_packages={}, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com'}, + ) + stop.assert_called_once() + + +def test_basic(ddev, helpers, data_dir, write_result_file, mocker): + metadata = {} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + result = ddev('env', 'start', integration, environment) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent-dev:master', + local_packages={}, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com'}, + ) + + +def test_agent_build_config(ddev, config_file, helpers, data_dir, write_result_file, mocker): + config_file.model.agent = '7' + config_file.save() + + metadata = {} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + result = ddev('env', 'start', integration, environment) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent:7', + local_packages={}, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com'}, + ) + + +def test_agent_build_env_var(ddev, config_file, helpers, data_dir, write_result_file, mocker): + config_file.model.agent = '7' + config_file.save() + + metadata = {} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + with EnvVars({E2EEnvVars.AGENT_BUILD: 'datadog/agent:6'}): + result = ddev('env', 'start', integration, environment) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent:6', + local_packages={}, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com'}, + ) + + +def test_agent_build_flag(ddev, config_file, helpers, data_dir, write_result_file, mocker): + config_file.model.agent = '7' + config_file.save() + + metadata = {} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + with EnvVars({E2EEnvVars.AGENT_BUILD: 'datadog/agent:6'}): + result = ddev('env', 'start', integration, environment, '-a', 'datadog/agent:7-rc') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent:7-rc', + local_packages={}, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com'}, + ) + + +def test_local_dev(ddev, helpers, local_repo, data_dir, write_result_file, mocker): + metadata = {} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + result = ddev('env', 'start', integration, environment, '--dev') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent-dev:master', + local_packages={local_repo / integration: '[deps]'}, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com'}, + ) + + +def test_local_base(ddev, helpers, local_repo, data_dir, write_result_file, mocker): + metadata = {} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + result = ddev('env', 'start', integration, environment, '--base') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent-dev:master', + local_packages={ + local_repo / 'datadog_checks_base': '[db,deps,http,json,kube]', + local_repo / integration: '[deps]', + }, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com'}, + ) + + +def test_env_vars(ddev, helpers, data_dir, write_result_file, mocker): + metadata = {'env_vars': {'FOO': 'BAZ', 'BAZ': 'BAR'}} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + result = ddev('env', 'start', integration, environment, '-e', 'FOO=BAR') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent-dev:master', + local_packages={}, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com', 'FOO': 'BAR', 'BAZ': 'BAR'}, + ) + + +def test_logs_detection(ddev, helpers, data_dir, write_result_file, mocker): + metadata = {'e2e_env_vars': {f'{E2EEnvVars.LOGS_DIR_PREFIX}1': 'path'}} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + result = ddev('env', 'start', integration, environment) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent-dev:master', + local_packages={}, + env_vars={'DD_DD_URL': 'https://app.datadoghq.com', 'DD_SITE': 'datadoghq.com', 'DD_LOGS_ENABLED': 'true'}, + ) + + +def test_dogstatsd(ddev, helpers, data_dir, write_result_file, mocker): + metadata = {'dogstatsd': 'true'} + config = {} + mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) + start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + + result = ddev('env', 'start', integration, environment) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + ─────────────────────────────── Starting: py3.12 ─────────────────────────────── + + Stop environment -> ddev env stop {integration} {environment} + Execute tests -> ddev env test {integration} {environment} + Check status -> ddev env agent {integration} {environment} status + Trigger run -> ddev env agent {integration} {environment} check + Reload config -> ddev env reload {integration} {environment} + Manage config -> ddev env config + Config file -> {env_data.config_file} + """ + ) + + assert env_data.read_config() == {'instances': [config]} + assert env_data.read_metadata() == metadata + + start.assert_called_once_with( + agent_build='datadog/agent-dev:master', + local_packages={}, + env_vars={ + 'DD_DD_URL': 'https://app.datadoghq.com', + 'DD_SITE': 'datadoghq.com', + 'DD_DOGSTATSD_PORT': str(DEFAULT_DOGSTATSD_PORT), + 'DD_DOGSTATSD_NON_LOCAL_TRAFFIC': 'true', + 'DD_DOGSTATSD_METRICS_STATS_ENABLE': 'true', + }, + ) diff --git a/ddev/tests/cli/env/test_stop.py b/ddev/tests/cli/env/test_stop.py new file mode 100644 index 0000000000000..028f9bd8d11fb --- /dev/null +++ b/ddev/tests/cli/env/test_stop.py @@ -0,0 +1,46 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from ddev.e2e.config import EnvDataStorage + + +def test_nonexistent(ddev, helpers, mocker): + mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + stop = mocker.patch('ddev.e2e.agent.docker.DockerAgent.stop') + + integration = 'postgres' + environment = 'py3.12' + + result = ddev('env', 'stop', integration, environment) + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + f""" + Environment `{environment}` for integration `{integration}` is not running + """ + ) + + stop.assert_not_called() + + +def test_basic(ddev, helpers, data_dir, mocker): + mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + stop = mocker.patch('ddev.e2e.agent.docker.DockerAgent.stop') + + integration = 'postgres' + environment = 'py3.12' + env_data = EnvDataStorage(data_dir).get(integration, environment) + env_data.write_metadata({}) + + result = ddev('env', 'stop', integration, environment) + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + """ + ─────────────────────────────── Stopping: py3.12 ─────────────────────────────── + """ + ) + + assert not env_data.exists() + + stop.assert_called_once() diff --git a/ddev/tests/conftest.py b/ddev/tests/conftest.py index cbba99e6fb0d5..36fba541184e8 100644 --- a/ddev/tests/conftest.py +++ b/ddev/tests/conftest.py @@ -16,6 +16,7 @@ from ddev.cli.terminal import Terminal from ddev.config.constants import AppEnvVars, ConfigEnvVars from ddev.config.file import ConfigFile +from ddev.e2e.constants import E2EEnvVars from ddev.repo.core import Repository from ddev.utils.ci import running_in_ci from ddev.utils.fs import Path, temp_directory @@ -72,6 +73,11 @@ def platform() -> Platform: return PLATFORM +@pytest.fixture(scope='session') +def docker_path(platform): + return platform.format_for_subprocess(['docker'], shell=False)[0] + + @pytest.fixture(scope='session') def terminal() -> Terminal: return Terminal(verbosity=0, enable_color=False, interactive=False) @@ -107,6 +113,8 @@ def config_file(tmp_path, monkeypatch, local_repo) -> ConfigFile: 'DDEV_REPO', 'DDEV_TEST_ENABLE_TRACING', 'PYTHON_FILTER', + E2EEnvVars.AGENT_BUILD, + E2EEnvVars.AGENT_BUILD_PY2, 'HATCH_VERBOSE', 'HATCH_QUIET', ): @@ -135,7 +143,16 @@ def temp_dir(tmp_path) -> Path: @pytest.fixture(scope='session', autouse=True) def isolation() -> Generator[Path, None, None]: with temp_directory() as d: - default_env_vars = {'DDEV_SELF_TESTING': 'true', AppEnvVars.NO_COLOR: '1', 'COLUMNS': '80', 'LINES': '24'} + data_dir = d / 'data' + data_dir.mkdir() + + default_env_vars = { + 'DDEV_SELF_TESTING': 'true', + ConfigEnvVars.DATA: str(data_dir), + AppEnvVars.NO_COLOR: '1', + 'COLUMNS': '80', + 'LINES': '24', + } with d.as_cwd(default_env_vars): yield d @@ -170,6 +187,13 @@ def repository(local_clone, config_file) -> Generator[ClonedRepo, None, None]: local_clone.reset_branch() +@pytest.fixture(scope='session') +def default_hostname(): + import socket + + return socket.gethostname().lower() + + @pytest.fixture def network_replay(local_repo): """ diff --git a/ddev/tests/e2e/__init__.py b/ddev/tests/e2e/__init__.py new file mode 100644 index 0000000000000..e0cc3d0a7662c --- /dev/null +++ b/ddev/tests/e2e/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) diff --git a/ddev/tests/e2e/agent/__init__.py b/ddev/tests/e2e/agent/__init__.py new file mode 100644 index 0000000000000..e0cc3d0a7662c --- /dev/null +++ b/ddev/tests/e2e/agent/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) diff --git a/ddev/tests/e2e/agent/test_docker.py b/ddev/tests/e2e/agent/test_docker.py new file mode 100644 index 0000000000000..374dc33d0983c --- /dev/null +++ b/ddev/tests/e2e/agent/test_docker.py @@ -0,0 +1,1172 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import os +import subprocess + +import pytest + +from ddev.e2e.agent.docker import DockerAgent +from ddev.integration.core import Integration +from ddev.repo.config import RepositoryConfig +from ddev.utils.fs import Path + + +@pytest.fixture(scope='module') +def get_integration(local_repo): + def _get_integration(name): + return Integration(local_repo / name, local_repo, RepositoryConfig(local_repo / '.ddev' / 'config.toml')) + + return _get_integration + + +@pytest.fixture(autouse=True) +def free_port(mocker): + port = 9000 + mocker.patch('ddev.e2e.agent.docker._find_free_port', return_value=port) + return port + + +class TestStart: + @pytest.mark.parametrize( + 'agent_build, agent_image, use_jmx', + [ + pytest.param('', 'datadog/agent-dev:master-py3', False, id='default'), + pytest.param('datadog/agent:7', 'datadog/agent:7', False, id='release'), + pytest.param('datadog/agent-dev:master-py3', 'datadog/agent-dev:master-py3', False, id='exact'), + pytest.param('datadog/agent-dev:master', 'datadog/agent-dev:master-py3-jmx', True, id='jmx'), + pytest.param('datadog/agent-dev:master-py3-jmx', 'datadog/agent-dev:master-py3-jmx', True, id='jmx exact'), + ], + ) + def test_agent_build( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + agent_build, + agent_image, + use_jmx, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'use_jmx': use_jmx} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build=agent_build, local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', agent_image], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + agent_image, + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_env_vars( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start( + agent_build='', + local_packages={}, + env_vars={'DD_API_KEY': 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'DD_LOGS_ENABLED': 'true'}, + ) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-e', + 'DD_API_KEY=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_LOGS_ENABLED=true', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_no_config_file( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + integration = 'postgres' + environment = 'py3.12' + metadata = {} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, temp_dir / 'config.yaml') + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_windows_container( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'docker_platform': 'windows'} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '-v', + f'{config_file.parent}:C:\\ProgramData\\Datadog\\conf.d\\{integration}.d', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + @pytest.mark.requires_linux + def test_docker_volumes_linux( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'docker_volumes': ['/a/b/c:/d/e/f']} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-v', + '/a/b/c:/d/e/f', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + @pytest.mark.requires_windows + def test_docker_volumes_windows_running_linux( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'docker_volumes': ['/a/b/c:/d/e/f', f'{config_file}:/mnt/{config_file.name}']} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-v', + '/a/b/c:/d/e/f', + '-v', + f'/{str(config_file).replace(":", "", 1).replace(os.sep, "/")}:/mnt/{config_file.name}', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + @pytest.mark.requires_windows + def test_docker_volumes_windows_running_windows( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'docker_volumes': [f'{config_file.parent.parent}:C:\\mnt'], 'docker_platform': 'windows'} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '-v', + f'{config_file.parent}:C:\\ProgramData\\Datadog\\conf.d\\{integration}.d', + '-v', + f'{config_file.parent.parent}:C:\\mnt', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_custom_hosts( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'custom_hosts': [['host2', '127.0.0.1'], ['host1', '127.0.0.1']]} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + '--add-host', + 'host2:127.0.0.1', + '--add-host', + 'host1:127.0.0.1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_dogstatsd_port( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={'DD_DOGSTATSD_PORT': '9000'}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_DOGSTATSD_PORT=9000', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + '-p', + '9000:9000/udp', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_proxies( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'proxy': {'http': 'http://localhost:8080', 'https': 'https://localhost:4443'}} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_PROXY_HTTP=http://localhost:8080', + '-e', + 'DD_PROXY_HTTPS=https://localhost:4443', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_start_commands( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'start_commands': ['echo "hello world"']} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + mocker.call([docker_path, 'exec', f'dd_{integration}_{environment}', 'echo', 'hello world'], shell=False), + mocker.call( + [docker_path, 'restart', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_post_install_commands( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'post_install_commands': ['echo "hello world"']} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + mocker.call([docker_path, 'exec', f'dd_{integration}_{environment}', 'echo', 'hello world'], shell=False), + mocker.call( + [docker_path, 'restart', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_local_packages_linux_container( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={temp_dir / 'foo': '[deps]'}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-v', + f'{temp_dir / "foo"}:/home/foo', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + mocker.call( + [ + docker_path, + 'exec', + f'dd_{integration}_{environment}', + '/opt/datadog-agent/embedded/bin/python3', + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '-e', + '/home/foo[deps]', + ], + shell=False, + ), + mocker.call( + [docker_path, 'restart', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_local_packages_windows_container( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'docker_platform': 'windows'} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={temp_dir / 'foo': '[deps]'}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '-v', + f'{config_file.parent}:C:\\ProgramData\\Datadog\\conf.d\\{integration}.d', + '-v', + f'{temp_dir / "foo"}:C:\\Users\\ContainerAdministrator\\foo', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + mocker.call( + [ + docker_path, + 'exec', + f'dd_{integration}_{environment}', + 'C:\\Program Files\\Datadog\\Datadog Agent\\embedded3\\python.exe', + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '-e', + 'C:\\Users\\ContainerAdministrator\\foo[deps]', + ], + shell=False, + ), + mocker.call( + [docker_path, 'restart', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_all_post_run_logic( + self, + platform, + temp_dir, + default_hostname, + get_integration, + docker_path, + free_port, + mocker, + ): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + config_file = temp_dir / 'config' / 'config.yaml' + config_file.parent.mkdir() + config_file.touch() + + integration = 'postgres' + environment = 'py3.12' + metadata = {'start_commands': ['echo "hello world1"'], 'post_install_commands': ['echo "hello world2"']} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, config_file) + agent.start(agent_build='', local_packages={temp_dir / 'foo': '[deps]'}, env_vars={}) + + assert run.call_args_list == [ + mocker.call([docker_path, 'pull', 'datadog/agent-dev:master-py3'], shell=False), + mocker.call( + [ + docker_path, + 'run', + '-d', + '--name', + f'dd_{integration}_{environment}', + '--network', + 'host', + '-v', + '/proc:/host/proc', + '-v', + f'{config_file.parent}:/etc/datadog-agent/conf.d/{integration}.d', + '-v', + f'{temp_dir / "foo"}:/home/foo', + '-e', + 'DD_API_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + '-e', + 'DD_APM_ENABLED=false', + '-e', + f'DD_CMD_PORT={free_port}', + '-e', + 'DD_EXPVAR_PORT=5000', + '-e', + f'DD_HOSTNAME={default_hostname}', + '-e', + 'DD_TELEMETRY_ENABLED=1', + '-e', + 'PYTHONDONTWRITEBYTECODE=1', + 'datadog/agent-dev:master-py3', + ], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + mocker.call([docker_path, 'exec', f'dd_{integration}_{environment}', 'echo', 'hello world1'], shell=False), + mocker.call( + [ + docker_path, + 'exec', + f'dd_{integration}_{environment}', + '/opt/datadog-agent/embedded/bin/python3', + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '-e', + '/home/foo[deps]', + ], + shell=False, + ), + mocker.call([docker_path, 'exec', f'dd_{integration}_{environment}', 'echo', 'hello world2'], shell=False), + mocker.call( + [docker_path, 'restart', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + +class TestStop: + def test_basic(self, platform, get_integration, docker_path, mocker): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + integration = 'postgres' + environment = 'py3.12' + metadata = {} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, Path('config.yaml')) + agent.stop() + + assert run.call_args_list == [ + mocker.call( + [docker_path, 'stop', '-t', '0', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + mocker.call( + [docker_path, 'rm', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + def test_stop_commands(self, platform, get_integration, docker_path, mocker): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + integration = 'postgres' + environment = 'py3.12' + metadata = {'stop_commands': ['echo "hello world"']} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, Path('config.yaml')) + agent.stop() + + assert run.call_args_list == [ + mocker.call([docker_path, 'exec', f'dd_{integration}_{environment}', 'echo', 'hello world'], shell=False), + mocker.call( + [docker_path, 'stop', '-t', '0', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + mocker.call( + [docker_path, 'rm', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + +class TestRestart: + def test_basic(self, platform, get_integration, docker_path, mocker): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + integration = 'postgres' + environment = 'py3.12' + metadata = {} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, Path('config.yaml')) + agent.restart() + + assert run.call_args_list == [ + mocker.call( + [docker_path, 'restart', f'dd_{integration}_{environment}'], + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ), + ] + + +class TestInvoke: + def test_basic(self, platform, get_integration, docker_path, mocker): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + + integration = 'postgres' + environment = 'py3.12' + metadata = {} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, Path('config.yaml')) + agent.invoke(['check', 'postgres']) + + assert run.call_args_list == [ + mocker.call( + [docker_path, 'exec', f'dd_{integration}_{environment}', 'agent', 'check', 'postgres'], + shell=False, + check=True, + ), + ] + + +class TestEnterShell: + def test_linux_container(self, platform, get_integration, docker_path, mocker): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + mocker.patch('sys.stdout', return_value=mocker.MagicMock(isatty=lambda: True)) + + integration = 'postgres' + environment = 'py3.12' + metadata = {} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, Path('config.yaml')) + agent.enter_shell() + + assert run.call_args_list == [ + mocker.call( + [docker_path, 'exec', '-it', f'dd_{integration}_{environment}', 'bash'], + shell=False, + check=True, + ), + ] + + def test_windows_container(self, platform, get_integration, docker_path, mocker): + run = mocker.patch('subprocess.run', return_value=mocker.MagicMock(returncode=0)) + mocker.patch('sys.stdout', return_value=mocker.MagicMock(isatty=lambda: True)) + + integration = 'postgres' + environment = 'py3.12' + metadata = {'docker_platform': 'windows'} + + agent = DockerAgent(platform, get_integration(integration), environment, metadata, Path('config.yaml')) + agent.enter_shell() + + assert run.call_args_list == [ + mocker.call( + [docker_path, 'exec', '-it', f'dd_{integration}_{environment}', 'cmd'], + shell=False, + check=True, + ), + ] diff --git a/ddev/tests/e2e/test_config.py b/ddev/tests/e2e/test_config.py new file mode 100644 index 0000000000000..286379dbb2dd1 --- /dev/null +++ b/ddev/tests/e2e/test_config.py @@ -0,0 +1,76 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from ddev.e2e.config import EnvData, EnvDataStorage + + +class TestEnvDataStorage: + def test_nonexistent_okay(self, temp_dir) -> None: + storage = EnvDataStorage(temp_dir) + + assert not storage.get_integrations() + assert not storage.get_environments('foo') + + def test_get_integrations(self, temp_dir) -> None: + storage = EnvDataStorage(temp_dir) + storage.get('foo', 'env').write_metadata({}) + storage.get('bar', 'env').write_metadata({}) + + assert storage.get_integrations() == ['bar', 'foo'] + + def test_get_environments(self, temp_dir) -> None: + storage = EnvDataStorage(temp_dir) + storage.get('foo', 'env1').write_metadata({}) + storage.get('foo', 'env2').write_metadata({}) + + assert storage.get_environments('foo') == ['env1', 'env2'] + + +class TestEnvData: + def test_default_not_exists(self, temp_dir) -> None: + env_data = EnvData(temp_dir / 'path') + + assert not env_data.exists() + + def test_metadata(self, temp_dir) -> None: + env_data = EnvData(temp_dir / 'path') + + metadata = {'foo': 'bar'} + env_data.write_metadata(metadata) + + assert env_data.exists() + assert env_data.read_metadata() == metadata + + def test_config_no_instances(self, temp_dir) -> None: + env_data = EnvData(temp_dir / 'path') + + config = {'foo': 'bar'} + env_data.write_config(config) + + assert env_data.exists() + assert env_data.read_config() == {'instances': [config]} + + def test_config_full_config(self, temp_dir) -> None: + env_data = EnvData(temp_dir / 'path') + + config = {'init_config': {'bar': 'baz'}, 'instances': [{'foo': 'bar'}]} + env_data.write_config(config) + + assert env_data.exists() + assert env_data.read_config() == config + + def test_config_none_no_write(self, temp_dir) -> None: + env_data = EnvData(temp_dir / 'path') + + env_data.write_config(None) + + assert not env_data.exists() + + def test_remove(self, temp_dir) -> None: + env_data = EnvData(temp_dir / 'path') + + env_data.write_config({'foo': 'bar'}) + assert env_data.exists() + + env_data.remove() + assert not env_data.exists() diff --git a/ddev/tests/e2e/test_run.py b/ddev/tests/e2e/test_run.py new file mode 100644 index 0000000000000..cfd7c88d11729 --- /dev/null +++ b/ddev/tests/e2e/test_run.py @@ -0,0 +1,91 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import os +import sys + +import pytest + +from ddev.e2e.constants import E2EEnvVars +from ddev.e2e.run import E2EEnvironmentRunner + + +@pytest.fixture(scope='module') +def trigger_command() -> list[str]: + def _trigger_command(env: str) -> list[str]: + return [ + sys.executable, + '-m', + 'hatch', + 'env', + 'run', + '--env', + env, + '--', + 'test', + '--capture=no', + '--disable-warnings', + '--exitfirst', + '-qqqq', + '--no-header', + ] + + return _trigger_command + + +@pytest.fixture(scope='module') +def trigger_command_py2(trigger_command) -> list[str]: + return lambda env: trigger_command(env)[:-1] + + +class TestE2EEnvironmentRunner: + def test_start(self, trigger_command) -> None: + old_env_vars = dict(os.environ) + runner = E2EEnvironmentRunner('py3.11', 0) + with runner.start() as command: + new_env_vars = dict(os.environ) + + assert command == trigger_command('py3.11') + assert set(new_env_vars) - set(old_env_vars) == {E2EEnvVars.TEAR_DOWN} + assert new_env_vars[E2EEnvVars.TEAR_DOWN] == 'false' + + def test_start_py2(self, trigger_command_py2) -> None: + old_env_vars = dict(os.environ) + runner = E2EEnvironmentRunner('py2.7', 0) + with runner.start() as command: + new_env_vars = dict(os.environ) + + assert command == trigger_command_py2('py2.7') + assert set(new_env_vars) - set(old_env_vars) == {E2EEnvVars.TEAR_DOWN} + assert new_env_vars[E2EEnvVars.TEAR_DOWN] == 'false' + + def test_stop(self, trigger_command) -> None: + old_env_vars = dict(os.environ) + runner = E2EEnvironmentRunner('py3.11', 0) + with runner.stop() as command: + new_env_vars = dict(os.environ) + + assert command == trigger_command('py3.11') + assert set(new_env_vars) - set(old_env_vars) == {E2EEnvVars.SET_UP} + assert new_env_vars[E2EEnvVars.SET_UP] == 'false' + + def test_stop_py2(self, trigger_command_py2) -> None: + old_env_vars = dict(os.environ) + runner = E2EEnvironmentRunner('py2.7', 0) + with runner.stop() as command: + new_env_vars = dict(os.environ) + + assert command == trigger_command_py2('py2.7') + assert set(new_env_vars) - set(old_env_vars) == {E2EEnvVars.SET_UP} + assert new_env_vars[E2EEnvVars.SET_UP] == 'false' + + def test_verbosity(self, trigger_command) -> None: + old_env_vars = dict(os.environ) + runner = E2EEnvironmentRunner('py3.11', 1) + with runner.start() as command: + new_env_vars = dict(os.environ) + + assert command == trigger_command('py3.11') + assert set(new_env_vars) - set(old_env_vars) == {E2EEnvVars.TEAR_DOWN, 'HATCH_VERBOSE'} + assert new_env_vars[E2EEnvVars.TEAR_DOWN] == 'false' + assert new_env_vars['HATCH_VERBOSE'] == '1' diff --git a/ddev/tests/testing/__init__.py b/ddev/tests/testing/__init__.py new file mode 100644 index 0000000000000..e0cc3d0a7662c --- /dev/null +++ b/ddev/tests/testing/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) diff --git a/ddev/tests/testing/test_hatch.py b/ddev/tests/testing/test_hatch.py new file mode 100644 index 0000000000000..7cbe05a5830b6 --- /dev/null +++ b/ddev/tests/testing/test_hatch.py @@ -0,0 +1,15 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from ddev.testing.hatch import get_hatch_env_vars + + +class TestGetHatchEnvVars: + def test_no_verbosity(self): + assert not get_hatch_env_vars(verbosity=0) + + def test_increased_verbosity(self): + assert get_hatch_env_vars(verbosity=1) == {'HATCH_VERBOSE': '1'} + + def test_decreased_verbosity(self): + assert get_hatch_env_vars(verbosity=-1) == {'HATCH_QUIET': '1'} diff --git a/docs/developer/e2e.md b/docs/developer/e2e.md index 87d730d358654..305ee63971b7b 100644 --- a/docs/developer/e2e.md +++ b/docs/developer/e2e.md @@ -5,38 +5,61 @@ Any integration that makes use of our [pytest plugin](ddev/plugins.md#pytest) in its test suite supports end-to-end testing on a live [Datadog Agent][]. -The entrypoint for E2E management is the command group `ddev env`. +The entrypoint for E2E management is the command group [`env`](ddev/cli.md#ddev-env). ## Discovery -Use the `ls` command to see what environments are available, for example: +Use the `show` command to see what environments are available, for example: ``` -$ ddev env ls envoy -envoy: - py27 - py38 +$ ddev env show postgres + Available +┏━━━━━━━━━━━━┓ +┃ Name ┃ +┡━━━━━━━━━━━━┩ +│ py3.9-9.6 │ +├────────────┤ +│ py3.9-10.0 │ +├────────────┤ +│ py3.9-11.0 │ +├────────────┤ +│ py3.9-12.1 │ +├────────────┤ +│ py3.9-13.0 │ +├────────────┤ +│ py3.9-14.0 │ +└────────────┘ ``` You'll notice that only environments that actually run tests are available. -Running simply `ddev env ls` with no arguments will display the active environments. +Running simply `ddev env show` with no arguments will display the active environments. ## Creation To start an environment run `ddev env start `, for example: ``` -$ ddev env start envoy py38 -Setting up environment `py38`... success! -Updating `datadog/agent-dev:master`... success! -Detecting the major version... Agent 7 detected -Writing configuration for `py38`... success! -Starting the Agent... success! - -Config file (copied to your clipboard): C:\Users\ofek\AppData\Local\dd-checks-dev\envs\envoy\py38\config\envoy.yaml -To run this check, do: ddev env check envoy py38 -To stop this check, do: ddev env stop envoy py38 +$ ddev env start postgres py3.9-14.0 +────────────────────────────────────── Starting: py3.9-14.0 ────────────────────────────────────── +[+] Running 4/4 + - Network compose_pg-net Created 0.1s + - Container compose-postgres_replica2-1 Started 0.9s + - Container compose-postgres_replica-1 Started 0.9s + - Container compose-postgres-1 Started 0.9s + +master-py3: Pulling from datadog/agent-dev +Digest: sha256:72824c9a986b0ef017eabba4e2cc9872333c7e16eec453b02b2276a40518655c +Status: Image is up to date for datadog/agent-dev:master-py3 +docker.io/datadog/agent-dev:master-py3 + +Stop environment -> ddev env stop postgres py3.9-14.0 +Execute tests -> ddev env test postgres py3.9-14.0 +Check status -> ddev env agent postgres py3.9-14.0 status +Trigger run -> ddev env agent postgres py3.9-14.0 check +Reload config -> ddev env reload postgres py3.9-14.0 +Manage config -> ddev env config +Config file -> C:\Users\ofek\AppData\Local\ddev\env\postgres\py3.9-14.0\config\postgres.yaml ``` This sets up the selected environment and an instance of the Agent running in a Docker container. The default @@ -47,23 +70,22 @@ Let's see what we have running: ``` $ docker ps --format "table {{.Image}}\t{{.Status}}\t{{.Ports}}\t{{.Names}}" -IMAGE STATUS PORTS NAMES -datadog/agent-dev:master-py3 Up 4 seconds (health: starting) dd_envoy_py38 -default_service2 Up 5 seconds 80/tcp, 10000/tcp default_service2_1 -envoyproxy/envoy:latest Up 5 seconds 0.0.0.0:8001->8001/tcp, 10000/tcp, 0.0.0.0:8000->80/tcp default_front-envoy_1 -default_xds Up 5 seconds 8080/tcp default_xds_1 -default_service1 Up 5 seconds 80/tcp, 10000/tcp default_service1_1 +IMAGE STATUS PORTS NAMES +datadog/agent-dev:master-py3 Up 3 minutes (healthy) dd_postgres_py3.9-14.0 +postgres:14-alpine Up 3 minutes (healthy) 5432/tcp, 0.0.0.0:5434->5434/tcp compose-postgres_replica2-1 +postgres:14-alpine Up 3 minutes (healthy) 0.0.0.0:5432->5432/tcp compose-postgres-1 +postgres:14-alpine Up 3 minutes (healthy) 5432/tcp, 0.0.0.0:5433->5433/tcp compose-postgres_replica-1 ``` ### Agent version -You can select a particular build of the Agent to use with the `--agent`/`-a` option. Any Docker image is valid e.g. `datadog/agent:7.17.0`. +You can select a particular build of the Agent to use with the `--agent`/`-a` option. Any Docker image is valid e.g. `datadog/agent:7.47.0`. A custom nightly build will be used by default, which is re-built on every commit to the [Datadog Agent repository][datadog-agent]. ### Integration version -By default the version of the integration used will be the one shipped with the chosen Agent version, as if you had passed in the `--prod` flag. If you wish +By default the version of the integration used will be the one shipped with the chosen Agent version. If you wish to modify an integration and test changes in real time, use the `--dev` flag. Doing so will mount and install the integration in the Agent container. All modifications to the integration's directory will be propagated to the Agent, @@ -76,25 +98,36 @@ If you modify the [base package](base/about.md) then you will need to mount that To run tests against the live Agent, use the `ddev env test` command. It is similar to the [test command](testing.md#usage) except it is capable of running tests [marked as E2E](ddev/plugins.md#agent-check-runner), and only runs such tests. -### Automation +## Agent invocation -You can use the `--new-env`/`-ne` flag to automate environment management. For example running: +You can invoke the Agent with arbitrary arguments using `ddev env agent [ARGS]`, for example: ``` -ddev env test apache:py38 vault:py38 -ne -``` +$ ddev env agent postgres py3.9-14.0 status +Getting the status from the agent. + -will start the `py38` environment for Apache, run E2E tests, tear down the environment, and then do the same for Vault. +================================== +Agent (v7.49.0-rc.2+git.5.2fe7360) +================================== -!!! tip - Since running tests implies code changes are being introduced, `--new-env` enables `--dev` by default. + Status date: 2023-10-06 05:16:45.079 UTC (1696569405079) + Agent start: 2023-10-06 04:58:26.113 UTC (1696568306113) + Pid: 395 + Go Version: go1.20.8 + Python Version: 3.9.17 + Build arch: amd64 + Agent flavor: agent + Check Runners: 4 + Log Level: info -## Execution +... +``` -Similar to the Agent's `check` command, you can perform manual check runs using `ddev env check `, for example: +Invoking the Agent's `check` command is special in that you may omit its required integration argument: ``` -$ ddev env check envoy py38 --log-level debug +$ ddev env agent postgres py3.9-14.0 check --log-level debug ... ========= Collector @@ -103,20 +136,33 @@ Collector Running Checks ============== - envoy (1.12.0) - -------------- - Instance ID: envoy:c705bd922a3c275c [OK] - Configuration Source: file:/etc/datadog-agent/conf.d/envoy.d/envoy.yaml + postgres (15.0.0) + ----------------- + Instance ID: postgres:973e44c6a9b27d18 [OK] + Configuration Source: file:/etc/datadog-agent/conf.d/postgres.d/postgres.yaml Total Runs: 1 - Metric Samples: Last Run: 546, Total: 546 + Metric Samples: Last Run: 2,971, Total: 2,971 Events: Last Run: 0, Total: 0 + Database Monitoring Metadata Samples: Last Run: 3, Total: 3 Service Checks: Last Run: 1, Total: 1 - Average Execution Time : 25ms - Last Execution Date : 2020-02-17 00:58:05.000000 UTC - Last Successful Execution Date : 2020-02-17 00:58:05.000000 UTC + Average Execution Time : 259ms + Last Execution Date : 2023-10-06 05:07:28 UTC (1696568848000) + Last Successful Execution Date : 2023-10-06 05:07:28 UTC (1696568848000) + + + Metadata + ======== + config.hash: postgres:973e44c6a9b27d18 + config.provider: file + resolved_hostname: ozone + version.major: 14 + version.minor: 9 + version.patch: 0 + version.raw: 14.9 + version.scheme: semver ``` -### Debugging +## Debugging You may start an [interactive debugging session][python-pdb] using the `--breakpoint`/`-b` option. @@ -124,23 +170,21 @@ The option accepts an integer representing the line number at which to break. Fo the first and last line of the integration's `check` method, respectively. ``` -$ ddev env check envoy py38 -b 0 -> /opt/datadog-agent/embedded/lib/python3.8/site-packages/datadog_checks/envoy/envoy.py(34)check() --> custom_tags = instance.get('tags', []) +$ ddev env agent postgres py3.9-14.0 check -b 0 +> /opt/datadog-agent/embedded/lib/python3.9/site-packages/datadog_checks/postgres/postgres.py(851)check() +-> tags = copy.copy(self.tags) (Pdb) list - 29 self.blacklisted_metrics = set() - 30 - 31 self.caching_metrics = None - 32 - 33 def check(self, instance): - 34 B-> custom_tags = instance.get('tags', []) - 35 - 36 try: - 37 stats_url = instance['stats_url'] - 38 except KeyError: - 39 msg = 'Envoy configuration setting `stats_url` is required' -(Pdb) print(instance) -{'stats_url': 'http://localhost:8001/stats'} +846 } +847 self._database_instance_emitted[self.resolved_hostname] = event +848 self.database_monitoring_metadata(json.dumps(event, default=default_json_event_encoding)) +849 +850 def check(self, _): +851 B-> tags = copy.copy(self.tags) +852 # Collect metrics +853 try: +854 # Check version +855 self._connect() +856 self.load_version() # We don't want to cache versions between runs to capture minor updates for metadata ``` !!! info "Caveat" @@ -155,6 +199,5 @@ of changes [in-app][], you will need to refresh the environment by running `ddev To stop an environment run `ddev env stop `. -Any environments that haven't been explicitly stopped will show as active in the output of `ddev env ls`, even persisting -through system restarts. If you are confident that environments are no longer active, you can run `ddev env prune` to -remove all accumulated environment state. +Any environments that haven't been explicitly stopped will show as active in the output of `ddev env show`, even persisting +through system restarts. diff --git a/snmp/tests/conftest.py b/snmp/tests/conftest.py index 2f90f67bec997..322aa124929e5 100644 --- a/snmp/tests/conftest.py +++ b/snmp/tests/conftest.py @@ -32,13 +32,13 @@ ] E2E_METADATA = { - 'start_commands': [ - # Ensure the Agent has access to profile definition files and auto_conf. - 'cp -r /home/snmp/datadog_checks/snmp/data/default_profiles /etc/datadog-agent/conf.d/snmp.d/', - ], 'docker_volumes': [ # Mount mock user profiles - '{}/fixtures/user_profiles:/etc/datadog-agent/conf.d/snmp.d/profiles'.format(HERE), + '{}:/etc/datadog-agent/conf.d/snmp.d/profiles'.format(os.path.join(HERE, 'fixtures', 'user_profiles')), + # Ensure the Agent has access to profile definition files + '{}:/etc/datadog-agent/conf.d/snmp.d/default_profiles'.format( + os.path.join(os.path.dirname(HERE), 'datadog_checks', 'snmp', 'data', 'default_profiles') + ), ], } @@ -60,7 +60,7 @@ def dd_environment(): with docker_run(os.path.join(COMPOSE_DIR, 'docker-compose.yaml'), env_vars=env, log_patterns="Listening at"): if SNMP_LISTENER_ENV == 'true': - instance_config = {} + instance_config = None new_e2e_metadata['docker_volumes'].append( '{}:/etc/datadog-agent/datadog.yaml'.format(create_datadog_conf_file(tmp_dir)), ) @@ -93,7 +93,7 @@ def _autodiscovery_ready(): autodiscovery_checks.append(result_line) # assert subnets discovered by `snmp_listener` config from datadog.yaml - assert len(autodiscovery_checks) == EXPECTED_AUTODISCOVERY_CHECKS + assert len(autodiscovery_checks) == EXPECTED_AUTODISCOVERY_CHECKS, result.stdout def create_datadog_conf_file(tmp_dir): From 782259a85042f12884f6d585c54f2de7f9ee2762 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Mon, 9 Oct 2023 10:51:33 -0400 Subject: [PATCH 2/3] address review --- ddev/src/ddev/e2e/agent/docker.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ddev/src/ddev/e2e/agent/docker.py b/ddev/src/ddev/e2e/agent/docker.py index 5c72bfc38f1c6..8b0af6969deac 100644 --- a/ddev/src/ddev/e2e/agent/docker.py +++ b/ddev/src/ddev/e2e/agent/docker.py @@ -259,6 +259,8 @@ def stop(self) -> None: for command in ( ['docker', 'stop', '-t', '0', self._container_name], + # Remove manually rather than using the `--rm` flag of the `run` command to allow for + # debugging issues that caused the Agent container to stop ['docker', 'rm', self._container_name], ): process = self._captured_process(command) From c8f242821c3dafb46d202d6bfe0510849e399031 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Wed, 11 Oct 2023 16:41:06 -0400 Subject: [PATCH 3/3] address review --- ddev/src/ddev/cli/env/agent.py | 4 ++-- ddev/src/ddev/cli/env/reload.py | 6 ++++-- ddev/src/ddev/cli/env/shell.py | 4 ++-- ddev/src/ddev/cli/env/show.py | 8 ++++---- ddev/src/ddev/cli/env/start.py | 8 ++++---- ddev/src/ddev/cli/env/stop.py | 6 +++--- ddev/src/ddev/cli/env/test.py | 3 ++- ddev/src/ddev/e2e/agent/docker.py | 7 ++++--- ddev/src/ddev/e2e/constants.py | 5 +++++ ddev/tests/cli/env/test_start.py | 4 ++-- 10 files changed, 32 insertions(+), 23 deletions(-) diff --git a/ddev/src/ddev/cli/env/agent.py b/ddev/src/ddev/cli/env/agent.py index f7614defc8dfc..12adca17114b7 100644 --- a/ddev/src/ddev/cli/env/agent.py +++ b/ddev/src/ddev/cli/env/agent.py @@ -27,7 +27,7 @@ def agent(app: Application, *, intg_name: str, environment: str, args: tuple[str from ddev.e2e.agent import get_agent_interface from ddev.e2e.config import EnvDataStorage - from ddev.e2e.constants import DEFAULT_AGENT_TYPE + from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EMetadata from ddev.utils.fs import Path integration = app.repo.integrations.get(intg_name) @@ -37,7 +37,7 @@ def agent(app: Application, *, intg_name: str, environment: str, args: tuple[str app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') metadata = env_data.read_metadata() - agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent_type = metadata.get(E2EMetadata.ENV_VARS, DEFAULT_AGENT_TYPE) agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) full_args = list(args) diff --git a/ddev/src/ddev/cli/env/reload.py b/ddev/src/ddev/cli/env/reload.py index ffb336161f2eb..952fa1f9c224e 100644 --- a/ddev/src/ddev/cli/env/reload.py +++ b/ddev/src/ddev/cli/env/reload.py @@ -21,7 +21,7 @@ def reload_command(app: Application, *, intg_name: str, environment: str): """ from ddev.e2e.agent import get_agent_interface from ddev.e2e.config import EnvDataStorage - from ddev.e2e.constants import DEFAULT_AGENT_TYPE + from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EMetadata integration = app.repo.integrations.get(intg_name) env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) @@ -30,10 +30,12 @@ def reload_command(app: Application, *, intg_name: str, environment: str): app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') metadata = env_data.read_metadata() - agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent_type = metadata.get(E2EMetadata.ENV_VARS, DEFAULT_AGENT_TYPE) agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) try: agent.restart() except Exception as e: app.abort(str(e)) + + app.display_success(f'Config reloaded: [link={env_data.config_file}]{env_data.config_file}[/]') diff --git a/ddev/src/ddev/cli/env/shell.py b/ddev/src/ddev/cli/env/shell.py index 7d92aadc43300..35006f389f696 100644 --- a/ddev/src/ddev/cli/env/shell.py +++ b/ddev/src/ddev/cli/env/shell.py @@ -23,7 +23,7 @@ def shell(app: Application, *, intg_name: str, environment: str): from ddev.e2e.agent import get_agent_interface from ddev.e2e.config import EnvDataStorage - from ddev.e2e.constants import DEFAULT_AGENT_TYPE + from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EMetadata integration = app.repo.integrations.get(intg_name) env_data = EnvDataStorage(app.data_dir).get(integration.name, environment) @@ -32,7 +32,7 @@ def shell(app: Application, *, intg_name: str, environment: str): app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') metadata = env_data.read_metadata() - agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent_type = metadata.get(E2EMetadata.ENV_VARS, DEFAULT_AGENT_TYPE) agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) try: diff --git a/ddev/src/ddev/cli/env/show.py b/ddev/src/ddev/cli/env/show.py index 4b8e3bb9b405d..84a562befdad9 100644 --- a/ddev/src/ddev/cli/env/show.py +++ b/ddev/src/ddev/cli/env/show.py @@ -21,7 +21,7 @@ def show(app: Application, *, intg_name: str | None, environment: str | None, fo Show active or available environments. """ from ddev.e2e.config import EnvDataStorage - from ddev.e2e.constants import DEFAULT_AGENT_TYPE + from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EMetadata storage = EnvDataStorage(app.data_dir) @@ -37,7 +37,7 @@ def show(app: Application, *, intg_name: str | None, environment: str | None, fo metadata = env_data.read_metadata() columns['Name'][i] = name - columns['Agent type'][i] = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + columns['Agent type'][i] = metadata.get(E2EMetadata.ENV_VARS, DEFAULT_AGENT_TYPE) app.display_table(active_integration, columns, show_lines=True, force_ascii=force_ascii) # Display active and available environments for a specific integration @@ -54,7 +54,7 @@ def show(app: Application, *, intg_name: str | None, environment: str | None, fo metadata = env_data.read_metadata() active_columns['Name'][i] = name - active_columns['Agent type'][i] = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + active_columns['Agent type'][i] = metadata.get(E2EMetadata.ENV_VARS, DEFAULT_AGENT_TYPE) with integration.path.as_cwd(): environments = json.loads( @@ -81,7 +81,7 @@ def show(app: Application, *, intg_name: str | None, environment: str | None, fo app.abort(f'Environment `{environment}` for integration `{integration.name}` is not running') metadata = env_data.read_metadata() - agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent_type = metadata.get(E2EMetadata.ENV_VARS, DEFAULT_AGENT_TYPE) agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) app.display_pair('Agent type', agent_type) diff --git a/ddev/src/ddev/cli/env/start.py b/ddev/src/ddev/cli/env/start.py index 56b16aef7c13d..4f30f7a95ff22 100644 --- a/ddev/src/ddev/cli/env/start.py +++ b/ddev/src/ddev/cli/env/start.py @@ -62,7 +62,7 @@ def start( from ddev.e2e.agent import get_agent_interface from ddev.e2e.config import EnvDataStorage - from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EEnvVars + from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EEnvVars, E2EMetadata from ddev.e2e.run import E2EEnvironmentRunner from ddev.utils.fs import Path, temp_directory @@ -126,7 +126,7 @@ def start( config = result['config'] env_data.write_config(config) - agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent_type = metadata.get(E2EMetadata.ENV_VARS, DEFAULT_AGENT_TYPE) agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) if not agent_build: @@ -161,7 +161,7 @@ def start( def _get_agent_env_vars(org_config, metadata, extra_env_vars, dogstatsd): - from ddev.e2e.constants import DEFAULT_DOGSTATSD_PORT, E2EEnvVars + from ddev.e2e.constants import DEFAULT_DOGSTATSD_PORT, E2EEnvVars, E2EMetadata # Use the environment variables defined by tests as defaults so tooling can override them env_vars: dict[str, str] = metadata.get('env_vars', {}).copy() @@ -188,7 +188,7 @@ def _get_agent_env_vars(org_config, metadata, extra_env_vars, dogstatsd): env_vars['DD_DOGSTATSD_METRICS_STATS_ENABLE'] = 'true' # Enable logs Agent by default if the environment is mounting logs - if any(ev.startswith(E2EEnvVars.LOGS_DIR_PREFIX) for ev in metadata.get('e2e_env_vars', {})): + if any(ev.startswith(E2EEnvVars.LOGS_DIR_PREFIX) for ev in metadata.get(E2EMetadata.ENV_VARS, {})): env_vars.setdefault('DD_LOGS_ENABLED', 'true') return env_vars diff --git a/ddev/src/ddev/cli/env/stop.py b/ddev/src/ddev/cli/env/stop.py index 7a3e6e417fa92..fc5395001b2db 100644 --- a/ddev/src/ddev/cli/env/stop.py +++ b/ddev/src/ddev/cli/env/stop.py @@ -22,7 +22,7 @@ def stop(app: Application, *, intg_name: str, environment: str, ignore_state: bo """ from ddev.e2e.agent import get_agent_interface from ddev.e2e.config import EnvDataStorage - from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EEnvVars + from ddev.e2e.constants import DEFAULT_AGENT_TYPE, E2EEnvVars, E2EMetadata from ddev.e2e.run import E2EEnvironmentRunner from ddev.utils.fs import temp_directory @@ -43,9 +43,9 @@ def stop(app: Application, *, intg_name: str, environment: str, ignore_state: bo env_vars = {E2EEnvVars.RESULT_FILE: str(result_file)} metadata = env_data.read_metadata() - env_vars.update(metadata.get('e2e_env_vars', {})) + env_vars.update(metadata.get(E2EMetadata.ENV_VARS, {})) - agent_type = metadata.get('agent_type', DEFAULT_AGENT_TYPE) + agent_type = metadata.get(E2EMetadata.ENV_VARS, DEFAULT_AGENT_TYPE) agent = get_agent_interface(agent_type)(app.platform, integration, environment, metadata, env_data.config_file) try: diff --git a/ddev/src/ddev/cli/env/test.py b/ddev/src/ddev/cli/env/test.py index b5d4da028026d..0b991205d6c64 100644 --- a/ddev/src/ddev/cli/env/test.py +++ b/ddev/src/ddev/cli/env/test.py @@ -69,6 +69,7 @@ def test_command( from ddev.cli.env.stop import stop from ddev.cli.test import test from ddev.e2e.config import EnvDataStorage + from ddev.e2e.constants import E2EMetadata from ddev.utils.ci import running_in_ci from ddev.utils.structures import EnvVars @@ -128,7 +129,7 @@ def test_command( env_data = storage.get(integration.name, env_name) metadata = env_data.read_metadata() try: - with EnvVars(metadata.get('e2e_env_vars', {})): + with EnvVars(metadata.get(E2EMetadata.ENV_VARS, {})): ctx.invoke( test, target_spec=f'{intg_name}:{env_name}', args=args, junit=junit, hide_header=True, e2e=True ) diff --git a/ddev/src/ddev/e2e/agent/docker.py b/ddev/src/ddev/e2e/agent/docker.py index 8b0af6969deac..a508e0d5c7f3f 100644 --- a/ddev/src/ddev/e2e/agent/docker.py +++ b/ddev/src/ddev/e2e/agent/docker.py @@ -162,9 +162,10 @@ def start(self, *, agent_build: str, local_packages: dict[Path, str], env_vars: remaining = ':'.join(parts[2:]) volumes[i] = f'/{vm_file}:{remaining}' - process = self._run_command(['docker', 'pull', agent_build]) - if process.returncode: - raise RuntimeError(f'Could not pull image {agent_build}') + if not os.getenv('DDEV_E2E_DOCKER_NO_PULL') != '1': + process = self._run_command(['docker', 'pull', agent_build]) + if process.returncode: + raise RuntimeError(f'Could not pull image {agent_build}') command = [ 'docker', diff --git a/ddev/src/ddev/e2e/constants.py b/ddev/src/ddev/e2e/constants.py index 8036a472fa0c4..bb2fbffaba116 100644 --- a/ddev/src/ddev/e2e/constants.py +++ b/ddev/src/ddev/e2e/constants.py @@ -11,3 +11,8 @@ class E2EEnvVars: PARENT_PYTHON = 'DDEV_E2E_PYTHON_PATH' RESULT_FILE = 'DDEV_E2E_RESULT_FILE' LOGS_DIR_PREFIX = 'DDEV_E2E_ENV_TEMP_DIR_DD_LOG_' + + +class E2EMetadata: + AGENT_TYPE = 'agent_type' + ENV_VARS = 'e2e_env_vars' diff --git a/ddev/tests/cli/env/test_start.py b/ddev/tests/cli/env/test_start.py index 64a1775b3a18c..0a4102d3bc95a 100644 --- a/ddev/tests/cli/env/test_start.py +++ b/ddev/tests/cli/env/test_start.py @@ -6,7 +6,7 @@ import pytest from ddev.e2e.config import EnvDataStorage -from ddev.e2e.constants import DEFAULT_DOGSTATSD_PORT, E2EEnvVars +from ddev.e2e.constants import DEFAULT_DOGSTATSD_PORT, E2EEnvVars, E2EMetadata from ddev.utils.fs import Path from ddev.utils.structures import EnvVars @@ -366,7 +366,7 @@ def test_env_vars(ddev, helpers, data_dir, write_result_file, mocker): def test_logs_detection(ddev, helpers, data_dir, write_result_file, mocker): - metadata = {'e2e_env_vars': {f'{E2EEnvVars.LOGS_DIR_PREFIX}1': 'path'}} + metadata = {E2EMetadata.ENV_VARS: {f'{E2EEnvVars.LOGS_DIR_PREFIX}1': 'path'}} config = {} mocker.patch('subprocess.run', side_effect=write_result_file({'metadata': metadata, 'config': config})) start = mocker.patch('ddev.e2e.agent.docker.DockerAgent.start')