From eb82026107b28c1d910c4c2d93687c6b12d73a37 Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 15:42:49 +0200 Subject: [PATCH 01/13] Migrate `create` from datadog_checks_dev to ddev and revamp the UX Port the `create` command into ddev as a click group with one subcommand per integration type (`check`, `check-only`, `jmx`, `logs`, `event`, `metrics-crawler`). The `tile`, `snmp_tile`, and `marketplace` templates are not exposed as subcommands but their files stay on disk under datadog_checks_dev/ (in-toto rule). UX changes bundled into the migration: - Manifest-less is the default. `--include-manifest` opts into the legacy manifest-shipped path. - `--display-name`, `--metrics-prefix`, `--platforms` are first-class options. Missing values prompt in TTY and abort listing every missing flag in non-TTY. - `--non-interactive` removed. `--skip-manifest` accepted as a no-op with a deprecation warning; conflicts with `--include-manifest`. - `--type` accepted as a deprecation shim; aborts for dropped types pointing at the Building Integrations Without a Manifest Confluence page. - Manifest-less integrations get three entries written into `.ddev/config.toml` under `[overrides.display-name]`, `[overrides.metrics-prefix]`, and `[overrides.manifest.platforms]`. Templates are copied into ddev's package data; pytest, ruff, and mypy configs exclude the templates tree. --- ddev/pyproject.toml | 12 +- ddev/src/ddev/cli/__init__.py | 2 +- ddev/src/ddev/cli/create/__init__.py | 141 ++++++ ddev/src/ddev/cli/create/_common.py | 194 +++++++++ ddev/src/ddev/cli/create/_config_overrides.py | 49 +++ ddev/src/ddev/cli/create/_naming.py | 49 +++ ddev/src/ddev/cli/create/_scaffold.py | 407 ++++++++++++++++++ ddev/src/ddev/cli/create/check.py | 56 +++ ddev/src/ddev/cli/create/check_only.py | 56 +++ ddev/src/ddev/cli/create/event.py | 56 +++ ddev/src/ddev/cli/create/jmx.py | 56 +++ ddev/src/ddev/cli/create/logs.py | 56 +++ ddev/src/ddev/cli/create/metrics_crawler.py | 56 +++ .../ddev/cli/create/templates/check/README.md | 6 + .../templates/check/{check_name}/CHANGELOG.md | 3 + .../templates/check/{check_name}/README.md | 51 +++ .../assets/configuration/spec.yaml | 10 + .../dashboards/{check_name}_overview.json | 1 + .../check/{check_name}/changelog.d/1.added | 1 + .../datadog_checks/{check_name}/__about__.py | 2 + .../datadog_checks/{check_name}/__init__.py | 5 + .../datadog_checks/{check_name}/check.py | 69 +++ .../{check_name}/config_models/__init__.py | 19 + .../{check_name}/config_models/defaults.py | 11 + .../{check_name}/config_models/instance.py | 45 ++ .../{check_name}/config_models/shared.py | 42 ++ .../{check_name}/config_models/validators.py | 11 + .../{check_name}/data/conf.yaml.example | 44 ++ .../templates/check/{check_name}/hatch.toml | 5 + .../check/{check_name}/images/.gitkeep | 0 .../check/{check_name}/manifest.json | 48 +++ .../templates/check/{check_name}/metadata.csv | 1 + .../check/{check_name}/pyproject.toml | 60 +++ .../check/{check_name}/tests/__init__.py | 1 + .../check/{check_name}/tests/conftest.py | 34 ++ .../check/{check_name}/tests/test_e2e.py | 25 ++ .../check/{check_name}/tests/test_unit.py | 37 ++ .../check_only/{check_name}/CHANGELOG.md | 6 + .../check_only/{check_name}/README.md | 11 + .../assets/configuration/spec.yaml | 10 + .../{check_name}/changelog.d/1.added | 1 + .../datadog_checks/{check_name}/__about__.py | 2 + .../datadog_checks/{check_name}/__init__.py | 5 + .../datadog_checks/{check_name}/check.py | 68 +++ .../{check_name}/config_models/__init__.py | 19 + .../{check_name}/config_models/defaults.py | 11 + .../{check_name}/config_models/instance.py | 45 ++ .../{check_name}/config_models/shared.py | 42 ++ .../{check_name}/config_models/validators.py | 11 + .../{check_name}/data/conf.yaml.example | 44 ++ .../check_only/{check_name}/hatch.toml | 4 + .../check_only/{check_name}/pyproject.toml | 60 +++ .../check_only/{check_name}/tests/__init__.py | 1 + .../check_only/{check_name}/tests/conftest.py | 12 + .../{check_name}/tests/test_unit.py | 17 + .../ddev/cli/create/templates/event/README.md | 8 + .../templates/event/{check_name}/CHANGELOG.md | 7 + .../templates/event/{check_name}/README.md | 41 ++ .../event/{check_name}/manifest.json | 38 ++ .../templates/jmx/{check_name}/CHANGELOG.md | 3 + .../templates/jmx/{check_name}/README.md | 45 ++ .../assets/configuration/spec.yaml | 10 + .../dashboards/{check_name}_overview.json | 0 .../jmx/{check_name}/changelog.d/1.added | 1 + .../datadog_checks/{check_name}/__about__.py | 2 + .../datadog_checks/{check_name}/__init__.py | 4 + .../{check_name}/config_models/__init__.py | 16 + .../{check_name}/config_models/defaults.py | 31 ++ .../{check_name}/config_models/instance.py | 63 +++ .../{check_name}/config_models/shared.py | 48 +++ .../{check_name}/config_models/validators.py | 1 + .../{check_name}/data/conf.yaml.example | 163 +++++++ .../{check_name}/data/metrics.yaml | 2 + .../templates/jmx/{check_name}/hatch.toml | 4 + .../jmx/{check_name}/images/.gitkeep | 0 .../templates/jmx/{check_name}/manifest.json | 41 ++ .../templates/jmx/{check_name}/metadata.csv | 1 + .../templates/jmx/{check_name}/pyproject.toml | 60 +++ .../jmx/{check_name}/tests/__init__.py | 1 + .../jmx/{check_name}/tests/common.py | 10 + .../jmx/{check_name}/tests/conftest.py | 7 + .../jmx/{check_name}/tests/metrics.py | 9 + .../jmx/{check_name}/tests/test_e2e.py | 28 ++ .../ddev/cli/create/templates/logs/README.md | 6 + .../templates/logs/{check_name}/CHANGELOG.md | 3 + .../templates/logs/{check_name}/README.md | 58 +++ .../assets/configuration/spec.yaml | 9 + .../dashboards/{check_name}_overview.json | 0 .../logs/{check_name}/changelog.d/1.added | 1 + .../datadog_checks/{check_name}/__about__.py | 2 + .../datadog_checks/{check_name}/__init__.py | 4 + .../{check_name}/data/conf.yaml.example | 20 + .../logs/{check_name}/images/.gitkeep | 0 .../templates/logs/{check_name}/manifest.json | 37 ++ .../templates/logs/{check_name}/metadata.csv | 1 + .../logs/{check_name}/pyproject.toml | 59 +++ .../templates/metrics_crawler/README.md | 6 + .../metrics_crawler/{check_name}/CHANGELOG.md | 7 + .../metrics_crawler/{check_name}/README.md | 35 ++ .../dashboards/{check_name}_overview.json | 0 .../{check_name}/assets/service_checks.json | 1 + .../{check_name}/images/.gitkeep | 0 .../{check_name}/manifest.json | 39 ++ .../metrics_crawler/{check_name}/metadata.csv | 1 + ddev/tests/cli/create/__init__.py | 0 ddev/tests/cli/create/conftest.py | 20 + ddev/tests/cli/create/test_create.py | 278 ++++++++++++ pyproject.toml | 6 +- 108 files changed, 3323 insertions(+), 4 deletions(-) create mode 100644 ddev/src/ddev/cli/create/__init__.py create mode 100644 ddev/src/ddev/cli/create/_common.py create mode 100644 ddev/src/ddev/cli/create/_config_overrides.py create mode 100644 ddev/src/ddev/cli/create/_naming.py create mode 100644 ddev/src/ddev/cli/create/_scaffold.py create mode 100644 ddev/src/ddev/cli/create/check.py create mode 100644 ddev/src/ddev/cli/create/check_only.py create mode 100644 ddev/src/ddev/cli/create/event.py create mode 100644 ddev/src/ddev/cli/create/jmx.py create mode 100644 ddev/src/ddev/cli/create/logs.py create mode 100644 ddev/src/ddev/cli/create/metrics_crawler.py create mode 100644 ddev/src/ddev/cli/create/templates/check/README.md create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/CHANGELOG.md create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/README.md create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/assets/configuration/spec.yaml create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/assets/dashboards/{check_name}_overview.json create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/changelog.d/1.added create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/__about__.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/check.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/defaults.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/instance.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/shared.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/validators.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/hatch.toml create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/images/.gitkeep create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/manifest.json create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/metadata.csv create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/pyproject.toml create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/tests/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/tests/conftest.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/tests/test_e2e.py create mode 100644 ddev/src/ddev/cli/create/templates/check/{check_name}/tests/test_unit.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/CHANGELOG.md create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/README.md create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/assets/configuration/spec.yaml create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/changelog.d/1.added create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/__about__.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/check.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/defaults.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/instance.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/shared.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/validators.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/hatch.toml create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/pyproject.toml create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/conftest.py create mode 100644 ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/test_unit.py create mode 100644 ddev/src/ddev/cli/create/templates/event/README.md create mode 100644 ddev/src/ddev/cli/create/templates/event/{check_name}/CHANGELOG.md create mode 100644 ddev/src/ddev/cli/create/templates/event/{check_name}/README.md create mode 100644 ddev/src/ddev/cli/create/templates/event/{check_name}/manifest.json create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/CHANGELOG.md create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/README.md create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/assets/configuration/spec.yaml create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/assets/dashboards/{check_name}_overview.json create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/changelog.d/1.added create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/__about__.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/defaults.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/instance.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/shared.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/validators.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/data/metrics.yaml create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/hatch.toml create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/images/.gitkeep create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/manifest.json create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/metadata.csv create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/pyproject.toml create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/common.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/conftest.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py create mode 100644 ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/test_e2e.py create mode 100644 ddev/src/ddev/cli/create/templates/logs/README.md create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/CHANGELOG.md create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/README.md create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/assets/configuration/spec.yaml create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/assets/dashboards/{check_name}_overview.json create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/changelog.d/1.added create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/__about__.py create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/__init__.py create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/images/.gitkeep create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/manifest.json create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/metadata.csv create mode 100644 ddev/src/ddev/cli/create/templates/logs/{check_name}/pyproject.toml create mode 100644 ddev/src/ddev/cli/create/templates/metrics_crawler/README.md create mode 100644 ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/CHANGELOG.md create mode 100644 ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/README.md create mode 100644 ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/assets/dashboards/{check_name}_overview.json create mode 100644 ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/assets/service_checks.json create mode 100644 ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/images/.gitkeep create mode 100644 ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/manifest.json create mode 100644 ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/metadata.csv create mode 100644 ddev/tests/cli/create/__init__.py create mode 100644 ddev/tests/cli/create/conftest.py create mode 100644 ddev/tests/cli/create/test_create.py diff --git a/ddev/pyproject.toml b/ddev/pyproject.toml index b82b8db3bc028..d370c1da0d9a4 100644 --- a/ddev/pyproject.toml +++ b/ddev/pyproject.toml @@ -74,12 +74,17 @@ version-file = "src/ddev/_version.py" [tool.hatch.build.targets.sdist] include = ["src"] +[tool.hatch.build.targets.wheel] +packages = ["src/ddev"] +artifacts = ["src/ddev/cli/create/templates"] + [tool.hatch.build.targets.binary] python-version = "3.13" scripts = ["ddev"] [tool.pytest.ini_options] asyncio_mode = "auto" +norecursedirs = ["src/ddev/cli/create/templates"] # Keep Black configuration to generate models through validate # Switch to Ruff after it provides a Python API @@ -92,7 +97,9 @@ extend-exclude = "src/ddev/_version.py" [tool.ruff] extend = "../pyproject.toml" -exclude = [] +exclude = [ + "src/ddev/cli/create/templates", +] target-version = "py313" line-length = 120 @@ -128,7 +135,8 @@ unfixable = [ [tool.ruff.format] quote-style = "preserve" exclude = [ - "src/ddev/_version.py" + "src/ddev/_version.py", + "src/ddev/cli/create/templates", ] [tool.ruff.lint.isort] diff --git a/ddev/src/ddev/cli/__init__.py b/ddev/src/ddev/cli/__init__.py index 21b552684ee14..5a1e560344b76 100644 --- a/ddev/src/ddev/cli/__init__.py +++ b/ddev/src/ddev/cli/__init__.py @@ -5,7 +5,6 @@ import click import pluggy -from datadog_checks.dev.tooling.commands.create import create from datadog_checks.dev.tooling.commands.run import run from ddev._version import __version__ @@ -14,6 +13,7 @@ from ddev.cli.ci import ci from ddev.cli.clean import clean from ddev.cli.config import config +from ddev.cli.create import create from ddev.cli.dep import dep from ddev.cli.docs import docs from ddev.cli.env import env diff --git a/ddev/src/ddev/cli/create/__init__.py b/ddev/src/ddev/cli/create/__init__.py new file mode 100644 index 0000000000000..6db628e5847bf --- /dev/null +++ b/ddev/src/ddev/cli/create/__init__.py @@ -0,0 +1,141 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +""" +``ddev create`` command group. + +This module is intentionally light at import time: only ``click`` and the +subcommand modules are imported up-front. Heavy helpers (template walker, +config-override writer, ``tomli_w``) load inside the command function +bodies so ``ddev create --help`` stays fast. +""" + +from __future__ import annotations + +import click + +from ddev.cli.create.check import check +from ddev.cli.create.check_only import check_only +from ddev.cli.create.event import event +from ddev.cli.create.jmx import jmx +from ddev.cli.create.logs import logs +from ddev.cli.create.metrics_crawler import metrics_crawler + +CONFLUENCE_NO_MANIFEST_URL = 'https://datadoghq.atlassian.net/wiki/spaces/AI/pages/6248108729/' + +LEGACY_TYPE_TO_SUBCOMMAND: dict[str, str] = { + 'check': 'check', + 'check_only': 'check-only', + 'jmx': 'jmx', + 'logs': 'logs', + 'event': 'event', + 'metrics_crawler': 'metrics-crawler', +} + +DROPPED_LEGACY_TYPES = {'tile', 'snmp_tile', 'marketplace'} + + +class _CreateGroup(click.Group): + """Group that accepts ``ddev create NAME --type=...`` as a deprecation shim.""" + + def resolve_command( # type: ignore[override] + self, ctx: click.Context, args: list[str] + ) -> tuple[str | None, click.Command | None, list[str]]: + if not args: + return super().resolve_command(ctx, args) + + first = args[0] + # If the first token matches a registered subcommand, use the normal flow. + if self.get_command(ctx, first) is not None: + return super().resolve_command(ctx, args) + + # If the user passes `--type=X` as a flag among `args`, we're in legacy mode. + legacy_type = _extract_legacy_type(args) + if legacy_type is None: + return super().resolve_command(ctx, args) + + if legacy_type in DROPPED_LEGACY_TYPES: + from ddev.cli.application import Application + + app: Application = ctx.obj + app.abort( + f'`--type={legacy_type}` is no longer supported. ' + f'Use the manifest-less workflow described at {CONFLUENCE_NO_MANIFEST_URL}.' + ) + + target = LEGACY_TYPE_TO_SUBCOMMAND.get(legacy_type) + if target is None: + ctx.fail(f'Unknown integration type: `{legacy_type}`.') + + subcommand = self.get_command(ctx, target) + if subcommand is None: + ctx.fail(f'Internal error: subcommand `{target}` not registered.') + + click.echo( + f'WARNING: `--type={legacy_type}` is deprecated. ' + f'Use `ddev create {target} NAME` instead. The flag will be removed in a future release.', + err=True, + ) + + # Strip the --type / -t flag out of args before handing back to click. + cleaned = _strip_type_flag(args) + return subcommand.name, subcommand, cleaned + + +def _extract_legacy_type(args: list[str]) -> str | None: + """Return the `--type` / `-t` value from ``args`` if present, else None.""" + iterator = iter(enumerate(args)) + for _, token in iterator: + if token == '--type' or token == '-t': + try: + _, value = next(iterator) + except StopIteration: + return None + return value + if token.startswith('--type='): + return token.split('=', 1)[1] + if token.startswith('-t=') or (token.startswith('-t') and len(token) > 2): + return token[3:] if token.startswith('-t=') else token[2:] + return None + + +def _strip_type_flag(args: list[str]) -> list[str]: + cleaned: list[str] = [] + skip_next = False + for token in args: + if skip_next: + skip_next = False + continue + if token == '--type' or token == '-t': + skip_next = True + continue + if token.startswith(('--type=', '-t=')) or ( + token.startswith('-t') and len(token) > 2 and not token.startswith('--') + ): + continue + cleaned.append(token) + return cleaned + + +@click.group( + cls=_CreateGroup, + short_help='Scaffold a new integration', + context_settings={'help_option_names': ['-h', '--help']}, +) +def create() -> None: + """Scaffold a new integration. + + Use one of the subcommands (``check``, ``check-only``, ``jmx``, ``logs``, + ``event``, ``metrics-crawler``). + + The ``--type`` / ``-t`` flag from the legacy CLI is accepted as a + deprecation shim and will be removed in a future release. + """ + + +create.add_command(check) +create.add_command(check_only) +create.add_command(jmx) +create.add_command(logs) +create.add_command(event) +create.add_command(metrics_crawler) diff --git a/ddev/src/ddev/cli/create/_common.py b/ddev/src/ddev/cli/create/_common.py new file mode 100644 index 0000000000000..c775284b3e05e --- /dev/null +++ b/ddev/src/ddev/cli/create/_common.py @@ -0,0 +1,194 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +""" +Shared subcommand implementation for the ``ddev create`` group. + +The per-type subcommand modules (``check.py``, ``jmx.py``, ...) are kept +deliberately thin so that ``ddev create --help`` doesn't trigger any heavy +imports. All real work lives here, behind a lazy import. +""" + +from __future__ import annotations + +import json +import sys +from typing import TYPE_CHECKING + +from ddev.cli.create._naming import normalize_package_name + +if TYPE_CHECKING: + from ddev.cli.application import Application + +SUPPORTED_PLATFORMS = ('linux', 'windows', 'mac_os') + + +def run_subcommand( + app: Application, + *, + integration_type: str, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms_csv: str | None, + location: str | None, + quiet: bool, + dry_run: bool, + include_manifest: bool, + skip_manifest: bool, +) -> None: + """Single entry point shared by all per-type subcommands.""" + if name.lower().startswith('datadog'): + app.abort('Integration names cannot start with `datadog`.') + + if skip_manifest and include_manifest: + app.abort('`--skip-manifest` and `--include-manifest` are mutually exclusive.') + + if skip_manifest: + app.display_warning( + '`--skip-manifest` is deprecated. The default for new integrations no longer ' + 'includes a `manifest.json`; pass `--include-manifest` to opt in. ' + '`--skip-manifest` will be removed in the next major release.' + ) + + is_interactive = sys.stdin.isatty() + + extra_fields: dict[str, object] = {} + override_integration_dir_name: str | None = None + if integration_type == 'check_only': + extra_fields, override_integration_dir_name = _resolve_check_only_inputs( + app, name, location, include_manifest=include_manifest + ) + + resolved_display_name: str | None = None + resolved_metrics_prefix: str | None = None + resolved_platforms: list[str] | None = None + if not include_manifest: + resolved_display_name, resolved_metrics_prefix, resolved_platforms = _resolve_manifestless_inputs( + app, + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms_csv, + is_interactive=is_interactive, + ) + + from ddev.cli.create._scaffold import render + + result = render( + app, + integration_type, + name, + location=location, + dry_run=dry_run, + quiet=quiet, + include_manifest=include_manifest, + extra_fields=extra_fields, + override_integration_dir_name=override_integration_dir_name, + ) + + if dry_run or include_manifest: + return + + if not include_manifest: + from ddev.cli.create._config_overrides import apply_manifestless_overrides + + # mypy: these are non-None because _resolve_manifestless_inputs aborts otherwise. + assert resolved_display_name is not None + assert resolved_metrics_prefix is not None + assert resolved_platforms is not None + + apply_manifestless_overrides( + app, + dir_name=result.integration_dir.name, + display_name=resolved_display_name, + metrics_prefix=resolved_metrics_prefix, + platforms=resolved_platforms, + ) + + +def _resolve_manifestless_inputs( + app: Application, + *, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms_csv: str | None, + is_interactive: bool, +) -> tuple[str, str, list[str]]: + suggested_display = name + suggested_prefix = f'{normalize_package_name(name)}.' + suggested_platforms_csv = ','.join(SUPPORTED_PLATFORMS) + + missing: list[str] = [] + if display_name is None: + if is_interactive: + display_name = app.prompt('Display name', default=suggested_display) + else: + missing.append('--display-name') + if metrics_prefix is None: + if is_interactive: + metrics_prefix = app.prompt('Metrics prefix', default=suggested_prefix) + else: + missing.append('--metrics-prefix') + if platforms_csv is None: + if is_interactive: + platforms_csv = app.prompt('Platforms (comma-separated)', default=suggested_platforms_csv) + else: + missing.append('--platforms') + + if missing: + app.abort('Missing required flags for non-interactive mode: ' + ', '.join(missing)) + + assert display_name is not None + assert metrics_prefix is not None + assert platforms_csv is not None + platforms = _parse_platforms(app, platforms_csv) + return display_name, metrics_prefix, platforms + + +def _parse_platforms(app: Application, csv: str) -> list[str]: + items = [p.strip() for p in csv.split(',') if p.strip()] + if not items: + app.abort('`--platforms` must contain at least one platform.') + unknown = [p for p in items if p not in SUPPORTED_PLATFORMS] + if unknown: + app.abort(f'Unknown platform(s): {", ".join(unknown)}. Valid values: {", ".join(SUPPORTED_PLATFORMS)}.') + return items + + +def _resolve_check_only_inputs( + app: Application, + name: str, + location: str | None, + *, + include_manifest: bool, +) -> tuple[dict[str, object], str | None]: + """For ``check_only`` integrations we expect the directory to already exist with a manifest. + + Returns the extra template fields prefilled from the existing manifest, plus + the override directory name (with the author prefix stripped). + """ + from ddev.cli.create._scaffold import prefill_check_only_fields + from ddev.utils.fs import Path + + integration_dir_name = normalize_package_name(name) + root = Path(location).resolve() if location else app.repo.path + integration_dir = root / integration_dir_name + manifest_path = integration_dir / 'manifest.json' + + if not manifest_path.is_file(): + app.abort(f'Expected {manifest_path} to exist') + + manifest_data = json.loads(manifest_path.read_text()) + author = (manifest_data.get('author') or {}).get('name') + if author is None: + app.abort('Unable to determine author from manifest') + + from ddev.cli.create._naming import normalize_display_name + + author_normalized = normalize_display_name(author) + stripped = integration_dir_name.removeprefix(f'{author_normalized}_') + + fields = prefill_check_only_fields(manifest_data, stripped) + return fields, stripped diff --git a/ddev/src/ddev/cli/create/_config_overrides.py b/ddev/src/ddev/cli/create/_config_overrides.py new file mode 100644 index 0000000000000..b69a04309c46e --- /dev/null +++ b/ddev/src/ddev/cli/create/_config_overrides.py @@ -0,0 +1,49 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +""" +Append manifest-less overrides to ``.ddev/config.toml``. + +Per the Building Integrations Without a Manifest spec, new integrations +that omit ``manifest.json`` need three entries under ``[overrides]`` so +the rest of the tooling can still resolve display name, metrics prefix, +and supported platforms. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +def apply_manifestless_overrides( + app: Application, + dir_name: str, + display_name: str, + metrics_prefix: str, + platforms: list[str], +) -> None: + """Add or update the three required overrides in ``.ddev/config.toml``.""" + config_file = app.repo.config + config_path = config_file.path + if config_path.is_file(): + data = config_file.load_data() + else: + data = {} + config_path.ensure_parent_dir_exists() + + overrides = data.setdefault('overrides', {}) + _set_entry(overrides, 'display-name', dir_name, display_name) + _set_entry(overrides, 'metrics-prefix', dir_name, metrics_prefix) + + manifest = overrides.setdefault('manifest', {}) + _set_entry(manifest, 'platforms', dir_name, platforms) + + config_file.save_data(data) + + +def _set_entry(table: dict, key: str, dir_name: str, value: object) -> None: + section = table.setdefault(key, {}) + section[dir_name] = value diff --git a/ddev/src/ddev/cli/create/_naming.py b/ddev/src/ddev/cli/create/_naming.py new file mode 100644 index 0000000000000..6cad17b5b3227 --- /dev/null +++ b/ddev/src/ddev/cli/create/_naming.py @@ -0,0 +1,49 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import re +from datetime import datetime, timezone + + +def normalize_package_name(name: str) -> str: + """Lowercase and collapse separators to underscore (used for directory and Python package names).""" + return re.sub(r'[-_. ]+', '_', name).lower() + + +def normalize_project_name(name: str) -> str: + """Normalize per PEP 503 for use as a distribution name.""" + if not re.search(r'^([A-Z0-9]|[A-Z0-9][A-Z0-9._-]*[A-Z0-9])$', name, re.IGNORECASE): + raise ValueError('Project name must only contain ASCII letters/digits, underscores, hyphens, and periods.') + return re.sub(r'[-_.]+', '-', name).lower() + + +def kebab_case_name(name: str) -> str: + """Lowercase and replace separators with hyphens.""" + return re.sub(r'[_ ]', '-', name.lower()) + + +def normalize_display_name(display_name: str) -> str: + """Lower-case, collapse runs of non-alphanumeric characters to underscores, strip leading/trailing underscores.""" + normalized = re.sub(r'[^0-9A-Za-z-]', '_', display_name) + normalized = re.sub(r'_+', '_', normalized).strip('_') + return normalized.lower() + + +def get_license_header() -> str: + year = datetime.now(timezone.utc).year + return ( + f'# (C) Datadog, Inc. {year}-present\n' + '# All rights reserved\n' + '# Licensed under a 3-clause BSD style license (see LICENSE)' + ) + + +def get_config_models_documentation() -> str: + return ( + '# This file is autogenerated.\n' + '# To change this file you should edit assets/configuration/spec.yaml and then run the following commands:\n' + '# ddev -x validate config -s \n' + '# ddev -x validate models -s \n' + ) diff --git a/ddev/src/ddev/cli/create/_scaffold.py b/ddev/src/ddev/cli/create/_scaffold.py new file mode 100644 index 0000000000000..c74f17e3594df --- /dev/null +++ b/ddev/src/ddev/cli/create/_scaffold.py @@ -0,0 +1,407 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +""" +Template rendering for `ddev create` subcommands. + +This module is loaded lazily from the subcommand functions (not at the +group / module top-level) so that `ddev create --help` stays fast. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import date, datetime, timezone +from pathlib import Path as _StdPath +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from ddev.cli.create._naming import ( + get_config_models_documentation, + get_license_header, + kebab_case_name, + normalize_display_name, + normalize_package_name, + normalize_project_name, +) +from ddev.utils.fs import Path + +if TYPE_CHECKING: + from collections.abc import Iterator + + from ddev.cli.application import Application + +TEMPLATES_ROOT = Path(_StdPath(__file__).parent / 'templates') + +BINARY_EXTENSIONS = ('.png',) + +CHECK_LINKS = """\ +[1]: **LINK_TO_INTEGRATION_SITE** +[2]: https://app.datadoghq.com/account/settings/agent/latest +[3]: https://docs.datadoghq.com/containers/kubernetes/integrations/ +[4]: https://github.com/DataDog/{repository}/blob/master/{name}/datadog_checks/{name}/data/conf.yaml.example +[5]: https://docs.datadoghq.com/agent/configuration/agent-commands/#start-stop-and-restart-the-agent +[6]: https://docs.datadoghq.com/agent/configuration/agent-commands/#agent-status-and-information +[7]: https://github.com/DataDog/{repository}/blob/master/{name}/metadata.csv +[8]: https://github.com/DataDog/{repository}/blob/master/{name}/assets/service_checks.json +[9]: https://docs.datadoghq.com/help/ +""" + +CHECK_ONLY_LINKS = """\ +[1]: **LINK_TO_INTEGRATION_SITE** +[2]: https://app.datadoghq.com/account/settings/agent/latest +[3]: https://docs.datadoghq.com/containers/kubernetes/integrations/ +[4]: https://github.com/DataDog/{repository}/blob/master/{name}/datadog_checks/{name}/data/conf.yaml.example +[5]: https://docs.datadoghq.com/agent/configuration/agent-commands/#start-stop-and-restart-the-agent +[6]: https://docs.datadoghq.com/agent/configuration/agent-commands/#agent-status-and-information +[9]: https://docs.datadoghq.com/help/ +""" + +LOGS_LINKS = """\ +[1]: https://docs.datadoghq.com/help/ +[2]: https://app.datadoghq.com/account/settings/agent/latest +[3]: https://docs.datadoghq.com/agent/configuration/agent-commands/#start-stop-and-restart-the-agent +[4]: **LINK_TO_INTEGRATION_SITE** +[5]: https://github.com/DataDog/{repository}/blob/master/{name}/assets/service_checks.json +""" + +JMX_LINKS = """\ +[1]: **LINK_TO_INTEGERATION_SITE** +[2]: https://app.datadoghq.com/account/settings/agent/latest +[3]: https://github.com/DataDog/{repository}/blob/master/{name}/datadog_checks/{name}/data/conf.yaml.example +[4]: https://docs.datadoghq.com/agent/configuration/agent-commands/#agent-status-and-information +[5]: https://docs.datadoghq.com/integrations/java/ +[6]: https://docs.datadoghq.com/help/ +[7]: https://docs.datadoghq.com/agent/configuration/agent-commands/#start-stop-and-restart-the-agent +[8]: https://github.com/DataDog/{repository}/blob/master/{name}/assets/service_checks.json +""" + +TILE_LINKS = """\ +[1]: **LINK_TO_INTEGRATION_SITE** +[2]: https://app.datadoghq.com/account/settings/agent/latest +[3]: https://docs.datadoghq.com/help/ +""" + +EVENT_TILE_LINKS = """\ +[1]: **LINK_TO_INTEGRATION_SITE** +[2]: https://docs.datadoghq.com/help/ +""" + +INTEGRATION_TYPE_LINKS: dict[str, str] = { + 'check': CHECK_LINKS, + 'check_only': CHECK_ONLY_LINKS, + 'logs': LOGS_LINKS, + 'jmx': JMX_LINKS, + 'metrics_crawler': TILE_LINKS, + 'event': EVENT_TILE_LINKS, +} + +TOWNCRIER_BODY = """\ + +""" + +PIPE = '│' +PIPE_MIDDLE = '├' +PIPE_END = '└' +HYPHEN = '──' + + +@dataclass +class TemplateFile: + """A single rendered template file ready to be written to disk.""" + + target_path: Path + source_path: Path + binary: bool + contents: bytes | str | None = None + + def read(self, config: dict[str, Any]) -> None: + if self.binary: + self.contents = self.source_path.read_bytes() + else: + self.contents = self.source_path.read_text().format(**config) + + def write(self) -> None: + self.target_path.ensure_parent_dir_exists() + if self.binary: + assert isinstance(self.contents, bytes) + self.target_path.write_bytes(self.contents) + else: + assert isinstance(self.contents, str) + self.target_path.write_text(self.contents) + + +@dataclass +class ScaffoldResult: + integration_dir: Path + files: list[TemplateFile] + config: dict[str, Any] = field(default_factory=dict) + + +def prefill_check_only_fields(manifest: dict[str, Any], normalized_name: str) -> dict[str, Any]: + """Extract reusable fields from a pre-existing `manifest.json` for a `check_only` integration.""" + author_name_raw = (manifest.get('author') or {}).get('name') + author = normalize_display_name(author_name_raw) if author_name_raw else None + check_name = normalize_package_name(f'{author}_{normalized_name}') if author is not None else None + candidates: dict[str, Any] = { + 'author_name': author, + 'check_name': check_name, + 'email': (manifest.get('author') or {}).get('support_email'), + 'homepage': (manifest.get('author') or {}).get('homepage'), + 'sales_email': (manifest.get('author') or {}).get('sales_email'), + } + return {k: v for k, v in candidates.items() if v is not None} + + +def construct_template_fields( + name: str, + integration_type: str, + **kwargs: Any, +) -> dict[str, Any]: + """Build the dict of substitution fields for the templates. + + The dropped types (`tile`, `snmp_tile`, `marketplace`) are intentionally + not handled here: the revamp only exposes core-style subcommands. + """ + normalized_name = normalize_package_name(name) + check_name_kebab = kebab_case_name(name) + + if integration_type == 'check_only': + check_name = '' + author = '' + email = '' + email_packages = '' + install_info = _third_party_install_info(name, normalized_name) + license_header = '' + support_type = '' + integration_links = '' + else: + check_name = normalized_name + author = 'Datadog' + email = 'help@datadoghq.com' + email_packages = 'packages@datadoghq.com' + install_info = ( + f'The {name} check is included in the [Datadog Agent][2] package.\n' + 'No additional installation is needed on your server.' + ) + license_header = get_license_header() + support_type = 'core' + integration_links = INTEGRATION_TYPE_LINKS[integration_type].format( + name=normalized_name, + repository='integrations-core', + ) + + config: dict[str, Any] = { + 'author': author, + 'auto_install': 'false' if integration_type == 'metrics_crawler' else 'true', + 'check_class': f"{''.join(part.capitalize() for part in normalized_name.split('_'))}Check", + 'check_name': check_name, + 'project_name': normalize_project_name(normalized_name), + 'documentation': get_config_models_documentation(), + 'integration_name': name, + 'check_name_kebab': check_name_kebab, + 'email': email, + 'email_packages': email_packages, + 'app_uuid': uuid4(), + 'license_header': license_header, + 'install_info': install_info, + 'repo_choice': 'core', + 'repo_name': 'integrations-core', + 'support_type': support_type, + 'integration_links': integration_links, + 'source_type_id': int(datetime.now(timezone.utc).timestamp()) - 1_700_000_000, + 'manifest_version': '1.0.0', + 'today': date.today(), + 'changelog_body': TOWNCRIER_BODY, + 'starting_version': '0.0.1', + 'display_on_public_website': 'false', + 'media': '[]', + 'description': '', + 'example_dashboard_short_name': '', + 'pricing_plan': '', + 'terms': '', + 'integration_id': kebab_case_name(name), + 'package_url': ( + "\n # The project's main homepage.\n url='https://github.com/DataDog/integrations-core'," + ), + 'author_info': ( + '\n "author": {\n' + ' "support_email": "help@datadoghq.com",\n' + ' "name": "Datadog",\n' + ' "homepage": "https://www.datadoghq.com",\n' + ' "sales_email": "info@datadoghq.com"\n' + ' }' + ), + } + config.update(kwargs) + return config + + +def _third_party_install_info(name: str, normalized_name: str) -> str: + return ( + f'To install the {name} check on your host:\n\n\n' + '1. Install the [developer toolkit]\n' + '(https://docs.datadoghq.com/developers/integrations/python/)\n' + ' on any machine.\n\n' + f'2. Run `ddev release build {normalized_name}` to build the package.\n\n' + '3. [Download the Datadog Agent][2].\n\n' + '4. Upload the build artifact to any host with an Agent and\n' + ' run `datadog-agent integration install -w\n' + f' path/to/{normalized_name}/dist/.whl`.' + ) + + +def collect_template_files( + integration_type: str, + target_root: Path, + config: dict[str, Any], + *, + include_manifest: bool, + read: bool, +) -> list[TemplateFile]: + """Walk the template directory for `integration_type` and produce the file list.""" + template_root = TEMPLATES_ROOT / integration_type + if not template_root.is_dir(): + return [] + + files: list[TemplateFile] = [] + integration_dir_name = config['check_name'] + for source in _walk_template(template_root): + rel = source.relative_to(template_root) + rel_str = str(rel) + + # Skip the template's own README.md (it documents the template, not the output). + if rel_str == 'README.md': + continue + if source.name.endswith(('.pyc', '.pyo')): + continue + + target_rel = rel_str.format(**config) + target_path = target_root / target_rel + + # Default behaviour drops the integration's manifest.json. + if not include_manifest and _is_manifest_path(_StdPath(target_rel), integration_dir_name): + continue + + binary = source.name.endswith(BINARY_EXTENSIONS) + tf = TemplateFile(target_path=target_path, source_path=source, binary=binary) + if read: + tf.read(config) + files.append(tf) + + return files + + +def _walk_template(root: Path) -> Iterator[Path]: + for child in sorted(root.iterdir()): + if child.is_dir(): + yield from _walk_template(child) + else: + yield child + + +def _is_manifest_path(target_rel: _StdPath, integration_dir_name: str) -> bool: + return target_rel == _StdPath(integration_dir_name) / 'manifest.json' + + +def render( + app: Application, + integration_type: str, + name: str, + *, + location: str | None, + dry_run: bool, + quiet: bool, + include_manifest: bool, + extra_fields: dict[str, Any] | None = None, + override_integration_dir_name: str | None = None, +) -> ScaffoldResult: + """Resolve target paths, render templates in memory (or just enumerate for dry-run).""" + extra_fields = extra_fields or {} + root = Path(location).resolve() if location else app.repo.path + integration_dir_name = override_integration_dir_name or normalize_package_name(name) + integration_dir = root / integration_dir_name + + if integration_type != 'check_only' and integration_dir.exists(): + app.abort(f'Path `{integration_dir}` already exists!') + + config = construct_template_fields(name, integration_type, **extra_fields) + if override_integration_dir_name is not None: + config['check_name'] = override_integration_dir_name + + files = collect_template_files( + integration_type, + root, + config, + include_manifest=include_manifest, + read=not dry_run, + ) + + if dry_run: + if quiet: + app.display_info(f'Will create `{integration_dir}`') + else: + app.display_info(f'Will create in `{root}`:') + _display_tree(app, root, files) + else: + for f in files: + f.write() + if quiet: + app.display_info(f'Created `{integration_dir}`') + else: + app.display_info(f'Created in `{root}`:') + _display_tree(app, root, files) + + return ScaffoldResult(integration_dir=integration_dir, files=files, config=config) + + +def _display_tree(app: Application, root: Path, files: list[TemplateFile]) -> None: + tree: defaultdict = defaultdict(dict) + for f in files: + try: + rel = f.target_path.relative_to(root) + except ValueError: + rel = _StdPath(str(f.target_path)) + branch: dict = tree + for part in rel.parts: + branch = branch.setdefault(part, {}) + + for indent, name, is_dir in _path_tree_output(tree): + line = f'{indent}{name}' + if is_dir: + app.display_success(line) + else: + app.display_info(line) + + +def _path_tree_output(node: dict, depth: int = 0) -> Iterator[tuple[str, str, bool]]: + dirs: list[str] = [] + files: list[str] = [] + for k, v in node.items(): + (dirs if v else files).append(k) + dirs.sort() + files.sort() + + total_dirs = len(dirs) + for i, name in enumerate(dirs, 1): + last = i == total_dirs and not files + yield _format_line(name, depth, last=last, is_dir=True) + yield from _path_tree_output(node[name], depth + 1) + + total_files = len(files) + for i, name in enumerate(files, 1): + last = i == total_files + yield _format_line(name, depth, last=last, is_dir=False) + + +def _format_line(name: str, depth: int, *, last: bool, is_dir: bool) -> tuple[str, str, bool]: + if depth == 0: + return '', name, is_dir + if depth == 1: + return f'{PIPE_END if last else PIPE_MIDDLE}{HYPHEN} ', name, is_dir + return ( + f"{PIPE} {' ' * 4 * (depth - 2)}{PIPE_END if last or is_dir else PIPE_MIDDLE}{HYPHEN} ", + name, + is_dir, + ) diff --git a/ddev/src/ddev/cli/create/check.py b/ddev/src/ddev/cli/create/check.py new file mode 100644 index 0000000000000..c77fe86565d9b --- /dev/null +++ b/ddev/src/ddev/cli/create/check.py @@ -0,0 +1,56 @@ +# (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('check', short_help='Scaffold a check-based integration') +@click.argument('name') +@click.option('--display-name', default=None, help='Human-readable display name for the integration.') +@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') +@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') +@click.option('--location', '-l', default=None, help='The directory where files will be written.') +@click.option('--quiet', '-q', is_flag=True, help='Show less output.') +@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') +@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') +@click.option( + '--skip-manifest', + is_flag=True, + help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', +) +@click.pass_obj +def check( + app: Application, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms: str | None, + location: str | None, + quiet: bool, + dry_run: bool, + include_manifest: bool, + skip_manifest: bool, +) -> None: + """Scaffold a check-based integration.""" + from ddev.cli.create._common import run_subcommand + + run_subcommand( + app, + integration_type='check', + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms, + location=location, + quiet=quiet, + dry_run=dry_run, + include_manifest=include_manifest, + skip_manifest=skip_manifest, + ) diff --git a/ddev/src/ddev/cli/create/check_only.py b/ddev/src/ddev/cli/create/check_only.py new file mode 100644 index 0000000000000..09b63bc1a7c58 --- /dev/null +++ b/ddev/src/ddev/cli/create/check_only.py @@ -0,0 +1,56 @@ +# (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('check-only', short_help='Scaffold check-only files inside an existing integration directory') +@click.argument('name') +@click.option('--display-name', default=None, help='Human-readable display name for the integration.') +@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') +@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') +@click.option('--location', '-l', default=None, help='The directory where files will be written.') +@click.option('--quiet', '-q', is_flag=True, help='Show less output.') +@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') +@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') +@click.option( + '--skip-manifest', + is_flag=True, + help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', +) +@click.pass_obj +def check_only( + app: Application, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms: str | None, + location: str | None, + quiet: bool, + dry_run: bool, + include_manifest: bool, + skip_manifest: bool, +) -> None: + """Add Python check scaffolding to an existing integration directory.""" + from ddev.cli.create._common import run_subcommand + + run_subcommand( + app, + integration_type='check_only', + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms, + location=location, + quiet=quiet, + dry_run=dry_run, + include_manifest=include_manifest, + skip_manifest=skip_manifest, + ) diff --git a/ddev/src/ddev/cli/create/event.py b/ddev/src/ddev/cli/create/event.py new file mode 100644 index 0000000000000..e9c7c578a8b74 --- /dev/null +++ b/ddev/src/ddev/cli/create/event.py @@ -0,0 +1,56 @@ +# (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('event', short_help='Scaffold an event-only integration') +@click.argument('name') +@click.option('--display-name', default=None, help='Human-readable display name for the integration.') +@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') +@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') +@click.option('--location', '-l', default=None, help='The directory where files will be written.') +@click.option('--quiet', '-q', is_flag=True, help='Show less output.') +@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') +@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') +@click.option( + '--skip-manifest', + is_flag=True, + help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', +) +@click.pass_obj +def event( + app: Application, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms: str | None, + location: str | None, + quiet: bool, + dry_run: bool, + include_manifest: bool, + skip_manifest: bool, +) -> None: + """Scaffold an event-only integration.""" + from ddev.cli.create._common import run_subcommand + + run_subcommand( + app, + integration_type='event', + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms, + location=location, + quiet=quiet, + dry_run=dry_run, + include_manifest=include_manifest, + skip_manifest=skip_manifest, + ) diff --git a/ddev/src/ddev/cli/create/jmx.py b/ddev/src/ddev/cli/create/jmx.py new file mode 100644 index 0000000000000..d7321392cc500 --- /dev/null +++ b/ddev/src/ddev/cli/create/jmx.py @@ -0,0 +1,56 @@ +# (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('jmx', short_help='Scaffold a JMX integration') +@click.argument('name') +@click.option('--display-name', default=None, help='Human-readable display name for the integration.') +@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') +@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') +@click.option('--location', '-l', default=None, help='The directory where files will be written.') +@click.option('--quiet', '-q', is_flag=True, help='Show less output.') +@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') +@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') +@click.option( + '--skip-manifest', + is_flag=True, + help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', +) +@click.pass_obj +def jmx( + app: Application, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms: str | None, + location: str | None, + quiet: bool, + dry_run: bool, + include_manifest: bool, + skip_manifest: bool, +) -> None: + """Scaffold a JMX-based integration.""" + from ddev.cli.create._common import run_subcommand + + run_subcommand( + app, + integration_type='jmx', + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms, + location=location, + quiet=quiet, + dry_run=dry_run, + include_manifest=include_manifest, + skip_manifest=skip_manifest, + ) diff --git a/ddev/src/ddev/cli/create/logs.py b/ddev/src/ddev/cli/create/logs.py new file mode 100644 index 0000000000000..b1475f66863c6 --- /dev/null +++ b/ddev/src/ddev/cli/create/logs.py @@ -0,0 +1,56 @@ +# (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('logs', short_help='Scaffold a logs-only integration') +@click.argument('name') +@click.option('--display-name', default=None, help='Human-readable display name for the integration.') +@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') +@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') +@click.option('--location', '-l', default=None, help='The directory where files will be written.') +@click.option('--quiet', '-q', is_flag=True, help='Show less output.') +@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') +@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') +@click.option( + '--skip-manifest', + is_flag=True, + help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', +) +@click.pass_obj +def logs( + app: Application, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms: str | None, + location: str | None, + quiet: bool, + dry_run: bool, + include_manifest: bool, + skip_manifest: bool, +) -> None: + """Scaffold a logs-only integration.""" + from ddev.cli.create._common import run_subcommand + + run_subcommand( + app, + integration_type='logs', + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms, + location=location, + quiet=quiet, + dry_run=dry_run, + include_manifest=include_manifest, + skip_manifest=skip_manifest, + ) diff --git a/ddev/src/ddev/cli/create/metrics_crawler.py b/ddev/src/ddev/cli/create/metrics_crawler.py new file mode 100644 index 0000000000000..396ab8e885c36 --- /dev/null +++ b/ddev/src/ddev/cli/create/metrics_crawler.py @@ -0,0 +1,56 @@ +# (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('metrics-crawler', short_help='Scaffold a metrics-crawler integration') +@click.argument('name') +@click.option('--display-name', default=None, help='Human-readable display name for the integration.') +@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') +@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') +@click.option('--location', '-l', default=None, help='The directory where files will be written.') +@click.option('--quiet', '-q', is_flag=True, help='Show less output.') +@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') +@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') +@click.option( + '--skip-manifest', + is_flag=True, + help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', +) +@click.pass_obj +def metrics_crawler( + app: Application, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms: str | None, + location: str | None, + quiet: bool, + dry_run: bool, + include_manifest: bool, + skip_manifest: bool, +) -> None: + """Scaffold a metrics-crawler integration.""" + from ddev.cli.create._common import run_subcommand + + run_subcommand( + app, + integration_type='metrics_crawler', + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms, + location=location, + quiet=quiet, + dry_run=dry_run, + include_manifest=include_manifest, + skip_manifest=skip_manifest, + ) diff --git a/ddev/src/ddev/cli/create/templates/check/README.md b/ddev/src/ddev/cli/create/templates/check/README.md new file mode 100644 index 0000000000000..d6e83d815559f --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/README.md @@ -0,0 +1,6 @@ +# Python Agent Check + +Also known as an "Agent integration", choose this template type if you need to write Python code to collect data (such as metrics, events, and logs) and submit it to Datadog through the Agent. + +It creates a Python package that can be installed on the Agent. +The changelog is managed with towncrier in integrations-core and manually in other repositories. diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/CHANGELOG.md b/ddev/src/ddev/cli/create/templates/check/{check_name}/CHANGELOG.md new file mode 100644 index 0000000000000..7671da835b82d --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/CHANGELOG.md @@ -0,0 +1,3 @@ +# CHANGELOG - {integration_name} + +{changelog_body} diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/README.md b/ddev/src/ddev/cli/create/templates/check/{check_name}/README.md new file mode 100644 index 0000000000000..c5024f63583d8 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/README.md @@ -0,0 +1,51 @@ +# Agent Check: {integration_name} + +## Overview + +This check monitors [{integration_name}][1] through the Datadog Agent. + +Include a high level overview of what this integration does: +- What does your product do (in 1-2 sentences)? +- What value will customers get from this integration, and why is it valuable to them? +- What specific data will your integration monitor, and what's the value of that data? + +## Setup + +Follow the instructions below to install and configure this check for an Agent running on a host. For containerized environments, see the [Autodiscovery integration templates][3] for guidance on applying these instructions. + +### Installation + +{install_info} + +### Configuration + +1. Edit the `{check_name}.d/conf.yaml` file, in the `conf.d/` folder at the root of your Agent's configuration directory to start collecting your {check_name} performance data. See the [sample {check_name}.d/conf.yaml][4] for all available configuration options. + +2. [Restart the Agent][5]. + +### Validation + +[Run the Agent's status subcommand][6] and look for `{check_name}` under the Checks section. + +## Data collected + +### Metrics + +See [metadata.csv][7] for a list of metrics provided by this integration. + +### Events + +The {integration_name} integration does not include any events. + +### Service checks + +The {integration_name} integration does not include any service checks. + +See [service_checks.json][8] for a list of service checks provided by this integration. + +## Troubleshooting + +Need help? Contact [Datadog support][9]. + + +{integration_links} \ No newline at end of file diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/assets/configuration/spec.yaml b/ddev/src/ddev/cli/create/templates/check/{check_name}/assets/configuration/spec.yaml new file mode 100644 index 0000000000000..9670b1f65b739 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/assets/configuration/spec.yaml @@ -0,0 +1,10 @@ +name: {integration_name} +files: +- name: {check_name}.yaml + options: + - template: init_config + options: + - template: init_config/default + - template: instances + options: + - template: instances/default diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/assets/dashboards/{check_name}_overview.json b/ddev/src/ddev/cli/create/templates/check/{check_name}/assets/dashboards/{check_name}_overview.json new file mode 100644 index 0000000000000..e9e23301af626 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/assets/dashboards/{check_name}_overview.json @@ -0,0 +1 @@ +Please build an out-of-the-box dashboard for your integration following our best practices here: https://datadoghq.dev/integrations-core/guidelines/dashboards/#best-practices \ No newline at end of file diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/changelog.d/1.added b/ddev/src/ddev/cli/create/templates/check/{check_name}/changelog.d/1.added new file mode 100644 index 0000000000000..aa949b47b7b41 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/changelog.d/1.added @@ -0,0 +1 @@ +Initial Release \ No newline at end of file diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/__about__.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/__about__.py new file mode 100644 index 0000000000000..52da1138aed94 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/__about__.py @@ -0,0 +1,2 @@ +{license_header} +__version__ = '{starting_version}' diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/__init__.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/__init__.py new file mode 100644 index 0000000000000..6e1e729d61695 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/__init__.py @@ -0,0 +1,5 @@ +{license_header} +from .__about__ import __version__ +from .check import {check_class} + +__all__ = ['__version__', '{check_class}'] diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/check.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/check.py new file mode 100644 index 0000000000000..b38534fec88a8 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/check.py @@ -0,0 +1,69 @@ +{license_header} + +from datadog_checks.base import AgentCheck +from datadog_checks.base.types import InitConfigType, InstanceType + +from .config_models import ConfigMixin + +# from datadog_checks.base.utils.db import QueryManager +# from requests.exceptions import ConnectionError, HTTPError, InvalidURL, Timeout +# from json import JSONDecodeError + + +class {check_class}(AgentCheck, ConfigMixin): + # This will be the prefix of every metric the integration sends + __NAMESPACE__ = '{check_name}' + + def __init__(self, name: str, init_config: InitConfigType, instances: list[InstanceType]) -> None: + super().__init__(name, init_config, instances) + + # If the check is going to perform SQL queries you should define a query manager here. + # More info at + # https://datadoghq.dev/integrations-core/base/databases/#datadog_checks.base.utils.db.core.QueryManager + # sample_query = {{ + # "name": "sample", + # "query": "SELECT * FROM sample_table", + # "columns": [ + # {{"name": "metric", "type": "gauge"}} + # ], + # }} + # self._query_manager = QueryManager(self, self.execute_query, queries=[sample_query]) + # self.check_initializations.append(self._query_manager.compile_queries) + + def check(self, _: InstanceType) -> None: + # The following are useful bits of code to help new users get started. + + # Read validated, typed configuration from self.config (and self.shared_config). + # Fields must be declared in spec.yaml and the models regenerated with `ddev validate models`. + # url = self.config.url + + # Perform HTTP Requests with our HTTP wrapper. + # More info at https://datadoghq.dev/integrations-core/base/http/ + # try: + # response = self.http.get(url) + # response.raise_for_status() + # response_json = response.json() + + # except (HTTPError, InvalidURL, ConnectionError, Timeout): + # self.log.debug("Could not connect", exc_info=True) + + # except JSONDecodeError: + # self.log.debug("Could not parse JSON", exc_info=True) + + # except ValueError: + # self.log.debug("Unexpected value", exc_info=True) + + # This is how you submit metrics + # There are different types of metrics that you can submit (gauge, event). + # More info at https://datadoghq.dev/integrations-core/base/api/#datadog_checks.base.checks.base.AgentCheck + # self.gauge("test", 1.23, tags=['foo:bar']) + + # Perform database queries using the Query Manager + # self._query_manager.execute() + + # This is how you use the persistent cache. This cache is file based and persists across agent restarts. + # If you need an in-memory cache that is persisted across runs + # You can define a dictionary in the __init__ method. + # self.write_persistent_cache("key", "value") + # value = self.read_persistent_cache("key") + pass diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/__init__.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/__init__.py new file mode 100644 index 0000000000000..1b6cb5a013ad3 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/__init__.py @@ -0,0 +1,19 @@ +{license_header} + +{documentation} + +from .instance import InstanceConfig +from .shared import SharedConfig + + +class ConfigMixin: + _config_model_instance: InstanceConfig + _config_model_shared: SharedConfig + + @property + def config(self) -> InstanceConfig: + return self._config_model_instance + + @property + def shared_config(self) -> SharedConfig: + return self._config_model_shared diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/defaults.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/defaults.py new file mode 100644 index 0000000000000..bb569afb751ff --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/defaults.py @@ -0,0 +1,11 @@ +{license_header} + +{documentation} + + +def instance_empty_default_hostname(): + return False + + +def instance_min_collection_interval(): + return 15 diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/instance.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/instance.py new file mode 100644 index 0000000000000..c1f54484fe1a7 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/instance.py @@ -0,0 +1,45 @@ +{license_header} + +{documentation} + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import defaults, validators + + +class InstanceConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + empty_default_hostname: Optional[bool] = None + min_collection_interval: Optional[float] = None + service: Optional[str] = None + tags: Optional[tuple[str, ...]] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_instance', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'instance_{{info.field_name}}', identity)(value, field=field) + else: + value = getattr(defaults, f'instance_{{info.field_name}}', lambda: value)() + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_instance', identity)(model)) diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/shared.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/shared.py new file mode 100644 index 0000000000000..dd76a1c12fa51 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/shared.py @@ -0,0 +1,42 @@ +{license_header} + +{documentation} + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import defaults, validators + + +class SharedConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + service: Optional[str] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_shared', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'shared_{{info.field_name}}', identity)(value, field=field) + else: + value = getattr(defaults, f'shared_{{info.field_name}}', lambda: value)() + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_shared', identity)(model)) diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/validators.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/validators.py new file mode 100644 index 0000000000000..31b99cc318f2d --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/config_models/validators.py @@ -0,0 +1,11 @@ +{license_header} + +# Here you can include additional config validators or transformers +# +# def initialize_instance(values, **kwargs): +# if 'my_option' not in values and 'my_legacy_option' in values: +# values['my_option'] = values['my_legacy_option'] +# if values.get('my_number') > 10: +# raise ValueError('my_number max value is 10, got %s' % str(values.get('my_number'))) +# +# return values diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example new file mode 100644 index 0000000000000..8ee633b1335fc --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example @@ -0,0 +1,44 @@ +## All options defined here are available to all instances. +# +init_config: + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Additionally, this sets the default `service` for every log source. + # + # service: + +## Every instance is scheduled independently of the others. +# +instances: + + - + ## @param tags - list of strings - optional + ## A list of tags to attach to every metric and service check emitted by this instance. + ## + ## Learn more about tagging at https://docs.datadoghq.com/tagging + # + # tags: + # - : + # - : + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Overrides any `service` defined in the `init_config` section. + # + # service: + + ## @param min_collection_interval - number - optional - default: 15 + ## This changes the collection interval of the check. For more information, see: + ## https://docs.datadoghq.com/developers/write_agent_check/#collection-interval + # + # min_collection_interval: 15 + + ## @param empty_default_hostname - boolean - optional - default: false + ## This forces the check to send metrics with no hostname. + ## + ## This is useful for cluster-level checks. + # + # empty_default_hostname: false diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/hatch.toml b/ddev/src/ddev/cli/create/templates/check/{check_name}/hatch.toml new file mode 100644 index 0000000000000..b12a1d1019dd3 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/hatch.toml @@ -0,0 +1,5 @@ +[env.collectors.datadog-checks] +check-types = true + +[[envs.default.matrix]] +python = ["3.13"] diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/images/.gitkeep b/ddev/src/ddev/cli/create/templates/check/{check_name}/images/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/manifest.json b/ddev/src/ddev/cli/create/templates/check/{check_name}/manifest.json new file mode 100644 index 0000000000000..03c9d876ac5b9 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/manifest.json @@ -0,0 +1,48 @@ +{{ + "manifest_version": "2.0.0", + "app_uuid": "{app_uuid}", + "app_id": "{integration_id}", + "display_on_public_website": {display_on_public_website}, + "tile": {{ + "overview": "README.md#Overview", + "configuration": "README.md#Setup", + "support": "README.md#Support", + "changelog": "CHANGELOG.md", + "description": "{description}", + "title": "{integration_name}", + "media": {media}, + "classifier_tags": [ + "", + "Supported OS::Linux", + "Supported OS::Windows", + "Supported OS::macOS", + "Category::", + "Offering::", + "Queried Data Type::", + "Submitted Data Type::" + ] + }}, + "assets": {{ + "integration": {{ + "auto_install": {auto_install}, + "source_type_id": {source_type_id}, + "source_type_name": "{integration_name}", + "configuration": {{ + "spec": "assets/configuration/spec.yaml" + }}, + "events": {{ + "creates_events": false + }}, + "metrics": {{ + "prefix": "{check_name}.", + "check": "", + "metadata_path": "metadata.csv" + }} + }}, + "dashboards": {{ + "{example_dashboard_short_name}": "assets/dashboards/.json" + }}, + "monitors": {{}}, + "saved_views": {{}} + }},{pricing_plan}{terms}{author_info} +}} diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/metadata.csv b/ddev/src/ddev/cli/create/templates/check/{check_name}/metadata.csv new file mode 100644 index 0000000000000..02cde5e98381e --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/metadata.csv @@ -0,0 +1 @@ +metric_name,metric_type,interval,unit_name,per_unit_name,description,orientation,integration,short_name,curated_metric,sample_tags diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/pyproject.toml b/ddev/src/ddev/cli/create/templates/check/{check_name}/pyproject.toml new file mode 100644 index 0000000000000..739ab30eaec49 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = [ + "hatchling>=0.13.0", +] +build-backend = "hatchling.build" + +[project] +name = "datadog-{project_name}" +description = "The {integration_name} check" +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.13" +keywords = [ + "datadog", + "datadog agent", + "datadog check", + "{check_name}", +] +authors = [ + {{ name = "{author}", email = "{email_packages}" }}, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Private :: Do Not Upload", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Monitoring", +] +dependencies = [ + "datadog-checks-base>=37.36.0", +] +dynamic = [ + "version", +] + +[project.optional-dependencies] +deps = [] + +[project.urls] +Source = "https://github.com/DataDog/{repo_name}" + +[tool.hatch.version] +path = "datadog_checks/{check_name}/__about__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/datadog_checks", + "/tests", + "/manifest.json", +] + +[tool.hatch.build.targets.wheel] +include = [ + "/datadog_checks/{check_name}", +] +dev-mode-dirs = [ + ".", +] diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/__init__.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/__init__.py new file mode 100644 index 0000000000000..4ad5a0451cb35 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/__init__.py @@ -0,0 +1 @@ +{license_header} diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/conftest.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/conftest.py new file mode 100644 index 0000000000000..30ef64a23997a --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/conftest.py @@ -0,0 +1,34 @@ +{license_header} +from typing import Iterator + +import pytest + +from datadog_checks.base.types import InstanceType + + +@pytest.fixture(scope='session') +def dd_environment() -> Iterator[None]: + # When the integration has a real test environment, wire it here. + # Typical Docker Compose setup: + # + # from pathlib import Path + # from datadog_checks.dev import docker_run + # from datadog_checks.dev.conditions import CheckEndpoints + # from datadog_checks.dev.docker import get_docker_hostname + # from datadog_checks.dev.utils import find_free_port + # + # host = get_docker_hostname() + # port = find_free_port(host) + # compose_file = Path(__file__).parent / "docker" / "docker-compose.yml" + # with docker_run( + # compose_file=str(compose_file), + # env_vars={{"PORT": str(port)}}, + # conditions=[CheckEndpoints(f"http://{{host}}:{{port}}/health", attempts=60, wait=2)], + # ): + # yield {{"instances": [{{"openmetrics_endpoint": f"http://{{host}}:{{port}}/metrics"}}]}} + yield + + +@pytest.fixture +def instance() -> InstanceType: + return {{}} diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/test_e2e.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/test_e2e.py new file mode 100644 index 0000000000000..0d4f9184790f9 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/test_e2e.py @@ -0,0 +1,25 @@ +{license_header} + +from typing import Any + +import pytest + +from datadog_checks.base.types import InstanceType +from datadog_checks.dev.utils import get_metadata_metrics + + +@pytest.mark.e2e +def test_e2e(dd_agent_check: Any, instance: InstanceType) -> None: + aggregator = dd_agent_check(instance, rate=True) + + # Assert every metric emitted is declared in metadata.csv with the correct type and unit, + # and that every metric in metadata.csv was emitted at least once. + aggregator.assert_metrics_using_metadata(get_metadata_metrics()) + + # Assert no metric was emitted that wasn't covered by an assertion above. + aggregator.assert_all_metrics_covered() + + # Other useful assertions to consider for end-to-end coverage: + # aggregator.assert_metric('{check_name}.', value=1.23, count=1, tags=['foo:bar']) + # aggregator.assert_metric_has_tag('{check_name}.', 'env:prod') + # aggregator.assert_metric_has_tag_prefix('{check_name}.', 'host:') diff --git a/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/test_unit.py b/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/test_unit.py new file mode 100644 index 0000000000000..9c8086422161f --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check/{check_name}/tests/test_unit.py @@ -0,0 +1,37 @@ +{license_header} + +from typing import Callable + +from datadog_checks.base.stubs.aggregator import AggregatorStub +from datadog_checks.base.types import InstanceType +from datadog_checks.dev.utils import get_metadata_metrics +from datadog_checks.{check_name} import {check_class} + + +def test_check( + dd_run_check: Callable[..., None], + aggregator: AggregatorStub, + instance: InstanceType, +) -> None: + check = {check_class}('{check_name}', {{}}, [instance]) + dd_run_check(check) + + # Assert every metric emitted is declared in metadata.csv with the correct type and unit, + # and that every metric in metadata.csv was emitted. + aggregator.assert_metrics_using_metadata(get_metadata_metrics()) + + # The following are useful assertions to help new users get started. + + # Assert a specific metric was emitted with a specific value, count, and tag set. + # aggregator.assert_metric('{check_name}.', value=1.23, count=1, tags=['foo:bar']) + + # Assert a metric carries a specific tag (exact match) or any tag with a given prefix. + # aggregator.assert_metric_has_tag('{check_name}.', 'env:prod') + # aggregator.assert_metric_has_tag_prefix('{check_name}.', 'host:') + + # Assert a service check was emitted with a specific status. + # from datadog_checks.base.constants import ServiceCheck + # aggregator.assert_service_check('{check_name}.can_connect', ServiceCheck.OK, count=1) + + # Assert nothing was emitted that wasn't covered by an assertion above. + aggregator.assert_all_metrics_covered() diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/CHANGELOG.md b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/CHANGELOG.md new file mode 100644 index 0000000000000..785a313d760c9 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/CHANGELOG.md @@ -0,0 +1,6 @@ +# CHANGELOG - {integration_name} + +## 1.0.0 / YYYY-MM-DD + +***Added:*** +* Initial Release \ No newline at end of file diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/README.md b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/README.md new file mode 100644 index 0000000000000..67aac661f9ed5 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/README.md @@ -0,0 +1,11 @@ +# {integration_name} + +## Overview +This check monitors [integration name] through the Datadog Agent. +To learn more, visit https://docs.datadoghq.com/integrations/{integration_name}/ + +## Setup +Visit our documentation to learn more. + +## Support +Need help? Contact Datadog support. diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/assets/configuration/spec.yaml b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/assets/configuration/spec.yaml new file mode 100644 index 0000000000000..9670b1f65b739 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/assets/configuration/spec.yaml @@ -0,0 +1,10 @@ +name: {integration_name} +files: +- name: {check_name}.yaml + options: + - template: init_config + options: + - template: init_config/default + - template: instances + options: + - template: instances/default diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/changelog.d/1.added b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/changelog.d/1.added new file mode 100644 index 0000000000000..aa949b47b7b41 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/changelog.d/1.added @@ -0,0 +1 @@ +Initial Release \ No newline at end of file diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/__about__.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/__about__.py new file mode 100644 index 0000000000000..52da1138aed94 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/__about__.py @@ -0,0 +1,2 @@ +{license_header} +__version__ = '{starting_version}' diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/__init__.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/__init__.py new file mode 100644 index 0000000000000..6e1e729d61695 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/__init__.py @@ -0,0 +1,5 @@ +{license_header} +from .__about__ import __version__ +from .check import {check_class} + +__all__ = ['__version__', '{check_class}'] diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/check.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/check.py new file mode 100644 index 0000000000000..17a960d376c7f --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/check.py @@ -0,0 +1,68 @@ +{license_header} +from typing import Any # noqa: F401 + +from datadog_checks.base import AgentCheck # noqa: F401 + +# from datadog_checks.base.utils.db import QueryManager +# from requests.exceptions import ConnectionError, HTTPError, InvalidURL, Timeout +# from json import JSONDecodeError + + +class {check_class}(AgentCheck): + + # This will be the prefix of every metric the integration sends + __NAMESPACE__ = '{check_name}' + + def __init__(self, name, init_config, instances): + super({check_class}, self).__init__(name, init_config, instances) + + # Use self.instance to read the check configuration + # self.url = self.instance.get("url") + + # If the check is going to perform SQL queries you should define a query manager here. + # More info at + # https://datadoghq.dev/integrations-core/base/databases/#datadog_checks.base.utils.db.core.QueryManager + # sample_query = {{ + # "name": "sample", + # "query": "SELECT * FROM sample_table", + # "columns": [ + # {{"name": "metric", "type": "gauge"}} + # ], + # }} + # self._query_manager = QueryManager(self, self.execute_query, queries=[sample_query]) + # self.check_initializations.append(self._query_manager.compile_queries) + + def check(self, _): + # type: (Any) -> None + # The following are useful bits of code to help new users get started. + + # Perform HTTP Requests with our HTTP wrapper. + # More info at https://datadoghq.dev/integrations-core/base/http/ + # try: + # response = self.http.get(self.url) + # response.raise_for_status() + # response_json = response.json() + + # except (HTTPError, InvalidURL, ConnectionError, Timeout) as e: + # self.log.debug("Could not connect", exc_info=True) + + # except JSONDecodeError as e: + # self.log.debug("Could not parse JSON", exc_info=True) + + # except ValueError as e: + # self.log.debug("Unexpected value", exc_info=True) + + # This is how you submit metrics + # There are different types of metrics that you can submit (gauge, event). + # More info at https://datadoghq.dev/integrations-core/base/api/#datadog_checks.base.checks.base.AgentCheck + # self.gauge("test", 1.23, tags=['foo:bar']) + + # Perform database queries using the Query Manager + # self._query_manager.execute() + + # This is how you use the persistent cache. This cache file based and persists across agent restarts. + # If you need an in-memory cache that is persisted across runs + # You can define a dictionary in the __init__ method. + # self.write_persistent_cache("key", "value") + # value = self.read_persistent_cache("key") + pass diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/__init__.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/__init__.py new file mode 100644 index 0000000000000..1b6cb5a013ad3 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/__init__.py @@ -0,0 +1,19 @@ +{license_header} + +{documentation} + +from .instance import InstanceConfig +from .shared import SharedConfig + + +class ConfigMixin: + _config_model_instance: InstanceConfig + _config_model_shared: SharedConfig + + @property + def config(self) -> InstanceConfig: + return self._config_model_instance + + @property + def shared_config(self) -> SharedConfig: + return self._config_model_shared diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/defaults.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/defaults.py new file mode 100644 index 0000000000000..bb569afb751ff --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/defaults.py @@ -0,0 +1,11 @@ +{license_header} + +{documentation} + + +def instance_empty_default_hostname(): + return False + + +def instance_min_collection_interval(): + return 15 diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/instance.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/instance.py new file mode 100644 index 0000000000000..c1f54484fe1a7 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/instance.py @@ -0,0 +1,45 @@ +{license_header} + +{documentation} + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import defaults, validators + + +class InstanceConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + empty_default_hostname: Optional[bool] = None + min_collection_interval: Optional[float] = None + service: Optional[str] = None + tags: Optional[tuple[str, ...]] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_instance', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'instance_{{info.field_name}}', identity)(value, field=field) + else: + value = getattr(defaults, f'instance_{{info.field_name}}', lambda: value)() + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_instance', identity)(model)) diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/shared.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/shared.py new file mode 100644 index 0000000000000..dd76a1c12fa51 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/shared.py @@ -0,0 +1,42 @@ +{license_header} + +{documentation} + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import defaults, validators + + +class SharedConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + service: Optional[str] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_shared', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'shared_{{info.field_name}}', identity)(value, field=field) + else: + value = getattr(defaults, f'shared_{{info.field_name}}', lambda: value)() + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_shared', identity)(model)) diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/validators.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/validators.py new file mode 100644 index 0000000000000..31b99cc318f2d --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/config_models/validators.py @@ -0,0 +1,11 @@ +{license_header} + +# Here you can include additional config validators or transformers +# +# def initialize_instance(values, **kwargs): +# if 'my_option' not in values and 'my_legacy_option' in values: +# values['my_option'] = values['my_legacy_option'] +# if values.get('my_number') > 10: +# raise ValueError('my_number max value is 10, got %s' % str(values.get('my_number'))) +# +# return values diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example new file mode 100644 index 0000000000000..b242294123103 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example @@ -0,0 +1,44 @@ +## All options defined here are available to all instances. +# +init_config: + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Additionally, this sets the default `service` for every log source. + # + # service: + +## Every instance is scheduled independently of the others. +# +instances: + + - + ## @param tags - list of strings - optional + ## A list of tags to attach to every metric and service check emitted by this instance. + ## + ## Learn more about tagging at https://docs.datadoghq.com/tagging + # + # tags: + # - : + # - : + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Overrides any `service` defined in the `init_config` section. + # + # service: + + ## @param min_collection_interval - integer - optional - default: 15 + ## This changes the collection interval of the check. For more information, see: + ## https://docs.datadoghq.com/developers/write_agent_check/#collection-interval + # + # min_collection_interval: 15 + + ## @param empty_default_hostname - boolean - optional - default: false + ## This forces the check to send metrics with no hostname. + ## + ## This is useful for cluster-level checks. + # + # empty_default_hostname: false diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/hatch.toml b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/hatch.toml new file mode 100644 index 0000000000000..fee2455000258 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/hatch.toml @@ -0,0 +1,4 @@ +[env.collectors.datadog-checks] + +[[envs.default.matrix]] +python = ["3.13"] diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/pyproject.toml b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/pyproject.toml new file mode 100644 index 0000000000000..1aa8c596fadc3 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = [ + "hatchling>=0.13.0", +] +build-backend = "hatchling.build" + +[project] +name = "datadog-{project_name}" +description = "The {integration_name} check" +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.12" +keywords = [ + "datadog", + "datadog agent", + "datadog check", + "{check_name}", +] +authors = [ + {{ name = "{author}", email = "{email_packages}" }}, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Private :: Do Not Upload", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Monitoring", +] +dependencies = [ + "datadog-checks-base>=37.33.0", +] +dynamic = [ + "version", +] + +[project.optional-dependencies] +deps = [] + +[project.urls] +Source = "https://github.com/DataDog/{repo_name}" + +[tool.hatch.version] +path = "datadog_checks/{check_name}/__about__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/datadog_checks", + "/tests", + "/manifest.json", +] + +[tool.hatch.build.targets.wheel] +include = [ + "/datadog_checks/{check_name}", +] +dev-mode-dirs = [ + ".", +] diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/__init__.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/__init__.py new file mode 100644 index 0000000000000..4ad5a0451cb35 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/__init__.py @@ -0,0 +1 @@ +{license_header} diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/conftest.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/conftest.py new file mode 100644 index 0000000000000..62ffe7303083c --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/conftest.py @@ -0,0 +1,12 @@ +{license_header} +import pytest + + +@pytest.fixture(scope='session') +def dd_environment(): + yield + + +@pytest.fixture +def instance(): + return {{}} diff --git a/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/test_unit.py b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/test_unit.py new file mode 100644 index 0000000000000..d69108f2c8722 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/check_only/{check_name}/tests/test_unit.py @@ -0,0 +1,17 @@ +{license_header} + +from typing import Any, Callable, Dict # noqa: F401 + +from datadog_checks.base import AgentCheck # noqa: F401 +from datadog_checks.base.stubs.aggregator import AggregatorStub # noqa: F401 +from datadog_checks.dev.utils import get_metadata_metrics +from datadog_checks.{check_name} import {check_class} + + +def test_check(dd_run_check, aggregator, instance): + # type: (Callable[[AgentCheck, bool], None], AggregatorStub, Dict[str, Any]) -> None + check = {check_class}('{check_name}', {{}}, [instance]) + dd_run_check(check) + + aggregator.assert_all_metrics_covered() + aggregator.assert_metrics_using_metadata(get_metadata_metrics()) diff --git a/ddev/src/ddev/cli/create/templates/event/README.md b/ddev/src/ddev/cli/create/templates/event/README.md new file mode 100644 index 0000000000000..bb7c96e493e7a --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/event/README.md @@ -0,0 +1,8 @@ +# Event Integration + +Choose this type of integration if your integration primarily sends events to Datadog. + +This template is for integrations that focus on event collection. + +There is no Python package for this type of integration, only a manifest is essential. + diff --git a/ddev/src/ddev/cli/create/templates/event/{check_name}/CHANGELOG.md b/ddev/src/ddev/cli/create/templates/event/{check_name}/CHANGELOG.md new file mode 100644 index 0000000000000..c7b9625fdbc33 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/event/{check_name}/CHANGELOG.md @@ -0,0 +1,7 @@ +# CHANGELOG - {integration_name} + +## 1.0.0 / {today} + +***Added***: + +* Initial Release diff --git a/ddev/src/ddev/cli/create/templates/event/{check_name}/README.md b/ddev/src/ddev/cli/create/templates/event/{check_name}/README.md new file mode 100644 index 0000000000000..e3f8b67354d22 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/event/{check_name}/README.md @@ -0,0 +1,41 @@ +# Event Integration: {integration_name} + +## Overview + +This is an event source integration, allowing you to ingest data from [{integration_name}][1] as events. + +Include a high level overview of what this integration does: +- What does your product do (in 1-2 sentences)? +- What value will customers get from this integration, and why is it valuable to them? +- What specific events will your integration collect, and what's the value of that data? + +## Setup + +### Installation + +{install_info} + +### Configuration + +!!! Add list of steps to set up this integration !!! + +### Validation + +!!! Add steps to validate integration is functioning as expected !!! + +## Data Collected + +### Metrics + +{integration_name} does not include any metrics. + +### Events + +The {integration_name} source surfaces {integration_name} data in the Datadog event stream. + +## Troubleshooting + +Need help? Contact [Datadog support][2]. + +{integration_links} + diff --git a/ddev/src/ddev/cli/create/templates/event/{check_name}/manifest.json b/ddev/src/ddev/cli/create/templates/event/{check_name}/manifest.json new file mode 100644 index 0000000000000..491b00155561b --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/event/{check_name}/manifest.json @@ -0,0 +1,38 @@ +{{ + "manifest_version": "2.0.0", + "app_uuid": "{app_uuid}", + "app_id": "{integration_id}", + "display_on_public_website": false, + "tile": {{ + "overview": "README.md#Overview", + "configuration": "README.md#Setup", + "support": "README.md#Support", + "changelog": "CHANGELOG.md", + "description": "{description}", + "title": "{integration_name}", + "media": [], + "classifier_tags": [ + "", + "Supported OS::Linux", + "Supported OS::Windows", + "Supported OS::macOS", + "Category::", + "Category::Event Management", + "Offering::Integration", + "Submitted Data Type::" + ] + }}, + "assets": {{ + "integration": {{ + "auto_install": {auto_install}, + "source_type_id": {source_type_id}, + "source_type_name": "{integration_name}", + "events": {{ + "creates_events": true + }}, + }}, + "monitors": {{}}, + "saved_views": {{}} + }},{pricing_plan}{terms}{author_info} +}} + diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/CHANGELOG.md b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/CHANGELOG.md new file mode 100644 index 0000000000000..7671da835b82d --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/CHANGELOG.md @@ -0,0 +1,3 @@ +# CHANGELOG - {integration_name} + +{changelog_body} diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/README.md b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/README.md new file mode 100644 index 0000000000000..19fbdb2d487fc --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/README.md @@ -0,0 +1,45 @@ +# Agent Check: {integration_name} + +## Overview + +This check monitors [{integration_name}][1]. + +## Setup + +### Installation + +{install_info} + +### Configuration + +1. Edit the `{check_name}.d/conf.yaml` file, in the `conf.d/` folder at the root of your + Agent's configuration directory to start collecting your {check_name} performance data. + See the [sample {check_name}.d/conf.yaml][3] for all available configuration options. + + This check has a limit of 350 metrics per instance. The number of returned metrics is indicated when running the Datadog Agent [status command][4]. + You can specify the metrics you are interested in by editing the [configuration][3]. + To learn how to customize the metrics to collect visit the [JMX Checks documentation][5] for more detailed instructions. + If you need to monitor more metrics, contact [Datadog support][6]. + +2. [Restart the Agent][7] + +### Validation + +[Run the Agent's `status` subcommand][4] and look for `{check_name}` under the Checks section. + +## Data Collected + +### Metrics + +{integration_name} does not include any metrics. + +### Events + +The {integration_name} integration does not include any events. + +## Troubleshooting + +Need help? Contact [Datadog support][6]. + + +{integration_links} diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/assets/configuration/spec.yaml b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/assets/configuration/spec.yaml new file mode 100644 index 0000000000000..2947abf44481b --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/assets/configuration/spec.yaml @@ -0,0 +1,10 @@ +name: {integration_name} +files: +- name: {check_name}.yaml + options: + - template: init_config + options: + - template: init_config/jmx + - template: instances + options: + - template: instances/jmx diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/assets/dashboards/{check_name}_overview.json b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/assets/dashboards/{check_name}_overview.json new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/changelog.d/1.added b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/changelog.d/1.added new file mode 100644 index 0000000000000..aa949b47b7b41 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/changelog.d/1.added @@ -0,0 +1 @@ +Initial Release \ No newline at end of file diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/__about__.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/__about__.py new file mode 100644 index 0000000000000..52da1138aed94 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/__about__.py @@ -0,0 +1,2 @@ +{license_header} +__version__ = '{starting_version}' diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/__init__.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/__init__.py new file mode 100644 index 0000000000000..cc6c78ea39f0d --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/__init__.py @@ -0,0 +1,4 @@ +{license_header} +from .__about__ import __version__ + +__all__ = ['__version__'] diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/__init__.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/__init__.py new file mode 100644 index 0000000000000..d92fb862e84b0 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/__init__.py @@ -0,0 +1,16 @@ +{license_header} +from .instance import InstanceConfig +from .shared import SharedConfig + + +class ConfigMixin: + _config_model_instance: InstanceConfig + _config_model_shared: SharedConfig + + @property + def config(self) -> InstanceConfig: + return self._config_model_instance + + @property + def shared_config(self) -> SharedConfig: + return self._config_model_shared diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/defaults.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/defaults.py new file mode 100644 index 0000000000000..f5b39ef8aa1aa --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/defaults.py @@ -0,0 +1,31 @@ +{license_header} + +{documentation} + + +def shared_new_gc_metrics(field, value): + return False + + +def instance_collect_default_jvm_metrics(field, value): + return True + + +def instance_empty_default_hostname(field, value): + return False + + +def instance_min_collection_interval(field, value): + return 15 + + +def instance_rmi_client_timeout(field, value): + return 15000 + + +def instance_rmi_connection_timeout(field, value): + return 20000 + + +def instance_rmi_registry_ssl(field, value): + return False diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/instance.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/instance.py new file mode 100644 index 0000000000000..a5273ebaae04f --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/instance.py @@ -0,0 +1,63 @@ +{license_header} + +{documentation} + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import defaults, validators + + +class InstanceConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + collect_default_jvm_metrics: Optional[bool] = None + empty_default_hostname: Optional[bool] = None + host: str + java_bin_path: Optional[str] = None + java_options: Optional[str] = None + jmx_url: Optional[str] = None + key_store_password: Optional[str] = None + key_store_path: Optional[str] = None + min_collection_interval: Optional[float] = None + name: Optional[str] = None + password: Optional[str] = None + port: int + process_name_regex: Optional[str] = None + rmi_client_timeout: Optional[float] = None + rmi_connection_timeout: Optional[float] = None + rmi_registry_ssl: Optional[bool] = None + service: Optional[str] = None + tags: Optional[tuple[str, ...]] = None + tools_jar_path: Optional[str] = None + trust_store_password: Optional[str] = None + trust_store_path: Optional[str] = None + user: Optional[str] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_instance', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'instance_{{info.field_name}}', identity)(value, field=field) + else: + value = getattr(defaults, f'instance_{{info.field_name}}', lambda: value)() + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_instance', identity)(model)) diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/shared.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/shared.py new file mode 100644 index 0000000000000..9adc113c3cf24 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/shared.py @@ -0,0 +1,48 @@ +{license_header} + +{documentation} + +from __future__ import annotations + +from types import MappingProxyType +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from datadog_checks.base.utils.functions import identity +from datadog_checks.base.utils.models import validation + +from . import defaults, validators + + +class SharedConfig(BaseModel): + model_config = ConfigDict( + validate_default=True, + arbitrary_types_allowed=True, + frozen=True, + ) + collect_default_metrics: bool + conf: Optional[tuple[MappingProxyType[str, Any], ...]] = None + is_jmx: bool + new_gc_metrics: Optional[bool] = None + service: Optional[str] = None + service_check_prefix: Optional[str] = None + + @model_validator(mode='before') + def _initial_validation(cls, values): + return validation.core.initialize_config(getattr(validators, 'initialize_shared', identity)(values)) + + @field_validator('*', mode='before') + def _validate(cls, value, info): + field = cls.model_fields[info.field_name] + field_name = field.alias or info.field_name + if field_name in info.context['configured_fields']: + value = getattr(validators, f'shared_{{info.field_name}}', identity)(value, field=field) + else: + value = getattr(defaults, f'shared_{{info.field_name}}', lambda: value)() + + return validation.utils.make_immutable(value) + + @model_validator(mode='after') + def _final_validation(cls, model): + return validation.core.check_model(getattr(validators, 'check_shared', identity)(model)) diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/validators.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/validators.py new file mode 100644 index 0000000000000..4ad5a0451cb35 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/validators.py @@ -0,0 +1 @@ +{license_header} diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example new file mode 100644 index 0000000000000..d81e3795382e1 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example @@ -0,0 +1,163 @@ +## All options defined here are available to all instances. +# +init_config: + + ## @param is_jmx - boolean - required + ## Whether or not this file is a configuration for a JMX integration. + # + is_jmx: true + + ## @param collect_default_metrics - boolean - required + ## Whether or not the check should collect all default metrics. + # + collect_default_metrics: true + + ## @param service_check_prefix - string - optional + ## Custom service check prefix. e.g. `my_prefix` to get a service check called `my_prefix.can_connect`. + ## If not set, the default service check used is the integration name. + # + # service_check_prefix: + + ## @param conf - list of mappings - optional + ## The list of metrics to be collected by the integration + ## Read http://docs.datadoghq.com/integrations/java/ to learn how to customize it + ## The default metrics to be collected are kept in metrics.yaml, but you can still + ## add your own metrics here. + # + # conf: + # - include: + # bean: + # attribute: + # MyAttribute: + # alias: my.metric.name + # metric_type: gauge + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Additionally, this sets the default `service` for every log source. + # + # service: + +## Every instance is scheduled independently of the others. +# +instances: + + ## @param host - string - required + ## JMX hostname to connect to. + # + - host: + + ## @param port - integer - required + ## JMX port to connect to. + # + port: + + ## @param user - string - optional + ## User to use when connecting to JMX. + # + # user: + + ## @param password - string - optional + ## Password to use when connecting to JMX. + # + # password: + + ## @param process_name_regex - string - optional + ## Instead of using a host and port, the Agent can connect using the attach API. + ## This requires the JDK to be installed and the path to tools.jar to be set below. + ## Note: It needs to be set when process_name_regex parameter is set + ## e.g. .*process_name.* + # + # process_name_regex: + + ## @param tools_jar_path - string - optional + ## The tool.jar path to be used with the `process_name_regex` parameter, + ## for example: /usr/lib/jvm/java-7-openjdk-amd64/lib/tools.jar + # + # tools_jar_path: + + ## @param name - string - optional + ## Set the instance name to be used as the `instance` tag. + # + # name: + + ## @param java_bin_path - string - optional + ## `java_bin_path` should be set if the Agent cannot find your java executable. + # + # java_bin_path: + + ## @param java_options - string - optional + ## A list of Java JVM options, for example: "-Xmx200m -Xms50m". + # + # java_options: + + ## @param trust_store_path - string - optional + ## The path to your trusted store. + ## `trust_store_path` should be set if SSL is enabled. + # + # trust_store_path: + + ## @param trust_store_password - string - optional + ## The password for your TrustStore.jks file. + ## `trust_store_password` should be set if SSL is enabled. + # + # trust_store_password: + + ## @param key_store_path - string - optional + ## The path to your key store. + ## `key_store_path` should be set if client authentication is enabled on the target JVM. + # + # key_store_path: + + ## @param key_store_password - string - optional + ## The password to your key store. + ## `key_store_password` should be set if client authentication is enabled on the target JVM. + # + # key_store_password: + + ## @param rmi_registry_ssl - boolean - optional - default: false + ## Whether or not the Agent should connect to the RMI registry using SSL. + # + # rmi_registry_ssl: false + + ## @param rmi_connection_timeout - number - optional - default: 20000 + ## The connection timeout, in milliseconds, when connecting to a remote JVM. + # + # rmi_connection_timeout: 20000 + + ## @param rmi_client_timeout - number - optional - default: 15000 + ## The timeout to consider a remote connection, already successfully established, as lost. + ## If a connected remote JVM does not reply after `rmi_client_timeout` milliseconds jmxfetch + ## will give up on that connection and retry. + # + # rmi_client_timeout: 15000 + + ## @param tags - list of strings - optional + ## A list of tags to attach to every metric and service check emitted by this instance. + ## + ## Learn more about tagging at https://docs.datadoghq.com/tagging + # + # tags: + # - : + # - : + + ## @param service - string - optional + ## Attach the tag `service:` to every metric, event, and service check emitted by this integration. + ## + ## Overrides any `service` defined in the `init_config` section. + # + # service: + + ## @param min_collection_interval - number - optional - default: 15 + ## This changes the collection interval of the check. For more information, see: + ## https://docs.datadoghq.com/developers/write_agent_check/#collection-interval + # + # min_collection_interval: 15 + + ## @param empty_default_hostname - boolean - optional - default: false + ## This forces the check to send metrics with no hostname. + ## + ## This is useful for cluster-level checks. + # + # empty_default_hostname: false diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/data/metrics.yaml b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/data/metrics.yaml new file mode 100644 index 0000000000000..f230b289fe4a3 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/data/metrics.yaml @@ -0,0 +1,2 @@ +# Default metrics collected by this check. You should not have to modify this. +jmx_metrics: [] diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/hatch.toml b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/hatch.toml new file mode 100644 index 0000000000000..fee2455000258 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/hatch.toml @@ -0,0 +1,4 @@ +[env.collectors.datadog-checks] + +[[envs.default.matrix]] +python = ["3.13"] diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/images/.gitkeep b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/images/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/manifest.json b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/manifest.json new file mode 100644 index 0000000000000..3d813a985e572 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/manifest.json @@ -0,0 +1,41 @@ +{{ + "manifest_version": "2.0.0", + "app_uuid": "{app_uuid}", + "app_id": "{integration_id}", + "display_on_public_website": false, + "tile": {{ + "overview": "README.md#Overview", + "configuration": "README.md#Setup", + "support": "README.md#Support", + "changelog": "CHANGELOG.md", + "description": "{description}", + "title": "{integration_name}", + "media": [], + "classifier_tags": [ + "Supported OS::Linux", + "Supported OS::Windows", + "Supported OS::macOS" + ] + }}, + "assets": {{ + "integration": {{ + "auto_install": {auto_install}, + "source_type_id": {source_type_id}, + "source_type_name": "{integration_name}", + "configuration": {{ + "spec": "assets/configuration/spec.yaml" + }}, + "events": {{ + "creates_events": false + }}, + "metrics": {{ + "prefix": "{check_name}.", + "check": "", + "metadata_path": "metadata.csv" + }} + }}, + "dashboards": {{}}, + "monitors": {{}}, + "saved_views": {{}} + }},{pricing_plan}{terms}{author_info} +}} diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/metadata.csv b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/metadata.csv new file mode 100644 index 0000000000000..02cde5e98381e --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/metadata.csv @@ -0,0 +1 @@ +metric_name,metric_type,interval,unit_name,per_unit_name,description,orientation,integration,short_name,curated_metric,sample_tags diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/pyproject.toml b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/pyproject.toml new file mode 100644 index 0000000000000..f81d534729510 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = [ + "hatchling>=0.13.0", +] +build-backend = "hatchling.build" + +[project] +name = "datadog-{project_name}" +description = "The {integration_name} check" +readme = "README.md" +license = "BSD-3-Clause" +requires-python = ">=3.13" +keywords = [ + "datadog", + "datadog agent", + "datadog check", + "{check_name}", +] +authors = [ + {{ name = "{author}", email = "{email_packages}" }}, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Private :: Do Not Upload", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Monitoring", +] +dependencies = [ + "datadog-checks-base>=37.33.0", +] +dynamic = [ + "version", +] + +[project.optional-dependencies] +deps = [] + +[project.urls] +Source = "https://github.com/DataDog/{repo_name}" + +[tool.hatch.version] +path = "datadog_checks/{check_name}/__about__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/datadog_checks", + "/tests", + "/manifest.json", +] + +[tool.hatch.build.targets.wheel] +include = [ + "/datadog_checks/{check_name}", +] +dev-mode-dirs = [ + ".", +] diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/__init__.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/__init__.py new file mode 100644 index 0000000000000..4ad5a0451cb35 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/__init__.py @@ -0,0 +1 @@ +{license_header} diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/common.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/common.py new file mode 100644 index 0000000000000..614c2a3c28c50 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/common.py @@ -0,0 +1,10 @@ +{license_header} +from datadog_checks.dev import get_docker_hostname, get_here, load_jmx_config + +HERE = get_here() +HOST = get_docker_hostname() + +INSTANCES = [{{'host': 'localhost', 'port': '9999'}}] + +CHECK_CONFIG = load_jmx_config() +CHECK_CONFIG['instances'] = INSTANCES diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/conftest.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/conftest.py new file mode 100644 index 0000000000000..758719e3a669f --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/conftest.py @@ -0,0 +1,7 @@ +{license_header} +import pytest + + +@pytest.fixture(scope='session') +def dd_environment(): + yield {{}}, {{'use_jmx': True}} diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py new file mode 100644 index 0000000000000..fee75c8688af0 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py @@ -0,0 +1,9 @@ +{license_header} +from datadog_checks.dev.jmx import JVM_E2E_METRICS_NEW + +METRICS = ( + [ + # integration metrics + ] + + JVM_E2E_METRICS_NEW +) diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/test_e2e.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/test_e2e.py new file mode 100644 index 0000000000000..6c2bb4a6df9f6 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/test_e2e.py @@ -0,0 +1,28 @@ +{license_header} +from typing import Any # noqa: F401 + +import pytest + +from datadog_checks.base import AgentCheck +from datadog_checks.base.stubs.aggregator import AggregatorStub # noqa: F401 + +from .common import CHECK_CONFIG +from .metrics import METRICS + + +@pytest.mark.e2e +def test_e2e(dd_agent_check): + # type: (Any) -> None + aggregator = dd_agent_check(CHECK_CONFIG, rate=True) # type: AggregatorStub + + for metric in METRICS: + aggregator.assert_metric(metric) + + aggregator.assert_all_metrics_covered() + + for instance in CHECK_CONFIG['instances']: + tags = [ + 'instance:{check_name}-{{}}-{{}}'.format(instance['host'], instance['port']), + 'jmx_server:{{}}'.format(instance['host']), + ] + aggregator.assert_service_check('{check_name}.can_connect', status=AgentCheck.OK, tags=tags) diff --git a/ddev/src/ddev/cli/create/templates/logs/README.md b/ddev/src/ddev/cli/create/templates/logs/README.md new file mode 100644 index 0000000000000..0cbd2845e3415 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/README.md @@ -0,0 +1,6 @@ +# Logs-only Integration + +Choose this type of integration if you only need some pipelines and a configuration for collecting and processing logs through the Agent. + +To help you get started with your config, this integration is turned into a Python package that can be installed in the Agent. +These integrations are released just like Agent Checks, and the changelog is managed with towncrier in integrations-core. diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/CHANGELOG.md b/ddev/src/ddev/cli/create/templates/logs/{check_name}/CHANGELOG.md new file mode 100644 index 0000000000000..7671da835b82d --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/CHANGELOG.md @@ -0,0 +1,3 @@ +# CHANGELOG - {integration_name} + +{changelog_body} diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/README.md b/ddev/src/ddev/cli/create/templates/logs/{check_name}/README.md new file mode 100644 index 0000000000000..00c114d4fc40a --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/README.md @@ -0,0 +1,58 @@ +# Agent Integration: {integration_name} + +## Overview + +This integration monitors [{integration_name}][4]. + +## Setup + +### Installation + +{install_info} + +### Configuration + +!!! Add list of steps to set up this integration !!! + +### Validation + +!!! Add steps to validate integration is functioning as expected !!! + +## Data Collected + +### Metrics + +{integration_name} does not include any metrics. + +### Log Collection + + +1. Collecting logs is disabled by default in the Datadog Agent. Enable it in the `datadog.yaml` file with: + + ```yaml + logs_enabled: true + ``` + +2. Add this configuration block to your `{check_name}.d/conf.yaml` file to start collecting your {integration_name} logs: + + ```yaml + logs: + - type: file + path: /var/log/{integration_name}.log + source: {check_name} + service: + ``` + + Change the `path` and `service` parameter values and configure them for your environment. + +3. [Restart the Agent][3]. + +### Events + +The {integration_name} integration does not include any events. + +## Troubleshooting + +Need help? Contact [Datadog support][1]. + +{integration_links} diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/assets/configuration/spec.yaml b/ddev/src/ddev/cli/create/templates/logs/{check_name}/assets/configuration/spec.yaml new file mode 100644 index 0000000000000..8503c70a208ce --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/assets/configuration/spec.yaml @@ -0,0 +1,9 @@ +name: {integration_name} +files: +- name: {check_name}.yaml + options: + - template: logs + example: + - type: file + path: /var/log/{check_name}.log + source: {check_name} diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/assets/dashboards/{check_name}_overview.json b/ddev/src/ddev/cli/create/templates/logs/{check_name}/assets/dashboards/{check_name}_overview.json new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/changelog.d/1.added b/ddev/src/ddev/cli/create/templates/logs/{check_name}/changelog.d/1.added new file mode 100644 index 0000000000000..aa949b47b7b41 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/changelog.d/1.added @@ -0,0 +1 @@ +Initial Release \ No newline at end of file diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/__about__.py b/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/__about__.py new file mode 100644 index 0000000000000..52da1138aed94 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/__about__.py @@ -0,0 +1,2 @@ +{license_header} +__version__ = '{starting_version}' diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/__init__.py b/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/__init__.py new file mode 100644 index 0000000000000..cc6c78ea39f0d --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/__init__.py @@ -0,0 +1,4 @@ +{license_header} +from .__about__ import __version__ + +__all__ = ['__version__'] diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example b/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example new file mode 100644 index 0000000000000..a4e8efcb63fa6 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/datadog_checks/{check_name}/data/conf.yaml.example @@ -0,0 +1,20 @@ +## Log Section +## +## type - required - Type of log input source (tcp / udp / file / windows_event). +## port / path / channel_path - required - Set port if type is tcp or udp. +## Set path if type is file. +## Set channel_path if type is windows_event. +## source - required - Attribute that defines which integration sent the logs +## service - required - The name of the service that generates the log. +## Overrides any `service` defined in the `init_config` section. +## encoding - optional - For file specifies the file encoding. Default is utf-8. Other +## possible values are utf-16-le and utf-16-be. +## tags - optional - Add tags to the collected logs +## +## Discover Datadog log collection: https://docs.datadoghq.com/logs/log_collection/ +# +# logs: +# - type: file +# path: /var/log/{check_name}.log +# source: {check_name} +# service: diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/images/.gitkeep b/ddev/src/ddev/cli/create/templates/logs/{check_name}/images/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/manifest.json b/ddev/src/ddev/cli/create/templates/logs/{check_name}/manifest.json new file mode 100644 index 0000000000000..12eecdfca51b0 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/manifest.json @@ -0,0 +1,37 @@ +{{ + "manifest_version": "2.0.0", + "app_uuid": "{app_uuid}", + "app_id": "{integration_id}", + "display_on_public_website": false, + "tile": {{ + "overview": "README.md#Overview", + "configuration": "README.md#Setup", + "support": "README.md#Support", + "changelog": "CHANGELOG.md", + "description": "{description}", + "title": "{integration_name}", + "media": [], + "classifier_tags": [ + "Supported OS::Linux", + "Supported OS::Windows", + "Supported OS::macOS", + "Category::Log Collection" + ] + }}, + "assets": {{ + "integration": {{ + "auto_install": {auto_install}, + "source_type_id": {source_type_id}, + "source_type_name": "{integration_name}", + "configuration": {{ + "spec": "assets/configuration/spec.yaml" + }}, + "events": {{ + "creates_events": false + }} + }}, + "dashboards": {{}}, + "monitors": {{}}, + "saved_views": {{}} + }},{pricing_plan}{terms}{author_info} +}} diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/metadata.csv b/ddev/src/ddev/cli/create/templates/logs/{check_name}/metadata.csv new file mode 100644 index 0000000000000..02cde5e98381e --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/metadata.csv @@ -0,0 +1 @@ +metric_name,metric_type,interval,unit_name,per_unit_name,description,orientation,integration,short_name,curated_metric,sample_tags diff --git a/ddev/src/ddev/cli/create/templates/logs/{check_name}/pyproject.toml b/ddev/src/ddev/cli/create/templates/logs/{check_name}/pyproject.toml new file mode 100644 index 0000000000000..2713cb7e28813 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/logs/{check_name}/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = [ + "hatchling>=0.13.0", +] +build-backend = "hatchling.build" + +[project] +name = "datadog-{project_name}" +description = "The {integration_name} check" +readme = "README.md" +license = "BSD-3-Clause" +keywords = [ + "datadog", + "datadog agent", + "datadog check", + "{check_name}", +] +authors = [ + {{ name = "{author}", email = "{email_packages}" }}, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Private :: Do Not Upload", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Monitoring", +] +dependencies = [ + "datadog-checks-base>=37.33.0", +] +dynamic = [ + "version", +] + +[project.optional-dependencies] +deps = [] + +[project.urls] +Source = "https://github.com/DataDog/{repo_name}" + +[tool.hatch.version] +path = "datadog_checks/{check_name}/__about__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/datadog_checks", + "/tests", + "/manifest.json", +] + +[tool.hatch.build.targets.wheel] +include = [ + "/datadog_checks/{check_name}", +] +dev-mode-dirs = [ + ".", +] diff --git a/ddev/src/ddev/cli/create/templates/metrics_crawler/README.md b/ddev/src/ddev/cli/create/templates/metrics_crawler/README.md new file mode 100644 index 0000000000000..1d47a22e82afa --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/metrics_crawler/README.md @@ -0,0 +1,6 @@ +# Crawler-based Integration + +Use this integration type for crawlers that run on Datadog's internal infrastructure. + +This type is similar to the `tile` type, but includes boilerplate that's specific to crawlers. +There is no Python package for this type of integration, it essentially contains a manifest and assets. diff --git a/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/CHANGELOG.md b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/CHANGELOG.md new file mode 100644 index 0000000000000..c7b9625fdbc33 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/CHANGELOG.md @@ -0,0 +1,7 @@ +# CHANGELOG - {integration_name} + +## 1.0.0 / {today} + +***Added***: + +* Initial Release diff --git a/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/README.md b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/README.md new file mode 100644 index 0000000000000..ef35ff94a2617 --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/README.md @@ -0,0 +1,35 @@ +# Agent Check: {integration_name} + +## Overview + +This check monitors [{integration_name}][1]. + +## Setup + +### Installation + +{install_info} + +### Configuration + +!!! Add list of steps to set up this integration !!! + +### Validation + +!!! Add steps to validate integration is functioning as expected !!! + +## Data Collected + +### Metrics + +{integration_name} does not include any metrics. + +### Events + +{integration_name} does not include any events. + +## Troubleshooting + +Need help? Contact [Datadog support][3]. + +{integration_links} diff --git a/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/assets/dashboards/{check_name}_overview.json b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/assets/dashboards/{check_name}_overview.json new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/assets/service_checks.json b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/assets/service_checks.json new file mode 100644 index 0000000000000..fe51488c7066f --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/assets/service_checks.json @@ -0,0 +1 @@ +[] diff --git a/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/images/.gitkeep b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/images/.gitkeep new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/manifest.json b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/manifest.json new file mode 100644 index 0000000000000..0efcfae15a4da --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/manifest.json @@ -0,0 +1,39 @@ +{{ + "manifest_version": "2.0.0", + "app_uuid": "{app_uuid}", + "app_id": "{integration_id}", + "display_on_public_website": false, + "tile": {{ + "overview": "README.md#Overview", + "configuration": "README.md#Setup", + "support": "README.md#Support", + "changelog": "CHANGELOG.md", + "description": "{description}", + "title": "{integration_name}", + "media": [], + "classifier_tags": [ + "Category::Metrics" + ] + }}, + "assets": {{ + "integration": {{ + "auto_install": {auto_install}, + "source_type_id": {source_type_id}, + "source_type_name": "{integration_name}", + "events": {{ + "creates_events": false + }}, + "metrics": {{ + "prefix": "{check_name}.", + "check": [], + "metadata_path": "metadata.csv" + }}, + "service_checks": {{ + "metadata_path": "assets/service_checks.json" + }} + }}, + "dashboards": {{ + "{example_dashboard_short_name}": "assets/dashboards/{check_name}_overview.json" + }} + }},{pricing_plan}{terms}{author_info} +}} diff --git a/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/metadata.csv b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/metadata.csv new file mode 100644 index 0000000000000..02cde5e98381e --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/metrics_crawler/{check_name}/metadata.csv @@ -0,0 +1 @@ +metric_name,metric_type,interval,unit_name,per_unit_name,description,orientation,integration,short_name,curated_metric,sample_tags diff --git a/ddev/tests/cli/create/__init__.py b/ddev/tests/cli/create/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ddev/tests/cli/create/conftest.py b/ddev/tests/cli/create/conftest.py new file mode 100644 index 0000000000000..01791c63c36b8 --- /dev/null +++ b/ddev/tests/cli/create/conftest.py @@ -0,0 +1,20 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import pytest + +from ddev.repo.core import Repository + + +@pytest.fixture +def empty_repo(tmp_path_factory, config_file): + """A clean repo on disk with no integrations, ready for `ddev create` to scaffold into.""" + repo_path = tmp_path_factory.mktemp('integrations-core') + repo = Repository('integrations-core', str(repo_path)) + + config_file.model.repos['core'] = str(repo.path) + config_file.save() + + return repo diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py new file mode 100644 index 0000000000000..51da770825cc7 --- /dev/null +++ b/ddev/tests/cli/create/test_create.py @@ -0,0 +1,278 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +"""Behaviour-level tests for the `ddev create` command group.""" + +from __future__ import annotations + +import json + +import pytest +import tomli + + +@pytest.mark.parametrize( + 'subcommand', + ['check', 'jmx', 'logs', 'event', 'metrics-crawler'], +) +def test_default_manifestless_writes_overrides_and_skips_manifest(ddev, empty_repo, subcommand): + result = ddev( + 'create', + subcommand, + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux,windows,mac_os', + ) + assert result.exit_code == 0, result.output + + integration_dir = empty_repo.path / 'my_integration' + assert integration_dir.is_dir() + assert not (integration_dir / 'manifest.json').exists() + + config_toml = empty_repo.path / '.ddev' / 'config.toml' + assert config_toml.is_file() + data = tomli.loads(config_toml.read_text()) + assert data['overrides']['display-name']['my_integration'] == 'My Integration' + assert data['overrides']['metrics-prefix']['my_integration'] == 'my_integration.' + assert data['overrides']['manifest']['platforms']['my_integration'] == ['linux', 'windows', 'mac_os'] + + +def test_include_manifest_writes_manifest_and_skips_overrides(ddev, empty_repo): + result = ddev( + 'create', + 'check', + 'my_integration', + '--include-manifest', + ) + assert result.exit_code == 0, result.output + + integration_dir = empty_repo.path / 'my_integration' + assert (integration_dir / 'manifest.json').exists() + + config_toml = empty_repo.path / '.ddev' / 'config.toml' + assert not config_toml.exists() + + +def test_skip_manifest_and_include_manifest_conflict(ddev, empty_repo): + result = ddev( + 'create', + 'check', + 'my_integration', + '--skip-manifest', + '--include-manifest', + ) + assert result.exit_code != 0 + assert 'mutually exclusive' in result.output + + +def test_skip_manifest_emits_deprecation_warning(ddev, empty_repo): + result = ddev( + 'create', + 'check', + 'my_integration', + '--skip-manifest', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + ) + assert result.exit_code == 0, result.output + assert '--skip-manifest` is deprecated' in result.output + + +def test_dropped_type_aborts_with_confluence_link(ddev, empty_repo): + result = ddev('create', 'foo', '--type', 'tile', '--dry-run') + assert result.exit_code != 0 + assert '6248108729' in result.output + + +@pytest.mark.parametrize('dropped', ['tile', 'snmp_tile', 'marketplace']) +def test_all_dropped_types_abort(ddev, empty_repo, dropped): + result = ddev('create', 'foo', '--type', dropped, '--dry-run') + assert result.exit_code != 0 + + +def test_type_shim_dispatches_to_subcommand(ddev, empty_repo): + result = ddev( + 'create', + 'my_integration', + '--type', + 'check', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + '--dry-run', + ) + assert result.exit_code == 0, result.output + assert '--type=check` is deprecated' in result.output + assert 'Will create' in result.output + + +def test_missing_required_flags_non_tty_abort(ddev, empty_repo, monkeypatch): + # CliRunner has no real TTY, so stdin is non-interactive by default. + result = ddev('create', 'check', 'my_integration') + assert result.exit_code != 0 + assert '--display-name' in result.output + assert '--metrics-prefix' in result.output + assert '--platforms' in result.output + + +def test_dry_run_does_not_write_anything(ddev, empty_repo): + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + '--dry-run', + ) + assert result.exit_code == 0, result.output + assert not (empty_repo.path / 'my_integration').exists() + assert not (empty_repo.path / '.ddev' / 'config.toml').exists() + + +def test_datadog_prefix_rejected(ddev, empty_repo): + result = ddev( + 'create', + 'check', + 'datadog_thing', + '--display-name', + 'Datadog Thing', + '--metrics-prefix', + 'datadog_thing.', + '--platforms', + 'linux', + ) + assert result.exit_code != 0 + assert 'cannot start with' in result.output + + +def test_existing_directory_aborts(ddev, empty_repo): + (empty_repo.path / 'my_integration').mkdir() + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + ) + assert result.exit_code != 0 + assert 'already exists' in result.output + + +def test_overrides_accumulate_across_creates(ddev, empty_repo): + for name in ('first_integration', 'second_integration'): + result = ddev( + 'create', + 'check', + name, + '--display-name', + name.title().replace('_', ' '), + '--metrics-prefix', + f'{name}.', + '--platforms', + 'linux', + ) + assert result.exit_code == 0, result.output + + data = tomli.loads((empty_repo.path / '.ddev' / 'config.toml').read_text()) + assert 'first_integration' in data['overrides']['display-name'] + assert 'second_integration' in data['overrides']['display-name'] + + +def test_invalid_platform_rejected(ddev, empty_repo): + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux,wonkos', + ) + assert result.exit_code != 0 + assert 'wonkos' in result.output + + +def test_help_lists_subcommands(ddev): + result = ddev('create', '--help') + assert result.exit_code == 0 + for sub in ('check', 'check-only', 'jmx', 'logs', 'event', 'metrics-crawler'): + assert sub in result.output + + +def test_all_values_via_flags_never_prompt(ddev, empty_repo, mocker): + spy = mocker.patch('click.prompt', side_effect=AssertionError('click.prompt should not be called')) + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + ) + assert result.exit_code == 0, result.output + spy.assert_not_called() + + +def test_check_only_requires_existing_manifest(ddev, empty_repo): + result = ddev( + 'create', + 'check-only', + 'partner_thing', + '--display-name', + 'Partner Thing', + '--metrics-prefix', + 'partner_thing.', + '--platforms', + 'linux', + ) + assert result.exit_code != 0 + assert 'manifest.json' in result.output + + +def test_check_only_with_prefilled_manifest(ddev, empty_repo): + integration_dir = empty_repo.path / 'partner_thing' + integration_dir.mkdir() + (integration_dir / 'manifest.json').write_text( + json.dumps( + { + 'author': { + 'name': 'Partner', + 'support_email': 'support@partner.com', + 'homepage': 'https://partner.com', + 'sales_email': 'sales@partner.com', + } + } + ) + ) + result = ddev( + 'create', + 'check-only', + 'partner_thing', + '--include-manifest', + ) + # check-only takes the existing manifest dir as input; --include-manifest is required to keep behaviour. + assert result.exit_code == 0, result.output diff --git a/pyproject.toml b/pyproject.toml index 42b720c3fc195..0e419fec84120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,10 @@ show_column_numbers = true mypy_path = "../.stubs" # Exclude generated files # TODO Remove when we drop python 2 -exclude = '.*/config_models/.*\.py$' +exclude = [ + '.*/config_models/.*\.py$', + '.*/ddev/cli/create/templates/.*', +] python_version = 3.13 [tool.ruff] @@ -47,6 +50,7 @@ exclude = [ "compat.py", "__init__.py", "**/datadog_checks/dev/tooling/templates", + "**/ddev/cli/create/templates", "**/datadog_checks/dev/tooling/signing.py", "**/datadog_checks/*/vendor/*", # Avoid linting or formatting autogenerated files from ddev validate models From 5a538a8d5680370d96c22593933e6f9514dacdaf Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 15:55:43 +0200 Subject: [PATCH 02/13] Add changelog --- ddev/changelog.d/23859.added | 1 + ddev/changelog.d/23859.changed | 1 + 2 files changed, 2 insertions(+) create mode 100644 ddev/changelog.d/23859.added create mode 100644 ddev/changelog.d/23859.changed diff --git a/ddev/changelog.d/23859.added b/ddev/changelog.d/23859.added new file mode 100644 index 0000000000000..090e3707c3336 --- /dev/null +++ b/ddev/changelog.d/23859.added @@ -0,0 +1 @@ +Legacy migration: `create` is now implemented natively in ddev (was previously delegated to datadog_checks_dev). diff --git a/ddev/changelog.d/23859.changed b/ddev/changelog.d/23859.changed new file mode 100644 index 0000000000000..04e7dbbf48069 --- /dev/null +++ b/ddev/changelog.d/23859.changed @@ -0,0 +1 @@ +Refresh the `ddev create` UX: the command is now a click group with one subcommand per integration type (`check`, `check-only`, `jmx`, `logs`, `event`, `metrics-crawler`). Manifest-less is the new default; pass `--include-manifest` to keep generating a `manifest.json`. New per-subcommand options `--display-name`, `--metrics-prefix`, and `--platforms` populate `.ddev/config.toml` overrides. The `tile`, `snmp_tile`, and `marketplace` types are no longer exposed; `--non-interactive` is removed; `--skip-manifest` is accepted with a deprecation warning; `--type` is accepted as a deprecation shim that dispatches to the matching subcommand. From 9bf1daab92abbb632ab609c791464598e3d83f01 Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 18:17:20 +0200 Subject: [PATCH 03/13] Fix check_only target directory when integration name has an author prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ddev create check-only partner_thing` was scaffolding into `root/thing/` instead of populating the existing `root/partner_thing/` directory that holds the manifest. The scaffold helper now distinguishes the on-disk integration directory (`target_integration_dir`) from the template ``{check_name}`` substitution (`check_name_override`). Other changes: - Replace `click.echo(..., err=True)` and `ctx.fail(...)` in the ``--type`` deprecation shim with `app.display_warning` / `app.abort` so the messages flow through ddev's color and verbosity layer. - Honour `app.interactive` (and therefore the global `--interactive/--no-interactive` flag) instead of probing `sys.stdin.isatty()` directly. - Surface a clear, subcommand-pointing error when a user runs the legacy `ddev create NAME` shape with no `--type`. - Update `docs/developer/tutorials/logs/http-crawler.md` to the new `ddev create logs ACME …` form. - Extract a `create_options` decorator factory in `_common.py`; the six per-type subcommand modules now share one option contract. - Wrap template rendering with template-path context on format errors, and replace `assert isinstance(...)` in `TemplateFile.write()` with an explicit runtime check. - On a per-file write failure, abort with a "wrote N/M files, remove `` and retry" message instead of leaving the user guessing. - Probe `.ddev/config.toml` readability before scaffolding and emit a hand-edit fallback if the post-scaffold override write fails. - Tighten the `--type` shim's short-flag matching to an allow-list of known integration types so future `-tXxx` flags can't be eaten. - Drop the unused `include_manifest` parameter from `_resolve_check_only_inputs`; flatten the dead-code guard in `run_subcommand`. - New tests cover the check_only directory regression, the manifest-less check_only overrides path, the bare-positional error, and the global `--no-interactive` flag's effect on `create`. --- ddev/src/ddev/cli/create/__init__.py | 67 +++++---- ddev/src/ddev/cli/create/_common.py | 133 +++++++++++++----- ddev/src/ddev/cli/create/_scaffold.py | 82 ++++++++--- ddev/src/ddev/cli/create/check.py | 47 +------ ddev/src/ddev/cli/create/check_only.py | 47 +------ ddev/src/ddev/cli/create/event.py | 47 +------ ddev/src/ddev/cli/create/jmx.py | 47 +------ ddev/src/ddev/cli/create/logs.py | 47 +------ ddev/src/ddev/cli/create/metrics_crawler.py | 47 +------ ddev/tests/cli/create/test_create.py | 85 +++++++++-- docs/developer/tutorials/logs/http-crawler.md | 2 +- 11 files changed, 309 insertions(+), 342 deletions(-) diff --git a/ddev/src/ddev/cli/create/__init__.py b/ddev/src/ddev/cli/create/__init__.py index 6db628e5847bf..353afc07d262b 100644 --- a/ddev/src/ddev/cli/create/__init__.py +++ b/ddev/src/ddev/cli/create/__init__.py @@ -41,77 +41,90 @@ class _CreateGroup(click.Group): def resolve_command( # type: ignore[override] self, ctx: click.Context, args: list[str] ) -> tuple[str | None, click.Command | None, list[str]]: + from ddev.cli.application import Application + if not args: return super().resolve_command(ctx, args) first = args[0] - # If the first token matches a registered subcommand, use the normal flow. if self.get_command(ctx, first) is not None: return super().resolve_command(ctx, args) - # If the user passes `--type=X` as a flag among `args`, we're in legacy mode. + app: Application = ctx.obj legacy_type = _extract_legacy_type(args) + + # No `--type`: the user passed a bare positional (the legacy `ddev create NAME` shape + # that is now ambiguous). Point them at the new subcommand surface before click's + # default "No such command" message lands. if legacy_type is None: - return super().resolve_command(ctx, args) + app.abort( + f'`ddev create {first}` is no longer supported. ' + f'Use a subcommand: `ddev create check {first}`, `ddev create logs {first}`, ' + f'etc. Run `ddev create --help` to see the full list.' + ) if legacy_type in DROPPED_LEGACY_TYPES: - from ddev.cli.application import Application - - app: Application = ctx.obj app.abort( f'`--type={legacy_type}` is no longer supported. ' f'Use the manifest-less workflow described at {CONFLUENCE_NO_MANIFEST_URL}.' ) + assert legacy_type is not None # narrowed by the `is None` branch above target = LEGACY_TYPE_TO_SUBCOMMAND.get(legacy_type) if target is None: - ctx.fail(f'Unknown integration type: `{legacy_type}`.') + app.abort(f'Unknown integration type: `{legacy_type}`.') subcommand = self.get_command(ctx, target) if subcommand is None: - ctx.fail(f'Internal error: subcommand `{target}` not registered.') + app.abort(f'Internal error: subcommand `{target}` not registered.') + assert subcommand is not None # for the type checker; abort above is NoReturn - click.echo( - f'WARNING: `--type={legacy_type}` is deprecated. ' - f'Use `ddev create {target} NAME` instead. The flag will be removed in a future release.', - err=True, + app.display_warning( + f'`--type={legacy_type}` is deprecated. ' + f'Use `ddev create {target} NAME` instead. The flag will be removed in a future release.' ) - # Strip the --type / -t flag out of args before handing back to click. cleaned = _strip_type_flag(args) return subcommand.name, subcommand, cleaned def _extract_legacy_type(args: list[str]) -> str | None: """Return the `--type` / `-t` value from ``args`` if present, else None.""" - iterator = iter(enumerate(args)) - for _, token in iterator: - if token == '--type' or token == '-t': - try: - _, value = next(iterator) - except StopIteration: - return None - return value + iterator = iter(args) + for token in iterator: + if token in ('--type', '-t'): + return next(iterator, None) if token.startswith('--type='): return token.split('=', 1)[1] - if token.startswith('-t=') or (token.startswith('-t') and len(token) > 2): - return token[3:] if token.startswith('-t=') else token[2:] + if token.startswith('-t='): + return token[3:] + if _is_concatenated_short_type(token): + return token[2:] return None +def _is_concatenated_short_type(token: str) -> bool: + """Match the legacy ``-tcheck`` short-flag form without swallowing future ``-tXxx`` options.""" + if not token.startswith('-t') or len(token) <= 2 or token.startswith('--'): + return False + candidate = token[2:] + return candidate in LEGACY_TYPE_TO_SUBCOMMAND or candidate in DROPPED_LEGACY_TYPES + + def _strip_type_flag(args: list[str]) -> list[str]: + """Remove the legacy ``--type`` / ``-t`` flag from ``args`` (allow-listed forms only).""" cleaned: list[str] = [] skip_next = False for token in args: if skip_next: skip_next = False continue - if token == '--type' or token == '-t': + if token in ('--type', '-t'): skip_next = True continue - if token.startswith(('--type=', '-t=')) or ( - token.startswith('-t') and len(token) > 2 and not token.startswith('--') - ): + if token.startswith(('--type=', '-t=')): + continue + if _is_concatenated_short_type(token): continue cleaned.append(token) return cleaned diff --git a/ddev/src/ddev/cli/create/_common.py b/ddev/src/ddev/cli/create/_common.py index c775284b3e05e..632535f9c07ed 100644 --- a/ddev/src/ddev/cli/create/_common.py +++ b/ddev/src/ddev/cli/create/_common.py @@ -12,8 +12,9 @@ from __future__ import annotations import json -import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable + +import click from ddev.cli.create._naming import normalize_package_name @@ -23,6 +24,39 @@ SUPPORTED_PLATFORMS = ('linux', 'windows', 'mac_os') +def create_options(f: Callable[..., Any]) -> Callable[..., Any]: + """Apply the full set of shared options (and the ``name`` argument) to a subcommand.""" + f = click.option( + '--skip-manifest', + is_flag=True, + help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', + )(f) + f = click.option( + '--include-manifest', + is_flag=True, + help='Generate a `manifest.json` (legacy behaviour).', + )(f) + f = click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.')(f) + f = click.option('--quiet', '-q', is_flag=True, help='Show less output.')(f) + f = click.option('--location', '-l', default=None, help='The directory where files will be written.')(f) + f = click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.')(f) + f = click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).')(f) + f = click.option('--display-name', default=None, help='Human-readable display name for the integration.')(f) + return click.argument('name')(f) + + +def dispatch(app: Application, *, integration_type: str, **options: Any) -> None: + """Translate click kwargs to ``run_subcommand`` parameters and execute. + + The factory binds the click flag ``--platforms`` to a kwarg named ``platforms``; + ``run_subcommand`` takes it as ``platforms_csv``. This wrapper does that one + rename so the per-subcommand files can ``**options``-through without thinking + about parameter names. + """ + platforms_csv = options.pop('platforms', None) + run_subcommand(app, integration_type=integration_type, platforms_csv=platforms_csv, **options) + + def run_subcommand( app: Application, *, @@ -51,26 +85,23 @@ def run_subcommand( '`--skip-manifest` will be removed in the next major release.' ) - is_interactive = sys.stdin.isatty() - extra_fields: dict[str, object] = {} - override_integration_dir_name: str | None = None + target_integration_dir: str | None = None + check_name_override: str | None = None if integration_type == 'check_only': - extra_fields, override_integration_dir_name = _resolve_check_only_inputs( - app, name, location, include_manifest=include_manifest - ) + extra_fields, target_integration_dir, check_name_override = _resolve_check_only_inputs(app, name, location) - resolved_display_name: str | None = None - resolved_metrics_prefix: str | None = None - resolved_platforms: list[str] | None = None if not include_manifest: + # Probe `.ddev/config.toml` writability before scaffolding so a malformed file + # aborts cleanly instead of leaving a half-finished integration on disk. + _probe_repo_config_readable(app) + resolved_display_name, resolved_metrics_prefix, resolved_platforms = _resolve_manifestless_inputs( app, name=name, display_name=display_name, metrics_prefix=metrics_prefix, platforms_csv=platforms_csv, - is_interactive=is_interactive, ) from ddev.cli.create._scaffold import render @@ -84,27 +115,47 @@ def run_subcommand( quiet=quiet, include_manifest=include_manifest, extra_fields=extra_fields, - override_integration_dir_name=override_integration_dir_name, + target_integration_dir=target_integration_dir, + check_name_override=check_name_override, ) if dry_run or include_manifest: return - if not include_manifest: - from ddev.cli.create._config_overrides import apply_manifestless_overrides - - # mypy: these are non-None because _resolve_manifestless_inputs aborts otherwise. - assert resolved_display_name is not None - assert resolved_metrics_prefix is not None - assert resolved_platforms is not None + from ddev.cli.create._config_overrides import apply_manifestless_overrides + override_dir_name = target_integration_dir or result.integration_dir.name + try: apply_manifestless_overrides( app, - dir_name=result.integration_dir.name, + dir_name=override_dir_name, display_name=resolved_display_name, metrics_prefix=resolved_metrics_prefix, platforms=resolved_platforms, ) + except OSError as exc: + app.abort( + f'Failed to update `.ddev/config.toml`: {exc}\n' + f'The integration was scaffolded at `{result.integration_dir}` but the ' + f'overrides were not recorded. Add these entries by hand:\n' + f' [overrides.display-name]\n' + f' {override_dir_name} = "{resolved_display_name}"\n' + f' [overrides.metrics-prefix]\n' + f' {override_dir_name} = "{resolved_metrics_prefix}"\n' + f' [overrides.manifest.platforms]\n' + f' {override_dir_name} = {resolved_platforms!r}' + ) + + +def _probe_repo_config_readable(app: Application) -> None: + """Ensure ``.ddev/config.toml`` can be loaded before we start scaffolding.""" + config_file = app.repo.config + if not config_file.path.is_file(): + return + try: + config_file.load_data() + except (OSError, ValueError) as exc: + app.abort(f'Failed to read `{config_file.path}`: {exc}. Fix or remove the file before creating an integration.') def _resolve_manifestless_inputs( @@ -114,7 +165,6 @@ def _resolve_manifestless_inputs( display_name: str | None, metrics_prefix: str | None, platforms_csv: str | None, - is_interactive: bool, ) -> tuple[str, str, list[str]]: suggested_display = name suggested_prefix = f'{normalize_package_name(name)}.' @@ -122,23 +172,26 @@ def _resolve_manifestless_inputs( missing: list[str] = [] if display_name is None: - if is_interactive: + if app.interactive: display_name = app.prompt('Display name', default=suggested_display) else: missing.append('--display-name') if metrics_prefix is None: - if is_interactive: + if app.interactive: metrics_prefix = app.prompt('Metrics prefix', default=suggested_prefix) else: missing.append('--metrics-prefix') if platforms_csv is None: - if is_interactive: + if app.interactive: platforms_csv = app.prompt('Platforms (comma-separated)', default=suggested_platforms_csv) else: missing.append('--platforms') if missing: - app.abort('Missing required flags for non-interactive mode: ' + ', '.join(missing)) + app.abort( + 'Missing required flag(s) while running with `--no-interactive` (or in a non-TTY ' + 'environment): ' + ', '.join(missing) + ) assert display_name is not None assert metrics_prefix is not None @@ -161,20 +214,24 @@ def _resolve_check_only_inputs( app: Application, name: str, location: str | None, - *, - include_manifest: bool, -) -> tuple[dict[str, object], str | None]: - """For ``check_only`` integrations we expect the directory to already exist with a manifest. - - Returns the extra template fields prefilled from the existing manifest, plus - the override directory name (with the author prefix stripped). +) -> tuple[dict[str, object], str, str]: + """For ``check_only`` integrations the directory must already exist with a manifest. + + Returns: + - extra template fields prefilled from the existing manifest + - the *target* integration directory name (the on-disk dir that holds the manifest; + e.g. ``partner_thing`` for a ``partner_`` author prefix) + - the *check_name* template substitution value (the stripped short name; + e.g. ``thing``). Used to populate the Python package name template variable + when the prefill helper did not provide a ``check_name``. """ + from ddev.cli.create._naming import normalize_display_name from ddev.cli.create._scaffold import prefill_check_only_fields from ddev.utils.fs import Path - integration_dir_name = normalize_package_name(name) + target_integration_dir = normalize_package_name(name) root = Path(location).resolve() if location else app.repo.path - integration_dir = root / integration_dir_name + integration_dir = root / target_integration_dir manifest_path = integration_dir / 'manifest.json' if not manifest_path.is_file(): @@ -185,10 +242,8 @@ def _resolve_check_only_inputs( if author is None: app.abort('Unable to determine author from manifest') - from ddev.cli.create._naming import normalize_display_name - author_normalized = normalize_display_name(author) - stripped = integration_dir_name.removeprefix(f'{author_normalized}_') + stripped = target_integration_dir.removeprefix(f'{author_normalized}_') fields = prefill_check_only_fields(manifest_data, stripped) - return fields, stripped + return fields, target_integration_dir, stripped diff --git a/ddev/src/ddev/cli/create/_scaffold.py b/ddev/src/ddev/cli/create/_scaffold.py index c74f17e3594df..d9155eca8f193 100644 --- a/ddev/src/ddev/cli/create/_scaffold.py +++ b/ddev/src/ddev/cli/create/_scaffold.py @@ -119,17 +119,21 @@ class TemplateFile: def read(self, config: dict[str, Any]) -> None: if self.binary: self.contents = self.source_path.read_bytes() - else: - self.contents = self.source_path.read_text().format(**config) + return + raw = self.source_path.read_text() + try: + self.contents = raw.format(**config) + except (KeyError, IndexError, ValueError) as exc: + raise RuntimeError(f'Failed to render template {self.source_path}: {exc}') from exc def write(self) -> None: + if self.contents is None: + raise RuntimeError(f'read() must be called before write() (target: {self.target_path})') self.target_path.ensure_parent_dir_exists() if self.binary: - assert isinstance(self.contents, bytes) - self.target_path.write_bytes(self.contents) + self.target_path.write_bytes(self.contents) # type: ignore[arg-type] else: - assert isinstance(self.contents, str) - self.target_path.write_text(self.contents) + self.target_path.write_text(self.contents) # type: ignore[arg-type] @dataclass @@ -257,16 +261,24 @@ def collect_template_files( target_root: Path, config: dict[str, Any], *, + target_integration_dir: str, include_manifest: bool, read: bool, ) -> list[TemplateFile]: - """Walk the template directory for `integration_type` and produce the file list.""" + """Walk the template directory for `integration_type` and produce the file list. + + ``target_integration_dir`` is the on-disk directory name that should hold the + rendered files. Template paths are formatted with ``config`` first; when the + resulting top-level segment is the template ``{check_name}`` value we rewrite it + to ``target_integration_dir`` so callers can keep the Python package name (which + drives ``{check_name}``) distinct from the integration's directory name. + """ template_root = TEMPLATES_ROOT / integration_type if not template_root.is_dir(): return [] files: list[TemplateFile] = [] - integration_dir_name = config['check_name'] + template_check_name = config['check_name'] for source in _walk_template(template_root): rel = source.relative_to(template_root) rel_str = str(rel) @@ -277,11 +289,12 @@ def collect_template_files( if source.name.endswith(('.pyc', '.pyo')): continue - target_rel = rel_str.format(**config) + formatted_rel = rel_str.format(**config) + target_rel = _retarget_top_segment(formatted_rel, template_check_name, target_integration_dir) target_path = target_root / target_rel # Default behaviour drops the integration's manifest.json. - if not include_manifest and _is_manifest_path(_StdPath(target_rel), integration_dir_name): + if not include_manifest and _is_manifest_path(_StdPath(target_rel), target_integration_dir): continue binary = source.name.endswith(BINARY_EXTENSIONS) @@ -293,6 +306,16 @@ def collect_template_files( return files +def _retarget_top_segment(formatted_rel: str, template_check_name: str, target_dir: str) -> str: + """Rewrite the leading path segment from ``template_check_name`` to ``target_dir``.""" + if not template_check_name or template_check_name == target_dir: + return formatted_rel + parts = _StdPath(formatted_rel).parts + if parts and parts[0] == template_check_name: + return str(_StdPath(target_dir, *parts[1:])) + return formatted_rel + + def _walk_template(root: Path) -> Iterator[Path]: for child in sorted(root.iterdir()): if child.is_dir(): @@ -315,25 +338,34 @@ def render( quiet: bool, include_manifest: bool, extra_fields: dict[str, Any] | None = None, - override_integration_dir_name: str | None = None, + target_integration_dir: str | None = None, + check_name_override: str | None = None, ) -> ScaffoldResult: - """Resolve target paths, render templates in memory (or just enumerate for dry-run).""" + """Resolve target paths, render templates in memory (or just enumerate for dry-run). + + ``target_integration_dir`` selects the on-disk directory that receives the rendered + files (the directory whose name appears as the top-level segment of every output + path). ``check_name_override`` substitutes the ``{check_name}`` template variable + independently — used by `check_only` to keep the Python package name distinct from + the on-disk integration directory when the manifest carries an author prefix. + """ extra_fields = extra_fields or {} root = Path(location).resolve() if location else app.repo.path - integration_dir_name = override_integration_dir_name or normalize_package_name(name) + integration_dir_name = target_integration_dir or normalize_package_name(name) integration_dir = root / integration_dir_name if integration_type != 'check_only' and integration_dir.exists(): app.abort(f'Path `{integration_dir}` already exists!') config = construct_template_fields(name, integration_type, **extra_fields) - if override_integration_dir_name is not None: - config['check_name'] = override_integration_dir_name + if check_name_override is not None and 'check_name' not in extra_fields: + config['check_name'] = check_name_override files = collect_template_files( integration_type, root, config, + target_integration_dir=integration_dir_name, include_manifest=include_manifest, read=not dry_run, ) @@ -345,8 +377,7 @@ def render( app.display_info(f'Will create in `{root}`:') _display_tree(app, root, files) else: - for f in files: - f.write() + _write_files_with_cleanup_hint(app, files, integration_dir) if quiet: app.display_info(f'Created `{integration_dir}`') else: @@ -356,6 +387,23 @@ def render( return ScaffoldResult(integration_dir=integration_dir, files=files, config=config) +def _write_files_with_cleanup_hint( + app: Application, + files: list[TemplateFile], + integration_dir: Path, +) -> None: + """Write files and, on failure, tell the user where the partial write happened.""" + total = len(files) + for index, f in enumerate(files, 1): + try: + f.write() + except OSError as exc: + app.abort( + f'Wrote {index - 1}/{total} files; failed at `{f.target_path}`: {exc}. ' + f'Remove `{integration_dir}` and retry.' + ) + + def _display_tree(app: Application, root: Path, files: list[TemplateFile]) -> None: tree: defaultdict = defaultdict(dict) for f in files: diff --git a/ddev/src/ddev/cli/create/check.py b/ddev/src/ddev/cli/create/check.py index c77fe86565d9b..0bf21f9d48744 100644 --- a/ddev/src/ddev/cli/create/check.py +++ b/ddev/src/ddev/cli/create/check.py @@ -3,54 +3,19 @@ # Licensed under a 3-clause BSD style license (see LICENSE) from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click +from ddev.cli.create._common import create_options, dispatch + if TYPE_CHECKING: from ddev.cli.application import Application @click.command('check', short_help='Scaffold a check-based integration') -@click.argument('name') -@click.option('--display-name', default=None, help='Human-readable display name for the integration.') -@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') -@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') -@click.option('--location', '-l', default=None, help='The directory where files will be written.') -@click.option('--quiet', '-q', is_flag=True, help='Show less output.') -@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') -@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') -@click.option( - '--skip-manifest', - is_flag=True, - help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', -) +@create_options @click.pass_obj -def check( - app: Application, - name: str, - display_name: str | None, - metrics_prefix: str | None, - platforms: str | None, - location: str | None, - quiet: bool, - dry_run: bool, - include_manifest: bool, - skip_manifest: bool, -) -> None: +def check(app: Application, **options: Any) -> None: """Scaffold a check-based integration.""" - from ddev.cli.create._common import run_subcommand - - run_subcommand( - app, - integration_type='check', - name=name, - display_name=display_name, - metrics_prefix=metrics_prefix, - platforms_csv=platforms, - location=location, - quiet=quiet, - dry_run=dry_run, - include_manifest=include_manifest, - skip_manifest=skip_manifest, - ) + dispatch(app, integration_type='check', **options) diff --git a/ddev/src/ddev/cli/create/check_only.py b/ddev/src/ddev/cli/create/check_only.py index 09b63bc1a7c58..ba5781fd80008 100644 --- a/ddev/src/ddev/cli/create/check_only.py +++ b/ddev/src/ddev/cli/create/check_only.py @@ -3,54 +3,19 @@ # Licensed under a 3-clause BSD style license (see LICENSE) from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click +from ddev.cli.create._common import create_options, dispatch + if TYPE_CHECKING: from ddev.cli.application import Application @click.command('check-only', short_help='Scaffold check-only files inside an existing integration directory') -@click.argument('name') -@click.option('--display-name', default=None, help='Human-readable display name for the integration.') -@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') -@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') -@click.option('--location', '-l', default=None, help='The directory where files will be written.') -@click.option('--quiet', '-q', is_flag=True, help='Show less output.') -@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') -@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') -@click.option( - '--skip-manifest', - is_flag=True, - help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', -) +@create_options @click.pass_obj -def check_only( - app: Application, - name: str, - display_name: str | None, - metrics_prefix: str | None, - platforms: str | None, - location: str | None, - quiet: bool, - dry_run: bool, - include_manifest: bool, - skip_manifest: bool, -) -> None: +def check_only(app: Application, **options: Any) -> None: """Add Python check scaffolding to an existing integration directory.""" - from ddev.cli.create._common import run_subcommand - - run_subcommand( - app, - integration_type='check_only', - name=name, - display_name=display_name, - metrics_prefix=metrics_prefix, - platforms_csv=platforms, - location=location, - quiet=quiet, - dry_run=dry_run, - include_manifest=include_manifest, - skip_manifest=skip_manifest, - ) + dispatch(app, integration_type='check_only', **options) diff --git a/ddev/src/ddev/cli/create/event.py b/ddev/src/ddev/cli/create/event.py index e9c7c578a8b74..3584212fbb76f 100644 --- a/ddev/src/ddev/cli/create/event.py +++ b/ddev/src/ddev/cli/create/event.py @@ -3,54 +3,19 @@ # Licensed under a 3-clause BSD style license (see LICENSE) from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click +from ddev.cli.create._common import create_options, dispatch + if TYPE_CHECKING: from ddev.cli.application import Application @click.command('event', short_help='Scaffold an event-only integration') -@click.argument('name') -@click.option('--display-name', default=None, help='Human-readable display name for the integration.') -@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') -@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') -@click.option('--location', '-l', default=None, help='The directory where files will be written.') -@click.option('--quiet', '-q', is_flag=True, help='Show less output.') -@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') -@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') -@click.option( - '--skip-manifest', - is_flag=True, - help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', -) +@create_options @click.pass_obj -def event( - app: Application, - name: str, - display_name: str | None, - metrics_prefix: str | None, - platforms: str | None, - location: str | None, - quiet: bool, - dry_run: bool, - include_manifest: bool, - skip_manifest: bool, -) -> None: +def event(app: Application, **options: Any) -> None: """Scaffold an event-only integration.""" - from ddev.cli.create._common import run_subcommand - - run_subcommand( - app, - integration_type='event', - name=name, - display_name=display_name, - metrics_prefix=metrics_prefix, - platforms_csv=platforms, - location=location, - quiet=quiet, - dry_run=dry_run, - include_manifest=include_manifest, - skip_manifest=skip_manifest, - ) + dispatch(app, integration_type='event', **options) diff --git a/ddev/src/ddev/cli/create/jmx.py b/ddev/src/ddev/cli/create/jmx.py index d7321392cc500..470629f2b3db8 100644 --- a/ddev/src/ddev/cli/create/jmx.py +++ b/ddev/src/ddev/cli/create/jmx.py @@ -3,54 +3,19 @@ # Licensed under a 3-clause BSD style license (see LICENSE) from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click +from ddev.cli.create._common import create_options, dispatch + if TYPE_CHECKING: from ddev.cli.application import Application @click.command('jmx', short_help='Scaffold a JMX integration') -@click.argument('name') -@click.option('--display-name', default=None, help='Human-readable display name for the integration.') -@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') -@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') -@click.option('--location', '-l', default=None, help='The directory where files will be written.') -@click.option('--quiet', '-q', is_flag=True, help='Show less output.') -@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') -@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') -@click.option( - '--skip-manifest', - is_flag=True, - help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', -) +@create_options @click.pass_obj -def jmx( - app: Application, - name: str, - display_name: str | None, - metrics_prefix: str | None, - platforms: str | None, - location: str | None, - quiet: bool, - dry_run: bool, - include_manifest: bool, - skip_manifest: bool, -) -> None: +def jmx(app: Application, **options: Any) -> None: """Scaffold a JMX-based integration.""" - from ddev.cli.create._common import run_subcommand - - run_subcommand( - app, - integration_type='jmx', - name=name, - display_name=display_name, - metrics_prefix=metrics_prefix, - platforms_csv=platforms, - location=location, - quiet=quiet, - dry_run=dry_run, - include_manifest=include_manifest, - skip_manifest=skip_manifest, - ) + dispatch(app, integration_type='jmx', **options) diff --git a/ddev/src/ddev/cli/create/logs.py b/ddev/src/ddev/cli/create/logs.py index b1475f66863c6..0295364ccce06 100644 --- a/ddev/src/ddev/cli/create/logs.py +++ b/ddev/src/ddev/cli/create/logs.py @@ -3,54 +3,19 @@ # Licensed under a 3-clause BSD style license (see LICENSE) from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click +from ddev.cli.create._common import create_options, dispatch + if TYPE_CHECKING: from ddev.cli.application import Application @click.command('logs', short_help='Scaffold a logs-only integration') -@click.argument('name') -@click.option('--display-name', default=None, help='Human-readable display name for the integration.') -@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') -@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') -@click.option('--location', '-l', default=None, help='The directory where files will be written.') -@click.option('--quiet', '-q', is_flag=True, help='Show less output.') -@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') -@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') -@click.option( - '--skip-manifest', - is_flag=True, - help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', -) +@create_options @click.pass_obj -def logs( - app: Application, - name: str, - display_name: str | None, - metrics_prefix: str | None, - platforms: str | None, - location: str | None, - quiet: bool, - dry_run: bool, - include_manifest: bool, - skip_manifest: bool, -) -> None: +def logs(app: Application, **options: Any) -> None: """Scaffold a logs-only integration.""" - from ddev.cli.create._common import run_subcommand - - run_subcommand( - app, - integration_type='logs', - name=name, - display_name=display_name, - metrics_prefix=metrics_prefix, - platforms_csv=platforms, - location=location, - quiet=quiet, - dry_run=dry_run, - include_manifest=include_manifest, - skip_manifest=skip_manifest, - ) + dispatch(app, integration_type='logs', **options) diff --git a/ddev/src/ddev/cli/create/metrics_crawler.py b/ddev/src/ddev/cli/create/metrics_crawler.py index 396ab8e885c36..93d40e934ef44 100644 --- a/ddev/src/ddev/cli/create/metrics_crawler.py +++ b/ddev/src/ddev/cli/create/metrics_crawler.py @@ -3,54 +3,19 @@ # Licensed under a 3-clause BSD style license (see LICENSE) from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import click +from ddev.cli.create._common import create_options, dispatch + if TYPE_CHECKING: from ddev.cli.application import Application @click.command('metrics-crawler', short_help='Scaffold a metrics-crawler integration') -@click.argument('name') -@click.option('--display-name', default=None, help='Human-readable display name for the integration.') -@click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).') -@click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.') -@click.option('--location', '-l', default=None, help='The directory where files will be written.') -@click.option('--quiet', '-q', is_flag=True, help='Show less output.') -@click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.') -@click.option('--include-manifest', is_flag=True, help='Generate a `manifest.json` (legacy behaviour).') -@click.option( - '--skip-manifest', - is_flag=True, - help='[DEPRECATED] No-op; manifest-less is now the default. Use `--include-manifest` to opt back in.', -) +@create_options @click.pass_obj -def metrics_crawler( - app: Application, - name: str, - display_name: str | None, - metrics_prefix: str | None, - platforms: str | None, - location: str | None, - quiet: bool, - dry_run: bool, - include_manifest: bool, - skip_manifest: bool, -) -> None: +def metrics_crawler(app: Application, **options: Any) -> None: """Scaffold a metrics-crawler integration.""" - from ddev.cli.create._common import run_subcommand - - run_subcommand( - app, - integration_type='metrics_crawler', - name=name, - display_name=display_name, - metrics_prefix=metrics_prefix, - platforms_csv=platforms, - location=location, - quiet=quiet, - dry_run=dry_run, - include_manifest=include_manifest, - skip_manifest=skip_manifest, - ) + dispatch(app, integration_type='metrics_crawler', **options) diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index 51da770825cc7..7b2a54a1761f8 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -117,15 +117,6 @@ def test_type_shim_dispatches_to_subcommand(ddev, empty_repo): assert 'Will create' in result.output -def test_missing_required_flags_non_tty_abort(ddev, empty_repo, monkeypatch): - # CliRunner has no real TTY, so stdin is non-interactive by default. - result = ddev('create', 'check', 'my_integration') - assert result.exit_code != 0 - assert '--display-name' in result.output - assert '--metrics-prefix' in result.output - assert '--platforms' in result.output - - def test_dry_run_does_not_write_anything(ddev, empty_repo): result = ddev( 'create', @@ -253,8 +244,7 @@ def test_check_only_requires_existing_manifest(ddev, empty_repo): assert 'manifest.json' in result.output -def test_check_only_with_prefilled_manifest(ddev, empty_repo): - integration_dir = empty_repo.path / 'partner_thing' +def _write_partner_manifest(integration_dir): integration_dir.mkdir() (integration_dir / 'manifest.json').write_text( json.dumps( @@ -268,11 +258,82 @@ def test_check_only_with_prefilled_manifest(ddev, empty_repo): } ) ) + + +def test_check_only_with_prefilled_manifest(ddev, empty_repo): + _write_partner_manifest(empty_repo.path / 'partner_thing') result = ddev( 'create', 'check-only', 'partner_thing', '--include-manifest', ) - # check-only takes the existing manifest dir as input; --include-manifest is required to keep behaviour. assert result.exit_code == 0, result.output + + +def test_check_only_writes_into_existing_author_prefixed_directory(ddev, empty_repo): + """Regression for finding #1: scaffolded files must land in the manifest's directory.""" + integration_dir = empty_repo.path / 'partner_thing' + _write_partner_manifest(integration_dir) + + result = ddev( + 'create', + 'check-only', + 'partner_thing', + '--include-manifest', + ) + assert result.exit_code == 0, result.output + + # Files must land in the manifest's directory, not a sibling stripped-name directory. + assert (integration_dir / 'pyproject.toml').is_file() + assert (integration_dir / 'datadog_checks' / 'partner_thing' / '__about__.py').is_file() + assert not (empty_repo.path / 'thing').exists() + + +def test_check_only_manifestless_writes_overrides_for_stripped_name(ddev, empty_repo): + """Regression for finding #14: manifest-less check-only writes overrides keyed by the stripped name.""" + integration_dir = empty_repo.path / 'partner_thing' + _write_partner_manifest(integration_dir) + + result = ddev( + 'create', + 'check-only', + 'partner_thing', + '--display-name', + 'Partner Thing', + '--metrics-prefix', + 'partner_thing.', + '--platforms', + 'linux,windows,mac_os', + ) + assert result.exit_code == 0, result.output + + # Files must land in the existing partner_thing directory (finding #1). + assert (integration_dir / 'pyproject.toml').is_file() + assert not (empty_repo.path / 'thing').exists() + + overrides_path = empty_repo.path / '.ddev' / 'config.toml' + assert overrides_path.is_file() + data = tomli.loads(overrides_path.read_text()) + assert data['overrides']['display-name']['partner_thing'] == 'Partner Thing' + assert data['overrides']['metrics-prefix']['partner_thing'] == 'partner_thing.' + assert data['overrides']['manifest']['platforms']['partner_thing'] == ['linux', 'windows', 'mac_os'] + + +def test_bare_positional_aborts_with_subcommand_hint(ddev, empty_repo): + """Regression for finding #4: legacy `ddev create NAME` must point at the new subcommand surface.""" + result = ddev('create', 'ACME') + assert result.exit_code != 0 + # The error must mention at least one of the new subcommands so users have something to copy. + output = result.output.lower() + assert 'ddev create check' in output or 'ddev create logs' in output + assert 'acme' in output.lower() + + +def test_global_no_interactive_flag_aborts_when_required_flags_missing(ddev, empty_repo): + """Regression for finding #3: `--no-interactive` at the root must surface in `create`.""" + result = ddev('--no-interactive', 'create', 'check', 'my_integration') + assert result.exit_code != 0 + assert '--display-name' in result.output + assert '--metrics-prefix' in result.output + assert '--platforms' in result.output diff --git a/docs/developer/tutorials/logs/http-crawler.md b/docs/developer/tutorials/logs/http-crawler.md index 73907ba1a24ac..60ca609114f29 100644 --- a/docs/developer/tutorials/logs/http-crawler.md +++ b/docs/developer/tutorials/logs/http-crawler.md @@ -12,7 +12,7 @@ Let's say we are building an integration for an API provided by *ACME Inc.* Run the following command to create the scaffolding for our integration: ``` -ddev create ACME +ddev create logs ACME --display-name="ACME" --metrics-prefix=acme. --platforms=linux,windows,mac_os ``` This adds a folder called `acme` in our `integrations-core` folder. From a05b8ca384a94eea6775b8b60a18f1dbb659fc65 Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 18:45:17 +0200 Subject: [PATCH 04/13] Fix directory connector in dry-run tree at depth >= 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_format_line` was using `PIPE_END if last or is_dir else PIPE_MIDDLE` for nodes at depth >= 2, so every directory rendered with `└──` regardless of its position among siblings. Drop the `is_dir` short-circuit so non-last directories use `├──` like every other non-last node. Other changes: - Restructure `run_subcommand` into two explicit paths (manifest-less vs `--include-manifest`) so the manifest-less locals' scope matches their use; the override write moves into its own helper. - Remove the unreferenced `CHECK_ONLY_LINKS` constant and its entry in `INTEGRATION_TYPE_LINKS`. The `check_only` branch in `construct_template_fields` never consulted the dict. - Distinguish "no `--type` flag" from "`--type` with no value" via a dedicated `_MissingTypeValue` sentinel; the latter now aborts with a targeted message naming the missing value. - Wrap the `manifest.json` read in `_resolve_check_only_inputs` with `try/except (OSError, json.JSONDecodeError)` and route through `app.abort` for parity with the surrounding aborts. - Add a `read_config` fixture that loads `.ddev/config.toml` and switch the test module from the gated `tomli` to stdlib `tomllib` (Python 3.13). Deduplicate the three tests that previously inlined the parse-and-assert dance. - New tests for the tree-connector regression and the `--type`-with- no-value abort. --- ddev/src/ddev/cli/create/__init__.py | 33 ++++++++-- ddev/src/ddev/cli/create/_common.py | 89 +++++++++++++++++---------- ddev/src/ddev/cli/create/_scaffold.py | 13 +--- ddev/tests/cli/create/conftest.py | 13 ++++ ddev/tests/cli/create/test_create.py | 50 +++++++++++---- 5 files changed, 136 insertions(+), 62 deletions(-) diff --git a/ddev/src/ddev/cli/create/__init__.py b/ddev/src/ddev/cli/create/__init__.py index 353afc07d262b..5f268e59208fa 100644 --- a/ddev/src/ddev/cli/create/__init__.py +++ b/ddev/src/ddev/cli/create/__init__.py @@ -53,6 +53,9 @@ def resolve_command( # type: ignore[override] app: Application = ctx.obj legacy_type = _extract_legacy_type(args) + if legacy_type is _MISSING_TYPE_VALUE: + app.abort('`--type` / `-t` requires a value (e.g. `--type=check`).') + # No `--type`: the user passed a bare positional (the legacy `ddev create NAME` shape # that is now ambiguous). Point them at the new subcommand surface before click's # default "No such command" message lands. @@ -69,7 +72,8 @@ def resolve_command( # type: ignore[override] f'Use the manifest-less workflow described at {CONFLUENCE_NO_MANIFEST_URL}.' ) - assert legacy_type is not None # narrowed by the `is None` branch above + # Narrowed by the two preceding `is` / `is None` branches; both call NoReturn `app.abort`. + assert isinstance(legacy_type, str) target = LEGACY_TYPE_TO_SUBCOMMAND.get(legacy_type) if target is None: app.abort(f'Unknown integration type: `{legacy_type}`.') @@ -88,12 +92,33 @@ def resolve_command( # type: ignore[override] return subcommand.name, subcommand, cleaned -def _extract_legacy_type(args: list[str]) -> str | None: - """Return the `--type` / `-t` value from ``args`` if present, else None.""" +class _MissingTypeValue: + """Sentinel: ``--type``/``-t`` was passed without a value (e.g. trailing ``--type``).""" + + _instance: _MissingTypeValue | None = None + + def __new__(cls) -> _MissingTypeValue: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + +_MISSING_TYPE_VALUE = _MissingTypeValue() + + +def _extract_legacy_type(args: list[str]) -> str | _MissingTypeValue | None: + """Return the `--type` / `-t` value from ``args``. + + Distinguishes three outcomes: + - ``None``: no `--type` / `-t` flag present at all. + - ``_MISSING_TYPE_VALUE``: the flag is present but has no following value. + - ``str``: the flag is present with a value. + """ iterator = iter(args) for token in iterator: if token in ('--type', '-t'): - return next(iterator, None) + value = next(iterator, _MISSING_TYPE_VALUE) + return value if token.startswith('--type='): return token.split('=', 1)[1] if token.startswith('-t='): diff --git a/ddev/src/ddev/cli/create/_common.py b/ddev/src/ddev/cli/create/_common.py index 632535f9c07ed..185f066c37281 100644 --- a/ddev/src/ddev/cli/create/_common.py +++ b/ddev/src/ddev/cli/create/_common.py @@ -91,59 +91,78 @@ def run_subcommand( if integration_type == 'check_only': extra_fields, target_integration_dir, check_name_override = _resolve_check_only_inputs(app, name, location) - if not include_manifest: - # Probe `.ddev/config.toml` writability before scaffolding so a malformed file - # aborts cleanly instead of leaving a half-finished integration on disk. - _probe_repo_config_readable(app) - - resolved_display_name, resolved_metrics_prefix, resolved_platforms = _resolve_manifestless_inputs( - app, - name=name, - display_name=display_name, - metrics_prefix=metrics_prefix, - platforms_csv=platforms_csv, - ) - from ddev.cli.create._scaffold import render - result = render( + render_kwargs: dict[str, Any] = { + 'location': location, + 'dry_run': dry_run, + 'quiet': quiet, + 'include_manifest': include_manifest, + 'extra_fields': extra_fields, + 'target_integration_dir': target_integration_dir, + 'check_name_override': check_name_override, + } + + if include_manifest: + render(app, integration_type, name, **render_kwargs) + return + + # Manifest-less path: resolve overrides and probe config writability before scaffolding + # so a malformed config aborts cleanly instead of leaving a half-finished integration on disk. + _probe_repo_config_readable(app) + resolved_display_name, resolved_metrics_prefix, resolved_platforms = _resolve_manifestless_inputs( app, - integration_type, - name, - location=location, - dry_run=dry_run, - quiet=quiet, - include_manifest=include_manifest, - extra_fields=extra_fields, - target_integration_dir=target_integration_dir, - check_name_override=check_name_override, + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms_csv, ) - if dry_run or include_manifest: + result = render(app, integration_type, name, **render_kwargs) + + if dry_run: return + _write_manifestless_overrides( + app, + integration_dir=result.integration_dir, + override_dir_name=target_integration_dir or result.integration_dir.name, + display_name=resolved_display_name, + metrics_prefix=resolved_metrics_prefix, + platforms=resolved_platforms, + ) + + +def _write_manifestless_overrides( + app: Application, + *, + integration_dir: Any, + override_dir_name: str, + display_name: str, + metrics_prefix: str, + platforms: list[str], +) -> None: from ddev.cli.create._config_overrides import apply_manifestless_overrides - override_dir_name = target_integration_dir or result.integration_dir.name try: apply_manifestless_overrides( app, dir_name=override_dir_name, - display_name=resolved_display_name, - metrics_prefix=resolved_metrics_prefix, - platforms=resolved_platforms, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms=platforms, ) except OSError as exc: app.abort( f'Failed to update `.ddev/config.toml`: {exc}\n' - f'The integration was scaffolded at `{result.integration_dir}` but the ' + f'The integration was scaffolded at `{integration_dir}` but the ' f'overrides were not recorded. Add these entries by hand:\n' f' [overrides.display-name]\n' - f' {override_dir_name} = "{resolved_display_name}"\n' + f' {override_dir_name} = "{display_name}"\n' f' [overrides.metrics-prefix]\n' - f' {override_dir_name} = "{resolved_metrics_prefix}"\n' + f' {override_dir_name} = "{metrics_prefix}"\n' f' [overrides.manifest.platforms]\n' - f' {override_dir_name} = {resolved_platforms!r}' + f' {override_dir_name} = {platforms!r}' ) @@ -237,7 +256,11 @@ def _resolve_check_only_inputs( if not manifest_path.is_file(): app.abort(f'Expected {manifest_path} to exist') - manifest_data = json.loads(manifest_path.read_text()) + try: + manifest_data = json.loads(manifest_path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + app.abort(f'Failed to read `{manifest_path}`: {exc}') + author = (manifest_data.get('author') or {}).get('name') if author is None: app.abort('Unable to determine author from manifest') diff --git a/ddev/src/ddev/cli/create/_scaffold.py b/ddev/src/ddev/cli/create/_scaffold.py index d9155eca8f193..8976aa2b8c048 100644 --- a/ddev/src/ddev/cli/create/_scaffold.py +++ b/ddev/src/ddev/cli/create/_scaffold.py @@ -48,16 +48,6 @@ [9]: https://docs.datadoghq.com/help/ """ -CHECK_ONLY_LINKS = """\ -[1]: **LINK_TO_INTEGRATION_SITE** -[2]: https://app.datadoghq.com/account/settings/agent/latest -[3]: https://docs.datadoghq.com/containers/kubernetes/integrations/ -[4]: https://github.com/DataDog/{repository}/blob/master/{name}/datadog_checks/{name}/data/conf.yaml.example -[5]: https://docs.datadoghq.com/agent/configuration/agent-commands/#start-stop-and-restart-the-agent -[6]: https://docs.datadoghq.com/agent/configuration/agent-commands/#agent-status-and-information -[9]: https://docs.datadoghq.com/help/ -""" - LOGS_LINKS = """\ [1]: https://docs.datadoghq.com/help/ [2]: https://app.datadoghq.com/account/settings/agent/latest @@ -90,7 +80,6 @@ INTEGRATION_TYPE_LINKS: dict[str, str] = { 'check': CHECK_LINKS, - 'check_only': CHECK_ONLY_LINKS, 'logs': LOGS_LINKS, 'jmx': JMX_LINKS, 'metrics_crawler': TILE_LINKS, @@ -449,7 +438,7 @@ def _format_line(name: str, depth: int, *, last: bool, is_dir: bool) -> tuple[st if depth == 1: return f'{PIPE_END if last else PIPE_MIDDLE}{HYPHEN} ', name, is_dir return ( - f"{PIPE} {' ' * 4 * (depth - 2)}{PIPE_END if last or is_dir else PIPE_MIDDLE}{HYPHEN} ", + f"{PIPE} {' ' * 4 * (depth - 2)}{PIPE_END if last else PIPE_MIDDLE}{HYPHEN} ", name, is_dir, ) diff --git a/ddev/tests/cli/create/conftest.py b/ddev/tests/cli/create/conftest.py index 01791c63c36b8..22c53aa204d0d 100644 --- a/ddev/tests/cli/create/conftest.py +++ b/ddev/tests/cli/create/conftest.py @@ -3,6 +3,9 @@ # Licensed under a 3-clause BSD style license (see LICENSE) from __future__ import annotations +import tomllib +from typing import Callable + import pytest from ddev.repo.core import Repository @@ -18,3 +21,13 @@ def empty_repo(tmp_path_factory, config_file): config_file.save() return repo + + +@pytest.fixture +def read_config(empty_repo) -> Callable[[], dict]: + """Return a callable that loads ``/.ddev/config.toml`` and returns its parsed contents.""" + + def _read() -> dict: + return tomllib.loads((empty_repo.path / '.ddev' / 'config.toml').read_text()) + + return _read diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index 7b2a54a1761f8..eb7396264b73e 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -8,14 +8,13 @@ import json import pytest -import tomli @pytest.mark.parametrize( 'subcommand', ['check', 'jmx', 'logs', 'event', 'metrics-crawler'], ) -def test_default_manifestless_writes_overrides_and_skips_manifest(ddev, empty_repo, subcommand): +def test_default_manifestless_writes_overrides_and_skips_manifest(ddev, empty_repo, read_config, subcommand): result = ddev( 'create', subcommand, @@ -33,9 +32,7 @@ def test_default_manifestless_writes_overrides_and_skips_manifest(ddev, empty_re assert integration_dir.is_dir() assert not (integration_dir / 'manifest.json').exists() - config_toml = empty_repo.path / '.ddev' / 'config.toml' - assert config_toml.is_file() - data = tomli.loads(config_toml.read_text()) + data = read_config() assert data['overrides']['display-name']['my_integration'] == 'My Integration' assert data['overrides']['metrics-prefix']['my_integration'] == 'my_integration.' assert data['overrides']['manifest']['platforms']['my_integration'] == ['linux', 'windows', 'mac_os'] @@ -168,7 +165,7 @@ def test_existing_directory_aborts(ddev, empty_repo): assert 'already exists' in result.output -def test_overrides_accumulate_across_creates(ddev, empty_repo): +def test_overrides_accumulate_across_creates(ddev, empty_repo, read_config): for name in ('first_integration', 'second_integration'): result = ddev( 'create', @@ -183,7 +180,7 @@ def test_overrides_accumulate_across_creates(ddev, empty_repo): ) assert result.exit_code == 0, result.output - data = tomli.loads((empty_repo.path / '.ddev' / 'config.toml').read_text()) + data = read_config() assert 'first_integration' in data['overrides']['display-name'] assert 'second_integration' in data['overrides']['display-name'] @@ -290,8 +287,8 @@ def test_check_only_writes_into_existing_author_prefixed_directory(ddev, empty_r assert not (empty_repo.path / 'thing').exists() -def test_check_only_manifestless_writes_overrides_for_stripped_name(ddev, empty_repo): - """Regression for finding #14: manifest-less check-only writes overrides keyed by the stripped name.""" +def test_check_only_manifestless_writes_overrides_for_integration_dir(ddev, empty_repo, read_config): + """Manifest-less check-only writes overrides keyed by the on-disk integration directory.""" integration_dir = empty_repo.path / 'partner_thing' _write_partner_manifest(integration_dir) @@ -308,13 +305,11 @@ def test_check_only_manifestless_writes_overrides_for_stripped_name(ddev, empty_ ) assert result.exit_code == 0, result.output - # Files must land in the existing partner_thing directory (finding #1). + # Files must land in the existing partner_thing directory (round-1 finding #1). assert (integration_dir / 'pyproject.toml').is_file() assert not (empty_repo.path / 'thing').exists() - overrides_path = empty_repo.path / '.ddev' / 'config.toml' - assert overrides_path.is_file() - data = tomli.loads(overrides_path.read_text()) + data = read_config() assert data['overrides']['display-name']['partner_thing'] == 'Partner Thing' assert data['overrides']['metrics-prefix']['partner_thing'] == 'partner_thing.' assert data['overrides']['manifest']['platforms']['partner_thing'] == ['linux', 'windows', 'mac_os'] @@ -337,3 +332,32 @@ def test_global_no_interactive_flag_aborts_when_required_flags_missing(ddev, emp assert '--display-name' in result.output assert '--metrics-prefix' in result.output assert '--platforms' in result.output + + +def test_type_flag_without_value_aborts_with_targeted_message(ddev, empty_repo): + """`ddev create NAME --type` (no value) must name the missing value, not the generic 'use a subcommand' message.""" + result = ddev('create', 'my_integration', '--type') + assert result.exit_code != 0 + assert '--type' in result.output + assert 'requires a value' in result.output + + +def test_dry_run_tree_uses_pipe_middle_for_non_last_directory(ddev, empty_repo): + """Regression for round-2 finding #3: non-last directories at depth >= 2 must use `├──`, not `└──`.""" + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + '--include-manifest', + '--dry-run', + ) + assert result.exit_code == 0, result.output + # The `assets/` subtree has both `configuration/` and `dashboards/`; the non-last + # of the two must use the middle connector. Prior bug always rendered `└──`. + assert '├── configuration' in result.output or '├── dashboards' in result.output From fd1973371be8ffbe51baed433754164d477956bd Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 19:12:05 +0200 Subject: [PATCH 05/13] Fix JMX template config_models so scaffolded integrations don't crash at runtime Every `defaults` function in the JMX template was defined `(field, value)` while `instance.py` invoked them with zero arguments via `getattr(defaults, ..., lambda: value)()`. Any integration scaffolded with `ddev create jmx` raised `TypeError: ...() missing 2 required positional arguments` on first run. Drop the parameters to match every production JMX integration in the repo (e.g. `activemq`). The legacy template under `datadog_checks_dev/` stays byte-identical per the in-toto rule. Other changes: - `_extract_legacy_type` now treats a flag-shaped token following `--type` / `-t` (e.g. `ddev create NAME --type --dry-run`) as the flag's value being missing, so the user gets the targeted "requires a value" message instead of `Unknown integration type '--dry-run'`. - `construct_template_fields` switches from `INTEGRATION_TYPE_LINKS[...]` to `.get(..., '')` so a not-yet-registered subcommand doesn't raise an unattributed `KeyError` during scaffolding. - Tighten `_write_manifestless_overrides`'s `integration_dir` from `Any` to `Path`, and introduce a `CheckOnlyPrefillFields` `TypedDict` for the manifest-prefilled fields produced by `prefill_check_only_fields`. - Guard `_resolve_check_only_inputs` against non-object JSON manifests with a clean `app.abort('... does not contain a JSON object')`. - Document the `TemplateFile` invariant (`binary=True` => `bytes`, `binary=False` => `str`) so the two `# type: ignore` lines read as intentional. - `_write_files_with_cleanup_hint` now branches by `integration_type`: for `check_only`, list the scaffolded files (so the user knows what to remove without touching their `manifest.json`); for everything else, the existing "Remove `` and retry" message stands. - Drop the unreachable `check_name_override` parameter from `render()` and the corresponding third return value of `_resolve_check_only_inputs`. `prefill_check_only_fields` already populates `check_name`, making the guard dead code. - Update `docs/developer/tutorials/jmx/integration.md` to the new `ddev create jmx NAME ...` form to match the round-1 update for the logs tutorial. --- ddev/src/ddev/cli/create/__init__.py | 5 + ddev/src/ddev/cli/create/_common.py | 23 +-- ddev/src/ddev/cli/create/_scaffold.py | 77 +++++++--- .../{check_name}/config_models/defaults.py | 14 +- ddev/tests/cli/create/test_create.py | 134 ++++++++++++++++++ docs/developer/tutorials/jmx/integration.md | 2 +- 6 files changed, 218 insertions(+), 37 deletions(-) diff --git a/ddev/src/ddev/cli/create/__init__.py b/ddev/src/ddev/cli/create/__init__.py index 5f268e59208fa..63378ac49f9b7 100644 --- a/ddev/src/ddev/cli/create/__init__.py +++ b/ddev/src/ddev/cli/create/__init__.py @@ -118,6 +118,11 @@ def _extract_legacy_type(args: list[str]) -> str | _MissingTypeValue | None: for token in iterator: if token in ('--type', '-t'): value = next(iterator, _MISSING_TYPE_VALUE) + # If the next token is itself a flag (e.g. `--type --dry-run`), treat the + # value as missing — the user clearly didn't intend to pass `--dry-run` + # as the type name. + if isinstance(value, str) and value.startswith('-'): + return _MISSING_TYPE_VALUE return value if token.startswith('--type='): return token.split('=', 1)[1] diff --git a/ddev/src/ddev/cli/create/_common.py b/ddev/src/ddev/cli/create/_common.py index 185f066c37281..fe3ec56200729 100644 --- a/ddev/src/ddev/cli/create/_common.py +++ b/ddev/src/ddev/cli/create/_common.py @@ -20,6 +20,8 @@ if TYPE_CHECKING: from ddev.cli.application import Application + from ddev.cli.create._scaffold import CheckOnlyPrefillFields + from ddev.utils.fs import Path SUPPORTED_PLATFORMS = ('linux', 'windows', 'mac_os') @@ -85,11 +87,10 @@ def run_subcommand( '`--skip-manifest` will be removed in the next major release.' ) - extra_fields: dict[str, object] = {} + extra_fields: CheckOnlyPrefillFields | dict[str, object] = {} target_integration_dir: str | None = None - check_name_override: str | None = None if integration_type == 'check_only': - extra_fields, target_integration_dir, check_name_override = _resolve_check_only_inputs(app, name, location) + extra_fields, target_integration_dir = _resolve_check_only_inputs(app, name, location) from ddev.cli.create._scaffold import render @@ -100,7 +101,6 @@ def run_subcommand( 'include_manifest': include_manifest, 'extra_fields': extra_fields, 'target_integration_dir': target_integration_dir, - 'check_name_override': check_name_override, } if include_manifest: @@ -136,7 +136,7 @@ def run_subcommand( def _write_manifestless_overrides( app: Application, *, - integration_dir: Any, + integration_dir: Path, override_dir_name: str, display_name: str, metrics_prefix: str, @@ -233,16 +233,14 @@ def _resolve_check_only_inputs( app: Application, name: str, location: str | None, -) -> tuple[dict[str, object], str, str]: +) -> tuple[CheckOnlyPrefillFields, str]: """For ``check_only`` integrations the directory must already exist with a manifest. Returns: - extra template fields prefilled from the existing manifest - the *target* integration directory name (the on-disk dir that holds the manifest; - e.g. ``partner_thing`` for a ``partner_`` author prefix) - - the *check_name* template substitution value (the stripped short name; - e.g. ``thing``). Used to populate the Python package name template variable - when the prefill helper did not provide a ``check_name``. + e.g. ``partner_thing`` for a ``partner_`` author prefix). The Python package + name (``{check_name}``) comes from the prefilled fields, not from this value. """ from ddev.cli.create._naming import normalize_display_name from ddev.cli.create._scaffold import prefill_check_only_fields @@ -261,6 +259,9 @@ def _resolve_check_only_inputs( except (OSError, json.JSONDecodeError) as exc: app.abort(f'Failed to read `{manifest_path}`: {exc}') + if not isinstance(manifest_data, dict): + app.abort(f'`{manifest_path}` does not contain a JSON object') + author = (manifest_data.get('author') or {}).get('name') if author is None: app.abort('Unable to determine author from manifest') @@ -269,4 +270,4 @@ def _resolve_check_only_inputs( stripped = target_integration_dir.removeprefix(f'{author_normalized}_') fields = prefill_check_only_fields(manifest_data, stripped) - return fields, target_integration_dir, stripped + return fields, target_integration_dir diff --git a/ddev/src/ddev/cli/create/_scaffold.py b/ddev/src/ddev/cli/create/_scaffold.py index 8976aa2b8c048..d3f162659ea57 100644 --- a/ddev/src/ddev/cli/create/_scaffold.py +++ b/ddev/src/ddev/cli/create/_scaffold.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from datetime import date, datetime, timezone from pathlib import Path as _StdPath -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypedDict, cast from uuid import uuid4 from ddev.cli.create._naming import ( @@ -98,7 +98,17 @@ @dataclass class TemplateFile: - """A single rendered template file ready to be written to disk.""" + """A single rendered template file ready to be written to disk. + + Invariant on ``contents`` (mirrored by the ``# type: ignore`` lines in ``write()``): + - ``binary=True`` => ``contents`` is ``bytes`` after ``read()``. + - ``binary=False`` => ``contents`` is ``str`` after ``read()``. + - ``contents`` is ``None`` only before ``read()`` has run. + + A discriminated union (one dataclass per branch) was considered but the + structural overhead — two classes, two ``isinstance`` branches at every call + site — outweighed the type-system clarity gain. + """ target_path: Path source_path: Path @@ -132,19 +142,33 @@ class ScaffoldResult: config: dict[str, Any] = field(default_factory=dict) -def prefill_check_only_fields(manifest: dict[str, Any], normalized_name: str) -> dict[str, Any]: +class CheckOnlyPrefillFields(TypedDict, total=False): + """Fields prefilled from a `check_only` integration's existing ``manifest.json``. + + All keys are optional: only the entries whose source field was present in the + manifest are populated (legacy behaviour). + """ + + author_name: str + check_name: str + email: str + homepage: str + sales_email: str + + +def prefill_check_only_fields(manifest: dict[str, Any], normalized_name: str) -> CheckOnlyPrefillFields: """Extract reusable fields from a pre-existing `manifest.json` for a `check_only` integration.""" author_name_raw = (manifest.get('author') or {}).get('name') author = normalize_display_name(author_name_raw) if author_name_raw else None check_name = normalize_package_name(f'{author}_{normalized_name}') if author is not None else None - candidates: dict[str, Any] = { + candidates: dict[str, str | None] = { 'author_name': author, 'check_name': check_name, 'email': (manifest.get('author') or {}).get('support_email'), 'homepage': (manifest.get('author') or {}).get('homepage'), 'sales_email': (manifest.get('author') or {}).get('sales_email'), } - return {k: v for k, v in candidates.items() if v is not None} + return cast(CheckOnlyPrefillFields, {k: v for k, v in candidates.items() if v is not None}) def construct_template_fields( @@ -180,7 +204,8 @@ def construct_template_fields( ) license_header = get_license_header() support_type = 'core' - integration_links = INTEGRATION_TYPE_LINKS[integration_type].format( + integration_links_template = INTEGRATION_TYPE_LINKS.get(integration_type, '') + integration_links = integration_links_template.format( name=normalized_name, repository='integrations-core', ) @@ -328,15 +353,14 @@ def render( include_manifest: bool, extra_fields: dict[str, Any] | None = None, target_integration_dir: str | None = None, - check_name_override: str | None = None, ) -> ScaffoldResult: """Resolve target paths, render templates in memory (or just enumerate for dry-run). ``target_integration_dir`` selects the on-disk directory that receives the rendered files (the directory whose name appears as the top-level segment of every output - path). ``check_name_override`` substitutes the ``{check_name}`` template variable - independently — used by `check_only` to keep the Python package name distinct from - the on-disk integration directory when the manifest carries an author prefix. + path). For ``check_only``, the ``check_name`` template variable comes from the + prefilled manifest fields in ``extra_fields``; for every other type, both default + to ``normalize_package_name(name)``. """ extra_fields = extra_fields or {} root = Path(location).resolve() if location else app.repo.path @@ -347,8 +371,6 @@ def render( app.abort(f'Path `{integration_dir}` already exists!') config = construct_template_fields(name, integration_type, **extra_fields) - if check_name_override is not None and 'check_name' not in extra_fields: - config['check_name'] = check_name_override files = collect_template_files( integration_type, @@ -366,7 +388,7 @@ def render( app.display_info(f'Will create in `{root}`:') _display_tree(app, root, files) else: - _write_files_with_cleanup_hint(app, files, integration_dir) + _write_files_with_cleanup_hint(app, files, integration_dir, integration_type) if quiet: app.display_info(f'Created `{integration_dir}`') else: @@ -380,17 +402,36 @@ def _write_files_with_cleanup_hint( app: Application, files: list[TemplateFile], integration_dir: Path, + integration_type: str, ) -> None: - """Write files and, on failure, tell the user where the partial write happened.""" + """Write files and, on failure, tell the user how to recover. + + For most types, the entire integration directory was created by this run and + is safe to delete. For ``check_only`` the directory pre-exists with the + user's ``manifest.json`` — tell them which scaffolded files to clean up + instead so they don't lose existing work. + """ total = len(files) + written: list[Path] = [] for index, f in enumerate(files, 1): try: f.write() except OSError as exc: - app.abort( - f'Wrote {index - 1}/{total} files; failed at `{f.target_path}`: {exc}. ' - f'Remove `{integration_dir}` and retry.' - ) + base = f'Wrote {index - 1}/{total} files; failed at `{f.target_path}`: {exc}.' + if integration_type == 'check_only': + if written: + listing = '\n - '.join(str(p) for p in written) + cleanup = ( + 'Remove the scaffolded files listed below (your `manifest.json` and any ' + 'other pre-existing files in the directory must be preserved) and retry:\n' + f' - {listing}' + ) + else: + cleanup = 'No files were written before the failure; safe to retry directly.' + else: + cleanup = f'Remove `{integration_dir}` and retry.' + app.abort(f'{base} {cleanup}') + written.append(f.target_path) def _display_tree(app: Application, root: Path, files: list[TemplateFile]) -> None: diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/defaults.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/defaults.py index f5b39ef8aa1aa..50d02c7575eb2 100644 --- a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/defaults.py +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/datadog_checks/{check_name}/config_models/defaults.py @@ -3,29 +3,29 @@ {documentation} -def shared_new_gc_metrics(field, value): +def shared_new_gc_metrics(): return False -def instance_collect_default_jvm_metrics(field, value): +def instance_collect_default_jvm_metrics(): return True -def instance_empty_default_hostname(field, value): +def instance_empty_default_hostname(): return False -def instance_min_collection_interval(field, value): +def instance_min_collection_interval(): return 15 -def instance_rmi_client_timeout(field, value): +def instance_rmi_client_timeout(): return 15000 -def instance_rmi_connection_timeout(field, value): +def instance_rmi_connection_timeout(): return 20000 -def instance_rmi_registry_ssl(field, value): +def instance_rmi_registry_ssl(): return False diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index eb7396264b73e..0729a64c74f5e 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -361,3 +361,137 @@ def test_dry_run_tree_uses_pipe_middle_for_non_last_directory(ddev, empty_repo): # The `assets/` subtree has both `configuration/` and `dashboards/`; the non-last # of the two must use the middle connector. Prior bug always rendered `└──`. assert '├── configuration' in result.output or '├── dashboards' in result.output + + +def test_jmx_template_defaults_take_no_arguments(ddev, empty_repo, tmp_path): + """Regression for round-3 finding #1: the scaffolded JMX `defaults.py` must define zero-arg functions. + + `instance.py` calls them as `getattr(defaults, ...)()` with no arguments; the old template's + `(field, value)` signatures crashed every JMX-scaffolded integration at runtime. + """ + import importlib.util + import inspect + + result = ddev( + 'create', + 'jmx', + 'smoke_jmx', + '--display-name', + 'Smoke JMX', + '--metrics-prefix', + 'smoke_jmx.', + '--platforms', + 'linux,windows,mac_os', + '--include-manifest', + ) + assert result.exit_code == 0, result.output + + defaults_path = empty_repo.path / 'smoke_jmx' / 'datadog_checks' / 'smoke_jmx' / 'config_models' / 'defaults.py' + assert defaults_path.is_file(), defaults_path + + spec = importlib.util.spec_from_file_location('smoke_jmx_defaults', str(defaults_path)) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + public_callables = [ + getattr(module, name) for name in dir(module) if not name.startswith('_') and callable(getattr(module, name)) + ] + assert public_callables, 'defaults.py exposed no callables' + for fn in public_callables: + sig = inspect.signature(fn) + assert len(sig.parameters) == 0, f'{fn.__name__}{sig} should take no arguments' + fn() # raises TypeError if it doesn't + + +def test_type_flag_consumes_flag_shaped_value_as_missing(ddev, empty_repo): + """Regression for round-3 finding #2: `--type --dry-run` must report the missing value, not parse `--dry-run`.""" + result = ddev('create', 'my_integration', '--type', '--dry-run') + assert result.exit_code != 0 + # The targeted "requires a value" message must fire, not the generic "Unknown integration type". + assert 'requires a value' in result.output + assert 'Unknown integration type' not in result.output + + +def test_check_only_non_object_manifest_aborts(ddev, empty_repo): + """Regression for round-3 finding #5: a JSON manifest that is not an object must abort cleanly.""" + integration_dir = empty_repo.path / 'partner_thing' + integration_dir.mkdir() + (integration_dir / 'manifest.json').write_text('["not", "an", "object"]') + + result = ddev( + 'create', + 'check-only', + 'partner_thing', + '--include-manifest', + ) + assert result.exit_code != 0 + assert 'does not contain a JSON object' in result.output + + +def test_check_only_partial_write_failure_does_not_recommend_deleting_directory( + ddev, empty_repo, monkeypatch, tmp_path +): + """Regression for round-3 finding #7: a partial-write failure on `check_only` must list scaffolded files, not the directory.""" + integration_dir = empty_repo.path / 'partner_thing' + integration_dir.mkdir() + (integration_dir / 'manifest.json').write_text( + json.dumps({'author': {'name': 'Partner', 'support_email': 'p@p.com'}}) + ) + + from ddev.cli.create import _scaffold as scaffold_module + + original_write = scaffold_module.TemplateFile.write + call_count = {'n': 0} + + def flaky_write(self): + call_count['n'] += 1 + if call_count['n'] == 2: + raise PermissionError(13, 'simulated mid-write failure') + return original_write(self) + + monkeypatch.setattr(scaffold_module.TemplateFile, 'write', flaky_write) + + result = ddev( + 'create', + 'check-only', + 'partner_thing', + '--include-manifest', + ) + assert result.exit_code != 0 + # The message must NOT tell the user to remove the directory (it holds their manifest). + assert 'Remove `' not in result.output or str(integration_dir) not in result.output + # The message MUST point at the scaffolded files specifically. + assert 'scaffolded files' in result.output or 'No files were written' in result.output + + +def test_non_check_only_partial_write_failure_recommends_deleting_directory(ddev, empty_repo, monkeypatch): + """Sanity: for `check` (and other types where the dir was freshly created), the message stays directory-scoped.""" + from ddev.cli.create import _scaffold as scaffold_module + + original_write = scaffold_module.TemplateFile.write + call_count = {'n': 0} + + def flaky_write(self): + call_count['n'] += 1 + if call_count['n'] == 2: + raise PermissionError(13, 'simulated mid-write failure') + return original_write(self) + + monkeypatch.setattr(scaffold_module.TemplateFile, 'write', flaky_write) + + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + '--include-manifest', + ) + assert result.exit_code != 0 + assert 'Remove `' in result.output + assert 'my_integration' in result.output diff --git a/docs/developer/tutorials/jmx/integration.md b/docs/developer/tutorials/jmx/integration.md index e769dca710370..c630b27cf3c8a 100644 --- a/docs/developer/tutorials/jmx/integration.md +++ b/docs/developer/tutorials/jmx/integration.md @@ -5,7 +5,7 @@ Tutorial for starting a JMX integration ## Step 1: Create a JMX integration scaffolding ```bash -ddev create --type jmx MyJMXIntegration +ddev create jmx MyJMXIntegration --display-name="MyJMXIntegration" --metrics-prefix=myjmxintegration. --platforms=linux,windows,mac_os ``` JMX integration contains specific init configs and instance configs: From ddb6437deec5df8c7714fa404a254c088e9f95bd Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 19:17:46 +0200 Subject: [PATCH 06/13] Reword test docstrings to describe behaviour and fit the line limit Several test docstrings in ddev/tests/cli/create/test_create.py described their own review-history provenance instead of the behaviour they lock in, and one of them was 131 characters wide which tripped ruff E501 on CI. Reword every affected docstring so each test reads as a self-contained behavioural assertion and stays under 120 chars. --- ddev/tests/cli/create/test_create.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index 0729a64c74f5e..d5e37d90dfaaf 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -269,7 +269,7 @@ def test_check_only_with_prefilled_manifest(ddev, empty_repo): def test_check_only_writes_into_existing_author_prefixed_directory(ddev, empty_repo): - """Regression for finding #1: scaffolded files must land in the manifest's directory.""" + """`check_only` scaffolded files land in the directory that holds the existing manifest.""" integration_dir = empty_repo.path / 'partner_thing' _write_partner_manifest(integration_dir) @@ -305,7 +305,7 @@ def test_check_only_manifestless_writes_overrides_for_integration_dir(ddev, empt ) assert result.exit_code == 0, result.output - # Files must land in the existing partner_thing directory (round-1 finding #1). + # Files must land in the existing partner_thing directory, not a sibling stripped-name directory. assert (integration_dir / 'pyproject.toml').is_file() assert not (empty_repo.path / 'thing').exists() @@ -316,7 +316,7 @@ def test_check_only_manifestless_writes_overrides_for_integration_dir(ddev, empt def test_bare_positional_aborts_with_subcommand_hint(ddev, empty_repo): - """Regression for finding #4: legacy `ddev create NAME` must point at the new subcommand surface.""" + """A bare-positional `ddev create NAME` points the user at the new subcommand surface.""" result = ddev('create', 'ACME') assert result.exit_code != 0 # The error must mention at least one of the new subcommands so users have something to copy. @@ -326,7 +326,7 @@ def test_bare_positional_aborts_with_subcommand_hint(ddev, empty_repo): def test_global_no_interactive_flag_aborts_when_required_flags_missing(ddev, empty_repo): - """Regression for finding #3: `--no-interactive` at the root must surface in `create`.""" + """The root-group `--no-interactive` flag aborts `create` when required flags are missing.""" result = ddev('--no-interactive', 'create', 'check', 'my_integration') assert result.exit_code != 0 assert '--display-name' in result.output @@ -343,7 +343,7 @@ def test_type_flag_without_value_aborts_with_targeted_message(ddev, empty_repo): def test_dry_run_tree_uses_pipe_middle_for_non_last_directory(ddev, empty_repo): - """Regression for round-2 finding #3: non-last directories at depth >= 2 must use `├──`, not `└──`.""" + """Non-last directories at depth >= 2 in the dry-run tree use `├──`, not `└──`.""" result = ddev( 'create', 'check', @@ -364,10 +364,10 @@ def test_dry_run_tree_uses_pipe_middle_for_non_last_directory(ddev, empty_repo): def test_jmx_template_defaults_take_no_arguments(ddev, empty_repo, tmp_path): - """Regression for round-3 finding #1: the scaffolded JMX `defaults.py` must define zero-arg functions. + """The scaffolded JMX `defaults.py` defines zero-argument functions. - `instance.py` calls them as `getattr(defaults, ...)()` with no arguments; the old template's - `(field, value)` signatures crashed every JMX-scaffolded integration at runtime. + `instance.py` invokes them as `getattr(defaults, ...)()` with no arguments; a `(field, value)` + signature would crash every JMX-scaffolded integration at runtime with `TypeError`. """ import importlib.util import inspect @@ -405,7 +405,7 @@ def test_jmx_template_defaults_take_no_arguments(ddev, empty_repo, tmp_path): def test_type_flag_consumes_flag_shaped_value_as_missing(ddev, empty_repo): - """Regression for round-3 finding #2: `--type --dry-run` must report the missing value, not parse `--dry-run`.""" + """A flag-shaped token following `--type` is treated as a missing value, not parsed as the type.""" result = ddev('create', 'my_integration', '--type', '--dry-run') assert result.exit_code != 0 # The targeted "requires a value" message must fire, not the generic "Unknown integration type". @@ -414,7 +414,7 @@ def test_type_flag_consumes_flag_shaped_value_as_missing(ddev, empty_repo): def test_check_only_non_object_manifest_aborts(ddev, empty_repo): - """Regression for round-3 finding #5: a JSON manifest that is not an object must abort cleanly.""" + """A `manifest.json` whose top-level JSON is not an object aborts with a clear message.""" integration_dir = empty_repo.path / 'partner_thing' integration_dir.mkdir() (integration_dir / 'manifest.json').write_text('["not", "an", "object"]') @@ -432,7 +432,7 @@ def test_check_only_non_object_manifest_aborts(ddev, empty_repo): def test_check_only_partial_write_failure_does_not_recommend_deleting_directory( ddev, empty_repo, monkeypatch, tmp_path ): - """Regression for round-3 finding #7: a partial-write failure on `check_only` must list scaffolded files, not the directory.""" + """`check_only` partial-write errors list scaffolded files instead of recommending directory deletion.""" integration_dir = empty_repo.path / 'partner_thing' integration_dir.mkdir() (integration_dir / 'manifest.json').write_text( From 062ed3957d7911fa9cfb26264d7fca5ac8afede8 Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 19:22:39 +0200 Subject: [PATCH 07/13] Restore JMX metrics.py template to match legacy ground truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new copy of `jmx/{check_name}/tests/metrics.py` had been cosmetically reformatted by ruff (multi-line list/concat layout) during an early implementation pass — before the templates tree was added to the ruff format/lint excludes. The two forms are functionally identical, but the new template now scaffolded a visibly different metrics.py than legacy `ddev create jmx` would have produced. Revert the file to the master legacy bytes so the two are byte-identical again. The ruff configs in `ddev/pyproject.toml` (`[tool.ruff].exclude`, `[tool.ruff.format].exclude`) and the root `pyproject.toml` already list `src/ddev/cli/create/templates`. Verified with `ddev test --lint ddev`: 303 files already formatted, no rewrite of the restored metrics.py. --- .../create/templates/jmx/{check_name}/tests/metrics.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py index fee75c8688af0..1b0e74a8d26bb 100644 --- a/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py @@ -1,9 +1,6 @@ {license_header} from datadog_checks.dev.jmx import JVM_E2E_METRICS_NEW -METRICS = ( - [ - # integration metrics - ] - + JVM_E2E_METRICS_NEW -) +METRICS = [ + # integration metrics +] + JVM_E2E_METRICS_NEW From 8a71988a0ff827b9224c6fe942c13af2df8750d2 Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 19:30:57 +0200 Subject: [PATCH 08/13] Reject empty author name when scaffolding check-only integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_check_only_inputs` checked `if author is None` against the value pulled from `manifest_data["author"]["name"]`. An empty string (or whitespace-only string) passed that guard and propagated through `prefill_check_only_fields` (which filtered the field out) and `construct_template_fields` (which fell back to `check_name = ''`). The rendered template paths then collapsed to forms like `/datadog_checks//__about__.py`, and `Path(root) / '/abs/path'` discards `root` on POSIX — scaffolded files would have been written to the filesystem root. Switch to a truthy check on the `.strip()`-ed value so empty and whitespace-only author names abort cleanly. Other changes: - Replace the 13-line `_MissingTypeValue` singleton class with a bare module-level `_MISSING_TYPE_VALUE = object()`. The only consumer uses `is`-comparison, not `isinstance`, so the class machinery added nothing. - Extract `_TYPE_FLAG_LITERALS` and `_TYPE_FLAG_EQUALS_PREFIXES` module constants shared by `_extract_legacy_type` and `_strip_type_flag` so future spellings only have to be added in one place. --- ddev/src/ddev/cli/create/__init__.py | 33 ++++++++++++---------------- ddev/src/ddev/cli/create/_common.py | 5 +++-- ddev/tests/cli/create/test_create.py | 19 ++++++++++++++++ 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/ddev/src/ddev/cli/create/__init__.py b/ddev/src/ddev/cli/create/__init__.py index 63378ac49f9b7..87d6283ab7976 100644 --- a/ddev/src/ddev/cli/create/__init__.py +++ b/ddev/src/ddev/cli/create/__init__.py @@ -92,21 +92,17 @@ def resolve_command( # type: ignore[override] return subcommand.name, subcommand, cleaned -class _MissingTypeValue: - """Sentinel: ``--type``/``-t`` was passed without a value (e.g. trailing ``--type``).""" +# Sentinel: ``--type``/``-t`` was passed but no value followed (e.g. trailing ``--type``). +_MISSING_TYPE_VALUE: object = object() - _instance: _MissingTypeValue | None = None +# Recognised spellings of the deprecated ``--type`` / ``-t`` flag. +# Used by both ``_extract_legacy_type`` and ``_strip_type_flag``; update once if a +# new spelling is ever added. +_TYPE_FLAG_LITERALS: tuple[str, ...] = ('--type', '-t') +_TYPE_FLAG_EQUALS_PREFIXES: tuple[str, ...] = ('--type=', '-t=') - def __new__(cls) -> _MissingTypeValue: - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - -_MISSING_TYPE_VALUE = _MissingTypeValue() - - -def _extract_legacy_type(args: list[str]) -> str | _MissingTypeValue | None: +def _extract_legacy_type(args: list[str]) -> str | object | None: """Return the `--type` / `-t` value from ``args``. Distinguishes three outcomes: @@ -116,7 +112,7 @@ def _extract_legacy_type(args: list[str]) -> str | _MissingTypeValue | None: """ iterator = iter(args) for token in iterator: - if token in ('--type', '-t'): + if token in _TYPE_FLAG_LITERALS: value = next(iterator, _MISSING_TYPE_VALUE) # If the next token is itself a flag (e.g. `--type --dry-run`), treat the # value as missing — the user clearly didn't intend to pass `--dry-run` @@ -124,10 +120,9 @@ def _extract_legacy_type(args: list[str]) -> str | _MissingTypeValue | None: if isinstance(value, str) and value.startswith('-'): return _MISSING_TYPE_VALUE return value - if token.startswith('--type='): - return token.split('=', 1)[1] - if token.startswith('-t='): - return token[3:] + for prefix in _TYPE_FLAG_EQUALS_PREFIXES: + if token.startswith(prefix): + return token[len(prefix) :] if _is_concatenated_short_type(token): return token[2:] return None @@ -149,10 +144,10 @@ def _strip_type_flag(args: list[str]) -> list[str]: if skip_next: skip_next = False continue - if token in ('--type', '-t'): + if token in _TYPE_FLAG_LITERALS: skip_next = True continue - if token.startswith(('--type=', '-t=')): + if token.startswith(_TYPE_FLAG_EQUALS_PREFIXES): continue if _is_concatenated_short_type(token): continue diff --git a/ddev/src/ddev/cli/create/_common.py b/ddev/src/ddev/cli/create/_common.py index fe3ec56200729..5ab1b55676f99 100644 --- a/ddev/src/ddev/cli/create/_common.py +++ b/ddev/src/ddev/cli/create/_common.py @@ -262,8 +262,9 @@ def _resolve_check_only_inputs( if not isinstance(manifest_data, dict): app.abort(f'`{manifest_path}` does not contain a JSON object') - author = (manifest_data.get('author') or {}).get('name') - if author is None: + author_raw = (manifest_data.get('author') or {}).get('name') + author = author_raw.strip() if isinstance(author_raw, str) else None + if not author: app.abort('Unable to determine author from manifest') author_normalized = normalize_display_name(author) diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index d5e37d90dfaaf..f32f6ab4599bf 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -429,6 +429,25 @@ def test_check_only_non_object_manifest_aborts(ddev, empty_repo): assert 'does not contain a JSON object' in result.output +@pytest.mark.parametrize('empty_name', ['""', '" "']) +def test_check_only_empty_author_name_aborts(ddev, empty_repo, empty_name): + """An empty or whitespace-only `author.name` aborts before any path computation.""" + integration_dir = empty_repo.path / 'partner_thing' + integration_dir.mkdir() + (integration_dir / 'manifest.json').write_text(f'{{"author": {{"name": {empty_name}}}}}') + + result = ddev( + 'create', + 'check-only', + 'partner_thing', + '--include-manifest', + ) + assert result.exit_code != 0 + assert 'Unable to determine author from manifest' in result.output + # No scaffolded files must have escaped to the filesystem root or anywhere outside the integration. + assert not (integration_dir / 'pyproject.toml').exists() + + def test_check_only_partial_write_failure_does_not_recommend_deleting_directory( ddev, empty_repo, monkeypatch, tmp_path ): From 6daf38ee4dd5ef7b14cba7faedae03745f60f22e Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 19:55:36 +0200 Subject: [PATCH 09/13] Reject all-symbol author names and surface invalid integration names cleanly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_check_only_inputs` previously accepted any truthy author name after `.strip()`. An all-symbol input like "!@#$" passed the guard but then `normalize_display_name` collapsed it to "", and the downstream `removeprefix` was a no-op — leaving the rendered template paths with a stray leading underscore. Consolidate the normalization and the truthy check: normalize first, abort when the normalized form is empty. Same guard now covers empty, whitespace-only, and all-symbol author names. Other changes: - Pre-validate the integration `name` argument in `run_subcommand` via a shared `is_valid_integration_name` predicate in `_naming.py`. Names with leading/trailing non-alphanumerics or characters outside `[A-Za-z0-9._\- ]` now abort with a clear message instead of surfacing a raw `ValueError` from `normalize_project_name`. - Swap the bare `_MISSING_TYPE_VALUE = object()` sentinel for a one-member `Enum` (`_TypeFlagSentinel.MISSING`). Same runtime semantics (identity comparison), distinct nominal type so mypy can narrow `_extract_legacy_type`'s return value. - Drop the per-subcommand `-q`/`--quiet` flag from `create_options`. The root `ddev` group already exposes a count-based `--quiet`/`--verbose`; `render()` now reads `app.quiet` and routes the one-line headline through `app.display` (unconditional) so `ddev -q create ...` still prints "Created ``". - Extract a `fail_on_second_write` pytest fixture in `conftest.py` and drop the duplicated `monkeypatch.setattr(TemplateFile.write, ...)` setup from the two partial-write tests. --- ddev/src/ddev/cli/create/__init__.py | 17 +++-- ddev/src/ddev/cli/create/_common.py | 29 ++++++--- ddev/src/ddev/cli/create/_naming.py | 11 ++++ ddev/src/ddev/cli/create/_scaffold.py | 9 ++- ddev/tests/cli/create/conftest.py | 21 ++++++ ddev/tests/cli/create/test_create.py | 93 +++++++++++++++++---------- 6 files changed, 127 insertions(+), 53 deletions(-) diff --git a/ddev/src/ddev/cli/create/__init__.py b/ddev/src/ddev/cli/create/__init__.py index 87d6283ab7976..03a113628ba2e 100644 --- a/ddev/src/ddev/cli/create/__init__.py +++ b/ddev/src/ddev/cli/create/__init__.py @@ -12,6 +12,8 @@ from __future__ import annotations +import enum + import click from ddev.cli.create.check import check @@ -72,7 +74,8 @@ def resolve_command( # type: ignore[override] f'Use the manifest-less workflow described at {CONFLUENCE_NO_MANIFEST_URL}.' ) - # Narrowed by the two preceding `is` / `is None` branches; both call NoReturn `app.abort`. + # mypy doesn't propagate `app.abort`'s NoReturn through the typed `ctx.obj` + # assignment, so narrow explicitly here. assert isinstance(legacy_type, str) target = LEGACY_TYPE_TO_SUBCOMMAND.get(legacy_type) if target is None: @@ -92,8 +95,14 @@ def resolve_command( # type: ignore[override] return subcommand.name, subcommand, cleaned -# Sentinel: ``--type``/``-t`` was passed but no value followed (e.g. trailing ``--type``). -_MISSING_TYPE_VALUE: object = object() +class _TypeFlagSentinel(enum.Enum): + """Nominal sentinel type so mypy can narrow ``_extract_legacy_type``'s return value.""" + + MISSING = 'missing' + + +# Sentinel: ``--type`` / ``-t`` was passed but no value followed (e.g. trailing ``--type``). +_MISSING_TYPE_VALUE: _TypeFlagSentinel = _TypeFlagSentinel.MISSING # Recognised spellings of the deprecated ``--type`` / ``-t`` flag. # Used by both ``_extract_legacy_type`` and ``_strip_type_flag``; update once if a @@ -102,7 +111,7 @@ def resolve_command( # type: ignore[override] _TYPE_FLAG_EQUALS_PREFIXES: tuple[str, ...] = ('--type=', '-t=') -def _extract_legacy_type(args: list[str]) -> str | object | None: +def _extract_legacy_type(args: list[str]) -> str | _TypeFlagSentinel | None: """Return the `--type` / `-t` value from ``args``. Distinguishes three outcomes: diff --git a/ddev/src/ddev/cli/create/_common.py b/ddev/src/ddev/cli/create/_common.py index 5ab1b55676f99..248ddb6ff844a 100644 --- a/ddev/src/ddev/cli/create/_common.py +++ b/ddev/src/ddev/cli/create/_common.py @@ -16,7 +16,7 @@ import click -from ddev.cli.create._naming import normalize_package_name +from ddev.cli.create._naming import is_valid_integration_name, normalize_package_name if TYPE_CHECKING: from ddev.cli.application import Application @@ -39,7 +39,6 @@ def create_options(f: Callable[..., Any]) -> Callable[..., Any]: help='Generate a `manifest.json` (legacy behaviour).', )(f) f = click.option('--dry-run', '-n', is_flag=True, help='Only show what would be created.')(f) - f = click.option('--quiet', '-q', is_flag=True, help='Show less output.')(f) f = click.option('--location', '-l', default=None, help='The directory where files will be written.')(f) f = click.option('--platforms', default=None, help='Comma-separated list of `linux,windows,mac_os`.')(f) f = click.option('--metrics-prefix', default=None, help='Metric namespace (e.g. `myintegration.`).')(f) @@ -68,14 +67,12 @@ def run_subcommand( metrics_prefix: str | None, platforms_csv: str | None, location: str | None, - quiet: bool, dry_run: bool, include_manifest: bool, skip_manifest: bool, ) -> None: """Single entry point shared by all per-type subcommands.""" - if name.lower().startswith('datadog'): - app.abort('Integration names cannot start with `datadog`.') + _validate_integration_name(app, name) if skip_manifest and include_manifest: app.abort('`--skip-manifest` and `--include-manifest` are mutually exclusive.') @@ -97,7 +94,6 @@ def run_subcommand( render_kwargs: dict[str, Any] = { 'location': location, 'dry_run': dry_run, - 'quiet': quiet, 'include_manifest': include_manifest, 'extra_fields': extra_fields, 'target_integration_dir': target_integration_dir, @@ -263,12 +259,27 @@ def _resolve_check_only_inputs( app.abort(f'`{manifest_path}` does not contain a JSON object') author_raw = (manifest_data.get('author') or {}).get('name') - author = author_raw.strip() if isinstance(author_raw, str) else None - if not author: + author = (author_raw or '').strip() if isinstance(author_raw, str) else '' + # Normalize first so an all-symbol author (e.g. "!@#$") collapses to "" and is rejected + # by the same guard as a truly empty name. A passing value is non-empty and underscore-safe. + author_normalized = normalize_display_name(author) + if not author_normalized: app.abort('Unable to determine author from manifest') - author_normalized = normalize_display_name(author) stripped = target_integration_dir.removeprefix(f'{author_normalized}_') fields = prefill_check_only_fields(manifest_data, stripped) return fields, target_integration_dir + + +def _validate_integration_name(app: Application, name: str) -> None: + """Reject names that would break path templating, package name normalization, or policy.""" + if not name: + app.abort('Integration name must not be empty.') + if not is_valid_integration_name(name): + app.abort( + f'Invalid integration name {name!r}. Names must contain only ASCII letters, digits, ' + "dots, hyphens, underscores, or spaces, and must begin and end with an alphanumeric character." + ) + if name.lower().startswith('datadog'): + app.abort('Integration names cannot start with `datadog`.') diff --git a/ddev/src/ddev/cli/create/_naming.py b/ddev/src/ddev/cli/create/_naming.py index 6cad17b5b3227..05d3eb09a5d64 100644 --- a/ddev/src/ddev/cli/create/_naming.py +++ b/ddev/src/ddev/cli/create/_naming.py @@ -6,6 +6,17 @@ import re from datetime import datetime, timezone +VALID_INTEGRATION_NAME = re.compile(r'^[A-Z0-9](?:[A-Z0-9._\- ]*[A-Z0-9])?$', re.IGNORECASE) + + +def is_valid_integration_name(name: str) -> bool: + """Return True iff `name` is acceptable to `normalize_project_name` / scaffold path templating. + + Must contain only ASCII letters/digits, dots, hyphens, underscores, or spaces, and must + begin and end with an alphanumeric character. + """ + return bool(VALID_INTEGRATION_NAME.match(name)) + def normalize_package_name(name: str) -> str: """Lowercase and collapse separators to underscore (used for directory and Python package names).""" diff --git a/ddev/src/ddev/cli/create/_scaffold.py b/ddev/src/ddev/cli/create/_scaffold.py index d3f162659ea57..ebbb15af4f92b 100644 --- a/ddev/src/ddev/cli/create/_scaffold.py +++ b/ddev/src/ddev/cli/create/_scaffold.py @@ -349,7 +349,6 @@ def render( *, location: str | None, dry_run: bool, - quiet: bool, include_manifest: bool, extra_fields: dict[str, Any] | None = None, target_integration_dir: str | None = None, @@ -382,15 +381,15 @@ def render( ) if dry_run: - if quiet: - app.display_info(f'Will create `{integration_dir}`') + if app.quiet: + app.display(f'Will create `{integration_dir}`') else: app.display_info(f'Will create in `{root}`:') _display_tree(app, root, files) else: _write_files_with_cleanup_hint(app, files, integration_dir, integration_type) - if quiet: - app.display_info(f'Created `{integration_dir}`') + if app.quiet: + app.display(f'Created `{integration_dir}`') else: app.display_info(f'Created in `{root}`:') _display_tree(app, root, files) diff --git a/ddev/tests/cli/create/conftest.py b/ddev/tests/cli/create/conftest.py index 22c53aa204d0d..622d7a4e64519 100644 --- a/ddev/tests/cli/create/conftest.py +++ b/ddev/tests/cli/create/conftest.py @@ -31,3 +31,24 @@ def _read() -> dict: return tomllib.loads((empty_repo.path / '.ddev' / 'config.toml').read_text()) return _read + + +@pytest.fixture +def fail_on_second_write(monkeypatch): + """Make `TemplateFile.write` raise `PermissionError` on its second invocation per test. + + Lets a test exercise the partial-write failure path without depending on a real + filesystem permission flip mid-scaffold. + """ + from ddev.cli.create import _scaffold as scaffold_module + + original_write = scaffold_module.TemplateFile.write + call_count = {'n': 0} + + def flaky_write(self): + call_count['n'] += 1 + if call_count['n'] == 2: + raise PermissionError(13, 'simulated mid-write failure') + return original_write(self) + + monkeypatch.setattr(scaffold_module.TemplateFile, 'write', flaky_write) diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index f32f6ab4599bf..9ac6f143572b8 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -148,6 +148,29 @@ def test_datadog_prefix_rejected(ddev, empty_repo): assert 'cannot start with' in result.output +@pytest.mark.parametrize('bad_name', ['_my_thing', 'my_thing_', 'foo-', '.bar', 'baz.', 'a@b', 'a/b']) +def test_invalid_integration_name_aborts(ddev, empty_repo, bad_name): + """Names with leading/trailing non-alphanumerics or disallowed characters abort before any scaffolding. + + Names starting with a dash are blocked earlier by click's option parser; this test covers the + full surface our own validator owns. + """ + result = ddev( + 'create', + 'check', + bad_name, + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + ) + assert result.exit_code != 0 + assert 'Invalid integration name' in result.output + assert bad_name in result.output + + def test_existing_directory_aborts(ddev, empty_repo): (empty_repo.path / 'my_integration').mkdir() result = ddev( @@ -342,6 +365,26 @@ def test_type_flag_without_value_aborts_with_targeted_message(ddev, empty_repo): assert 'requires a value' in result.output +def test_global_quiet_suppresses_tree_and_keeps_headline(ddev, empty_repo): + """`ddev -q create ...` suppresses the file-tree printout but still emits the one-line `Created` headline.""" + result = ddev( + '-q', + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + ) + assert result.exit_code == 0, result.output + assert 'my_integration' in result.output + # The full tree must NOT appear in quiet mode. + assert '└──' not in result.output and '├──' not in result.output + + def test_dry_run_tree_uses_pipe_middle_for_non_last_directory(ddev, empty_repo): """Non-last directories at depth >= 2 in the dry-run tree use `├──`, not `└──`.""" result = ddev( @@ -429,12 +472,20 @@ def test_check_only_non_object_manifest_aborts(ddev, empty_repo): assert 'does not contain a JSON object' in result.output -@pytest.mark.parametrize('empty_name', ['""', '" "']) -def test_check_only_empty_author_name_aborts(ddev, empty_repo, empty_name): - """An empty or whitespace-only `author.name` aborts before any path computation.""" +@pytest.mark.parametrize( + 'json_author_name', + [ + '""', # empty + '" "', # whitespace-only + '"!@#$"', # all-symbol -> normalize_display_name collapses to "" + '" !!! "', # whitespace + all-symbol + ], +) +def test_check_only_rejects_unusable_author_name(ddev, empty_repo, json_author_name): + """Author names that normalize to an empty value abort before any path computation.""" integration_dir = empty_repo.path / 'partner_thing' integration_dir.mkdir() - (integration_dir / 'manifest.json').write_text(f'{{"author": {{"name": {empty_name}}}}}') + (integration_dir / 'manifest.json').write_text(f'{{"author": {{"name": {json_author_name}}}}}') result = ddev( 'create', @@ -448,9 +499,7 @@ def test_check_only_empty_author_name_aborts(ddev, empty_repo, empty_name): assert not (integration_dir / 'pyproject.toml').exists() -def test_check_only_partial_write_failure_does_not_recommend_deleting_directory( - ddev, empty_repo, monkeypatch, tmp_path -): +def test_check_only_partial_write_failure_does_not_recommend_deleting_directory(ddev, empty_repo, fail_on_second_write): """`check_only` partial-write errors list scaffolded files instead of recommending directory deletion.""" integration_dir = empty_repo.path / 'partner_thing' integration_dir.mkdir() @@ -458,19 +507,6 @@ def test_check_only_partial_write_failure_does_not_recommend_deleting_directory( json.dumps({'author': {'name': 'Partner', 'support_email': 'p@p.com'}}) ) - from ddev.cli.create import _scaffold as scaffold_module - - original_write = scaffold_module.TemplateFile.write - call_count = {'n': 0} - - def flaky_write(self): - call_count['n'] += 1 - if call_count['n'] == 2: - raise PermissionError(13, 'simulated mid-write failure') - return original_write(self) - - monkeypatch.setattr(scaffold_module.TemplateFile, 'write', flaky_write) - result = ddev( 'create', 'check-only', @@ -484,21 +520,8 @@ def flaky_write(self): assert 'scaffolded files' in result.output or 'No files were written' in result.output -def test_non_check_only_partial_write_failure_recommends_deleting_directory(ddev, empty_repo, monkeypatch): - """Sanity: for `check` (and other types where the dir was freshly created), the message stays directory-scoped.""" - from ddev.cli.create import _scaffold as scaffold_module - - original_write = scaffold_module.TemplateFile.write - call_count = {'n': 0} - - def flaky_write(self): - call_count['n'] += 1 - if call_count['n'] == 2: - raise PermissionError(13, 'simulated mid-write failure') - return original_write(self) - - monkeypatch.setattr(scaffold_module.TemplateFile, 'write', flaky_write) - +def test_non_check_only_partial_write_failure_recommends_deleting_directory(ddev, empty_repo, fail_on_second_write): + """For `check` (and other types where the dir was freshly created), the message stays directory-scoped.""" result = ddev( 'create', 'check', From bae7faf5aadef90e0c98f52eb3d6fd0a1eaf947d Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Thu, 28 May 2026 20:19:13 +0200 Subject: [PATCH 10/13] Fix package name collision when manifest author contains a hyphen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_resolve_check_only_inputs` stripped the author prefix from the target integration directory using `normalize_display_name`, which preserves hyphens. The directory name is built via `normalize_package_name`, which converts hyphens to underscores. A manifest with `"author": {"name": "My-Partner"}` paired with a directory named `my_partner_thing` produced an `author_normalized = "my-partner"` prefix that no longer matched the underscore form in the dir. The `removeprefix` silently did nothing, and `prefill_check_only_fields` then re-prefixed the (unstripped) name with the same author segment, yielding a doubled `my_partner_my_partner_thing` Python package path. Use `normalize_package_name(author_normalized)` so the prefix matches the directory's normalization. Other changes: - Reword `ddev/changelog.d/23859.added` to describe the genuinely new user-facing surface (subcommand-based interface plus the new options) instead of the migration-implementation detail; the migration narrative stays in `23859.changed`. - Add behaviour tests for `--location` with both absolute and relative target paths, plus an interactive-prompt test that exercises the empty-input default-acceptance branch of `_resolve_manifestless_inputs`. - Add a comment at `TemplateFile.read()` documenting that template rendering failures indicate a broken shipped template, not user error — the user's `name` is validated upstream by `is_valid_integration_name` before scaffolding starts. --- ddev/changelog.d/23859.added | 2 +- ddev/src/ddev/cli/create/_common.py | 8 ++- ddev/src/ddev/cli/create/_scaffold.py | 2 + ddev/tests/cli/create/test_create.py | 94 +++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/ddev/changelog.d/23859.added b/ddev/changelog.d/23859.added index 090e3707c3336..717cce86a1cbd 100644 --- a/ddev/changelog.d/23859.added +++ b/ddev/changelog.d/23859.added @@ -1 +1 @@ -Legacy migration: `create` is now implemented natively in ddev (was previously delegated to datadog_checks_dev). +Add subcommand-based `ddev create` interface (`check`, `check-only`, `jmx`, `logs`, `event`, `metrics-crawler`) with `--display-name`, `--metrics-prefix`, `--platforms`, and `--include-manifest` options for manifest-less integrations. diff --git a/ddev/src/ddev/cli/create/_common.py b/ddev/src/ddev/cli/create/_common.py index 248ddb6ff844a..8a22d258802a0 100644 --- a/ddev/src/ddev/cli/create/_common.py +++ b/ddev/src/ddev/cli/create/_common.py @@ -266,7 +266,13 @@ def _resolve_check_only_inputs( if not author_normalized: app.abort('Unable to determine author from manifest') - stripped = target_integration_dir.removeprefix(f'{author_normalized}_') + # `target_integration_dir` runs through `normalize_package_name`, which converts + # hyphens to underscores. The author prefix must use the same normalization, or + # a hyphenated author (e.g. "My-Partner") wouldn't match the underscore form in + # the directory name, leaving the prefix in place and causing + # `prefill_check_only_fields` to double the author segment downstream. + author_pkg = normalize_package_name(author_normalized) + stripped = target_integration_dir.removeprefix(f'{author_pkg}_') fields = prefill_check_only_fields(manifest_data, stripped) return fields, target_integration_dir diff --git a/ddev/src/ddev/cli/create/_scaffold.py b/ddev/src/ddev/cli/create/_scaffold.py index ebbb15af4f92b..712e3a1b6c990 100644 --- a/ddev/src/ddev/cli/create/_scaffold.py +++ b/ddev/src/ddev/cli/create/_scaffold.py @@ -120,6 +120,8 @@ def read(self, config: dict[str, Any]) -> None: self.contents = self.source_path.read_bytes() return raw = self.source_path.read_text() + # Templates are trusted; a RuntimeError here indicates a broken shipped template, not user error. + # User-supplied `name` is validated upstream by `is_valid_integration_name` before scaffolding starts. try: self.contents = raw.format(**config) except (KeyError, IndexError, ValueError) as exc: diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index 9ac6f143572b8..906668dc84da6 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -310,6 +310,36 @@ def test_check_only_writes_into_existing_author_prefixed_directory(ddev, empty_r assert not (empty_repo.path / 'thing').exists() +def test_check_only_hyphenated_author_does_not_double_segment(ddev, empty_repo): + """A hyphenated author (e.g. `My-Partner`) must match the underscored directory and strip cleanly.""" + integration_dir = empty_repo.path / 'my_partner_thing' + integration_dir.mkdir() + (integration_dir / 'manifest.json').write_text( + json.dumps( + { + 'author': { + 'name': 'My-Partner', + 'support_email': 'support@my-partner.com', + 'homepage': 'https://my-partner.com', + 'sales_email': 'sales@my-partner.com', + } + } + ) + ) + + result = ddev( + 'create', + 'check-only', + 'my_partner_thing', + '--include-manifest', + ) + assert result.exit_code == 0, result.output + + # The package path must be `my_partner_thing`, NOT `my_partner_my_partner_thing`. + assert (integration_dir / 'datadog_checks' / 'my_partner_thing' / '__about__.py').is_file() + assert not (integration_dir / 'datadog_checks' / 'my_partner_my_partner_thing').exists() + + def test_check_only_manifestless_writes_overrides_for_integration_dir(ddev, empty_repo, read_config): """Manifest-less check-only writes overrides keyed by the on-disk integration directory.""" integration_dir = empty_repo.path / 'partner_thing' @@ -357,6 +387,70 @@ def test_global_no_interactive_flag_aborts_when_required_flags_missing(ddev, emp assert '--platforms' in result.output +def test_interactive_prompts_accept_default_values(ddev, empty_repo, read_config): + """Under `--interactive`, missing flags prompt with computed defaults; pressing enter accepts each one.""" + result = ddev( + '--interactive', + 'create', + 'check', + 'my_integration', + input='\n\n\n', # accept defaults for display-name, metrics-prefix, platforms + ) + assert result.exit_code == 0, result.output + + data = read_config() + assert data['overrides']['display-name']['my_integration'] == 'my_integration' + assert data['overrides']['metrics-prefix']['my_integration'] == 'my_integration.' + assert data['overrides']['manifest']['platforms']['my_integration'] == ['linux', 'windows', 'mac_os'] + + +def test_location_flag_writes_outside_repo(ddev, empty_repo, tmp_path): + """`--location ` scaffolds into /NAME/ instead of /NAME/.""" + target = tmp_path / 'outside_repo' + target.mkdir() + + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + '--include-manifest', + '--location', + str(target), + ) + assert result.exit_code == 0, result.output + assert (target / 'my_integration' / 'pyproject.toml').is_file() + assert not (empty_repo.path / 'my_integration').exists() + + +def test_location_flag_accepts_relative_path(ddev, empty_repo, tmp_path, monkeypatch): + """`--location` accepts a relative path and resolves it against the current working directory.""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'relative_target').mkdir() + + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + '--include-manifest', + '--location', + 'relative_target', + ) + assert result.exit_code == 0, result.output + assert (tmp_path / 'relative_target' / 'my_integration' / 'pyproject.toml').is_file() + + def test_type_flag_without_value_aborts_with_targeted_message(ddev, empty_repo): """`ddev create NAME --type` (no value) must name the missing value, not the generic 'use a subcommand' message.""" result = ddev('create', 'my_integration', '--type') From 5e06a78e68a68693d575e6ea46751408c4ee329d Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Fri, 29 May 2026 11:41:56 +0200 Subject: [PATCH 11/13] Show TOML section headers in the create config-write-failure message The manual-fix instructions printed when `.ddev/config.toml` can't be written listed the override values without their `[overrides.*]` section headers: Rich parsed the bracketed headers as style tags and stripped them. Pass markup=False so the headers survive. Also hardens the surrounding scaffolding: - Treat `--type=` with an empty value as a missing type, not the type name ``. - Derive check_class from the final check_name and assert it is populated before scaffolding, so an empty name can't collapse paths to the filesystem root. - Reuse the caller's already-normalized author in prefill_check_only_fields instead of re-deriving it from the raw manifest. - Narrow the override-setter type hints. - Add tests for the malformed-config and config-write-failure abort paths. --- ddev/src/ddev/cli/create/__init__.py | 5 +- ddev/src/ddev/cli/create/_common.py | 10 +++- ddev/src/ddev/cli/create/_config_overrides.py | 6 ++- ddev/src/ddev/cli/create/_scaffold.py | 30 ++++++++--- ddev/tests/cli/create/test_create.py | 53 +++++++++++++++++++ 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/ddev/src/ddev/cli/create/__init__.py b/ddev/src/ddev/cli/create/__init__.py index 03a113628ba2e..b52be5feb567f 100644 --- a/ddev/src/ddev/cli/create/__init__.py +++ b/ddev/src/ddev/cli/create/__init__.py @@ -131,7 +131,10 @@ def _extract_legacy_type(args: list[str]) -> str | _TypeFlagSentinel | None: return value for prefix in _TYPE_FLAG_EQUALS_PREFIXES: if token.startswith(prefix): - return token[len(prefix) :] + value = token[len(prefix) :] + # `--type=` with nothing after the equals sign is a missing value, not the + # empty-string type name `''` (which would abort with a confusing message). + return value if value else _MISSING_TYPE_VALUE if _is_concatenated_short_type(token): return token[2:] return None diff --git a/ddev/src/ddev/cli/create/_common.py b/ddev/src/ddev/cli/create/_common.py index 8a22d258802a0..0d23ca8bd909e 100644 --- a/ddev/src/ddev/cli/create/_common.py +++ b/ddev/src/ddev/cli/create/_common.py @@ -87,6 +87,8 @@ def run_subcommand( extra_fields: CheckOnlyPrefillFields | dict[str, object] = {} target_integration_dir: str | None = None if integration_type == 'check_only': + # Read unconditionally (even with --include-manifest): the manifest supplies check_name, + # the Python package name consumed by both the manifest-less and manifest paths. extra_fields, target_integration_dir = _resolve_check_only_inputs(app, name, location) from ddev.cli.create._scaffold import render @@ -149,6 +151,9 @@ def _write_manifestless_overrides( platforms=platforms, ) except OSError as exc: + # markup=False: the TOML section headers ([overrides.display-name], ...) would + # otherwise be parsed by Rich as style tags and stripped from the output, leaving + # the user with copy-paste instructions missing their section headers. app.abort( f'Failed to update `.ddev/config.toml`: {exc}\n' f'The integration was scaffolded at `{integration_dir}` but the ' @@ -158,7 +163,8 @@ def _write_manifestless_overrides( f' [overrides.metrics-prefix]\n' f' {override_dir_name} = "{metrics_prefix}"\n' f' [overrides.manifest.platforms]\n' - f' {override_dir_name} = {platforms!r}' + f' {override_dir_name} = {platforms!r}', + markup=False, ) @@ -274,7 +280,7 @@ def _resolve_check_only_inputs( author_pkg = normalize_package_name(author_normalized) stripped = target_integration_dir.removeprefix(f'{author_pkg}_') - fields = prefill_check_only_fields(manifest_data, stripped) + fields = prefill_check_only_fields(manifest_data, stripped, author_normalized) return fields, target_integration_dir diff --git a/ddev/src/ddev/cli/create/_config_overrides.py b/ddev/src/ddev/cli/create/_config_overrides.py index b69a04309c46e..af86603a286dc 100644 --- a/ddev/src/ddev/cli/create/_config_overrides.py +++ b/ddev/src/ddev/cli/create/_config_overrides.py @@ -17,6 +17,10 @@ if TYPE_CHECKING: from ddev.cli.application import Application +# An override entry maps a directory name to its value: a string (display name, +# metrics prefix) or a list of strings (platforms). +OverrideValue = str | list[str] + def apply_manifestless_overrides( app: Application, @@ -44,6 +48,6 @@ def apply_manifestless_overrides( config_file.save_data(data) -def _set_entry(table: dict, key: str, dir_name: str, value: object) -> None: +def _set_entry(table: dict[str, dict[str, OverrideValue]], key: str, dir_name: str, value: OverrideValue) -> None: section = table.setdefault(key, {}) section[dir_name] = value diff --git a/ddev/src/ddev/cli/create/_scaffold.py b/ddev/src/ddev/cli/create/_scaffold.py index 712e3a1b6c990..97e331bcbc27f 100644 --- a/ddev/src/ddev/cli/create/_scaffold.py +++ b/ddev/src/ddev/cli/create/_scaffold.py @@ -21,7 +21,6 @@ get_config_models_documentation, get_license_header, kebab_case_name, - normalize_display_name, normalize_package_name, normalize_project_name, ) @@ -158,13 +157,19 @@ class CheckOnlyPrefillFields(TypedDict, total=False): sales_email: str -def prefill_check_only_fields(manifest: dict[str, Any], normalized_name: str) -> CheckOnlyPrefillFields: - """Extract reusable fields from a pre-existing `manifest.json` for a `check_only` integration.""" - author_name_raw = (manifest.get('author') or {}).get('name') - author = normalize_display_name(author_name_raw) if author_name_raw else None - check_name = normalize_package_name(f'{author}_{normalized_name}') if author is not None else None +def prefill_check_only_fields( + manifest: dict[str, Any], + normalized_name: str, + author_normalized: str, +) -> CheckOnlyPrefillFields: + """Extract reusable fields from a pre-existing `manifest.json` for a `check_only` integration. + + ``author_normalized`` is the already-validated, normalized author name resolved by the + caller; we reuse it for the package name instead of re-deriving it from the raw manifest. + """ + check_name = normalize_package_name(f'{author_normalized}_{normalized_name}') candidates: dict[str, str | None] = { - 'author_name': author, + 'author_name': author_normalized, 'check_name': check_name, 'email': (manifest.get('author') or {}).get('support_email'), 'homepage': (manifest.get('author') or {}).get('homepage'), @@ -215,7 +220,6 @@ def construct_template_fields( config: dict[str, Any] = { 'author': author, 'auto_install': 'false' if integration_type == 'metrics_crawler' else 'true', - 'check_class': f"{''.join(part.capitalize() for part in normalized_name.split('_'))}Check", 'check_name': check_name, 'project_name': normalize_project_name(normalized_name), 'documentation': get_config_models_documentation(), @@ -255,6 +259,16 @@ def construct_template_fields( ), } config.update(kwargs) + + # Derive check_class from the final check_name: for check_only, prefilled fields + # may supply a check_name that differs from normalized_name. + package_name = config['check_name'] or normalized_name + config['check_class'] = f"{''.join(part.capitalize() for part in package_name.split('_'))}Check" + + # An empty check_name would let template paths like '{check_name}/...' collapse to an + # absolute '/...', discarding the target root and scaffolding to the filesystem root. + # Upstream validation guarantees a non-empty name; assert it locally so a regression fails loudly. + assert config['check_name'], 'check_name must be populated before scaffolding' return config diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index 906668dc84da6..9027f7d984757 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -631,3 +631,56 @@ def test_non_check_only_partial_write_failure_recommends_deleting_directory(ddev assert result.exit_code != 0 assert 'Remove `' in result.output assert 'my_integration' in result.output + + +def test_malformed_repo_config_aborts_before_scaffolding(ddev, empty_repo): + """A malformed `.ddev/config.toml` aborts a manifest-less create before any files are written.""" + config_toml = empty_repo.path / '.ddev' / 'config.toml' + config_toml.ensure_parent_dir_exists() + config_toml.write_text('this is = not ][ valid toml') + + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + ) + assert result.exit_code != 0 + assert 'Fix or remove' in result.output + assert not (empty_repo.path / 'my_integration').exists() + + +def test_config_write_failure_prints_manual_override_instructions(ddev, empty_repo, monkeypatch): + """When `.ddev/config.toml` can't be written, the abort lists the three overrides to add by hand.""" + from ddev.repo.config import RepositoryConfig + + def boom(self, data): + raise OSError(28, 'No space left on device') + + monkeypatch.setattr(RepositoryConfig, 'save_data', boom) + + result = ddev( + 'create', + 'check', + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux,windows', + ) + assert result.exit_code != 0 + output = result.output + assert 'config.toml' in output + assert '[overrides.display-name]' in output + assert '[overrides.metrics-prefix]' in output + assert '[overrides.manifest.platforms]' in output + assert 'my_integration = "My Integration"' in output + assert 'my_integration = "my_integration."' in output + assert "my_integration = ['linux', 'windows']" in output From 15b0382e8907ac22b14b90c7f329221905035e5f Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Fri, 29 May 2026 11:57:41 +0200 Subject: [PATCH 12/13] Test that create rejects --type= with an empty value --- ddev/tests/cli/create/test_create.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index 9027f7d984757..8e5700e1fd846 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -459,6 +459,15 @@ def test_type_flag_without_value_aborts_with_targeted_message(ddev, empty_repo): assert 'requires a value' in result.output +def test_type_flag_empty_after_equals_aborts_with_targeted_message(ddev, empty_repo): + """`ddev create NAME --type=` (empty value after the equals) is a missing value, not the type name ``.""" + result = ddev('create', 'my_integration', '--type=') + assert result.exit_code != 0 + assert '--type' in result.output + assert 'requires a value' in result.output + assert 'Unknown integration type' not in result.output + + def test_global_quiet_suppresses_tree_and_keeps_headline(ddev, empty_repo): """`ddev -q create ...` suppresses the file-tree printout but still emits the one-line `Created` headline.""" result = ddev( From 7f4080d45bfb948cf56b5dd0d95f26c0a99338ee Mon Sep 17 00:00:00 2001 From: Juanpe Araque Date: Fri, 29 May 2026 14:13:42 +0200 Subject: [PATCH 13/13] Accept the legacy --type flag before the positional name The deprecation shim resolved --type inside the group's command resolution, which click only reaches after its option parser runs. When --type appeared before the name (the form the pre-migration docs used, e.g. `ddev create --type jmx NAME`), the parser rejected it with "No such option" before the shim could dispatch. Setting ignore_unknown_options on the group lets the flag reach the shim in any position; subcommand option typos are still rejected because that setting does not propagate to the resolved subcommand. --- ddev/src/ddev/cli/create/__init__.py | 6 ++++- ddev/tests/cli/create/test_create.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/ddev/src/ddev/cli/create/__init__.py b/ddev/src/ddev/cli/create/__init__.py index b52be5feb567f..dc775900d9154 100644 --- a/ddev/src/ddev/cli/create/__init__.py +++ b/ddev/src/ddev/cli/create/__init__.py @@ -170,7 +170,11 @@ def _strip_type_flag(args: list[str]) -> list[str]: @click.group( cls=_CreateGroup, short_help='Scaffold a new integration', - context_settings={'help_option_names': ['-h', '--help']}, + # ignore_unknown_options lets the legacy `--type` / `-t` flag survive the group's + # option parser when it appears before the positional name (e.g. the previously + # documented `ddev create --type jmx NAME`). Without it, click rejects `--type` + # with "No such option" before resolve_command's deprecation shim ever runs. + context_settings={'help_option_names': ['-h', '--help'], 'ignore_unknown_options': True}, ) def create() -> None: """Scaffold a new integration. diff --git a/ddev/tests/cli/create/test_create.py b/ddev/tests/cli/create/test_create.py index 8e5700e1fd846..d48ff6605c905 100644 --- a/ddev/tests/cli/create/test_create.py +++ b/ddev/tests/cli/create/test_create.py @@ -468,6 +468,46 @@ def test_type_flag_empty_after_equals_aborts_with_targeted_message(ddev, empty_r assert 'Unknown integration type' not in result.output +@pytest.mark.parametrize( + 'type_args', + [ + pytest.param(['--type', 'jmx'], id='long-space'), + pytest.param(['--type=jmx'], id='long-equals'), + pytest.param(['-t', 'jmx'], id='short-space'), + ], +) +def test_type_shim_accepts_legacy_prefix_position(ddev, empty_repo, type_args): + """The legacy `ddev create --type jmx NAME` form (flag before the name) still dispatches. + + The shim resolves `--type` inside the group's command resolution, which click only reaches + if its option parser does not reject the unknown flag first; this covers the flag appearing + before the positional name, the form the pre-migration docs used. + """ + result = ddev( + 'create', + *type_args, + 'my_integration', + '--display-name', + 'My Integration', + '--metrics-prefix', + 'my_integration.', + '--platforms', + 'linux', + '--dry-run', + ) + assert result.exit_code == 0, result.output + assert 'deprecated' in result.output + assert 'No such option' not in result.output + + +def test_type_shim_prefix_position_aborts_for_dropped_type(ddev, empty_repo): + """A dropped type in the legacy prefix position still aborts with the manifest-less pointer.""" + result = ddev('create', '--type', 'tile', 'my_integration') + assert result.exit_code != 0 + assert 'no longer supported' in result.output + assert 'No such option' not in result.output + + def test_global_quiet_suppresses_tree_and_keeps_headline(ddev, empty_repo): """`ddev -q create ...` suppresses the file-tree printout but still emits the one-line `Created` headline.""" result = ddev(