Skip to content

Commit ff6ed7e

Browse files
committed
Split test-agent helpers into validation/images/dispatch modules
Move the supporting logic for `ddev release test-agent` into sibling modules so the command file reads as the orchestration story it tells: - validation.py — input regex checks, git ref existence on origin, and workflow-file presence on the resolved ref (including the file-missing vs ref-not-fetched vs unknown-git-failure dispatch in the error path). - images.py — RC version resolution, image ref construction, manifest existence checks, and the registry_errors context manager that translates httpx errors into clean abort messages. - dispatch.py — the parallel workflow_dispatch orchestration. The async coroutine is nested inside dispatch_both so the reader sees the full flow in one function rather than bouncing between two near-empty stack frames. extract_run_urls keeps the partial/total-failure surface next to the dispatch. `__init__.py` now contains only the Click command plus the small `_print_plan`/`_print_result` display helpers. Every sibling module is imported lazily inside the command body so `ddev --help` only pays for `click` from this package. Also narrow `AsyncGitHubClient.create_workflow_dispatch`'s `inputs` parameter from `dict[str, Any] | None` to `dict[str, str] | None` across both `@overload`s and the implementation. The workflow_dispatch API contract is string-to-string (booleans are matched against the lowercase string form), every in-tree caller already passes a `dict[str, str]`, and the wider type silently accepted values that would surface as runtime 422s from GitHub. The fake test client mirror is updated to match.
1 parent bba8926 commit ff6ed7e

6 files changed

Lines changed: 291 additions & 247 deletions

File tree

Lines changed: 24 additions & 243 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,22 @@
11
# (C) Datadog, Inc. 2026-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4-
"""`ddev release test-agent` — dispatch the Linux + Windows Agent test workflows."""
4+
"""`ddev release test-agent` — dispatch the Linux + Windows Agent test workflows.
5+
6+
The orchestration body lives here so the file reads top-to-bottom as the command's story.
7+
Each step delegates to a sibling module (`validation`, `images`, `dispatch`), and every
8+
helper module is imported lazily inside the function body so `ddev --help` only pays for
9+
`click` from this package.
10+
"""
511

612
from __future__ import annotations
713

8-
import asyncio
9-
import contextlib
10-
import re
1114
from typing import TYPE_CHECKING
1215

1316
import click
1417

1518
if TYPE_CHECKING:
16-
from collections.abc import Iterator, Sequence
17-
1819
from ddev.cli.application import Application
19-
from ddev.utils.github_async import GitHubResponse
20-
from ddev.utils.github_async.models import WorkflowDispatchResult
21-
22-
DispatchOutcome = GitHubResponse[WorkflowDispatchResult] | BaseException
23-
24-
BRANCH_PATTERN = r'^\d+\.\d+\.x$'
25-
TAG_PATTERN = r'^\d+\.\d+\.\d+(-rc\.\d+)?$'
26-
27-
WORKFLOW_LINUX = 'test-agent.yml'
28-
WORKFLOW_WINDOWS = 'test-agent-windows.yml'
29-
WORKFLOW_FILES = [
30-
f'.github/workflows/{WORKFLOW_LINUX}',
31-
f'.github/workflows/{WORKFLOW_WINDOWS}',
32-
]
33-
34-
# Hard-coded: the two test workflows only live on DataDog/integrations-core. Forks and other
35-
# integrations repos (extras, marketplace) have nothing to dispatch even if the branch/tag exists,
36-
# so deferring either component to repo metadata would just hide misconfiguration. If we ever
37-
# ship the workflows elsewhere, plumb the target through here.
38-
REPO_OWNER = 'DataDog'
39-
REPO_NAME = 'integrations-core'
40-
41-
# git error fragments that mean "ref exists but file is not in that tree" — i.e. the workflow
42-
# really isn't on this branch/tag, as opposed to the ref itself being unreachable locally.
43-
GIT_FILE_MISSING_FRAGMENTS = (
44-
'exists on disk',
45-
'does not exist',
46-
'no such path',
47-
)
48-
GIT_REF_MISSING_FRAGMENTS = (
49-
'invalid object name',
50-
'unknown revision',
51-
'bad revision',
52-
'ambiguous argument',
53-
)
5420

5521

5622
@click.command('test-agent', short_help='Dispatch the Agent test workflows against a branch or tag')
@@ -67,19 +33,27 @@ def test_agent(app: Application, branch: str | None, tag: str | None, dry_run: b
6733
When `--tag` is given, that exact tag is used. Linux and Windows (servercore) variants are
6834
both validated against the registry before either workflow is dispatched.
6935
"""
70-
branch, tag = _validate_input(app, branch, tag)
36+
from ddev.cli.release.test_agent.dispatch import dispatch_both
37+
from ddev.cli.release.test_agent.images import build_image_refs, resolve_version, validate_images_exist
38+
from ddev.cli.release.test_agent.validation import (
39+
validate_input,
40+
verify_ref_exists,
41+
verify_workflows_present_on_ref,
42+
)
43+
44+
branch, tag = validate_input(app, branch, tag)
7145
ref = branch or tag
7246
assert ref is not None
7347

7448
if not app.config.github.token:
7549
app.abort('GitHub token required. Set `github.token` via `ddev config set github.token <token>`.')
7650

77-
_verify_ref_exists(app, branch=branch, tag=tag)
78-
_verify_workflows_present_on_ref(app, branch=branch, tag=tag)
51+
verify_ref_exists(app, branch=branch, tag=tag)
52+
verify_workflows_present_on_ref(app, branch=branch, tag=tag)
7953

80-
version = _resolve_version(app, branch=branch, tag=tag)
81-
_validate_images_exist(app, version)
82-
linux_image, windows_image = _build_image_refs(version)
54+
version = resolve_version(app, branch=branch, tag=tag)
55+
validate_images_exist(app, version)
56+
linux_image, windows_image = build_image_refs(version)
8357

8458
# GitHub's workflow_dispatch API expects every value in `inputs` to be a string, even for
8559
# `type: boolean` workflow inputs — booleans are parsed from the lowercase string form.
@@ -102,139 +76,13 @@ def test_agent(app: Application, branch: str | None, tag: str | None, dry_run: b
10276
app.abort('Aborted by user.')
10377

10478
try:
105-
linux_url, windows_url = _dispatch_both(app.config.github.token, ref=ref, inputs=inputs)
79+
linux_url, windows_url = dispatch_both(app.config.github.token, ref=ref, inputs=inputs)
10680
except RuntimeError as e:
10781
app.abort(str(e))
10882
else:
10983
_print_result(app, linux_url=linux_url, windows_url=windows_url)
11084

11185

112-
def _validate_input(app: Application, branch: str | None, tag: str | None) -> tuple[str | None, str | None]:
113-
"""Normalize and validate inputs, returning (branch, tag) with at most one set."""
114-
if bool(branch) == bool(tag):
115-
app.abort('Exactly one of --branch or --tag must be provided.')
116-
117-
if branch is not None and not re.match(BRANCH_PATTERN, branch):
118-
app.abort(f'Invalid branch: {branch!r}. Must match {BRANCH_PATTERN}.')
119-
120-
if tag is not None:
121-
normalized = tag.removeprefix('v')
122-
if not re.match(TAG_PATTERN, normalized):
123-
app.abort(f'Invalid tag: {tag!r}. Must match {TAG_PATTERN}.')
124-
tag = normalized
125-
126-
return branch, tag
127-
128-
129-
def _verify_ref_exists(app: Application, *, branch: str | None, tag: str | None) -> None:
130-
"""Confirm the ref is published on origin via `git ls-remote`."""
131-
if branch is not None:
132-
kind, value, flag = 'branch', branch, '--heads'
133-
else:
134-
assert tag is not None
135-
kind, value, flag = 'tag', tag, '--tags'
136-
137-
output = app.repo.git.capture('ls-remote', flag, 'origin', value)
138-
if not output.strip():
139-
app.abort(f'{kind.capitalize()} `{value}` not found on origin.')
140-
141-
142-
def _verify_workflows_present_on_ref(app: Application, *, branch: str | None, tag: str | None) -> None:
143-
"""Confirm both workflow files exist at the target ref.
144-
145-
`git show <ref>:<path>` only resolves against local refs, so a branch the user has not yet
146-
fetched will not be found under its bare name. For branches we read `origin/<branch>` to
147-
consult the remote-tracking ref; for tags we use the tag name directly. Either way, the
148-
git error text is inspected to distinguish "file missing from the tree" from "ref not
149-
local" so the abort message points at the real problem.
150-
"""
151-
if branch is not None:
152-
local_ref = f'origin/{branch}'
153-
fetch_hint = f'Run `git fetch origin {branch}` and try again.'
154-
else:
155-
assert tag is not None
156-
local_ref = tag
157-
fetch_hint = f'Run `git fetch origin tag {tag}` and try again.'
158-
159-
missing: list[str] = []
160-
for path in WORKFLOW_FILES:
161-
try:
162-
app.repo.git.show_file(path, local_ref)
163-
except OSError as e:
164-
msg = str(e).lower()
165-
if any(fragment in msg for fragment in GIT_FILE_MISSING_FRAGMENTS):
166-
missing.append(path)
167-
elif any(fragment in msg for fragment in GIT_REF_MISSING_FRAGMENTS):
168-
app.abort(f'Ref `{local_ref}` is not in your local clone. {fetch_hint} (git error: {e})')
169-
else:
170-
app.abort(f'Failed to read `{path}` from `{local_ref}`: {e}')
171-
172-
if missing:
173-
app.abort(
174-
f'Ref `{local_ref}` is missing required workflow file(s): {", ".join(missing)}. '
175-
'Pick a newer ref that includes both `test-agent.yml` and `test-agent-windows.yml`.'
176-
)
177-
178-
179-
@contextlib.contextmanager
180-
def _registry_errors(app: Application, target: str) -> Iterator[None]:
181-
"""Translate any `httpx.HTTPError` raised inside the block into a clean `app.abort` message.
182-
183-
`target` is interpolated into the abort text — e.g. `'tags'` for the tag listing or an
184-
image ref like `'registry.datadoghq.com/agent:7.80.0-rc.3'` for a manifest probe.
185-
"""
186-
import httpx
187-
188-
try:
189-
yield
190-
except httpx.HTTPError as e:
191-
app.abort(f'Failed to query registry.datadoghq.com for {target}: {e}')
192-
193-
194-
def _resolve_version(app: Application, *, branch: str | None, tag: str | None) -> str:
195-
"""Pick the Agent image tag to test: the explicit tag, or the highest published RC for a branch."""
196-
if tag is not None:
197-
return tag
198-
199-
assert branch is not None
200-
from ddev.cli.release.test_agent.registry import list_agent_rc_tags
201-
202-
major_str, minor_str, _ = branch.split('.')
203-
major, minor = int(major_str), int(minor_str)
204-
205-
app.display_waiting(f'Looking up latest {major}.{minor}.0-rc.* in registry.datadoghq.com...')
206-
with _registry_errors(app, 'tags'):
207-
tags = list_agent_rc_tags(major, minor)
208-
if not tags:
209-
app.abort(
210-
f'No `{major}.{minor}.0-rc.*` tags found in registry.datadoghq.com/agent. '
211-
'Pass --tag explicitly once the first RC is published.'
212-
)
213-
latest = tags[-1]
214-
app.display_success(f'Latest RC: {latest}')
215-
return latest
216-
217-
218-
def _build_image_refs(version: str) -> tuple[str, str]:
219-
base = f'registry.datadoghq.com/agent:{version}'
220-
return base, f'{base}-servercore'
221-
222-
223-
def _validate_images_exist(app: Application, version: str) -> None:
224-
"""Check that both the Linux (`<version>`) and Windows (`<version>-servercore`) manifests are published."""
225-
from ddev.cli.release.test_agent.registry import manifest_exists
226-
227-
for tag in (version, f'{version}-servercore'):
228-
image = f'registry.datadoghq.com/agent:{tag}'
229-
app.display_waiting(f'Checking `{image}`...')
230-
with _registry_errors(app, f'`{image}`'):
231-
exists = manifest_exists(tag)
232-
if not exists:
233-
app.abort(
234-
f'Image `{image}` not found in registry.datadoghq.com. Confirm the Agent release has been published.'
235-
)
236-
237-
23886
def _print_plan(
23987
app: Application,
24088
*,
@@ -249,6 +97,8 @@ def _print_plan(
24997
plan on the same channel means piping the command into a file leaves stdout clean and
25098
keeps the whole pre-dispatch narrative coherent on stderr.
25199
"""
100+
from ddev.cli.release.test_agent.dispatch import WORKFLOW_LINUX, WORKFLOW_WINDOWS
101+
252102
app.display_info('Dispatch plan')
253103
app.display_info(f' Workflows: {WORKFLOW_LINUX}, {WORKFLOW_WINDOWS}')
254104
app.display_info(f' Ref: {ref}')
@@ -262,72 +112,3 @@ def _print_result(app: Application, *, linux_url: str, windows_url: str) -> None
262112
app.display_success('Workflows dispatched.')
263113
app.display_pair('Linux', linux_url)
264114
app.display_pair('Windows', windows_url)
265-
266-
267-
def _dispatch_both(token: str, *, ref: str, inputs: dict[str, str]) -> tuple[str, str]:
268-
"""Dispatch both workflows in parallel via the async GitHub client. Returns (linux_url, windows_url)."""
269-
results = asyncio.run(_dispatch_both_async(token, REPO_OWNER, REPO_NAME, ref, inputs))
270-
return _extract_run_urls(results)
271-
272-
273-
async def _dispatch_both_async(
274-
token: str,
275-
owner: str,
276-
repo: str,
277-
ref: str,
278-
inputs: dict[str, str],
279-
) -> Sequence[DispatchOutcome]:
280-
from ddev.utils.github_async import async_github_client
281-
282-
async with async_github_client(token=token) as client:
283-
return await asyncio.gather(
284-
client.create_workflow_dispatch(
285-
owner=owner,
286-
repo=repo,
287-
workflow_id=WORKFLOW_LINUX,
288-
ref=ref,
289-
inputs=inputs,
290-
return_run_details=True,
291-
),
292-
client.create_workflow_dispatch(
293-
owner=owner,
294-
repo=repo,
295-
workflow_id=WORKFLOW_WINDOWS,
296-
ref=ref,
297-
inputs=inputs,
298-
return_run_details=True,
299-
),
300-
return_exceptions=True,
301-
)
302-
303-
304-
def _extract_run_urls(results: Sequence[DispatchOutcome]) -> tuple[str, str]:
305-
"""Pull html_urls out of two gather results, raising on any exception with a partial-success hint.
306-
307-
`asyncio.gather(return_exceptions=True)` captures `CancelledError`/`KeyboardInterrupt`
308-
(`BaseException` subclasses, not `Exception`) into its result list. Re-raise those first
309-
so flow-control exceptions propagate cleanly instead of being wrapped in `RuntimeError`.
310-
"""
311-
linux_result, windows_result = results
312-
313-
for result in (linux_result, windows_result):
314-
if isinstance(result, BaseException) and not isinstance(result, Exception):
315-
raise result
316-
317-
if isinstance(linux_result, BaseException):
318-
if isinstance(windows_result, BaseException):
319-
raise RuntimeError(
320-
f'Both dispatches failed. Linux: {linux_result!r}. Windows: {windows_result!r}.'
321-
) from linux_result
322-
sibling = windows_result.data.html_url
323-
raise RuntimeError(
324-
f'Linux dispatch failed: {linux_result}. The other workflow was dispatched at {sibling}.'
325-
) from linux_result
326-
327-
if isinstance(windows_result, BaseException):
328-
sibling = linux_result.data.html_url
329-
raise RuntimeError(
330-
f'Windows dispatch failed: {windows_result}. The other workflow was dispatched at {sibling}.'
331-
) from windows_result
332-
333-
return linux_result.data.html_url, windows_result.data.html_url

0 commit comments

Comments
 (0)