diff --git a/ddev/changelog.d/23859.added b/ddev/changelog.d/23859.added new file mode 100644 index 0000000000000..717cce86a1cbd --- /dev/null +++ b/ddev/changelog.d/23859.added @@ -0,0 +1 @@ +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/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. 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..dc775900d9154 --- /dev/null +++ b/ddev/src/ddev/cli/create/__init__.py @@ -0,0 +1,195 @@ +# (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 enum + +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]]: + from ddev.cli.application import Application + + if not args: + return super().resolve_command(ctx, args) + + first = args[0] + if self.get_command(ctx, first) is not None: + return super().resolve_command(ctx, args) + + 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. + if legacy_type is None: + 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: + app.abort( + f'`--type={legacy_type}` is no longer supported. ' + f'Use the manifest-less workflow described at {CONFLUENCE_NO_MANIFEST_URL}.' + ) + + # 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: + app.abort(f'Unknown integration type: `{legacy_type}`.') + + subcommand = self.get_command(ctx, target) + if subcommand is None: + app.abort(f'Internal error: subcommand `{target}` not registered.') + assert subcommand is not None # for the type checker; abort above is NoReturn + + 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.' + ) + + cleaned = _strip_type_flag(args) + return subcommand.name, subcommand, cleaned + + +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 +# new spelling is ever added. +_TYPE_FLAG_LITERALS: tuple[str, ...] = ('--type', '-t') +_TYPE_FLAG_EQUALS_PREFIXES: tuple[str, ...] = ('--type=', '-t=') + + +def _extract_legacy_type(args: list[str]) -> str | _TypeFlagSentinel | 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_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` + # as the type name. + if isinstance(value, str) and value.startswith('-'): + return _MISSING_TYPE_VALUE + return value + for prefix in _TYPE_FLAG_EQUALS_PREFIXES: + if token.startswith(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 + + +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 in _TYPE_FLAG_LITERALS: + skip_next = True + continue + if token.startswith(_TYPE_FLAG_EQUALS_PREFIXES): + continue + if _is_concatenated_short_type(token): + continue + cleaned.append(token) + return cleaned + + +@click.group( + cls=_CreateGroup, + short_help='Scaffold a new integration', + # 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. + + 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..0d23ca8bd909e --- /dev/null +++ b/ddev/src/ddev/cli/create/_common.py @@ -0,0 +1,297 @@ +# (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 +from typing import TYPE_CHECKING, Any, Callable + +import click + +from ddev.cli.create._naming import is_valid_integration_name, normalize_package_name + +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') + + +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('--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, + *, + integration_type: str, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms_csv: str | None, + location: str | None, + dry_run: bool, + include_manifest: bool, + skip_manifest: bool, +) -> None: + """Single entry point shared by all per-type subcommands.""" + _validate_integration_name(app, name) + + 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.' + ) + + 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 + + render_kwargs: dict[str, Any] = { + 'location': location, + 'dry_run': dry_run, + 'include_manifest': include_manifest, + 'extra_fields': extra_fields, + 'target_integration_dir': target_integration_dir, + } + + 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, + name=name, + display_name=display_name, + metrics_prefix=metrics_prefix, + platforms_csv=platforms_csv, + ) + + 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: Path, + override_dir_name: str, + display_name: str, + metrics_prefix: str, + platforms: list[str], +) -> None: + from ddev.cli.create._config_overrides import apply_manifestless_overrides + + try: + apply_manifestless_overrides( + app, + dir_name=override_dir_name, + display_name=display_name, + metrics_prefix=metrics_prefix, + 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 ' + f'overrides were not recorded. Add these entries by hand:\n' + f' [overrides.display-name]\n' + f' {override_dir_name} = "{display_name}"\n' + f' [overrides.metrics-prefix]\n' + f' {override_dir_name} = "{metrics_prefix}"\n' + f' [overrides.manifest.platforms]\n' + f' {override_dir_name} = {platforms!r}', + markup=False, + ) + + +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( + app: Application, + *, + name: str, + display_name: str | None, + metrics_prefix: str | None, + platforms_csv: str | None, +) -> 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 app.interactive: + display_name = app.prompt('Display name', default=suggested_display) + else: + missing.append('--display-name') + if metrics_prefix is None: + if app.interactive: + metrics_prefix = app.prompt('Metrics prefix', default=suggested_prefix) + else: + missing.append('--metrics-prefix') + if platforms_csv is None: + if app.interactive: + platforms_csv = app.prompt('Platforms (comma-separated)', default=suggested_platforms_csv) + else: + missing.append('--platforms') + + if 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 + 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, +) -> 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 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 + from ddev.utils.fs import Path + + target_integration_dir = normalize_package_name(name) + root = Path(location).resolve() if location else app.repo.path + integration_dir = root / target_integration_dir + manifest_path = integration_dir / 'manifest.json' + + if not manifest_path.is_file(): + app.abort(f'Expected {manifest_path} to exist') + + try: + manifest_data = json.loads(manifest_path.read_text()) + 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_raw = (manifest_data.get('author') or {}).get('name') + 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') + + # `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, author_normalized) + 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/_config_overrides.py b/ddev/src/ddev/cli/create/_config_overrides.py new file mode 100644 index 0000000000000..af86603a286dc --- /dev/null +++ b/ddev/src/ddev/cli/create/_config_overrides.py @@ -0,0 +1,53 @@ +# (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 + +# 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, + 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[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/_naming.py b/ddev/src/ddev/cli/create/_naming.py new file mode 100644 index 0000000000000..05d3eb09a5d64 --- /dev/null +++ b/ddev/src/ddev/cli/create/_naming.py @@ -0,0 +1,60 @@ +# (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 + +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).""" + 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..97e331bcbc27f --- /dev/null +++ b/ddev/src/ddev/cli/create/_scaffold.py @@ -0,0 +1,500 @@ +# (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, TypedDict, cast +from uuid import uuid4 + +from ddev.cli.create._naming import ( + get_config_models_documentation, + get_license_header, + kebab_case_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/ +""" + +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, + '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. + + 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 + 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() + 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: + 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: + self.target_path.write_bytes(self.contents) # type: ignore[arg-type] + else: + self.target_path.write_text(self.contents) # type: ignore[arg-type] + + +@dataclass +class ScaffoldResult: + integration_dir: Path + files: list[TemplateFile] + config: dict[str, Any] = field(default_factory=dict) + + +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, + 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_normalized, + '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 cast(CheckOnlyPrefillFields, {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_template = INTEGRATION_TYPE_LINKS.get(integration_type, '') + integration_links = integration_links_template.format( + name=normalized_name, + repository='integrations-core', + ) + + config: dict[str, Any] = { + 'author': author, + 'auto_install': 'false' if integration_type == 'metrics_crawler' else 'true', + '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) + + # 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 + + +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], + *, + target_integration_dir: str, + include_manifest: bool, + read: bool, +) -> list[TemplateFile]: + """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] = [] + template_check_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 + + 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), target_integration_dir): + 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 _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(): + 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, + include_manifest: bool, + extra_fields: dict[str, Any] | None = None, + target_integration_dir: 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). 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 + 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) + + files = collect_template_files( + integration_type, + root, + config, + target_integration_dir=integration_dir_name, + include_manifest=include_manifest, + read=not dry_run, + ) + + if dry_run: + 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 app.quiet: + app.display(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 _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 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: + 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: + 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 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..0bf21f9d48744 --- /dev/null +++ b/ddev/src/ddev/cli/create/check.py @@ -0,0 +1,21 @@ +# (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, 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') +@create_options +@click.pass_obj +def check(app: Application, **options: Any) -> None: + """Scaffold a check-based integration.""" + 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 new file mode 100644 index 0000000000000..ba5781fd80008 --- /dev/null +++ b/ddev/src/ddev/cli/create/check_only.py @@ -0,0 +1,21 @@ +# (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, 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') +@create_options +@click.pass_obj +def check_only(app: Application, **options: Any) -> None: + """Add Python check scaffolding to an existing integration directory.""" + 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 new file mode 100644 index 0000000000000..3584212fbb76f --- /dev/null +++ b/ddev/src/ddev/cli/create/event.py @@ -0,0 +1,21 @@ +# (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, 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') +@create_options +@click.pass_obj +def event(app: Application, **options: Any) -> None: + """Scaffold an event-only integration.""" + dispatch(app, integration_type='event', **options) diff --git a/ddev/src/ddev/cli/create/jmx.py b/ddev/src/ddev/cli/create/jmx.py new file mode 100644 index 0000000000000..470629f2b3db8 --- /dev/null +++ b/ddev/src/ddev/cli/create/jmx.py @@ -0,0 +1,21 @@ +# (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, 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') +@create_options +@click.pass_obj +def jmx(app: Application, **options: Any) -> None: + """Scaffold a JMX-based integration.""" + dispatch(app, integration_type='jmx', **options) diff --git a/ddev/src/ddev/cli/create/logs.py b/ddev/src/ddev/cli/create/logs.py new file mode 100644 index 0000000000000..0295364ccce06 --- /dev/null +++ b/ddev/src/ddev/cli/create/logs.py @@ -0,0 +1,21 @@ +# (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, 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') +@create_options +@click.pass_obj +def logs(app: Application, **options: Any) -> None: + """Scaffold a logs-only integration.""" + 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 new file mode 100644 index 0000000000000..93d40e934ef44 --- /dev/null +++ b/ddev/src/ddev/cli/create/metrics_crawler.py @@ -0,0 +1,21 @@ +# (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, 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') +@create_options +@click.pass_obj +def metrics_crawler(app: Application, **options: Any) -> None: + """Scaffold a metrics-crawler integration.""" + dispatch(app, integration_type='metrics_crawler', **options) 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..50d02c7575eb2 --- /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(): + return False + + +def instance_collect_default_jvm_metrics(): + return True + + +def instance_empty_default_hostname(): + return False + + +def instance_min_collection_interval(): + return 15 + + +def instance_rmi_client_timeout(): + return 15000 + + +def instance_rmi_connection_timeout(): + return 20000 + + +def instance_rmi_registry_ssl(): + 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..1b0e74a8d26bb --- /dev/null +++ b/ddev/src/ddev/cli/create/templates/jmx/{check_name}/tests/metrics.py @@ -0,0 +1,6 @@ +{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..622d7a4e64519 --- /dev/null +++ b/ddev/tests/cli/create/conftest.py @@ -0,0 +1,54 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# 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 + + +@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 + + +@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 + + +@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 new file mode 100644 index 0000000000000..d48ff6605c905 --- /dev/null +++ b/ddev/tests/cli/create/test_create.py @@ -0,0 +1,735 @@ +# (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 + + +@pytest.mark.parametrize( + 'subcommand', + ['check', 'jmx', 'logs', 'event', 'metrics-crawler'], +) +def test_default_manifestless_writes_overrides_and_skips_manifest(ddev, empty_repo, read_config, 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() + + 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_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_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 + + +@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( + '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, read_config): + 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 = read_config() + 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 _write_partner_manifest(integration_dir): + 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', + } + } + ) + ) + + +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', + ) + assert result.exit_code == 0, result.output + + +def test_check_only_writes_into_existing_author_prefixed_directory(ddev, empty_repo): + """`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) + + 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_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' + _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, not a sibling stripped-name directory. + assert (integration_dir / 'pyproject.toml').is_file() + assert not (empty_repo.path / 'thing').exists() + + 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'] + + +def test_bare_positional_aborts_with_subcommand_hint(ddev, empty_repo): + """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. + 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): + """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 + assert '--metrics-prefix' in result.output + 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') + assert result.exit_code != 0 + assert '--type' in result.output + 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 + + +@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( + '-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( + '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 + + +def test_jmx_template_defaults_take_no_arguments(ddev, empty_repo, tmp_path): + """The scaffolded JMX `defaults.py` defines zero-argument functions. + + `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 + + 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): + """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". + 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): + """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"]') + + result = ddev( + 'create', + 'check-only', + 'partner_thing', + '--include-manifest', + ) + assert result.exit_code != 0 + assert 'does not contain a JSON object' in result.output + + +@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": {json_author_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, 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() + (integration_dir / 'manifest.json').write_text( + json.dumps({'author': {'name': 'Partner', 'support_email': 'p@p.com'}}) + ) + + 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, fail_on_second_write): + """For `check` (and other types where the dir was freshly created), the message stays directory-scoped.""" + 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 + + +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 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: 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. 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