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
612from __future__ import annotations
713
8- import asyncio
9- import contextlib
10- import re
1114from typing import TYPE_CHECKING
1215
1316import click
1417
1518if 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-
23886def _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