Skip to content

Commit 9e89c6f

Browse files
authored
Detect stale and unreleased entries in Agent release requirements (DataDog#23813)
* Validate stale Agent release requirements * Skip unreleased integrations in Agent release output * Tighten unreleased-integrations parsing and broaden tests - Validate that by-agent-version-range keys contain the '..' separator and raise a clear ValueError naming the offending key. - Drop the redundant else branch in exclude_unreleased_integrations and move the historical folder-name comment back next to normalize_catalog. - mkdir(exist_ok=True) in the write_repo_config helper for consistency with neighbours. - Parametrize test_agent_version_in_range_is_inclusive (now covers below/above bounds and the malformed-range error path). - Add a direct unit test on get_unreleased_integrations that exercises by-integration and by-agent-version-range together. - Add a clean-pass test for validate agent-reqs and pull the set_root teardown into an isolated_root yield fixture. - Add changelog entries for ddev and datadog_checks_dev. * Surface malformed version ranges as a clean ddev abort - Drop the redundant empty parent table header in .ddev/config.toml; the two sub-tables imply it. - Catch ValueError from agent_version_in_range at every command entry point that triggers the lookup (integrations, changelog, integrations_changelog) and surface it via app.abort so config authors get a clean message instead of a Python traceback. - Document that exclude_unreleased_integrations accepts both raw and folder-normalized catalog keys. * Scope stale-entry detection to whole-file invocations When the user runs `ddev validate agent-reqs <check>`, only the requested check should be validated; previously the new stale-entry detection still scanned the whole requirements file and surfaced unrelated stale packages, defeating per-check pre-commit usage.
1 parent 0ce067c commit 9e89c6f

12 files changed

Lines changed: 283 additions & 11 deletions

File tree

.ddev/config.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,24 @@ trace-captures = false
240240
## Just in case __pycache__ is present in the root of the repo
241241
__pycache__ = false
242242

243+
# Integrations that were pinned in requirements-agent-release.txt but were not shipped
244+
# in the listed Agent releases. Agent release generation uses these entries to skip
245+
# false positives when building AGENT_INTEGRATIONS.md and Agent changelog data.
246+
# Use by-integration for one-off skips:
247+
# integration_name = ["7.78.0", "7.79.0"]
248+
# Use by-agent-version-range for inclusive Agent version ranges:
249+
# "7.74.0..7.78.0" = ["datadog-first-integration", "datadog-second-integration"]
250+
[overrides.release.agent.unreleased-integrations.by-integration]
251+
252+
[overrides.release.agent.unreleased-integrations.by-agent-version-range]
253+
"7.74.0..7.78.0" = [
254+
"datadog-control-m",
255+
"datadog-krakend",
256+
"datadog-lustre",
257+
"datadog-n8n",
258+
"datadog-prefect",
259+
]
260+
243261
# Explicitely add the platforms supported by an integration for those where the manifest has been
244262
# removed.
245263
# This is a temporary fix while we implement a metadata.json file that we can add to each integration
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

datadog_checks_dev/datadog_checks/dev/tooling/commands/validate/agent_reqs.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@
1313
echo_warning,
1414
)
1515
from datadog_checks.dev.tooling.constants import AGENT_V5_ONLY, NOT_CHECKS, get_agent_release_requirements
16-
from datadog_checks.dev.tooling.release import get_package_name
16+
from datadog_checks.dev.tooling.release import get_folder_name, get_package_name
1717
from datadog_checks.dev.tooling.testing import process_checks_option
18-
from datadog_checks.dev.tooling.utils import complete_valid_checks, get_version_string, parse_agent_req_file
18+
from datadog_checks.dev.tooling.utils import (
19+
complete_valid_checks,
20+
get_valid_checks,
21+
get_version_string,
22+
parse_agent_req_file,
23+
)
1924
from datadog_checks.dev.utils import read_file
2025

2126

@@ -63,6 +68,33 @@ def agent_reqs(check):
6368
if unreleased_checks:
6469
joined_checks = ', '.join(unreleased_checks)
6570
echo_warning(f"{len(unreleased_checks)} unreleased checks: {joined_checks}")
71+
if check is None or check.lower() == 'all':
72+
stale_released_checks = find_stale_released_checks(agent_reqs_content)
73+
if stale_released_checks:
74+
failed_checks += len(stale_released_checks)
75+
for package_name in stale_released_checks:
76+
folder_name = get_folder_name(package_name)
77+
message = (
78+
f"{package_name} is pinned in requirements-agent-release.txt "
79+
f"but `{folder_name}` is not present in the repo"
80+
)
81+
echo_failure(message)
82+
annotate_error(release_requirements_file, message)
6683
if failed_checks:
6784
echo_failure(f"{failed_checks} checks out of sync")
6885
abort()
86+
87+
88+
def find_stale_released_checks(agent_reqs_content: dict[str, str]) -> list[str]:
89+
"""Return pinned Agent packages that no longer match a repo check."""
90+
expected_packages = {
91+
get_package_name(check_name)
92+
for check_name in get_valid_checks()
93+
if check_name not in AGENT_V5_ONLY | NOT_CHECKS
94+
}
95+
96+
return sorted(
97+
package_name
98+
for package_name in agent_reqs_content
99+
if package_name.startswith('datadog-') and package_name not in expected_packages
100+
)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
import os
5+
6+
import pytest
7+
from click.testing import CliRunner
8+
9+
from datadog_checks.dev.tooling.commands.validate.agent_reqs import agent_reqs
10+
from datadog_checks.dev.tooling.constants import get_root, set_root
11+
12+
13+
@pytest.fixture
14+
def isolated_root():
15+
runner = CliRunner()
16+
previous_root = get_root()
17+
with runner.isolated_filesystem():
18+
set_root(os.getcwd())
19+
try:
20+
yield runner
21+
finally:
22+
set_root(previous_root)
23+
24+
25+
def test_validate_agent_reqs_fails_on_stale_release_entry(isolated_root):
26+
write_check('foo', '1.0.0')
27+
with open('requirements-agent-release.txt', 'w', encoding='utf-8') as f:
28+
f.write('# DO NOT PASS THIS TO PIP DIRECTLY\ndatadog-foo==1.0.0\ndatadog-snowflake==7.13.0\n')
29+
30+
result = isolated_root.invoke(agent_reqs)
31+
32+
assert result.exit_code == 1
33+
assert (
34+
'datadog-snowflake is pinned in requirements-agent-release.txt but `snowflake` is not present in the repo'
35+
) in result.output
36+
assert 'datadog-foo is pinned' not in result.output
37+
38+
39+
def test_validate_agent_reqs_passes_when_every_entry_has_a_folder(isolated_root):
40+
write_check('foo', '1.0.0')
41+
with open('requirements-agent-release.txt', 'w', encoding='utf-8') as f:
42+
f.write('# DO NOT PASS THIS TO PIP DIRECTLY\ndatadog-foo==1.0.0\n')
43+
44+
result = isolated_root.invoke(agent_reqs)
45+
46+
assert result.exit_code == 0
47+
assert 'pinned in requirements-agent-release.txt' not in result.output
48+
49+
50+
def test_validate_agent_reqs_does_not_report_stale_entries_when_scoped_to_a_check(isolated_root):
51+
write_check('foo', '1.0.0')
52+
with open('requirements-agent-release.txt', 'w', encoding='utf-8') as f:
53+
f.write('# DO NOT PASS THIS TO PIP DIRECTLY\ndatadog-foo==1.0.0\ndatadog-snowflake==7.13.0\n')
54+
55+
result = isolated_root.invoke(agent_reqs, ['foo'])
56+
57+
assert result.exit_code == 0
58+
assert 'datadog-snowflake is pinned' not in result.output
59+
60+
61+
def write_check(name: str, version: str) -> None:
62+
"""Create the minimum check structure needed by agent-reqs."""
63+
check_package = os.path.join(name, 'datadog_checks', name)
64+
os.makedirs(check_package)
65+
with open(os.path.join(check_package, '__about__.py'), 'w', encoding='utf-8') as f:
66+
f.write(f'__version__ = "{version}"\n')

ddev/changelog.d/23813.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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`.

ddev/src/ddev/cli/release/agent/changelog.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ def changelog(app: Application, since: str, to: str, write: bool, force: bool):
5858

5959
app.repo.git.fetch_tags()
6060

61-
changes_per_agent = get_changes_per_agent(app.repo, since, to)
61+
try:
62+
changes_per_agent = get_changes_per_agent(app.repo, since, to)
63+
except ValueError as exc:
64+
app.abort(str(exc))
6265

6366
# store the changelog in memory
6467
changelog_contents = StringIO()

ddev/src/ddev/cli/release/agent/common.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
AgentChangelog = dict[str, dict[str, tuple[str, bool, bool]]]
1212

1313
DATADOG_PACKAGE_PREFIX = 'datadog-'
14+
UNRELEASED_INTEGRATIONS_CONFIG = '/overrides/release/agent/unreleased-integrations'
1415

1516

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

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

8280
changes_per_agent[current_tag] = {}
8381

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

9694

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

100101

102+
def exclude_unreleased_integrations(repo: Repository, catalog: dict[str, str], agent_version: str) -> dict[str, str]:
103+
"""Filter integrations listed as unreleased for ``agent_version``; catalog keys may be raw or folder-normalized."""
104+
skipped_integrations = get_unreleased_integrations(repo, agent_version)
105+
if not skipped_integrations:
106+
return catalog
107+
return {
108+
name: version for name, version in catalog.items() if normalize_package_name(name) not in skipped_integrations
109+
}
110+
111+
112+
def get_unreleased_integrations(repo: Repository, agent_version: str) -> set[str]:
113+
unreleased_integrations = repo.config.get(UNRELEASED_INTEGRATIONS_CONFIG, default={})
114+
by_integration = unreleased_integrations.get('by-integration', {})
115+
by_agent_version_range = unreleased_integrations.get('by-agent-version-range', {})
116+
117+
skipped_integrations = {
118+
normalize_package_name(name) for name, versions in by_integration.items() if agent_version in versions
119+
}
120+
for version_range, integration_names in by_agent_version_range.items():
121+
if agent_version_in_range(agent_version, version_range):
122+
skipped_integrations.update(normalize_package_name(name) for name in integration_names)
123+
124+
return skipped_integrations
125+
126+
127+
def agent_version_in_range(agent_version: str, version_range: str) -> bool:
128+
from packaging.version import parse as parse_version
129+
130+
parts = version_range.split('..', 1)
131+
if len(parts) != 2:
132+
raise ValueError(
133+
f"Invalid version range {version_range!r} in "
134+
f"{UNRELEASED_INTEGRATIONS_CONFIG}/by-agent-version-range; "
135+
"expected format: 'START..END'"
136+
)
137+
start, end = parts
138+
version = parse_version(agent_version)
139+
start_version = parse_version(start)
140+
end_version = parse_version(end)
141+
142+
return start_version <= version <= end_version
143+
144+
101145
def normalize_package_name(name: str) -> str:
102146
"""
103147
Given a Python package name for a check, return the corresponding folder

ddev/src/ddev/cli/release/agent/integrations.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def integrations(app: Application, since: str, to: str, write: bool, force: bool
2929
tool will generate the list for every Agent since version 6.3.0
3030
(before that point we don't have enough information to build the log).
3131
"""
32-
from ddev.cli.release.agent.common import get_agent_tags, parse_agent_req_file
32+
from ddev.cli.release.agent.common import exclude_unreleased_integrations, get_agent_tags, parse_agent_req_file
3333

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

ddev/src/ddev/cli/release/agent/integrations_changelog.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ def integrations_changelog(app: Application, integrations: tuple[str], since: st
3939
if not integrations:
4040
integrations = [integration.name for integration in app.repo.integrations.iter_all('all')]
4141

42-
changes_per_agent = get_changes_per_agent(app.repo, since, to)
42+
try:
43+
changes_per_agent = get_changes_per_agent(app.repo, since, to)
44+
except ValueError as exc:
45+
app.abort(str(exc))
4346

4447
integrations_versions: dict[str, dict[str, str]] = defaultdict(dict)
4548
for agent_version, version_changes in changes_per_agent.items():

ddev/tests/cli/release/agent/conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
# (C) Datadog, Inc. 2023-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from collections.abc import Callable
5+
46
import pytest
57

68
from ddev.repo.core import Repository
9+
from ddev.utils.fs import Path
710

811

912
@pytest.fixture
@@ -55,6 +58,16 @@ def commit(msg):
5558
yield repo
5659

5760

61+
@pytest.fixture
62+
def write_repo_config() -> Callable[[Path, str], None]:
63+
def write_config(repo_path: Path, contents: str) -> None:
64+
config_dir = repo_path / '.ddev'
65+
config_dir.mkdir(exist_ok=True)
66+
(config_dir / 'config.toml').write_text(contents)
67+
68+
return write_config
69+
70+
5871
def write_agent_requirements(repo_path, requirements):
5972
with open(repo_path / 'requirements-agent-release.txt', 'w') as req_file:
6073
req_file.write('\n'.join(requirements))

0 commit comments

Comments
 (0)