Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .ddev/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,24 @@ trace-captures = false
## Just in case __pycache__ is present in the root of the repo
__pycache__ = false

# Integrations that were pinned in requirements-agent-release.txt but were not shipped
# in the listed Agent releases. Agent release generation uses these entries to skip
# false positives when building AGENT_INTEGRATIONS.md and Agent changelog data.
# Use by-integration for one-off skips:
# integration_name = ["7.78.0", "7.79.0"]
# Use by-agent-version-range for inclusive Agent version ranges:
# "7.74.0..7.78.0" = ["datadog-first-integration", "datadog-second-integration"]
[overrides.release.agent.unreleased-integrations.by-integration]

[overrides.release.agent.unreleased-integrations.by-agent-version-range]
"7.74.0..7.78.0" = [
"datadog-control-m",
"datadog-krakend",
"datadog-lustre",
"datadog-n8n",
"datadog-prefect",
]

# Explicitely add the platforms supported by an integration for those where the manifest has been
# removed.
# This is a temporary fix while we implement a metadata.json file that we can add to each integration
Expand Down
1 change: 1 addition & 0 deletions datadog_checks_dev/changelog.d/23813.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fail `ddev validate agent-reqs` when `requirements-agent-release.txt` pins a `datadog-*` package whose integration folder is no longer present in the repo.
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
echo_warning,
)
from datadog_checks.dev.tooling.constants import AGENT_V5_ONLY, NOT_CHECKS, get_agent_release_requirements
from datadog_checks.dev.tooling.release import get_package_name
from datadog_checks.dev.tooling.release import get_folder_name, get_package_name
from datadog_checks.dev.tooling.testing import process_checks_option
from datadog_checks.dev.tooling.utils import complete_valid_checks, get_version_string, parse_agent_req_file
from datadog_checks.dev.tooling.utils import (
complete_valid_checks,
get_valid_checks,
get_version_string,
parse_agent_req_file,
)
from datadog_checks.dev.utils import read_file


Expand Down Expand Up @@ -63,6 +68,33 @@ def agent_reqs(check):
if unreleased_checks:
joined_checks = ', '.join(unreleased_checks)
echo_warning(f"{len(unreleased_checks)} unreleased checks: {joined_checks}")
if check is None or check.lower() == 'all':
stale_released_checks = find_stale_released_checks(agent_reqs_content)
if stale_released_checks:
failed_checks += len(stale_released_checks)
for package_name in stale_released_checks:
folder_name = get_folder_name(package_name)
message = (
f"{package_name} is pinned in requirements-agent-release.txt "
f"but `{folder_name}` is not present in the repo"
)
echo_failure(message)
annotate_error(release_requirements_file, message)
if failed_checks:
echo_failure(f"{failed_checks} checks out of sync")
abort()


def find_stale_released_checks(agent_reqs_content: dict[str, str]) -> list[str]:
"""Return pinned Agent packages that no longer match a repo check."""
expected_packages = {
get_package_name(check_name)
for check_name in get_valid_checks()
if check_name not in AGENT_V5_ONLY | NOT_CHECKS
}

return sorted(
package_name
for package_name in agent_reqs_content
if package_name.startswith('datadog-') and package_name not in expected_packages
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# (C) Datadog, Inc. 2026-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
import os

import pytest
from click.testing import CliRunner

from datadog_checks.dev.tooling.commands.validate.agent_reqs import agent_reqs
from datadog_checks.dev.tooling.constants import get_root, set_root


@pytest.fixture
def isolated_root():
runner = CliRunner()
previous_root = get_root()
with runner.isolated_filesystem():
set_root(os.getcwd())
try:
yield runner
finally:
set_root(previous_root)


def test_validate_agent_reqs_fails_on_stale_release_entry(isolated_root):
write_check('foo', '1.0.0')
with open('requirements-agent-release.txt', 'w', encoding='utf-8') as f:
f.write('# DO NOT PASS THIS TO PIP DIRECTLY\ndatadog-foo==1.0.0\ndatadog-snowflake==7.13.0\n')

result = isolated_root.invoke(agent_reqs)

assert result.exit_code == 1
assert (
'datadog-snowflake is pinned in requirements-agent-release.txt but `snowflake` is not present in the repo'
) in result.output
assert 'datadog-foo is pinned' not in result.output


def test_validate_agent_reqs_passes_when_every_entry_has_a_folder(isolated_root):
write_check('foo', '1.0.0')
with open('requirements-agent-release.txt', 'w', encoding='utf-8') as f:
f.write('# DO NOT PASS THIS TO PIP DIRECTLY\ndatadog-foo==1.0.0\n')

result = isolated_root.invoke(agent_reqs)

assert result.exit_code == 0
assert 'pinned in requirements-agent-release.txt' not in result.output


def test_validate_agent_reqs_does_not_report_stale_entries_when_scoped_to_a_check(isolated_root):
write_check('foo', '1.0.0')
with open('requirements-agent-release.txt', 'w', encoding='utf-8') as f:
f.write('# DO NOT PASS THIS TO PIP DIRECTLY\ndatadog-foo==1.0.0\ndatadog-snowflake==7.13.0\n')

result = isolated_root.invoke(agent_reqs, ['foo'])

assert result.exit_code == 0
assert 'datadog-snowflake is pinned' not in result.output


def write_check(name: str, version: str) -> None:
"""Create the minimum check structure needed by agent-reqs."""
check_package = os.path.join(name, 'datadog_checks', name)
os.makedirs(check_package)
with open(os.path.join(check_package, '__about__.py'), 'w', encoding='utf-8') as f:
f.write(f'__version__ = "{version}"\n')
1 change: 1 addition & 0 deletions ddev/changelog.d/23813.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Skip integrations pinned in Agent release requirements but not actually shipped in a given Agent release, configurable under `[overrides.release.agent.unreleased-integrations]` in `.ddev/config.toml`.
5 changes: 4 additions & 1 deletion ddev/src/ddev/cli/release/agent/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ def changelog(app: Application, since: str, to: str, write: bool, force: bool):

app.repo.git.fetch_tags()

changes_per_agent = get_changes_per_agent(app.repo, since, to)
try:
changes_per_agent = get_changes_per_agent(app.repo, since, to)
except ValueError as exc:
app.abort(str(exc))

# store the changelog in memory
changelog_contents = StringIO()
Expand Down
54 changes: 49 additions & 5 deletions ddev/src/ddev/cli/release/agent/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
AgentChangelog = dict[str, dict[str, tuple[str, bool, bool]]]

DATADOG_PACKAGE_PREFIX = 'datadog-'
UNRELEASED_INTEGRATIONS_CONFIG = '/overrides/release/agent/unreleased-integrations'


def get_agent_tags(repo: Repository, since: str, to: str) -> list[str]:
Expand Down Expand Up @@ -73,11 +74,8 @@ def get_changes_per_agent(repo: Repository, since: str, to: str) -> AgentChangel
file_contents = repo.git.show_file(req_file_name, agent_tags[i])
catalog_prev = parse_agent_req_file(file_contents)

# at some point in the git history, the requirements file erroneously
# contained the folder name instead of the package name for each check,
# let's be resilient by normalizing all entries to be folder names
catalog_now = normalize_catalog(catalog_now)
catalog_prev = normalize_catalog(catalog_prev)
catalog_now = exclude_unreleased_integrations(repo, normalize_catalog(catalog_now), current_tag)
catalog_prev = exclude_unreleased_integrations(repo, normalize_catalog(catalog_prev), agent_tags[i])

changes_per_agent[current_tag] = {}

Expand All @@ -94,10 +92,56 @@ def get_changes_per_agent(repo: Repository, since: str, to: str) -> AgentChangel
return changes_per_agent


# at some point in the git history, the requirements file erroneously
# contained the folder name instead of the package name for each check,
# let's be resilient by normalizing all entries to be folder names
def normalize_catalog(catalog: dict[str, str]) -> dict[str, str]:
return {normalize_package_name(k): v for k, v in catalog.items()}


def exclude_unreleased_integrations(repo: Repository, catalog: dict[str, str], agent_version: str) -> dict[str, str]:
"""Filter integrations listed as unreleased for ``agent_version``; catalog keys may be raw or folder-normalized."""
skipped_integrations = get_unreleased_integrations(repo, agent_version)
if not skipped_integrations:
return catalog
return {
name: version for name, version in catalog.items() if normalize_package_name(name) not in skipped_integrations
}


def get_unreleased_integrations(repo: Repository, agent_version: str) -> set[str]:
unreleased_integrations = repo.config.get(UNRELEASED_INTEGRATIONS_CONFIG, default={})
by_integration = unreleased_integrations.get('by-integration', {})
by_agent_version_range = unreleased_integrations.get('by-agent-version-range', {})

skipped_integrations = {
normalize_package_name(name) for name, versions in by_integration.items() if agent_version in versions
}
for version_range, integration_names in by_agent_version_range.items():
if agent_version_in_range(agent_version, version_range):
skipped_integrations.update(normalize_package_name(name) for name in integration_names)

return skipped_integrations


def agent_version_in_range(agent_version: str, version_range: str) -> bool:
from packaging.version import parse as parse_version

parts = version_range.split('..', 1)
if len(parts) != 2:
raise ValueError(
f"Invalid version range {version_range!r} in "
f"{UNRELEASED_INTEGRATIONS_CONFIG}/by-agent-version-range; "
"expected format: 'START..END'"
)
start, end = parts
version = parse_version(agent_version)
start_version = parse_version(start)
end_version = parse_version(end)

return start_version <= version <= end_version


def normalize_package_name(name: str) -> str:
"""
Given a Python package name for a check, return the corresponding folder
Expand Down
8 changes: 6 additions & 2 deletions ddev/src/ddev/cli/release/agent/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def integrations(app: Application, since: str, to: str, write: bool, force: bool
tool will generate the list for every Agent since version 6.3.0
(before that point we don't have enough information to build the log).
"""
from ddev.cli.release.agent.common import get_agent_tags, parse_agent_req_file
from ddev.cli.release.agent.common import exclude_unreleased_integrations, get_agent_tags, parse_agent_req_file

agent_tags = get_agent_tags(app.repo, since, to)
# get the list of integrations shipped with the agent from the requirements file
Expand All @@ -40,7 +40,11 @@ def integrations(app: Application, since: str, to: str, write: bool, force: bool
integrations_contents.write(f'## Datadog Agent version {tag}\n\n')
# Requirements for current tag
file_contents = app.repo.git.show_file(req_file_name, tag)
for name, ver in parse_agent_req_file(file_contents).items():
try:
catalog = exclude_unreleased_integrations(app.repo, parse_agent_req_file(file_contents), tag)
except ValueError as exc:
app.abort(str(exc))
for name, ver in catalog.items():
integrations_contents.write(f'* {name}: {ver}\n')
integrations_contents.write('\n')

Expand Down
5 changes: 4 additions & 1 deletion ddev/src/ddev/cli/release/agent/integrations_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ def integrations_changelog(app: Application, integrations: tuple[str], since: st
if not integrations:
integrations = [integration.name for integration in app.repo.integrations.iter_all('all')]

changes_per_agent = get_changes_per_agent(app.repo, since, to)
try:
changes_per_agent = get_changes_per_agent(app.repo, since, to)
except ValueError as exc:
app.abort(str(exc))

integrations_versions: dict[str, dict[str, str]] = defaultdict(dict)
for agent_version, version_changes in changes_per_agent.items():
Expand Down
13 changes: 13 additions & 0 deletions ddev/tests/cli/release/agent/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# (C) Datadog, Inc. 2023-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from collections.abc import Callable

import pytest

from ddev.repo.core import Repository
from ddev.utils.fs import Path


@pytest.fixture
Expand Down Expand Up @@ -55,6 +58,16 @@ def commit(msg):
yield repo


@pytest.fixture
def write_repo_config() -> Callable[[Path, str], None]:
def write_config(repo_path: Path, contents: str) -> None:
config_dir = repo_path / '.ddev'
config_dir.mkdir(exist_ok=True)
(config_dir / 'config.toml').write_text(contents)

return write_config


def write_agent_requirements(repo_path, requirements):
with open(repo_path / 'requirements-agent-release.txt', 'w') as req_file:
req_file.write('\n'.join(requirements))
Expand Down
26 changes: 26 additions & 0 deletions ddev/tests/cli/release/agent/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,32 @@ def test_new_integration_with_non_initial_version(repo_with_new_integration_patc
assert mock_fetch_tags.call_count == 1


def test_changelog_skips_unreleased_integrations(repo_with_history, config_file, ddev, mocker, write_repo_config):
config_file.model.repos['core'] = str(repo_with_history.path)
config_file.save()
write_repo_config(
repo_with_history.path,
"""
[overrides.release.agent.unreleased-integrations.by-integration]
bar = ["7.38.0"]
""",
)
mock_fetch_tags = mocker.patch('ddev.utils.git.GitRepository.fetch_tags')

result = ddev('release', 'agent', 'changelog', '--since', '7.37.0', '--to', '7.38.0')
assert result.exit_code == 0

expected_output = """## Datadog Agent version [7.38.0](https://github.com/DataDog/datadog-agent/blob/master/CHANGELOG.rst#7380)

### New Integrations
* datadog_checks_base [2.1.3](https://github.com/DataDog/integrations-core/blob/master/datadog_checks_base/CHANGELOG.md)
### Integration Updates
* foo [1.5.0](https://github.com/DataDog/integrations-core/blob/master/foo/CHANGELOG.md)
"""
assert result.output.rstrip('\n') == expected_output.strip('\n')
assert mock_fetch_tags.call_count == 1


@pytest.fixture
def repo_with_fake_changelog(repo_with_history, config_file):
config_file.model.repos['core'] = str(repo_with_history.path)
Expand Down
Loading
Loading