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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ treemap*.png
# Claude Code PR review artifacts
.agint-review/

# `ddev release port-commit` worktrees
.worktrees/

# Ignore any metadata file except root json in the downloader
datadog_checks_downloader/datadog_checks/downloader/data/repo/targets/*
!datadog_checks_downloader/datadog_checks/downloader/data/repo/targets/.gitignore
Expand Down
1 change: 1 addition & 0 deletions datadog_checks_dev/changelog.d/23687.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Restore the legacy `validate jmx-metrics` command file to keep the release pipeline's in-toto attestation valid; the command is still served by `ddev`.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .eula import eula
from .imports import imports
from .integration_style import integration_style
from .jmx_metrics import jmx_metrics
from .license_headers import license_headers
from .models import models
from .package import package
Expand All @@ -30,6 +31,7 @@
eula,
imports,
integration_style,
jmx_metrics,
legacy_signature,
license_headers,
models,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# (C) Datadog, Inc. 2020-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from ast import literal_eval
from collections import defaultdict

import click
import yaml

from datadog_checks.dev.tooling.commands.console import (
CONTEXT_SETTINGS,
abort,
annotate_error,
echo_failure,
echo_info,
echo_success,
)
from datadog_checks.dev.tooling.testing import process_checks_option
from datadog_checks.dev.tooling.utils import (
complete_valid_checks,
file_exists,
get_default_config_spec,
get_jmx_metrics_file,
is_jmx_integration,
read_file,
)


@click.command('jmx-metrics', context_settings=CONTEXT_SETTINGS, short_help='Validate JMX metrics files')
@click.argument('check', shell_complete=complete_valid_checks, required=False)
@click.option('--verbose', '-v', is_flag=True, help='Verbose mode')
def jmx_metrics(check, verbose):
"""Validate all default JMX metrics definitions.

If `check` is specified, only the check will be validated, if check value is 'changed' will only apply to changed
checks, an 'all' or empty `check` value will validate all README files.
"""

checks = process_checks_option(check, source='integrations')
integrations = sorted(check for check in checks if is_jmx_integration(check))
echo_info(f"Validating JMX metrics files for {len(integrations)} checks ...")

saved_errors = defaultdict(list)

for check_name in integrations:
validate_jmx_metrics(check_name, saved_errors, verbose)
validate_config_spec(check_name, saved_errors)

for key, errors in saved_errors.items():
if not errors:
continue
check_name, filepath = key
annotate_error(filepath, "\n".join(errors))
echo_info(f"{check_name}:")
for err in errors:
echo_failure(f" - {err}")

echo_info(f"{len(integrations)} total JMX integrations")
echo_success(f"{len(integrations) - len(saved_errors)} valid metrics files")
if saved_errors:
echo_failure(f"{len(saved_errors)} invalid metrics files")
abort()


def validate_jmx_metrics(check_name, saved_errors, verbose):
jmx_metrics_file, metrics_file_exists = get_jmx_metrics_file(check_name)

if not metrics_file_exists:
saved_errors[(check_name, None)].append(f'{jmx_metrics_file} does not exist')
return
try:
# Load yaml config with custom constructor. The default loader overwrites duplicate keys:
# https://github.com/yaml/pyyaml/issues/165
yaml.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, no_duplicates_constructor)
yaml.load(read_file(jmx_metrics_file), Loader=yaml.FullLoader).get('jmx_metrics')
except Exception as errors:
saved_errors[(check_name, jmx_metrics_file)].append("The config contains the following duplicates entries:")
# Convert Exception -> String -> List
errors = literal_eval(str(errors))
for e in errors:
saved_errors[(check_name, jmx_metrics_file)].append(f"{e[0]} on line {e[-1]}")

jmx_metrics_data = yaml.safe_load(read_file(jmx_metrics_file)).get('jmx_metrics')
if jmx_metrics_data is None:
saved_errors[(check_name, jmx_metrics_file)].append(f'{jmx_metrics_file} does not have jmx_metrics definition')
return

for rule in jmx_metrics_data:
include = rule.get('include')
include_str = truncate_message(str(include), verbose)
rule_str = truncate_message(str(rule), verbose)

if not include:
saved_errors[(check_name, jmx_metrics_file)].append(f"missing include: {rule_str}")
return

domain = include.get('domain')
domain_regex = include.get('domain_regex')
beans = include.get('bean')
if (not domain) and (not domain_regex) and (not beans):
# Require `domain`, `domain_regex`, or `bean` to be present,
# that helps JMXFetch to better scope the beans to retrieve
saved_errors[(check_name, jmx_metrics_file)].append(
f"domain, domain_regex or bean attribute is missing for rule: {include_str}"
)

duplicates = duplicate_bean_check(jmx_metrics_data)
if duplicates:
saved_errors[(check_name, jmx_metrics_file)].append(
"The following bean and attribute combination is a duplicate:"
)
for k, v in duplicates.items():
saved_errors[(check_name, jmx_metrics_file)].append(f"{k}: {v}")


def duplicate_bean_check(bean_list):
bean_dict = defaultdict(list)
duplicate_bean = defaultdict(list)
for beans in bean_list:
bean = beans.get("include").get("bean")
if type(bean) == list:
for b in bean:
for attr in beans.get("include").get("attribute", {}).keys():
if attr in bean_dict[b]:
duplicate_bean[b].append(attr)
else:
bean_dict[b].append(attr)
elif bean:
for attr in beans.get("include").get("attribute", {}).keys():
if attr in bean_dict[bean]:
duplicate_bean[bean].append(attr)
else:
bean_dict[bean].append(attr)
return dict(duplicate_bean)


def validate_config_spec(check_name, saved_errors):
config_file = get_default_config_spec(check_name)

if not file_exists(config_file):
saved_errors[(check_name, None)].append(f"config spec does not exist: {config_file}")
return

spec_files = yaml.safe_load(read_file(config_file)).get('files')
init_config_jmx = False
instances_jmx = False

for spec_file in spec_files:
for base_option in spec_file.get('options', []):
base_template = base_option.get('template')
for option in base_option.get("options", []):
template = option.get('template')
if template == 'init_config/jmx' and base_template == 'init_config':
init_config_jmx = True
elif template == 'instances/jmx' and base_template == 'instances':
instances_jmx = True

if not init_config_jmx:
saved_errors[(check_name, config_file)].append("config spec: does not use `init_config/jmx` template")
if not instances_jmx:
saved_errors[(check_name, config_file)].append("config spec: does not use `instances/jmx` template")


def truncate_message(s, verbose):
if not verbose:
s = (s[:100] + '...') if len(s) > 100 else s
return s


# Modified version of:
# https://gist.github.com/pypt/94d747fe5180851196eb
def no_duplicates_constructor(loader, node, deep=False):
"""Check for duplicate keys."""

mapping = {}
errors = []
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
value = loader.construct_object(value_node, deep=deep)
if key in mapping:
errors.append([key, key_node.start_mark.line])
mapping[key] = value
if len(errors) > 0:
raise Exception(errors)
return loader.construct_mapping(node, deep)
1 change: 1 addition & 0 deletions ddev/changelog.d/23685.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Restructure `ddev.utils.github_async` into a package with lazy model imports, add `create_pull_request` and `add_labels_to_issue` endpoints, and add a `FakeAsyncGitHubClient` test helper with a `mock_response` API.
1 change: 1 addition & 0 deletions ddev/changelog.d/23686.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `ddev release port-commit` command to backport a commit to a target branch.
3 changes: 3 additions & 0 deletions ddev/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ include = ["src"]
python-version = "3.13"
scripts = ["ddev"]

[tool.pytest.ini_options]
asyncio_mode = "auto"

# Keep Black configuration to generate models through validate
# Switch to Ruff after it provides a Python API
[tool.black]
Expand Down
2 changes: 2 additions & 0 deletions ddev/src/ddev/cli/release/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from ddev.cli.release.branch import branch
from ddev.cli.release.changelog import changelog
from ddev.cli.release.list_versions import list_versions
from ddev.cli.release.port_commit import port_commit
from ddev.cli.release.show import show
from ddev.cli.release.stats import stats

Expand All @@ -28,6 +29,7 @@ def release():
release.add_command(changelog)
release.add_command(list_versions)
release.add_command(make)
release.add_command(port_commit)
release.add_command(show)
release.add_command(stats)
release.add_command(tag)
Expand Down
107 changes: 107 additions & 0 deletions ddev/src/ddev/cli/release/port_commit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# (C) Datadog, Inc. 2026-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(name='port-commit', short_help='Backport a commit onto a target branch')
@click.pass_obj
@click.argument('commit_hash', required=False)
@click.option('-t', '--target-branch', default='master', show_default=True, help='Target branch to port to.')
@click.option('-p', '--branch-prefix', default='port', show_default=True, help='Branch name prefix.')
@click.option('-s', '--branch-suffix', default=None, help='Branch name suffix. Defaults to `to-<target-branch>`.')
@click.option(
'-l',
'--pr-labels',
default='qa/skip-qa',
show_default=True,
help='Comma-separated PR labels.',
)
@click.option('--no-pr', is_flag=True, default=False, help="Don't create a pull request.")
@click.option('--draft', is_flag=True, default=False, help='Open the PR as a draft.')
@click.option('--verify', is_flag=True, default=False, help='Run commit hooks (skipped by default).')
@click.option('--dry-run', is_flag=True, default=False, help='Print every step instead of executing it.')
def port_commit(
app: Application,
commit_hash: str | None,
target_branch: str,
branch_prefix: str,
branch_suffix: str | None,
pr_labels: str,
no_pr: bool,
draft: bool,
verify: bool,
dry_run: bool,
) -> None:
"""
Backport a commit onto a target branch.

Cherry-picks COMMIT_HASH onto `--target-branch` (default `master`) on a new branch named
`<github-user>/<prefix>-<sha[:10]>-<suffix>`, preserving `.in-toto` files from the target
branch so package signatures stay intact. Pushes the branch and, unless `--no-pr` is set,
opens a pull request titled `[Backport] <subject>` and labeled with `--pr-labels`.

If COMMIT_HASH is omitted, the current HEAD commit is used after confirmation.

The GitHub user for the branch prefix is taken from `ddev config` (`github.user`) or the
`DD_GITHUB_USER` / `GITHUB_USER` / `GITHUB_ACTOR` environment variables.
"""
from ddev.cli.release.port_commit_workflow import (
PortStepError,
build_port_steps,
display_completion_summary,
resolve_port_plan,
)

plan = resolve_port_plan(
app,
commit_hash=commit_hash,
target_branch=target_branch,
branch_prefix=branch_prefix,
branch_suffix=branch_suffix,
pr_labels=pr_labels,
no_pr=no_pr,
draft=draft,
verify=verify,
dry_run=dry_run,
)
bundle = build_port_steps(app, plan)

success = False
error_msg: str | None = None
try:
for step in bundle.steps:
step.run()
success = True
except PortStepError as e:
error_msg = str(e)
finally:
# If the PR was created before the failure (e.g. labeling failed afterwards), the worktree
# holds no recoverable state — the work is pushed and the PR exists on GitHub. Suppress the
# warning in that case to avoid a misleading "inspect the worktree" message.
pr_already_created = bundle.pr_step is not None and bundle.pr_step.pr_url is not None
if not success and not plan.dry_run and not pr_already_created:
app.display_warning(f'Worktree left at `{plan.worktree_path}` for inspection.')

if error_msg is not None:
app.abort(error_msg)

try:
bundle.teardown.run()
except PortStepError as e:
app.display_warning(f'Could not remove worktree at `{plan.worktree_path}`: {e}')
app.display_warning(f'Run `git worktree remove --force {plan.worktree_path}` to clean it up manually.')

if plan.dry_run:
app.display_success('Dry run complete.')
return

pr_url = bundle.pr_step.pr_url if bundle.pr_step is not None else None
display_completion_summary(app, plan, pr_url=pr_url)
Loading
Loading