diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 84efcbd16e6f9..f4ba387e7d26c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -816,7 +816,7 @@ plaid/assets/logs/ @DataDog/saa
/anomali_threatstream/ @DataDog/saas-integrations
/anomali_threatstream/*.md @DataDog/saas-integrations @DataDog/documentation
/anomali_threatstream/manifest.json @DataDog/saas-integrations @DataDog/documentation
-/anomali_threatstream/assets/logs/ @DataDog/saas-integrations @DataDog/documentation @DataDog/logs-integrations-reviewers
+
/palo_alto_networks_cortex_xsoar/ @DataDog/saas-integrations
/palo_alto_networks_cortex_xsoar/*.md @DataDog/saas-integrations @DataDog/documentation
diff --git a/anomali_threatstream/README.md b/anomali_threatstream/README.md
index b91cf9e636d68..bc1c9492a145b 100644
--- a/anomali_threatstream/README.md
+++ b/anomali_threatstream/README.md
@@ -1,39 +1,41 @@
-# Agent Check: Anomali ThreatStream
-
## Overview
-This check monitors [Anomali ThreatStream][1].
-
-## Setup
-
-### Installation
+[Anomali ThreatStream][1] is a threat intelligence platform (TIP) that automates the collection, curation, and analysis of threat data from global, open-source, and premium feeds.
-The Anomali ThreatStream check is included in the [Datadog Agent][2] package.
-No additional installation is needed on your server.
+This integration collects the following indicator types:
-### Configuration
+- IPv4
+- Domain
+- SHA256
-!!! Add list of steps to set up this integration !!!
+Integrate Anomali ThreatStream with Datadog to enhance your security logs with threat intelligence, enabling analysis of matched Indicators of Compromise (IOCs) through pre-built dashboards. Additionally, the integration can be used for Cloud SIEM detection rules for enhanced monitoring and security.
-### Validation
-
-!!! Add steps to validate integration is functioning as expected !!!
-
-## Data Collected
+## Setup
-### Metrics
+### Obtaining Anomali ThreatStream API credentials and domain
-Anomali ThreatStream does not include any metrics.
+1. Log in to the Anomali ThreatStream instance.
+2. Navigate to **Settings** > **My profile**.
+3. Under **Account Information**, click **Reveal** next to the **API Key** and copy it. Also, copy your **Email**.
+4. Identify your Anomali ThreatStream Domain using the URL of your Anomali ThreatStream instance.
+ - For example, if your Anomali ThreatStream instance URL is `https://ui.threatstream.com/`, then your Anomali ThreatStream domain is `ui.threatstream.com`.
-### Events
+### Connect your Anomali ThreatStream account to Datadog
-Anomali ThreatStream does not include any events.
+1. Provide the following details:
+ | Parameter | Description |
+ | ---------- | ---------------------------------------------- |
+ | Domain | Your Anomali ThreatStream domain. |
+ | Email | Email address associated with your ThreatStream account. |
+ | API Key | API key of your Anomali ThreatStream account. |
+ | Collect IPv4 IOCs | Enable to collect IPv4 IOCs. The default value is `true`. |
+ | Collect Domain IOCs | Enable to collect Domain IOCs. The default value is `true`. |
+ | Collect SHA256 IOCs | Enable to collect SHA256 IOCs. The default value is `true`. |
+2. Click **Save**.
## Troubleshooting
-Need help? Contact [Datadog support][3].
-
-[1]: **LINK_TO_INTEGRATION_SITE**
-[2]: https://app.datadoghq.com/account/settings/agent/latest
-[3]: https://docs.datadoghq.com/help/
+Need help? Contact [Datadog support][2].
+[1]: https://www.anomali.com/products/threatstream
+[2]: https://docs.datadoghq.com/help/
diff --git a/anomali_threatstream/assets/anomali_threatstream.svg b/anomali_threatstream/assets/anomali_threatstream.svg
new file mode 100644
index 0000000000000..34bc04f63b4d2
--- /dev/null
+++ b/anomali_threatstream/assets/anomali_threatstream.svg
@@ -0,0 +1,18 @@
+
diff --git a/anomali_threatstream/assets/dashboards/anomali_threatstream_threat_intelligence.json b/anomali_threatstream/assets/dashboards/anomali_threatstream_threat_intelligence.json
new file mode 100644
index 0000000000000..2fef9e7f6e2d5
--- /dev/null
+++ b/anomali_threatstream/assets/dashboards/anomali_threatstream_threat_intelligence.json
@@ -0,0 +1,1880 @@
+{
+ "title": "Anomali ThreatStream Threat Intelligence",
+ "description": "Comprehensive dashboard for monitoring Anomali ThreatStream Threat Intelligence enrichment. Provides IOC detection trends, geographic analysis for IP indicators, detailed drill-down tables, and correlated Cloud SIEM Security Signals.",
+ "widgets": [
+ {
+ "id": 1000111111111111,
+ "definition": {
+ "type": "image",
+ "url": "https://static.datadoghq.com/static/images/logos/anomali-threatstream_small.svg",
+ "url_dark_theme": "https://static.datadoghq.com/static/images/logos/anomali-threatstream_reversed_small.svg",
+ "sizing": "contain",
+ "margin": "sm",
+ "has_background": false,
+ "has_border": false,
+ "vertical_align": "center",
+ "horizontal_align": "center"
+ },
+ "layout": {
+ "x": 0,
+ "y": 0,
+ "width": 4,
+ "height": 3
+ }
+ },
+ {
+ "id": 1000222222222222,
+ "definition": {
+ "type": "note",
+ "content": "[Anomali ThreatStream](https://www.anomali.com/products/threatstream) is a threat intelligence platform (TIP) that automates the collection, curation, and analysis of threat data from global, open-source, and premium feeds.\n\nThis dashboard provides comprehensive visibility into IOCs detected by **Anomali ThreatStream** enrichment, including detection trends, geographic analysis for IP-based indicators, and correlated Cloud SIEM Security Signals.\n\nFor setup and configuration, see the [Anomali ThreatStream Datadog Integration Documentation](https://docs.datadoghq.com/integrations/anomali_threatstream/).\n\n**Quick Start**: Use the template variables above to filter by IOC type and service.",
+ "background_color": "white",
+ "font_size": "14",
+ "text_align": "left",
+ "vertical_align": "center",
+ "show_tick": true,
+ "tick_pos": "50%",
+ "tick_edge": "left",
+ "has_padding": true
+ },
+ "layout": {
+ "x": 4,
+ "y": 0,
+ "width": 8,
+ "height": 3
+ }
+ },
+ {
+ "id": 1000777777777777,
+ "definition": {
+ "background_color": "vivid_blue",
+ "layout_type": "ordered",
+ "show_title": true,
+ "title": "At a Glance",
+ "type": "group",
+ "widgets": [
+ {
+ "id": 1000333333333333,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "comparison": {
+ "directionality": "decrease_better",
+ "duration": {
+ "type": "previous_day"
+ },
+ "type": "relative"
+ },
+ "conditional_formats": [
+ {
+ "comparator": ">",
+ "palette": "white_on_red",
+ "value": 1000
+ },
+ {
+ "comparator": ">",
+ "palette": "white_on_yellow",
+ "value": 100
+ },
+ {
+ "comparator": ">=",
+ "palette": "white_on_green",
+ "value": 0
+ }
+ ]
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Total Events with IOC Matches",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 0,
+ "y": 0,
+ "width": 3,
+ "height": 3
+ }
+ },
+ {
+ "id": 1000444444444444,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "cardinality",
+ "metric": "@threat_intel.results.indicator"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "conditional_formats": [
+ {
+ "comparator": ">",
+ "palette": "white_on_red",
+ "value": 50
+ },
+ {
+ "comparator": ">",
+ "palette": "white_on_yellow",
+ "value": 10
+ },
+ {
+ "comparator": ">=",
+ "palette": "white_on_green",
+ "value": 0
+ }
+ ]
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Unique IOCs Detected",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 3,
+ "y": 0,
+ "width": 3,
+ "height": 3
+ }
+ },
+ {
+ "id": 1000555555555555,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "cardinality",
+ "metric": "service"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar"
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Services with IOC Matches",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 6,
+ "y": 0,
+ "width": 3,
+ "height": 3
+ }
+ },
+ {
+ "id": 1000666666666666,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "cardinality",
+ "metric": "@threat_intel.results.category"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar"
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Unique IOC Categories",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 9,
+ "y": 0,
+ "width": 3,
+ "height": 3
+ }
+ }
+ ]
+ },
+ "layout": {
+ "x": 0,
+ "y": 3,
+ "width": 12,
+ "height": 4
+ }
+ },
+ {
+ "id": 1001111111111110,
+ "definition": {
+ "background_color": "blue",
+ "layout_type": "ordered",
+ "show_title": true,
+ "title": "Detection Trends",
+ "type": "group",
+ "widgets": [
+ {
+ "id": 1000888888888888,
+ "definition": {
+ "legend_columns": [
+ "avg",
+ "min",
+ "max",
+ "value",
+ "sum"
+ ],
+ "legend_layout": "auto",
+ "requests": [
+ {
+ "display_type": "bars",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@threat_intel.indicators_matched",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "timeseries",
+ "style": {
+ "line_type": "solid",
+ "line_width": "normal",
+ "palette": "datadog16"
+ }
+ }
+ ],
+ "show_legend": true,
+ "title": "IOC Detections Over Time by Indicator Type",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "timeseries"
+ },
+ "layout": {
+ "x": 0,
+ "y": 0,
+ "width": 6,
+ "height": 4
+ }
+ },
+ {
+ "id": 1000999999999999,
+ "definition": {
+ "legend_columns": [
+ "avg",
+ "min",
+ "max",
+ "value",
+ "sum"
+ ],
+ "legend_layout": "auto",
+ "requests": [
+ {
+ "display_type": "bars",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@threat_intel.results.intention",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "timeseries",
+ "style": {
+ "line_type": "solid",
+ "line_width": "normal",
+ "palette": "semantic"
+ }
+ }
+ ],
+ "show_legend": true,
+ "title": "IOC Detections Over Time by Intention",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "timeseries"
+ },
+ "layout": {
+ "x": 6,
+ "y": 0,
+ "width": 6,
+ "height": 4
+ }
+ }
+ ]
+ },
+ "layout": {
+ "x": 0,
+ "y": 7,
+ "width": 12,
+ "height": 5
+ }
+ },
+ {
+ "id": 1001555555555554,
+ "definition": {
+ "background_color": "vivid_purple",
+ "layout_type": "ordered",
+ "show_title": true,
+ "title": "IOC Breakdown",
+ "type": "group",
+ "widgets": [
+ {
+ "id": 1001222222222221,
+ "definition": {
+ "legend": {
+ "type": "automatic"
+ },
+ "requests": [
+ {
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@threat_intel.indicators_matched",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ },
+ {
+ "facet": "@threat_intel.results.intention",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 100,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ },
+ "style": {
+ "palette": "datadog16"
+ }
+ }
+ ],
+ "title": "IOC Distribution (Type / Intention)",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "sunburst"
+ },
+ "layout": {
+ "x": 0,
+ "y": 0,
+ "width": 4,
+ "height": 4
+ }
+ },
+ {
+ "id": 1001333333333332,
+ "definition": {
+ "requests": [
+ {
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@threat_intel.results.category",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 10,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "style": {
+ "display": {
+ "legend": "automatic",
+ "type": "stacked"
+ }
+ },
+ "title": "Top Indicator Categories",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "toplist"
+ },
+ "layout": {
+ "x": 4,
+ "y": 0,
+ "width": 4,
+ "height": 4
+ }
+ },
+ {
+ "id": 1001444444444443,
+ "definition": {
+ "requests": [
+ {
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@threat_intel.results.indicator",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 10,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "style": {
+ "display": {
+ "legend": "automatic",
+ "type": "stacked"
+ }
+ },
+ "title": "Most Frequently Seen Indicators",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "toplist"
+ },
+ "layout": {
+ "x": 8,
+ "y": 0,
+ "width": 4,
+ "height": 4
+ }
+ }
+ ]
+ },
+ "layout": {
+ "x": 0,
+ "y": 12,
+ "width": 12,
+ "height": 5
+ }
+ },
+ {
+ "id": 1001999999999998,
+ "definition": {
+ "background_color": "vivid_green",
+ "layout_type": "ordered",
+ "show_title": true,
+ "title": "Geographic Intelligence (IP Indicators)",
+ "type": "group",
+ "widgets": [
+ {
+ "id": 1001666666666665,
+ "definition": {
+ "background_color": "green",
+ "content": "This section focuses on **IP-type indicators** only. The geomap shows the geographic distribution of IPs that matched Anomali ThreatStream threat intelligence, based on the `@network.client.geoip` enrichment.",
+ "font_size": "14",
+ "has_padding": true,
+ "show_tick": false,
+ "text_align": "left",
+ "type": "note",
+ "vertical_align": "center"
+ },
+ "layout": {
+ "x": 0,
+ "y": 0,
+ "width": 12,
+ "height": 1
+ }
+ },
+ {
+ "id": 1001777777777776,
+ "definition": {
+ "custom_links": [
+ {
+ "label": "View Security Signals for this country",
+ "link": "/security/signals?query=@workflow.rule.type%3A(%22Log%20Detection%22%20OR%20%22Signal%20Correlation%22)%20{{@network.client.geoip.country.iso_code}}&start={{timestamp_start}}&end={{timestamp_end}}&paused=true"
+ }
+ ],
+ "requests": [
+ {
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@network.client.geoip.country.iso_code",
+ "limit": 250,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali @threat_intel.indicators_matched:IP $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 250,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "style": {
+ "palette": "YlOrRd",
+ "palette_flip": false
+ },
+ "title": "IOC-Matched IPs by Country",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "geomap",
+ "view": {
+ "focus": "WORLD"
+ }
+ },
+ "layout": {
+ "x": 0,
+ "y": 1,
+ "width": 6,
+ "height": 5
+ }
+ },
+ {
+ "id": 1001888888888887,
+ "definition": {
+ "custom_links": [
+ {
+ "label": "Investigate IP in Logs",
+ "link": "/logs?query=%40threat_intel.results.source.name%3AAnomali%20%40network.client.ip%3A{{@network.client.ip.value}}&start={{timestamp_start}}&end={{timestamp_end}}&paused=true"
+ }
+ ],
+ "has_search_bar": "auto",
+ "requests": [
+ {
+ "formulas": [
+ {
+ "alias": "Events",
+ "cell_display_mode": "bar",
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@network.client.geoip.country.name",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ },
+ {
+ "facet": "@network.client.ip",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ },
+ {
+ "facet": "@threat_intel.results.intention",
+ "limit": 5,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali @threat_intel.indicators_matched:IP $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 500,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "title": "Top Threatening IPs by Country",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_table"
+ },
+ "layout": {
+ "x": 6,
+ "y": 1,
+ "width": 6,
+ "height": 5
+ }
+ }
+ ]
+ },
+ "layout": {
+ "x": 0,
+ "y": 17,
+ "width": 12,
+ "height": 7
+ }
+ },
+ {
+ "id": 1002666666666664,
+ "definition": {
+ "background_color": "vivid_orange",
+ "layout_type": "ordered",
+ "show_title": true,
+ "title": "Security Signals from Threat Intel",
+ "type": "group",
+ "widgets": [
+ {
+ "id": 1002111111111109,
+ "definition": {
+ "background_color": "orange",
+ "content": "Security Signals generated by Cloud SIEM detection rules that were triggered on logs containing **Anomali ThreatStream threat intelligence** matches (`@threat_intel.results.source.name:Anomali`). Only signals with Anomali ThreatStream IOC enrichment are shown here.\n\nIf no signals appear, ensure [detection rules](https://docs.datadoghq.com/security/cloud_siem/log_detection_rules/) are configured to trigger on `@threat_intel` attributes.",
+ "font_size": "14",
+ "has_padding": true,
+ "show_tick": false,
+ "text_align": "left",
+ "type": "note",
+ "vertical_align": "center"
+ },
+ "layout": {
+ "x": 0,
+ "y": 0,
+ "width": 12,
+ "height": 1
+ }
+ },
+ {
+ "id": 1002222222222220,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "security_signals",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ }
+ }
+ ],
+ "response_format": "scalar",
+ "conditional_formats": [
+ {
+ "comparator": ">",
+ "palette": "white_on_red",
+ "value": 0
+ }
+ ]
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Anomali ThreatStream Threat Intel Signals",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 0,
+ "y": 1,
+ "width": 3,
+ "height": 3
+ }
+ },
+ {
+ "id": 1002333333333331,
+ "definition": {
+ "legend_columns": [
+ "avg",
+ "min",
+ "max",
+ "value",
+ "sum"
+ ],
+ "legend_layout": "auto",
+ "requests": [
+ {
+ "display_type": "bars",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "security_signals",
+ "group_by": [
+ {
+ "facet": "status",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ }
+ }
+ ],
+ "response_format": "timeseries",
+ "style": {
+ "line_type": "solid",
+ "line_width": "normal",
+ "palette": "semantic"
+ }
+ }
+ ],
+ "show_legend": true,
+ "title": "Signals Over Time by Severity",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "timeseries"
+ },
+ "layout": {
+ "x": 3,
+ "y": 1,
+ "width": 9,
+ "height": 3
+ }
+ },
+ {
+ "id": 1002444444444442,
+ "definition": {
+ "requests": [
+ {
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "security_signals",
+ "group_by": [
+ {
+ "facet": "@workflow.rule.name",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ }
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 10,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "style": {
+ "display": {
+ "legend": "automatic",
+ "type": "stacked"
+ }
+ },
+ "title": "Top Detection Rules (Signals)",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "toplist",
+ "custom_links": [
+ {
+ "label": "View Signals for this Rule",
+ "link": "/security/signals?query=%40workflow.rule.name%3A%22{{@workflow.rule.name.value}}%22%20%40threat_intel.results.source.name%3AAnomali&start={{timestamp_start}}&end={{timestamp_end}}&paused=true"
+ }
+ ]
+ },
+ "layout": {
+ "x": 0,
+ "y": 4,
+ "width": 6,
+ "height": 4
+ }
+ },
+ {
+ "id": 1002555555555553,
+ "definition": {
+ "requests": [
+ {
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "security_signals",
+ "group_by": [
+ {
+ "facet": "status",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ }
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 10,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "style": {
+ "display": {
+ "legend": "automatic",
+ "type": "stacked"
+ }
+ },
+ "title": "Signals by Severity",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "toplist"
+ },
+ "layout": {
+ "x": 6,
+ "y": 4,
+ "width": 6,
+ "height": 4
+ }
+ }
+ ]
+ },
+ "layout": {
+ "x": 0,
+ "y": 24,
+ "width": 12,
+ "height": 9
+ }
+ },
+ {
+ "id": 1003111111111108,
+ "definition": {
+ "background_color": "vivid_blue",
+ "layout_type": "ordered",
+ "show_title": true,
+ "title": "Detailed Investigation",
+ "type": "group",
+ "widgets": [
+ {
+ "id": 1002777777777775,
+ "definition": {
+ "custom_links": [
+ {
+ "label": "Investigate Indicator in Logs",
+ "link": "/logs?query=%40threat_intel.results.source.name%3AAnomali%20%40threat_intel.results.indicator%3A%22{{@threat_intel.results.indicator.value}}%22&start={{timestamp_start}}&end={{timestamp_end}}&paused=true"
+ }
+ ],
+ "has_search_bar": "auto",
+ "requests": [
+ {
+ "formulas": [
+ {
+ "alias": "Events",
+ "cell_display_mode": "bar",
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@threat_intel.indicators_matched",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ },
+ {
+ "facet": "@threat_intel.results.intention",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ },
+ {
+ "facet": "@threat_intel.results.category",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ },
+ {
+ "facet": "@threat_intel.results.indicator",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 10000,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "title": "IOC Details Drill-Down (Type / Intention / Category / Indicator)",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_table"
+ },
+ "layout": {
+ "x": 0,
+ "y": 0,
+ "width": 12,
+ "height": 4
+ }
+ },
+ {
+ "id": 1002888888888886,
+ "definition": {
+ "has_search_bar": "auto",
+ "requests": [
+ {
+ "formulas": [
+ {
+ "alias": "Events",
+ "cell_display_mode": "bar",
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "service",
+ "limit": 25,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ },
+ {
+ "facet": "@threat_intel.indicators_matched",
+ "limit": 5,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 125,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "title": "IOC Matches by Service",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_table"
+ },
+ "layout": {
+ "x": 0,
+ "y": 4,
+ "width": 12,
+ "height": 3
+ }
+ },
+ {
+ "id": 1002999999999997,
+ "definition": {
+ "requests": [
+ {
+ "columns": [
+ {
+ "field": "status_line",
+ "width": "auto"
+ },
+ {
+ "field": "timestamp",
+ "width": "auto"
+ },
+ {
+ "field": "#indicator",
+ "width": "auto"
+ },
+ {
+ "field": "#intention",
+ "width": "auto"
+ },
+ {
+ "field": "service",
+ "width": "auto"
+ },
+ {
+ "field": "content",
+ "width": "auto"
+ }
+ ],
+ "query": {
+ "calculated_fields": [
+ {
+ "expression": "concat(@threat_intel.results.indicator, \"\")",
+ "name": "indicator"
+ },
+ {
+ "expression": "concat(@threat_intel.results.intention, \"\")",
+ "name": "intention"
+ }
+ ],
+ "data_source": "logs_stream",
+ "indexes": [],
+ "query_string": "@threat_intel.results.source.name:Anomali $ioc_type $service",
+ "sort": {
+ "column": "timestamp",
+ "order": "desc"
+ },
+ "storage": "hot"
+ },
+ "response_format": "event_list"
+ }
+ ],
+ "title": "Recent Logs with IOC Matches",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "list_stream"
+ },
+ "layout": {
+ "x": 0,
+ "y": 7,
+ "width": 12,
+ "height": 4
+ }
+ }
+ ]
+ },
+ "layout": {
+ "x": 0,
+ "y": 33,
+ "width": 12,
+ "height": 12
+ }
+ },
+ {
+ "id": 1004111111111107,
+ "definition": {
+ "background_color": "gray",
+ "layout_type": "ordered",
+ "show_title": true,
+ "title": "Anomali ThreatStream Insights",
+ "type": "group",
+ "widgets": [
+ {
+ "id": 1003222222222219,
+ "definition": {
+ "background_color": "gray",
+ "content": "IOC attributes specific to **Anomali ThreatStream** that are not part of the shared Cloud SIEM threat-intelligence template: source severity, targeted industries, and confidence scoring.",
+ "font_size": "14",
+ "has_padding": true,
+ "show_tick": false,
+ "text_align": "left",
+ "type": "note",
+ "vertical_align": "center"
+ },
+ "layout": {
+ "x": 0,
+ "y": 0,
+ "width": 12,
+ "height": 1
+ }
+ },
+ {
+ "id": 1003333333333330,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "conditional_formats": [
+ {
+ "comparator": ">",
+ "value": 0,
+ "palette": "red_on_white"
+ }
+ ],
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali @threat_intel.results.additional_data.severity:very-high $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar"
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Very-High Severity IOC Matches",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 0,
+ "y": 1,
+ "width": 3,
+ "height": 3
+ }
+ },
+ {
+ "id": 1003444444444441,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "conditional_formats": [
+ {
+ "comparator": ">",
+ "value": 0,
+ "palette": "yellow_on_white"
+ }
+ ],
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali @threat_intel.results.additional_data.severity:high $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar"
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "High Severity IOC Matches",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 3,
+ "y": 1,
+ "width": 3,
+ "height": 3
+ }
+ },
+ {
+ "id": 1003555555555552,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "conditional_formats": [
+ {
+ "comparator": ">",
+ "value": 0,
+ "palette": "custom_text",
+ "custom_fg_color": "#fab455"
+ }
+ ],
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali @threat_intel.results.additional_data.severity:medium $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar"
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Medium Severity IOC Matches",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 6,
+ "y": 1,
+ "width": 3,
+ "height": 3
+ }
+ },
+ {
+ "id": 1003666666666663,
+ "definition": {
+ "autoscale": true,
+ "precision": 0,
+ "requests": [
+ {
+ "aggregator": "last",
+ "conditional_formats": [
+ {
+ "comparator": ">",
+ "value": 0,
+ "palette": "custom_text",
+ "custom_fg_color": "#a5b7fd"
+ }
+ ],
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali @threat_intel.results.additional_data.severity:low $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar"
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Low Severity IOC Matches",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 9,
+ "y": 1,
+ "width": 3,
+ "height": 3
+ }
+ },
+ {
+ "id": 1003777777777774,
+ "definition": {
+ "requests": [
+ {
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "count"
+ },
+ "data_source": "logs",
+ "group_by": [
+ {
+ "facet": "@threat_intel.results.additional_data.target_industry",
+ "limit": 10,
+ "should_exclude_missing": true,
+ "sort": {
+ "aggregation": "count",
+ "metric": "count",
+ "order": "desc"
+ }
+ }
+ ],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar",
+ "sort": {
+ "count": 10,
+ "order_by": [
+ {
+ "index": 0,
+ "order": "desc",
+ "type": "formula"
+ }
+ ]
+ }
+ }
+ ],
+ "style": {
+ "display": {
+ "legend": "automatic",
+ "type": "stacked"
+ }
+ },
+ "title": "Top Targeted Industries",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "toplist"
+ },
+ "layout": {
+ "x": 0,
+ "y": 4,
+ "width": 6,
+ "height": 4
+ }
+ },
+ {
+ "id": 1003888888888885,
+ "definition": {
+ "autoscale": true,
+ "precision": 2,
+ "requests": [
+ {
+ "aggregator": "last",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "avg",
+ "metric": "@threat_intel.results.additional_data.confidence"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar"
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Average Confidence",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 6,
+ "y": 4,
+ "width": 3,
+ "height": 4
+ }
+ },
+ {
+ "id": 1003999999999996,
+ "definition": {
+ "autoscale": true,
+ "precision": 2,
+ "requests": [
+ {
+ "aggregator": "last",
+ "formulas": [
+ {
+ "formula": "query1"
+ }
+ ],
+ "queries": [
+ {
+ "compute": {
+ "aggregation": "avg",
+ "metric": "@threat_intel.results.additional_data.source_reported_confidence"
+ },
+ "data_source": "logs",
+ "group_by": [],
+ "indexes": [
+ "*"
+ ],
+ "name": "query1",
+ "search": {
+ "query": "@threat_intel.results.source.name:Anomali $ioc_type $service"
+ },
+ "storage": "hot"
+ }
+ ],
+ "response_format": "scalar"
+ }
+ ],
+ "timeseries_background": {
+ "type": "area"
+ },
+ "title": "Average Source-Reported Confidence",
+ "title_align": "left",
+ "title_size": "16",
+ "type": "query_value"
+ },
+ "layout": {
+ "x": 9,
+ "y": 4,
+ "width": 3,
+ "height": 4
+ }
+ }
+ ]
+ },
+ "layout": {
+ "x": 0,
+ "y": 45,
+ "width": 12,
+ "height": 8
+ }
+ }
+ ],
+ "template_variables": [
+ {
+ "name": "ioc_type",
+ "prefix": "@threat_intel.indicators_matched",
+ "default": "*"
+ },
+ {
+ "name": "service",
+ "prefix": "service",
+ "available_values": [],
+ "default": "*"
+ }
+ ],
+ "layout_type": "ordered",
+ "notify_list": [],
+ "reflow_type": "fixed"
+}
diff --git a/anomali_threatstream/assets/dataflows.yaml b/anomali_threatstream/assets/dataflows.yaml
new file mode 100644
index 0000000000000..f556da0a99308
--- /dev/null
+++ b/anomali_threatstream/assets/dataflows.yaml
@@ -0,0 +1,6 @@
+provides:
+ - id: anomali-threatstream-threat-intel-feed
+ always_on: false
+ granular: true
+ data_type: threat_intel_feed
+ direction: inbound
diff --git a/anomali_threatstream/images/anomali_threatstream_threat_intelligence_dark.png b/anomali_threatstream/images/anomali_threatstream_threat_intelligence_dark.png
new file mode 100644
index 0000000000000..5b6dbe5c0a31a
Binary files /dev/null and b/anomali_threatstream/images/anomali_threatstream_threat_intelligence_dark.png differ
diff --git a/anomali_threatstream/images/anomali_threatstream_threat_intelligence_light.png b/anomali_threatstream/images/anomali_threatstream_threat_intelligence_light.png
new file mode 100644
index 0000000000000..6e2160c360c9e
Binary files /dev/null and b/anomali_threatstream/images/anomali_threatstream_threat_intelligence_light.png differ
diff --git a/anomali_threatstream/manifest.json b/anomali_threatstream/manifest.json
index 7ecf4b2cb2c87..b90e75cb7e73d 100644
--- a/anomali_threatstream/manifest.json
+++ b/anomali_threatstream/manifest.json
@@ -7,12 +7,24 @@
"tile": {
"overview": "README.md#Overview",
"configuration": "README.md#Setup",
- "support": "README.md#Support",
+ "support": "README.md#Troubleshooting",
"changelog": "CHANGELOG.md",
"description": "Enriches your security logs with threat intelligence data from Anomali ThreatStream.",
"title": "Anomali ThreatStream",
- "media": [],
+ "media": [
+ {
+ "caption": "Anomali ThreatStream Threat Intelligence",
+ "image_url": "images/anomali_threatstream_threat_intelligence_light.png",
+ "media_type": "image"
+ },
+ {
+ "caption": "Anomali ThreatStream Threat Intelligence",
+ "image_url": "images/anomali_threatstream_threat_intelligence_dark.png",
+ "media_type": "image"
+ }
+ ],
"classifier_tags": [
+ "Category::Cloud",
"Category::Security",
"Offering::Integration"
]
@@ -25,6 +37,9 @@
"events": {
"creates_events": false
}
+ },
+ "dashboards": {
+ "Anomali ThreatStream Threat Intelligence": "assets/dashboards/anomali_threatstream_threat_intelligence.json"
}
},
"author": {
diff --git a/anthropic_compliance_logs/manifest.json b/anthropic_compliance_logs/manifest.json
index 0f53cb2a40f7d..c43f428155b7e 100644
--- a/anthropic_compliance_logs/manifest.json
+++ b/anthropic_compliance_logs/manifest.json
@@ -46,6 +46,7 @@
"classifier_tags": [
"Category::AI/ML",
"Category::Log Collection",
+ "Category::SIEM",
"Category::Security",
"Submitted Data Type::Logs",
"Offering::Integration"
diff --git a/cofense_triage/manifest.json b/cofense_triage/manifest.json
index 632216d8daad3..a4512883f6a06 100644
--- a/cofense_triage/manifest.json
+++ b/cofense_triage/manifest.json
@@ -26,6 +26,7 @@
"classifier_tags": [
"Category::Cloud",
"Category::Log Collection",
+ "Category::SIEM",
"Category::Security",
"Offering::Integration",
"Submitted Data Type::Logs"
diff --git a/crowdstrike_fdr/manifest.json b/crowdstrike_fdr/manifest.json
index ff707d7d1e4d5..c4d3f34c31abd 100644
--- a/crowdstrike_fdr/manifest.json
+++ b/crowdstrike_fdr/manifest.json
@@ -47,6 +47,7 @@
"Category::AWS",
"Category::Cloud",
"Category::Log Collection",
+ "Category::SIEM",
"Category::Security",
"Offering::Integration",
"Submitted Data Type::Logs"
diff --git a/ddev/changelog.d/24687.added b/ddev/changelog.d/24687.added
new file mode 100644
index 0000000000000..68e1818d1d48d
--- /dev/null
+++ b/ddev/changelog.d/24687.added
@@ -0,0 +1 @@
+Add deterministic Dispatcher test batch planning, with each job carrying the Python version it runs under and the Agent image its E2E tests run against. The internal ``max_jobs_per_batch`` option moves from ``[dispatcher]`` to ``[dispatcher.batching]``.
diff --git a/ddev/src/ddev/cli/ci/tests/batching/AGENTS.md b/ddev/src/ddev/cli/ci/tests/batching/AGENTS.md
new file mode 100644
index 0000000000000..052b34cc86b63
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/AGENTS.md
@@ -0,0 +1,91 @@
+# Dispatcher Test Batching
+
+This package turns a set of changed files into the batches of test jobs the Dispatcher dispatches
+to GitHub Actions. It is pure planning: nothing here runs tests, calls GitHub, or touches the
+network. See the repository-wide [AGENTS.md](../../../../../../../AGENTS.md) for general
+conventions.
+
+## Pipeline
+
+```
+changed files -> affected targets -> test units -> batch jobs -> job groups -> TestBatch messages
+ (see below) targets.py units.py jobs.py strategy/ build.py
+```
+
+Everything is composed by `build.py`, the package's public entry point, which also turns the
+final groups into messages. Callers use `build_test_units` or `build_test_batches` and never
+assemble the stages themselves.
+
+Changed files arrive as `ChangedFile` records and are not produced here. `ddev.utils.git` reads
+them from git, and `../changes.py` decides which two commits a CI run compares.
+
+| Module | Role |
+| --- | --- |
+| `build.py` | Composes the stages and adapts concrete `Repository`/`Integration` objects to them. The package's public entry point. |
+| `targets.py` | Maps changed files to affected target names through ordered, independent rules. |
+| `units.py` | Expands targets into `TestUnit` values: one target, one platform, one environment. |
+| `jobs.py` | Turns each unit into the concrete `BatchJob` the workflow runs. |
+| `strategy/` | Packs jobs into capacity-bounded groups. `types.py` is the contract, `default.py` the implementation. |
+| `validation.py` | Checks any strategy's partition against the execution contract. |
+| `exceptions.py` | `PlanningError` and `BatchValidationError`. |
+
+The `BatchJob` type itself lives in `../messages.py`, alongside the other Dispatcher messages.
+
+## Relationship to `ci_matrix.py`
+
+The implementation this package shadows is `ddev/src/ddev/utils/scripts/ci_matrix.py`, and that is
+still the one CI uses. The two will run side by side until the Dispatcher takes over, so a
+behavioural change here that CI does not make is a divergence, not an improvement.
+
+Some values are duplicated between them on purpose: `ci_matrix.py` must run standalone with no
+dependencies, so it cannot import from this package. `PLATFORMS` and the path patterns are the
+copies that matter. Change one and change the other.
+
+Environment discovery is the one place they deliberately differ. This package asks Hatch through an
+injected `EnvironmentProvider`, where `ci_matrix.py` reads the `hatch.toml` matrix directly. Asking
+Hatch is accurate but costs one subprocess per target, and the repository-wide rule selects every testable
+target, so a `datadog_checks_base` change means hundreds of serial subprocesses. That needs
+concurrency or a `hatch.toml`-reading provider before this runs on real pull requests.
+
+## Rules
+
+**Planning is deterministic and offline.** The same changed files must always produce the same
+plan, byte for byte. Never introduce ordering that depends on a set, a dict built from an unordered
+source, wall-clock time, or randomness, and never make a network call while planning. Registry
+lookups belong in an explicit preflight such as `find_unpublished_images`, not in the plan itself.
+
+**External systems come in through injected protocols.** There are five of them: `RepositoryFacts`,
+`TargetRule`, `EnvironmentProvider`, `BatchStrategy` and `AgentImageResolver`. They exist so that
+tests never need a real repository or Hatch, and so the pieces can be recomposed later. A planning
+function depends on the protocol and never constructs the adapter itself. A concrete adapter may
+live beside its protocol, the way `RegistryRepositoryFacts` does, as long as it imports its
+dependency lazily or behind a type-checking guard.
+
+**Validation is independent of the strategy.** A strategy is untrusted input: `validate_batches`
+must catch a partition that drops, duplicates, overfills, or illegally splits, no matter which
+callable produced it. Do not move a check into a strategy.
+
+**Parse strictly, but only what a human wrote.** Reject configuration you do not understand instead
+of guessing at what it probably meant: a Python version that is not `major.minor`, an unknown or
+repeated platform name in a CI override, or a target reaching expansion with no environments all
+raise `PlanningError`. The alternative is a plan that looks fine and tests the wrong thing.
+
+Generated or advertised data is different. `manifest.json` lists platforms ddev has no runner for,
+such as AIX, so the supported OS list is filtered rather than parsed. Failing on it would turn
+someone else's metadata into an outage for every target in the run.
+
+**Failures surface as `PlanningError`.** Anything that stops a plan being produced raises it, so a
+future command has one thing to catch. Errors from outside the package, such as the Agent-image
+exceptions, are wrapped at the boundary that calls them rather than made to subclass it, which
+would point the dependency the wrong way.
+
+**Comments explain intent, not mechanics.** State the contract and the reasoning a caller cannot
+infer from the signature, and use inline field comments for per-field notes. Do not restate what the
+code says, do not narrate what other modules do, and do not use Sphinx roles (`:class:`, `:func:`)
+or double backticks.
+
+## Keeping this file current
+
+Update it in the same change that makes it wrong. Adding, removing, or renaming a module means
+updating the pipeline diagram and the module table. Changing a boundary, a protocol, or one of the
+rules above means updating that section.
diff --git a/ddev/src/ddev/cli/ci/tests/batching/CLAUDE.md b/ddev/src/ddev/cli/ci/tests/batching/CLAUDE.md
new file mode 100644
index 0000000000000..43c994c2d3617
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/ddev/src/ddev/cli/ci/tests/batching/__init__.py b/ddev/src/ddev/cli/ci/tests/batching/__init__.py
new file mode 100644
index 0000000000000..75c6647cb9233
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/__init__.py
@@ -0,0 +1,3 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
diff --git a/ddev/src/ddev/cli/ci/tests/batching/build.py b/ddev/src/ddev/cli/ci/tests/batching/build.py
new file mode 100644
index 0000000000000..dca49be05c8d0
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/build.py
@@ -0,0 +1,216 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Public entry points that turn changed files into test units and batches."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from ddev.cli.ci.tests.batching.exceptions import PlanningError
+from ddev.cli.ci.tests.batching.jobs import expand_batch_jobs
+from ddev.cli.ci.tests.batching.strategy import BatchStrategy, default_strategy
+from ddev.cli.ci.tests.batching.targets import (
+ RegistryRepositoryFacts,
+ default_target_rules,
+ find_affected_targets,
+)
+from ddev.cli.ci.tests.batching.units import (
+ ResolvedEnvironment,
+ TargetDefinition,
+ TestUnit,
+ expand_test_units,
+ resolve_platforms,
+)
+from ddev.cli.ci.tests.batching.validation import validate_batches
+from ddev.cli.ci.tests.messages import TestBatch
+from ddev.e2e.agent_images import PYTHON_VERSION_PATTERN
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from ddev.cli.ci.tests.batching.targets import TargetRule
+ from ddev.cli.ci.tests.batching.units import EnvironmentProvider
+ from ddev.cli.ci.tests.dispatcher_config import BatchingConfig
+ from ddev.cli.ci.tests.messages import BatchJob
+ from ddev.integration.core import Integration
+ from ddev.repo.core import Repository
+ from ddev.utils.git import ChangedFile
+ from ddev.utils.hatch import Environment
+ from ddev.utils.platform import Platform, PlatformName
+
+logger = logging.getLogger(__name__)
+
+
+def build_test_units(
+ repo: Repository,
+ changed_files: Sequence[ChangedFile],
+ *,
+ environment_provider: EnvironmentProvider,
+ rules: Sequence[TargetRule] | None = None,
+) -> list[TestUnit]:
+ """Turn changed files into the complete, deterministic list of test units.
+
+ Without explicit `rules`, the default set is used, with the repository-wide rule enabled only
+ for the core repository.
+ """
+ if rules is None:
+ rules = default_target_rules(is_core=repo.name == "core")
+
+ facts = RegistryRepositoryFacts(repo.integrations)
+ target_names = find_affected_targets(changed_files, facts, rules=rules)
+
+ definitions: list[TargetDefinition] = []
+ for name in target_names:
+ ci_override = repo.config.get(f"/overrides/ci/{name}", {}) or {}
+ if ci_override.get("exclude", False):
+ continue
+
+ integration = repo.integrations.get(name)
+ platforms = resolve_platforms(ci_override.get("platforms", []), _supported_os(integration), target=name)
+ environments = tuple(environment_provider(integration, platforms))
+ if not environments:
+ # A `hatch.toml` makes a target testable, so one that enables no test or E2E
+ # environment contradicts itself. Deliberate opt-out is `overrides.ci..exclude`.
+ logger.warning("%s has a hatch.toml but no testable environment", name)
+ continue
+
+ definitions.append(
+ TargetDefinition(
+ name=name,
+ display_name=integration.display_name,
+ platforms=tuple(platforms),
+ runners=ci_override.get("runners", {}),
+ environments=environments,
+ )
+ )
+
+ return expand_test_units(definitions)
+
+
+def build_test_batches(
+ repo: Repository,
+ changed_files: Sequence[ChangedFile],
+ *,
+ environment_provider: EnvironmentProvider,
+ config: BatchingConfig,
+ strategy: BatchStrategy = default_strategy,
+ rules: Sequence[TargetRule] | None = None,
+) -> list[TestBatch]:
+ """Turn changed files into the complete, ordered list of `TestBatch` messages.
+
+ The partition is validated independently of the strategy that produced it. Empty input yields
+ no batches.
+ """
+ units = build_test_units(
+ repo,
+ changed_files,
+ environment_provider=environment_provider,
+ rules=rules,
+ )
+ jobs = expand_batch_jobs(units)
+ job_groups = strategy(jobs, config=config)
+ validate_batches(job_groups, jobs, config=config)
+ return create_test_batches(job_groups)
+
+
+def create_test_batches(job_groups: Sequence[Sequence[BatchJob]]) -> list[TestBatch]:
+ """Build ordered `TestBatch` messages, numbering from `batch-01` on every call.
+
+ The message `id` is set to the same value as `batch_id` for now; processors correlate on
+ `batch_id`, so the two are free to diverge later.
+ """
+ batches: list[TestBatch] = []
+ for index, group in enumerate(job_groups, start=1):
+ batch_id = f"batch-{index:02d}"
+ integrations = list(dict.fromkeys(job.target for job in group))
+ batches.append(
+ TestBatch(
+ id=batch_id,
+ batch_id=batch_id,
+ job_list=list(group),
+ jobs_count=len(group),
+ integrations=integrations,
+ )
+ )
+ return batches
+
+
+def _supported_os(integration: Integration) -> list[str]:
+ # TODO(manifest): platform detection reads `manifest.json` classifier tags. A planned change
+ # will remove ddev tooling's dependency on the manifest; revisit this once that lands.
+ supported_os: list[str] = []
+ for classifier_tag in integration.manifest.get("/tile/classifier_tags", []) or []:
+ key, _, value = classifier_tag.partition("::")
+ if key == "Supported OS":
+ supported_os.append(value)
+ return supported_os
+
+
+@dataclass(frozen=True, eq=False)
+class HatchEnvironmentProvider:
+ """An `EnvironmentProvider` backed by ddev's Hatch integration."""
+
+ platform: Platform
+ default_python_version: str
+
+ def __call__(self, integration: Integration, platforms: Sequence[PlatformName]) -> list[ResolvedEnvironment]:
+ from ddev.utils.hatch import list_environments
+
+ return resolve_hatch_environments(
+ list_environments(self.platform, integration),
+ platforms,
+ default_python_version=self.default_python_version,
+ )
+
+
+def resolve_hatch_environments(
+ environments: Sequence[Environment],
+ platforms: Sequence[PlatformName],
+ *,
+ default_python_version: str,
+) -> list[ResolvedEnvironment]:
+ """Map ddev `Environment` values onto target platforms, keeping environments that test anything.
+
+ An environment constrained to specific platforms is routed only to those the target also runs
+ on; an unconstrained one runs on every platform the target runs on.
+
+ The Python version comes from Hatch's own `python` value, never from the environment name,
+ which only encodes it by convention.
+ """
+ if not platforms:
+ return []
+
+ by_name = {str(platform): platform for platform in platforms}
+ resolved: list[ResolvedEnvironment] = []
+ for environment in environments:
+ if not (environment.test_env or environment.e2e_env):
+ continue
+
+ if environment.platforms:
+ # Raw configuration, so a platform ddev does not target drops out of the intersection
+ # instead of failing the plan.
+ candidate_platforms = [by_name[name] for name in environment.platforms if name in by_name]
+ else:
+ candidate_platforms = list(platforms)
+
+ python_version = environment.python or default_python_version
+ if not PYTHON_VERSION_PATTERN.match(python_version):
+ raise PlanningError(
+ f'Environment {environment.name!r} reports Python {python_version!r}; '
+ f'expected a `major.minor` version such as `3.13`'
+ )
+
+ for platform in candidate_platforms:
+ resolved.append(
+ ResolvedEnvironment(
+ name=environment.name,
+ platform=platform,
+ python_version=python_version,
+ test_available=environment.test_env,
+ e2e_available=environment.e2e_env,
+ )
+ )
+ return resolved
diff --git a/ddev/src/ddev/cli/ci/tests/batching/exceptions.py b/ddev/src/ddev/cli/ci/tests/batching/exceptions.py
new file mode 100644
index 0000000000000..0586b1de9d936
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/exceptions.py
@@ -0,0 +1,14 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Exceptions raised while constructing and validating test batch plans."""
+
+from __future__ import annotations
+
+
+class PlanningError(Exception):
+ """Raised when a valid batch plan cannot be produced under the configured policy."""
+
+
+class BatchValidationError(PlanningError):
+ """Raised when a batch partition violates the execution contract."""
diff --git a/ddev/src/ddev/cli/ci/tests/batching/jobs.py b/ddev/src/ddev/cli/ci/tests/batching/jobs.py
new file mode 100644
index 0000000000000..b1fcc989483e5
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/jobs.py
@@ -0,0 +1,71 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Expansion of test units into the concrete jobs a workflow runs."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Protocol
+
+from ddev.cli.ci.tests.batching.exceptions import PlanningError
+from ddev.cli.ci.tests.messages import BatchJob
+from ddev.e2e.agent_images import AgentImageError, get_agent_image
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from ddev.cli.ci.tests.batching.units import TestUnit
+ from ddev.utils.platform import PlatformName
+
+
+class AgentImageResolver(Protocol):
+ """Resolves the E2E Agent image for a Python version on a platform."""
+
+ def __call__(self, python_version: str, platform: PlatformName) -> str: ...
+
+
+def expand_batch_jobs(
+ units: Sequence[TestUnit],
+ *,
+ agent_image_resolver: AgentImageResolver = get_agent_image,
+) -> list[BatchJob]:
+ """Expand test units into concrete jobs, one per unit, preserving order.
+
+ The Agent image is resolved here rather than in the workflow so the recorded plan states what
+ every job ran against. A job with no E2E tests gets no image, so an unresolvable one can only
+ fail a plan that would actually have used it.
+ """
+ jobs: list[BatchJob] = []
+ for unit in units:
+ environment = unit.environment
+ jobs.append(
+ BatchJob(
+ name=unit.name,
+ target=unit.target,
+ runner_labels=unit.runner_labels,
+ environment=environment.name,
+ platform=unit.platform,
+ python_version=environment.python_version,
+ unit_tests=environment.test_available,
+ e2e_tests=environment.e2e_available,
+ agent_image=_resolve_agent_image(unit, agent_image_resolver),
+ )
+ )
+
+ return jobs
+
+
+def _resolve_agent_image(unit: TestUnit, resolver: AgentImageResolver) -> str | None:
+ """Resolve one unit's Agent image, reporting a failure as a planning failure.
+
+ The resolver raises its own Agent-image exceptions, which say nothing about which job asked.
+ They are translated here rather than at their source so `ddev.e2e` stays independent of the
+ planner.
+ """
+ if not unit.environment.e2e_available:
+ return None
+
+ try:
+ return resolver(unit.environment.python_version, unit.platform)
+ except AgentImageError as e:
+ raise PlanningError(f"{unit.name!r} needs an E2E Agent image but none resolves: {e}") from e
diff --git a/ddev/src/ddev/cli/ci/tests/batching/strategy/__init__.py b/ddev/src/ddev/cli/ci/tests/batching/strategy/__init__.py
new file mode 100644
index 0000000000000..5a2aeae63d4a0
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/strategy/__init__.py
@@ -0,0 +1,18 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Batching strategies: the `BatchStrategy` contract and the default implementation.
+
+Adding a strategy means adding one module here; validation and message construction live one
+level up and apply to every strategy.
+"""
+
+from __future__ import annotations
+
+from ddev.cli.ci.tests.batching.strategy.default import default_strategy
+from ddev.cli.ci.tests.batching.strategy.types import BatchStrategy
+
+__all__ = [
+ "BatchStrategy",
+ "default_strategy",
+]
diff --git a/ddev/src/ddev/cli/ci/tests/batching/strategy/default.py b/ddev/src/ddev/cli/ci/tests/batching/strategy/default.py
new file mode 100644
index 0000000000000..151093256ed10
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/strategy/default.py
@@ -0,0 +1,59 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""The default batching strategy."""
+
+from __future__ import annotations
+
+from itertools import batched, chain
+from typing import TYPE_CHECKING
+
+from ddev.cli.ci.tests.batching.exceptions import PlanningError
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from ddev.cli.ci.tests.dispatcher_config import BatchingConfig
+ from ddev.cli.ci.tests.messages import BatchJob
+
+
+def default_strategy(jobs: Sequence[BatchJob], *, config: BatchingConfig) -> list[list[BatchJob]]:
+ """Pack jobs into batches, keeping each integration atomic unless it exceeds capacity.
+
+ An integration too big for one batch raises `PlanningError` unless
+ `allow_integration_splitting` is set, in which case it spills across batches.
+ """
+ capacity = config.max_jobs_per_batch
+ batches: list[list[BatchJob]] = []
+ current: list[BatchJob] = []
+
+ for group in _group_by_integration(list(jobs)):
+ if len(group) > capacity:
+ if not config.allow_integration_splitting:
+ raise PlanningError(
+ f"Integration {group[0].target!r} needs {len(group)} jobs, exceeding the batch "
+ f"capacity of {capacity}; enable allow_integration_splitting to span multiple batches."
+ )
+ # Spill across full batches, starting from the current remainder so no capacity is
+ # wasted. The last chunk is short by design and stays open for following integrations.
+ *full, current = map(list, batched(chain(current, group), capacity, strict=False))
+ batches.extend(full)
+ elif len(current) + len(group) <= capacity:
+ current.extend(group)
+ else:
+ # Fits a batch but not this one's remainder, so start a fresh batch. `current` is
+ # non-empty here, since an empty one would have matched the branch above.
+ batches.append(current)
+ current = list(group)
+
+ if current:
+ batches.append(current)
+ return batches
+
+
+def _group_by_integration(jobs: list[BatchJob]) -> list[list[BatchJob]]:
+ """Group jobs by integration (`target`), preserving first-appearance order."""
+ groups: dict[str, list[BatchJob]] = {}
+ for job in jobs:
+ groups.setdefault(job.target, []).append(job)
+ return list(groups.values())
diff --git a/ddev/src/ddev/cli/ci/tests/batching/strategy/types.py b/ddev/src/ddev/cli/ci/tests/batching/strategy/types.py
new file mode 100644
index 0000000000000..845c294523921
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/strategy/types.py
@@ -0,0 +1,20 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""The batching-strategy contract, kept separate so a strategy need not import the default one."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Protocol
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from ddev.cli.ci.tests.dispatcher_config import BatchingConfig
+ from ddev.cli.ci.tests.messages import BatchJob
+
+
+class BatchStrategy(Protocol):
+ """Maps an ordered list of jobs to an ordered list of capacity-bounded job groups."""
+
+ def __call__(self, jobs: Sequence[BatchJob], *, config: BatchingConfig) -> list[list[BatchJob]]: ...
diff --git a/ddev/src/ddev/cli/ci/tests/batching/targets.py b/ddev/src/ddev/cli/ci/tests/batching/targets.py
new file mode 100644
index 0000000000000..67761d8326de3
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/targets.py
@@ -0,0 +1,177 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Discovery of the targets a change set affects, as a composition of independent rules."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Protocol
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Iterator, Sequence
+
+ from ddev.repo.core import IntegrationRegistry
+ from ddev.utils.git import ChangedFile
+
+NON_TESTABLE_FILES = frozenset({"auto_conf.yaml"})
+
+# Integrations ddev still considers testable (they have a `hatch.toml`) but that CI no longer
+# runs. This is CI policy layered on top of ddev's `is_testable`, which does not encode it.
+UNTESTABLE_TARGETS = frozenset({"mesos_slave"})
+
+# Paths within a target that, when changed, warrant running that target's tests.
+TESTABLE_PATH_PATTERN = re.compile(
+ r"""
+ assets/configuration/.+
+ | tests/.+
+ | [^/]+\.py
+ | hatch\.toml
+ | metadata\.csv
+ | pyproject\.toml
+ | datadog_checks/[^/]+/data/metrics\.yaml
+ | datadog_checks/snmp/data/default_profiles/.+
+ | datadog_checks/dev/tooling/templates/configuration/.+yaml
+ """,
+ re.VERBOSE,
+)
+
+# Repository-wide paths that, when changed, trigger the full eligible target set.
+#
+# Deliberately narrower than the `paths` filter of `.github/workflows/pr-all.yml`, which also
+# triggers on `ddev/src/**`, the tooling `pyproject.toml` files, and the workflow definitions.
+# Whether Dispatcher should match those too is still an open decision.
+#
+# TODO(manifest): once ddev no longer depends on `manifest.json`, each integration should declare
+# its own triggers as structured configuration instead of this shared regex.
+REPOSITORY_WIDE_PATTERNS = re.compile(
+ r"""
+ # Shared testing framework.
+ datadog_checks_base/datadog_checks/.+
+ | datadog_checks_dev/datadog_checks/dev/[^/]+\.py
+ # ddev's test planning and execution code. Other ddev tooling is intentionally absent and only
+ # selects the `ddev` target through the direct rule.
+ | ddev/src/ddev/cli/test/.+
+ | ddev/src/ddev/cli/env/test\.py
+ | ddev/src/ddev/testing/.+
+ | ddev/src/ddev/utils/hatch\.py
+ | ddev/src/ddev/cli/ci/tests/.+
+ | ddev/src/ddev/integration/core\.py
+ | ddev/src/ddev/repo/core\.py
+ """,
+ re.VERBOSE,
+)
+
+
+class RepositoryFacts(Protocol):
+ """Narrow read-only view of the repository used by target rules."""
+
+ def is_testable_target(self, name: str) -> bool: ...
+
+ def eligible_targets(self) -> list[str]: ...
+
+
+@dataclass(frozen=True, eq=False)
+class RegistryRepositoryFacts:
+ """`RepositoryFacts` backed by ddev's integration registry, plus the `UNTESTABLE_TARGETS` policy."""
+
+ registry: IntegrationRegistry
+
+ def is_testable_target(self, name: str) -> bool:
+ if name in UNTESTABLE_TARGETS:
+ return False
+ try:
+ return self.registry.get(name).is_testable
+ except OSError:
+ return False
+
+ def eligible_targets(self) -> list[str]:
+ # An empty selection means `changed`, so the whole repository has to be asked for explicitly
+ return sorted(
+ integration.name
+ for integration in self.registry.iter_testable('all')
+ if integration.name not in UNTESTABLE_TARGETS
+ )
+
+
+class TargetRule(Protocol):
+ """A behavior that maps changed files to affected target names."""
+
+ def __call__(self, changed_files: Sequence[ChangedFile], facts: RepositoryFacts) -> Iterable[str]: ...
+
+
+@dataclass(frozen=True)
+class DirectTargetRule:
+ """Recognize every directly modified testable target in the change set.
+
+ Each change is matched against every path it affects, so a rename selects both the target it
+ left and the one it landed in.
+ """
+
+ testable_pattern: re.Pattern[str] = TESTABLE_PATH_PATTERN
+ non_testable_files: frozenset[str] = NON_TESTABLE_FILES
+
+ def __call__(self, changed_files: Sequence[ChangedFile], facts: RepositoryFacts) -> Iterator[str]:
+ for changed_file in changed_files:
+ for path in changed_file.affected_paths:
+ target = self._target_for_path(path, facts)
+ if target is not None:
+ yield target
+
+ def _target_for_path(self, path: str, facts: RepositoryFacts) -> str | None:
+ directory, separator, remaining = path.partition("/")
+ if not separator or not remaining:
+ return None
+ if not facts.is_testable_target(directory):
+ return None
+ if remaining.rsplit("/", 1)[-1] in self.non_testable_files:
+ return None
+ if self.testable_pattern.search(remaining):
+ return directory
+ return None
+
+
+@dataclass(frozen=True)
+class RepositoryWideRule:
+ """Trigger the full eligible target set when a repository-wide path changes.
+
+ Those paths only exist in the core repository, so the rule yields nothing elsewhere. `is_core`
+ is required rather than defaulted so a rule can never be built without stating where it applies.
+ """
+
+ is_core: bool
+ patterns: re.Pattern[str] = REPOSITORY_WIDE_PATTERNS
+
+ def __call__(self, changed_files: Sequence[ChangedFile], facts: RepositoryFacts) -> Iterator[str]:
+ if not self.is_core:
+ return
+
+ if any(self.patterns.search(path) for changed_file in changed_files for path in changed_file.affected_paths):
+ yield from facts.eligible_targets()
+
+
+def default_target_rules(*, is_core: bool) -> tuple[TargetRule, ...]:
+ """Build the default ordered rule set for a repository."""
+ return (DirectTargetRule(), RepositoryWideRule(is_core=is_core))
+
+
+def find_affected_targets(
+ changed_files: Sequence[ChangedFile],
+ facts: RepositoryFacts,
+ *,
+ rules: Sequence[TargetRule],
+) -> list[str]:
+ """Combine every rule's results into the unique set of testable targets.
+
+ Deduplication is what matters: a target selected by two rules would otherwise plan two
+ identically named jobs. The dict keeps insertion order on top of that, which costs nothing
+ over a set and makes runs comparable when debugging, but nothing downstream depends on it.
+ """
+ union: dict[str, None] = {}
+ for rule in rules:
+ for target in rule(changed_files, facts):
+ if facts.is_testable_target(target):
+ union.setdefault(target, None)
+
+ return list(union)
diff --git a/ddev/src/ddev/cli/ci/tests/batching/units.py b/ddev/src/ddev/cli/ci/tests/batching/units.py
new file mode 100644
index 0000000000000..a58c7d10f6865
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/units.py
@@ -0,0 +1,224 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Expansion of affected targets into test units."""
+
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, ClassVar, NamedTuple, Protocol
+
+from ddev.cli.ci.tests.batching.exceptions import PlanningError
+from ddev.utils.platform import PlatformName
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping, Sequence
+
+ from ddev.integration.core import Integration
+
+
+class PlatformSpec(NamedTuple):
+ """Display name and default GitHub runner image for one platform."""
+
+ name: str
+ image: str
+
+
+# Kept in sync by hand with `ci_matrix.PLATFORMS`, which CI still uses and which cannot import from
+# here because it has to run standalone. A runner image changed in one place and not the other makes
+# the two plans disagree.
+PLATFORMS: dict[PlatformName, PlatformSpec] = {
+ PlatformName.LINUX: PlatformSpec("Linux", "ubuntu-22.04"),
+ PlatformName.WINDOWS: PlatformSpec("Windows", "windows-2022"),
+ PlatformName.MACOS: PlatformSpec("macOS", "macos-14-large"),
+}
+
+# Targets rendered before everything else, in this order.
+DISPLAY_ORDER_OVERRIDE: dict[str, int] = {
+ name: index
+ for index, name in enumerate(
+ (
+ "ddev",
+ "datadog_checks_base",
+ "datadog_checks_dev",
+ "datadog_checks_downloader",
+ )
+ )
+}
+
+# Job names end up in file paths, so characters Windows reserves must be replaced.
+# https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
+JOB_NAME_RESERVED_PATTERN = re.compile(r'[<>:"/\\|?*]')
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class ResolvedEnvironment:
+ """One environment a target runs, already routed onto a platform.
+
+ The two availability flags carry intent rather than a decision. They exist so a later change can
+ split unit and E2E work into separate jobs per environment and platform, which is why they are
+ per-environment here instead of per-target. Nothing splits on them yet: the workflow runs both
+ kinds of test and each works out at runtime whether it has anything to do, which is how CI
+ behaves today.
+
+ Splitting is deferred because Hatch cannot answer the question at planning time. It resolves
+ `platform.*` overrides against the machine it runs on, so `platform.windows.e2e-env = false`
+ (ibm_mq, ibm_ace, network, sqlserver) is invisible when planning on Linux, and it resolves
+ `env.*` overrides against the ambient environment, so azure_iot_edge's E2E availability depends
+ on a secret the planner does not have. Neither is knowable from one host. Note the `env.*` case
+ fails toward reporting no E2E work, so it drops coverage rather than wasting compute once
+ anything gates on these flags. The per-integration tooling configuration that replaces
+ `manifest.json` and `.ddev/config.toml` is where each environment will declare this
+ deterministically, and that is what these flags should be driven from.
+ """
+
+ name: str
+ platform: PlatformName
+ python_version: str # `major.minor`, picks both the runner Python and the E2E Agent image
+ # TODO(manifest): drive these from the per-integration tooling configuration planned to replace
+ # `manifest.json`, which can declare them per platform deterministically, and split unit and
+ # E2E work into separate jobs once it can.
+ test_available: bool = True # ddev's `test_env`
+ e2e_available: bool = False # ddev's `e2e_env`
+
+
+class EnvironmentProvider(Protocol):
+ """Resolves the environments an integration runs, routed onto the given platforms."""
+
+ def __call__(self, integration: Integration, platforms: Sequence[PlatformName]) -> list[ResolvedEnvironment]: ...
+
+
+@dataclass(frozen=True)
+class TargetDefinition:
+ """A single target to expand, with everything expansion needs already resolved."""
+
+ name: str
+ display_name: str | None = None
+ platforms: tuple[PlatformName, ...] = (PlatformName.LINUX,)
+ runners: Mapping[str, Sequence[str]] = field(default_factory=dict)
+ environments: tuple[ResolvedEnvironment, ...] = ()
+
+
+@dataclass(frozen=True)
+class TestUnit:
+ """One target, on one platform, in one environment. Becomes exactly one job.
+
+ `name` is already unique across the plan and is reused verbatim as the job's display name.
+ """
+
+ # Prevent pytest from collecting this domain class as a test case.
+ __test__: ClassVar[bool] = False
+
+ target: str
+ name: str
+ platform: PlatformName
+ runner_labels: tuple[str, ...]
+ environment: ResolvedEnvironment
+
+
+def normalize_job_name(job_name: str) -> str:
+ """Replace characters reserved on Windows so the name can be used in file paths."""
+ return JOB_NAME_RESERVED_PATTERN.sub("_", job_name)
+
+
+def parse_platform_name(value: str, *, target: str) -> PlatformName:
+ """Convert a configured platform string into a `PlatformName`, naming the target on failure."""
+ try:
+ return PlatformName(value.lower())
+ except ValueError:
+ supported = ", ".join(sorted(PLATFORMS))
+ raise PlanningError(f"Unsupported platform for `{target}`: {value} (expected one of {supported})") from None
+
+
+def resolve_platforms(
+ platform_override: Sequence[str],
+ supported_os: Sequence[str],
+ *,
+ target: str,
+) -> list[PlatformName]:
+ """Resolve the platforms a target runs on, from CI overrides then its supported OS list.
+
+ Only the override is parsed strictly. It is hand-written configuration, so a name we do not
+ recognize is a mistake worth failing on, and a repeated one would plan two identically named
+ jobs.
+ """
+ if platform_override:
+ platforms = [parse_platform_name(value, target=target) for value in platform_override]
+ if len(set(platforms)) != len(platforms):
+ raise PlanningError(f"Duplicate platform in the CI `platforms` override for `{target}`")
+ return platforms
+
+ # `manifest.json` advertises platforms ddev has no runner for, such as AIX, so the supported OS
+ # list only decides Windows-exclusivity rather than being parsed. Only a Windows-exclusive
+ # target runs on Windows by default; anything else runs on Linux alone, and extra platforms are
+ # opt-in through the CI `platforms` override handled above.
+ if [value.lower() for value in supported_os] == [str(PlatformName.WINDOWS)]:
+ return [PlatformName.WINDOWS]
+
+ return [PlatformName.LINUX]
+
+
+def group_environments_by_platform(
+ environments: Sequence[ResolvedEnvironment],
+) -> dict[PlatformName, list[ResolvedEnvironment]]:
+ """Group resolved environments by their target platform, preserving order."""
+ grouped: dict[PlatformName, list[ResolvedEnvironment]] = {}
+ for environment in environments:
+ grouped.setdefault(environment.platform, []).append(environment)
+ return grouped
+
+
+def _display_order_key(target: str) -> tuple[int, str]:
+ return DISPLAY_ORDER_OVERRIDE.get(target, len(DISPLAY_ORDER_OVERRIDE)), target
+
+
+def expand_test_units(targets: Sequence[TargetDefinition]) -> list[TestUnit]:
+ """Expand targets into deterministically ordered test units, one per resolved environment.
+
+ A platform whose environments are all constrained elsewhere gets no units, which is the
+ constraint working as intended rather than an error.
+ """
+ ordered_targets = sorted(targets, key=lambda target: _display_order_key(target.name))
+
+ units: list[TestUnit] = []
+ for target in ordered_targets:
+ if not target.environments:
+ raise PlanningError(f"{target.name!r} reached unit expansion with no environments")
+
+ display_name = target.display_name or target.name
+ environments_by_platform = group_environments_by_platform(target.environments)
+
+ for platform_id in target.platforms:
+ platform = PLATFORMS[platform_id]
+ base_name = display_name
+ if len(target.platforms) > 1:
+ base_name += f" on {platform.name}"
+ job_name = normalize_job_name(base_name)
+ runner_labels = tuple(target.runners.get(platform_id, [platform.image]))
+
+ platform_environments = environments_by_platform.get(platform_id, [])
+ if not platform_environments:
+ logger.warning("%s runs on %s but no environment tests it", target.name, platform_id)
+ continue
+
+ for environment in platform_environments:
+ if environment.name and environment.name != target.name:
+ name = f"{job_name} ({environment.name})"
+ else:
+ name = job_name
+ units.append(
+ TestUnit(
+ target=target.name,
+ name=name,
+ platform=platform_id,
+ runner_labels=runner_labels,
+ environment=environment,
+ )
+ )
+
+ return units
diff --git a/ddev/src/ddev/cli/ci/tests/batching/validation.py b/ddev/src/ddev/cli/ci/tests/batching/validation.py
new file mode 100644
index 0000000000000..c2b9db177ee5f
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/batching/validation.py
@@ -0,0 +1,87 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Validation of a batch partition, applied to every strategy's output."""
+
+from __future__ import annotations
+
+from collections import Counter
+from typing import TYPE_CHECKING
+
+from ddev.cli.ci.tests.batching.exceptions import BatchValidationError
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ from ddev.cli.ci.tests.dispatcher_config import BatchingConfig
+ from ddev.cli.ci.tests.messages import BatchJob
+
+
+def validate_batches(
+ job_groups: Sequence[Sequence[BatchJob]],
+ jobs: Sequence[BatchJob],
+ *,
+ config: BatchingConfig,
+):
+ """Enforce the batch-execution contract, so no strategy can emit an unrunnable plan.
+
+ Rejects empty or over-capacity batches, duplicate job names or artifact identities within a
+ batch, any deviation from exact once-per-job coverage of `jobs`, and unjustified integration
+ splitting.
+
+ Artifact identity is checked as well as the display name because sanitization can collapse two
+ differently named jobs onto one artifact, whose files would then overwrite each other.
+ """
+ capacity = config.max_jobs_per_batch
+ for index, group in enumerate(job_groups):
+ if not group:
+ raise BatchValidationError(f"Batch at index {index} is empty.")
+ if len(group) > capacity:
+ raise BatchValidationError(f"Batch at index {index} has {len(group)} jobs, exceeding capacity {capacity}.")
+ names = [job.name for job in group]
+ if len(names) != len(set(names)):
+ raise BatchValidationError(f"Batch at index {index} has duplicate job names.")
+ artifact_names = [job.artifact_name() for job in group]
+ if len(artifact_names) != len(set(artifact_names)):
+ raise BatchValidationError(f"Batch at index {index} has duplicate artifact identities.")
+
+ _validate_coverage(job_groups, jobs)
+ _validate_splitting(job_groups, jobs, capacity=capacity, config=config)
+
+
+def _validate_coverage(job_groups: Sequence[Sequence[BatchJob]], jobs: Sequence[BatchJob]):
+ """Require the partition to contain every input job exactly once, compared by value.
+
+ By value, not identity, so a strategy may rebuild equal jobs instead of passing the original
+ instances through.
+ """
+ planned = Counter(job for group in job_groups for job in group)
+ expected = Counter(jobs)
+ if planned != expected:
+ raise BatchValidationError("Planned batches must cover every input job exactly once.")
+
+
+def _validate_splitting(
+ job_groups: Sequence[Sequence[BatchJob]],
+ jobs: Sequence[BatchJob],
+ *,
+ capacity: int,
+ config: BatchingConfig,
+):
+ target_counts = Counter(job.target for job in jobs)
+ batches_per_target: dict[str, set[int]] = {}
+ for index, group in enumerate(job_groups):
+ for job in group:
+ batches_per_target.setdefault(job.target, set()).add(index)
+
+ for target, indices in batches_per_target.items():
+ if len(indices) <= 1:
+ continue
+ if not config.allow_integration_splitting:
+ raise BatchValidationError(
+ f"Integration {target!r} is split across batches but integration splitting is disabled."
+ )
+ if target_counts[target] <= capacity:
+ raise BatchValidationError(
+ f"Integration {target!r} fits in one batch ({target_counts[target]} <= {capacity}) but was split."
+ )
diff --git a/ddev/src/ddev/cli/ci/tests/changes.py b/ddev/src/ddev/cli/ci/tests/changes.py
new file mode 100644
index 0000000000000..12bab1e77e7fa
--- /dev/null
+++ b/ddev/src/ddev/cli/ci/tests/changes.py
@@ -0,0 +1,42 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Selection of the commits a CI run compares to find what it must test."""
+
+from __future__ import annotations
+
+import enum
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from ddev.utils.git import ChangedFile, GitRepository
+
+
+class CIContext(enum.Enum):
+ """The comparison context that determines which revisions are diffed."""
+
+ PULL_REQUEST = enum.auto()
+ DEFAULT_BRANCH = enum.auto()
+
+
+def get_changed_files(
+ git: GitRepository,
+ tested_commit: str,
+ *,
+ context: CIContext,
+ target_branch: str | None = None,
+) -> list[ChangedFile]:
+ """Return the changes the tested commit is responsible for.
+
+ A pull request is compared with the merge base of its target branch, so unrelated commits
+ landing on that branch meanwhile do not count as changes. On the default branch the comparison
+ is against the tested commit's first parent, which is the commit's own contribution.
+ """
+ if context is CIContext.PULL_REQUEST:
+ if not target_branch:
+ raise ValueError("A target branch is required to compare a pull request against its merge base")
+ base = target_branch
+ else:
+ base = f"{tested_commit}^1"
+
+ return git.changed_files(base, tested_commit)
diff --git a/ddev/src/ddev/cli/ci/tests/dispatcher_config.py b/ddev/src/ddev/cli/ci/tests/dispatcher_config.py
index f5d7ec0c03328..fc9d45cf33f10 100644
--- a/ddev/src/ddev/cli/ci/tests/dispatcher_config.py
+++ b/ddev/src/ddev/cli/ci/tests/dispatcher_config.py
@@ -15,13 +15,26 @@
from ddev.repo.config import RepositoryConfig
+class BatchingConfig(BaseModel):
+ """Policy for turning discovered test units into batched plans, read from `[dispatcher.batching]`."""
+
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ # 240 is GitHub's 256-job matrix cap minus a 16-job setup buffer.
+ max_jobs_per_batch: int = Field(default=240, gt=0, le=240)
+ # Lets an integration with more jobs than one batch holds span several batches.
+ allow_integration_splitting: bool = False
+
+
class DispatcherConfig(BaseModel):
"""Per-repository Dispatcher configuration."""
model_config = ConfigDict(frozen=True, extra="forbid")
- max_jobs_per_batch: int = Field(default=240, gt=0, le=240) # 256 GitHub job cap - 16-job setup buffer; the safe max
global_timeout_seconds: float = Field(default=10800.0, gt=0) # 3 hours
+ # Used when Hatch does not declare a Python version.
+ default_python_version: str = Field(default="3.13", pattern=r"^\d+\.\d+$")
+ batching: BatchingConfig = BatchingConfig()
github_rate_limits: RateLimiterFactoryConfig = RateLimiterFactoryConfig()
@classmethod
diff --git a/ddev/src/ddev/cli/ci/tests/messages.py b/ddev/src/ddev/cli/ci/tests/messages.py
index 83489ed6ca955..7fcd40f85fbe0 100644
--- a/ddev/src/ddev/cli/ci/tests/messages.py
+++ b/ddev/src/ddev/cli/ci/tests/messages.py
@@ -5,12 +5,12 @@
import re
from dataclasses import dataclass, field
-from enum import StrEnum, auto
from typing import TYPE_CHECKING
from ddev.cli.ci.tests.status import Status
from ddev.event_bus.orchestrator import BaseMessage
from ddev.utils.junit import TestStatus
+from ddev.utils.platform import PlatformName
if TYPE_CHECKING:
from pathlib import Path
@@ -20,14 +20,6 @@
from ddev.utils.junit import JUnitReport, JUnitTestCase
-class Platform(StrEnum):
- """Operating system a test job runs on."""
-
- LINUX = auto()
- WINDOWS = auto()
- MACOS = auto()
-
-
# Characters GitHub disallows in an artifact name (plus CR/LF).
ARTIFACT_NAME_DISALLOWED = re.compile(r'["\:<>|*?\\/\r\n]')
# Separator between the artifact name's fields. Names are matched by reconstruction, not by
@@ -35,27 +27,33 @@ class Platform(StrEnum):
ARTIFACT_NAME_SEPARATOR = "_"
-@dataclass
+@dataclass(frozen=True)
class BatchJob:
- """A single job entry in a TestBatch."""
+ """One execution of a target, in one environment, on one platform.
+
+ A job is never duplicated into separate unit and E2E rows; the facet flags say which kinds of
+ tests the single execution produces. Frozen so jobs are hashable and a batch partition can be
+ validated by value.
+ """
name: str
target: str
- runner: str
- environment: str
- platform: Platform
+ runner_labels: tuple[str, ...]
+ environment: str # empty for a target that defines no environments
+ platform: PlatformName
+ python_version: str # `major.minor`, set up on the runner
unit_tests: bool
e2e_tests: bool
+ agent_image: str | None = None # `None` when the job runs no E2E tests
def artifact_name(self) -> str:
- """Deterministic artifact name built from the job's target, environment, and platform.
+ """Sanitized, deterministic name built from the job's target, environment, and platform.
- Pure and deterministic. Each field is sanitized to GitHub's artifact-name constraints and
- joined by the separator. Uniqueness within a batch relies on those three fields being
- distinct per job.
+ Those three fields are the job's identity, so the name is unique within a batch. An
+ environmentless job contributes no segment rather than an empty one.
"""
fields = (self.target, self.environment, self.platform)
- return ARTIFACT_NAME_SEPARATOR.join(ARTIFACT_NAME_DISALLOWED.sub("_", field) for field in fields)
+ return ARTIFACT_NAME_SEPARATOR.join(ARTIFACT_NAME_DISALLOWED.sub("_", field) for field in fields if field)
@dataclass
@@ -68,7 +66,7 @@ class JobResult:
integration: str
environment: str
- platform: Platform
+ platform: PlatformName
status: Status
failed_steps: list[str] = field(default_factory=list)
reports: tuple[JUnitReport, ...] = ()
@@ -89,8 +87,8 @@ def failed_tests(self) -> list[JUnitTestCase]:
class BatchJobResult:
"""Everything known about a single job in a finished batch, correlated by the producer.
- ``artifact_name_path`` is the single downloaded folder for the job (named after the job's
- ``artifact_name``); the three ``*_artifact_name`` fields are the expected per-facet file names
+ `artifact_name_path` is the single downloaded folder for the job (named after the job's
+ `artifact_name`); the three `*_artifact_name` fields are the expected per-facet file names
inside that folder.
"""
@@ -110,9 +108,9 @@ def correlate(
"""Correlate each job's spec, its workflow-run result, and its artifact directory.
The workflow-job join is by name (tolerant of misses). Each job's artifact folder is matched
- by reconstructing its name from the job's fields (``artifact_name``) and looking it up among
+ by reconstructing its name from the job's fields (`artifact_name`) and looking it up among
the downloaded folders; the path is recorded only when it exists on disk. That single folder
- holds the three per-facet files, whose names (``unit-``/``e2e-``/``coverage-`` prefixed on
+ holds the three per-facet files, whose names (`unit-`/`e2e-`/`coverage-` prefixed on
the base name) are recorded for the gatherer. A job missing from the API or from disk still
yields a well-formed result.
"""
@@ -140,8 +138,8 @@ def correlate(
class WorkflowStatus:
"""Status of a single GitHub Actions workflow run (one batch), with every job's result.
- ``batch_id`` is the human batch identifier (e.g. ``batch-01``) the comment renders; ``id`` is the
- numeric workflow run id and ``url`` links to the run.
+ `batch_id` is the human batch identifier (e.g. `batch-01`) the comment renders; `id` is the
+ numeric workflow run id and `url` links to the run.
"""
batch_id: str
@@ -166,10 +164,12 @@ def status(self) -> Status:
class TestBatch(BaseMessage):
"""Dispatched to trigger a matrix of test jobs.
- ``batch_id`` is the logical batch identity (e.g. ``batch-01``): assigned during planning, stable
- across workflow attempts, and distinct from ``BaseMessage.id``, which identifies one message.
+ The `batch_id` is the logical batch identity (e.g. `batch-01`): assigned during planning, stable
+ across workflow attempts, and distinct from `BaseMessage.id`, which identifies one message.
"""
+ # Logical batch identity (`batch-01`) that downstream processors correlate on. Distinct from
+ # the inherited message `id`, which identifies the message instance.
batch_id: str
job_list: list[BatchJob]
jobs_count: int
@@ -186,7 +186,7 @@ class BatchFinished(BaseMessage):
batch_id: str
status: Status
- run_id: int
+ run_id: int # GitHub Actions workflow run
workflow_url: str
artifacts_path: str
timed_out: bool = False
diff --git a/ddev/src/ddev/cli/ci/tests/task_test_gatherer.py b/ddev/src/ddev/cli/ci/tests/task_test_gatherer.py
index e7044763c01f1..35bfcc7044ad4 100644
--- a/ddev/src/ddev/cli/ci/tests/task_test_gatherer.py
+++ b/ddev/src/ddev/cli/ci/tests/task_test_gatherer.py
@@ -223,7 +223,7 @@ def _organize_artifacts(self, job_artifacts_path: Path, batch_job: BatchJob) ->
"""Copy coverage and JUnit files into the output tree, prefixed by the job's
target/environment/platform — the same fields that make ``BatchJob.artifact_name`` unique.
"""
- prefix = f"{batch_job.target}-{batch_job.environment}-{batch_job.platform}"
+ prefix = batch_job.artifact_name()
coverage_dir = self._output_base_path / "coverage"
for index, coverage_file in enumerate(sorted(job_artifacts_path.rglob(COVERAGE_GLOB))):
@@ -234,7 +234,7 @@ def _organize_artifacts(self, job_artifacts_path: Path, batch_job: BatchJob) ->
for junit_file in sorted(job_artifacts_path.rglob(JUNIT_GLOB)):
self._copy(junit_file, test_results_dir / f"{prefix}-{junit_file.stem}.xml")
- def _copy(self, source: Path, destination: Path) -> None:
+ def _copy(self, source: Path, destination: Path):
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
self._logger.debug("Organized artifact %s -> %s", source, destination)
diff --git a/ddev/src/ddev/cli/ci/tests/task_test_runner.py b/ddev/src/ddev/cli/ci/tests/task_test_runner.py
index 288f9f506bea5..214dd37251fc0 100644
--- a/ddev/src/ddev/cli/ci/tests/task_test_runner.py
+++ b/ddev/src/ddev/cli/ci/tests/task_test_runner.py
@@ -39,13 +39,13 @@ class TaskTestRunner(AsyncProcessor[TestBatch]):
and emits a ``BatchFinished``.
"""
- def __init__(self, name: str, client: AsyncGitHubClient, options: TestRunnerOptions) -> None:
+ def __init__(self, name: str, client: AsyncGitHubClient, options: TestRunnerOptions):
super().__init__(name)
self._client = client
self._options = options
self._logger = logging.getLogger(f"{__name__}.{name}")
- async def process_message(self, message: TestBatch) -> None:
+ async def process_message(self, message: TestBatch):
inputs = self._build_inputs(message)
log_extra: dict[str, Any] = {"batch_id": message.batch_id}
diff --git a/ddev/src/ddev/e2e/agent_images.py b/ddev/src/ddev/e2e/agent_images.py
new file mode 100644
index 0000000000000..f8bf827e7adfe
--- /dev/null
+++ b/ddev/src/ddev/e2e/agent_images.py
@@ -0,0 +1,127 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Agent Docker images to run E2E tests against, selected by Python version and platform.
+
+The Agent embeds its own Python, so an E2E run is only meaningful against an Agent whose embedded
+Python matches the one the environment tests under. This module owns that mapping.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+from ddev.utils.platform import PlatformName
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+REGISTRY = 'registry.datadoghq.com'
+
+# Hatch reports an environment's Python as `major.minor`. Anything else (a bare major, a
+# free-threaded `3.13t`, an interpreter path) is rejected rather than guessed at.
+PYTHON_VERSION_PATTERN = re.compile(r'^\d+\.\d+$')
+
+IMAGE_REFERENCE_PATTERN = re.compile(r'^(?P[^/]+)/(?P.+):(?P[^:]+)$')
+
+
+class AgentImageError(Exception):
+ """Raised when no Agent image can be named for what was asked."""
+
+
+class UnknownPythonVersion(AgentImageError):
+ """Raised when no Agent release line embeds the requested Python version."""
+
+
+class UnsupportedAgentPlatform(AgentImageError):
+ """Raised when no Agent Docker image is published for the requested platform."""
+
+
+@dataclass(frozen=True)
+class AgentImages:
+ """The Linux and Windows Agent Docker images for one Agent release line."""
+
+ linux: str
+ windows: str
+
+ def for_platform(self, platform: PlatformName) -> str:
+ if platform is PlatformName.LINUX:
+ return self.linux
+ if platform is PlatformName.WINDOWS:
+ return self.windows
+
+ raise UnsupportedAgentPlatform(f'No Agent Docker image is published for platform {platform!r}')
+
+
+def released_images(version: str) -> AgentImages:
+ """Build the image pair for a published Agent release."""
+ return AgentImages(linux=f'{REGISTRY}/agent:{version}', windows=f'{REGISTRY}/agent:{version}-servercore')
+
+
+# Each Agent release line embeds one Python version, recorded in datadog-agent's
+# `omnibus/config/software/python3.rb`. Superseded lines are pinned to their last release, the
+# current one tracks the dev build. When a new line bumps its Python, pin the outgoing one to its
+# final release and point the new version at the dev images.
+# There is no 3.10 entry because the Agent went from 3.9 straight to 3.11.
+#
+# These are base tags. `-jmx` is absent by design: whether an environment needs it is runtime
+# metadata (`use_jmx`), and `ddev.e2e.agent.docker` appends the suffix itself.
+AGENT_IMAGES_BY_PYTHON: dict[str, AgentImages] = {
+ # 7.72 onwards
+ '3.13': AgentImages(linux=f'{REGISTRY}/agent-dev:master-py3', windows=f'{REGISTRY}/agent:7-rc-servercore'),
+ # 7.58 - 7.71
+ '3.12': released_images('7.71.1'),
+ # 7.51 - 7.57
+ '3.11': released_images('7.57.2'),
+ # 7.47 - 7.50
+ '3.9': released_images('7.50.3'),
+ # 7.38 - 7.46
+ '3.8': released_images('7.46.0'),
+}
+
+
+def get_agent_image(python_version: str, platform: PlatformName) -> str:
+ """Return the Agent Docker image to run E2E tests for `python_version` on `platform`.
+
+ Pure and offline, so the same inputs always plan the same image.
+ """
+ if not PYTHON_VERSION_PATTERN.match(python_version):
+ raise UnknownPythonVersion(
+ f'Invalid Python version {python_version!r}; expected a `major.minor` version such as `3.13`'
+ )
+
+ images = AGENT_IMAGES_BY_PYTHON.get(python_version)
+ if images is None:
+ supported = ', '.join(sorted(AGENT_IMAGES_BY_PYTHON))
+ raise UnknownPythonVersion(f'No Agent release embeds Python {python_version}; known versions: {supported}')
+
+ return images.for_platform(platform)
+
+
+def parse_image_reference(image: str) -> tuple[str, str, str]:
+ """Split a fully qualified image into its registry host, repository, and tag."""
+ match = IMAGE_REFERENCE_PATTERN.match(image)
+ if match is None:
+ raise ValueError(f'Not a fully qualified `host/repository:tag` image reference: {image!r}')
+
+ return match.group('host'), match.group('repository'), match.group('tag')
+
+
+def find_unpublished_images(images: Iterable[str]) -> list[str]:
+ """Return the given images that the registry does not serve, in first-seen order.
+
+ An explicit preflight, kept out of `get_agent_image` so planning stays offline. Each distinct
+ image is queried once, and registry errors other than a missing manifest propagate rather than
+ being reported as an absent image.
+ """
+ from ddev.utils.docker_registry import manifest_exists
+
+ missing: list[str] = []
+ for image in dict.fromkeys(images):
+ host, repository, tag = parse_image_reference(image)
+ if not manifest_exists(repository, tag, host=host):
+ missing.append(image)
+
+ return missing
diff --git a/ddev/src/ddev/utils/platform.py b/ddev/src/ddev/utils/platform.py
index 246dcc2bc11bd..acfa616a70a44 100644
--- a/ddev/src/ddev/utils/platform.py
+++ b/ddev/src/ddev/utils/platform.py
@@ -6,10 +6,26 @@
import os
import sys
from collections.abc import Callable
+from enum import StrEnum, auto
from functools import lru_cache
from importlib import import_module
+class PlatformName(StrEnum):
+ """A platform ddev knows how to target.
+
+ Values match `normalize_platform_name` output, `.ddev/config.toml` `platforms` entries, and
+ Hatch's `platforms` constraints, so a raw configuration string compares equal to its member.
+
+ `normalize_platform_name` still returns a plain string, since `platform.system()` can report
+ systems ddev does not target. This is unrelated to `Platform`, the host ddev runs on.
+ """
+
+ LINUX = auto()
+ WINDOWS = auto()
+ MACOS = auto()
+
+
@lru_cache(maxsize=None)
def get_platform_name():
import platform
diff --git a/ddev/tests/cli/ci/tests/batching/__init__.py b/ddev/tests/cli/ci/tests/batching/__init__.py
new file mode 100644
index 0000000000000..75c6647cb9233
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/batching/__init__.py
@@ -0,0 +1,3 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
diff --git a/ddev/tests/cli/ci/tests/batching/test_build.py b/ddev/tests/cli/ci/tests/batching/test_build.py
new file mode 100644
index 0000000000000..b435afe1ab333
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/batching/test_build.py
@@ -0,0 +1,368 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""End-to-end tests for the public entry point, from changed files to ordered test units.
+
+These use synthetic stand-ins for ddev's repository/registry/config and a synthetic
+environment provider, so neither Git nor Hatch is ever invoked.
+"""
+
+from __future__ import annotations
+
+import logging
+
+import pytest
+
+from ddev.cli.ci.tests.batching.build import (
+ build_test_batches,
+ build_test_units,
+ create_test_batches,
+ resolve_hatch_environments,
+)
+from ddev.cli.ci.tests.batching.exceptions import BatchValidationError, PlanningError
+from ddev.cli.ci.tests.dispatcher_config import BatchingConfig
+from ddev.utils.platform import PlatformName
+from tests.cli.ci.tests.helpers import DEFAULT_PYTHON_VERSION, FakeIntegration, FakeRegistry, env, jobs, modified
+
+
+class FakeConfig:
+ def __init__(self, ci=None):
+ self._ci = ci or {}
+
+ def get(self, pointer, default=None):
+ prefix = "/overrides/ci/"
+ if pointer.startswith(prefix):
+ return self._ci.get(pointer[len(prefix) :], default)
+ return default
+
+
+class FakeRepo:
+ def __init__(self, integrations, ci=None, name="core"):
+ self.name = name
+ self.integrations = FakeRegistry(integrations)
+ self.config = FakeConfig(ci)
+
+
+class FakeEnvironmentProvider:
+ """Returns pre-configured resolved environments per integration; ignores the platforms hint."""
+
+ def __init__(self, environments):
+ self._environments = environments
+
+ def __call__(self, integration, platforms):
+ return list(self._environments.get(integration.name, []))
+
+
+class EnvStub:
+ """Minimal stand-in for ddev's Hatch ``Environment`` (no Hatch invocation)."""
+
+ def __init__(self, name, *, test_env=True, e2e_env=False, platforms=(), python=None):
+ self.name = name
+ self.test_env = test_env
+ self.e2e_env = e2e_env
+ self.platforms = list(platforms)
+ self.python = python
+
+
+def test_build_end_to_end_direct_and_broad_overlap():
+ repo = FakeRepo(
+ [
+ FakeIntegration("postgres"),
+ FakeIntegration("mysql"),
+ FakeIntegration("datadog_checks_base"),
+ ]
+ )
+ provider = FakeEnvironmentProvider(
+ {
+ "postgres": [env("py3.11")],
+ "mysql": [env("py3.11")],
+ "datadog_checks_base": [env("py3.11")],
+ }
+ )
+ changed = [
+ modified("postgres/tests/test_a.py"),
+ modified("datadog_checks_base/datadog_checks/base/utils/foo.py"),
+ ]
+
+ units = build_test_units(repo, changed, environment_provider=provider)
+
+ # Broad rule adds the full eligible set; direct rule adds postgres; deduped and then ordered
+ # by the display-order override (datadog_checks_base first, then alphabetical).
+ assert [(u.target, u.name, u.environment.name) for u in units] == [
+ ("datadog_checks_base", "datadog_checks_base (py3.11)", "py3.11"),
+ ("mysql", "mysql (py3.11)", "py3.11"),
+ ("postgres", "postgres (py3.11)", "py3.11"),
+ ]
+
+
+def test_build_warns_about_a_target_with_no_testable_environment(caplog):
+ repo = FakeRepo([FakeIntegration("ddev")])
+ provider = FakeEnvironmentProvider({})
+ changed = [modified("ddev/src/ddev/foo.py")]
+
+ with caplog.at_level(logging.WARNING, logger="ddev.cli.ci.tests.batching.build"):
+ units = build_test_units(repo, changed, environment_provider=provider)
+
+ # The target is dropped rather than planned with an invented environment.
+ assert units == []
+ assert "ddev has a hatch.toml but no testable environment" in caplog.text
+
+
+def test_build_plans_nothing_for_a_platform_whose_environments_are_constrained_elsewhere(caplog):
+ # A target declaring a platform that every environment is constrained away from is a weaker
+ # version of the same contradiction: odd configuration, worth surfacing, not worth failing.
+ repo = FakeRepo([FakeIntegration("disk")], ci={"disk": {"platforms": ["linux", "windows"]}})
+ provider = FakeEnvironmentProvider({"disk": [env("py3.13", platform=PlatformName.LINUX)]})
+ changed = [modified("disk/tests/test_a.py")]
+
+ with caplog.at_level(logging.WARNING, logger="ddev.cli.ci.tests.batching.units"):
+ units = build_test_units(repo, changed, environment_provider=provider)
+
+ assert [unit.platform for unit in units] == [PlatformName.LINUX]
+ assert "disk runs on windows but no environment tests it" in caplog.text
+
+
+def test_build_excludes_target_via_ci_override():
+ repo = FakeRepo(
+ [FakeIntegration("postgres"), FakeIntegration("hyperv")],
+ ci={"hyperv": {"exclude": True}},
+ )
+ provider = FakeEnvironmentProvider({"postgres": [env("py3.11")], "hyperv": [env("py3.11")]})
+ changed = [modified("postgres/tests/test_a.py"), modified("hyperv/tests/test_b.py")]
+
+ units = build_test_units(repo, changed, environment_provider=provider)
+
+ assert {u.target for u in units} == {"postgres"}
+
+
+def test_build_applies_platform_and_runner_overrides():
+ repo = FakeRepo(
+ [FakeIntegration("sqlserver")],
+ ci={"sqlserver": {"platforms": ["windows", "linux"], "runners": {"windows": ["windows-2022"]}}},
+ )
+ provider = FakeEnvironmentProvider(
+ {"sqlserver": [env("py3.13", PlatformName.WINDOWS), env("py3.13", PlatformName.LINUX)]},
+ )
+ changed = [modified("sqlserver/tests/test_a.py")]
+
+ units = build_test_units(repo, changed, environment_provider=provider)
+
+ assert [(u.platform, u.runner_labels) for u in units] == [
+ (PlatformName.WINDOWS, ("windows-2022",)),
+ (PlatformName.LINUX, ("ubuntu-22.04",)),
+ ]
+
+
+def test_resolve_hatch_environments_includes_both_facets_and_excludes_neither():
+ environments = [
+ EnvStub("unit-only", test_env=True, e2e_env=False),
+ EnvStub("e2e-only", test_env=False, e2e_env=True),
+ EnvStub("both", test_env=True, e2e_env=True),
+ EnvStub("neither", test_env=False, e2e_env=False),
+ ]
+
+ resolved = resolve_hatch_environments(
+ environments, default_python_version=DEFAULT_PYTHON_VERSION, platforms=[PlatformName.LINUX]
+ )
+
+ assert [(r.name, r.test_available, r.e2e_available) for r in resolved] == [
+ ("unit-only", True, False),
+ ("e2e-only", False, True),
+ ("both", True, True),
+ ]
+
+
+@pytest.mark.parametrize("python", ["3", "3.13t", "/usr/bin/python3.13", "three.thirteen"])
+def test_resolve_hatch_environments_rejects_a_python_that_is_not_major_minor(python):
+ # A unit-only environment never reaches the Agent image resolver, so this boundary is the only
+ # place its version is checked.
+ environments = [EnvStub("unit-only", test_env=True, e2e_env=False, python=python)]
+
+ with pytest.raises(PlanningError, match="expected a `major.minor` version"):
+ resolve_hatch_environments(
+ environments, default_python_version=DEFAULT_PYTHON_VERSION, platforms=[PlatformName.LINUX]
+ )
+
+
+def test_resolve_hatch_environments_routes_constrained_platforms_without_crossing():
+ # Mirrors sqlserver: os matrix surfaces as Environment.platforms via overrides.matrix.os.platforms.
+ environments = [
+ EnvStub("py3.13-linux", platforms=["linux", "macos"]),
+ EnvStub("py3.13-windows", platforms=["windows"]),
+ ]
+
+ resolved = resolve_hatch_environments(
+ environments,
+ default_python_version=DEFAULT_PYTHON_VERSION,
+ platforms=[PlatformName.WINDOWS, PlatformName.LINUX],
+ )
+
+ # Each environment lands only on its declared platform (intersected with the target's);
+ # the Linux env never duplicates onto Windows and vice versa, and macos is dropped.
+ assert [(r.name, r.platform) for r in resolved] == [
+ ("py3.13-linux", PlatformName.LINUX),
+ ("py3.13-windows", PlatformName.WINDOWS),
+ ]
+
+
+def test_resolve_hatch_environments_unconstrained_runs_on_every_platform():
+ environments = [EnvStub("py3.11", platforms=[])]
+
+ resolved = resolve_hatch_environments(
+ environments,
+ default_python_version=DEFAULT_PYTHON_VERSION,
+ platforms=[PlatformName.LINUX, PlatformName.WINDOWS],
+ )
+
+ # An environment that names no platform belongs to all of them, so the platform a target
+ # happens to list first carries no meaning.
+ assert [(r.name, r.platform) for r in resolved] == [
+ ("py3.11", PlatformName.LINUX),
+ ("py3.11", PlatformName.WINDOWS),
+ ]
+
+
+def test_resolve_hatch_environments_carries_facets_and_python_to_every_platform():
+ # Regression: the second platform used to fall through to a synthesised environment that
+ # claimed the default Python and no E2E, silently dropping Windows E2E for targets like disk.
+ environments = [EnvStub("py3.11", test_env=True, e2e_env=True, python="3.11")]
+
+ resolved = resolve_hatch_environments(
+ environments,
+ default_python_version=DEFAULT_PYTHON_VERSION,
+ platforms=[PlatformName.LINUX, PlatformName.WINDOWS],
+ )
+
+ assert [(r.platform, r.python_version, r.test_available, r.e2e_available) for r in resolved] == [
+ (PlatformName.LINUX, "3.11", True, True),
+ (PlatformName.WINDOWS, "3.11", True, True),
+ ]
+
+
+def test_resolve_hatch_environments_reads_the_python_version_from_hatch():
+ environments = [EnvStub("py3.11-1.23", python="3.11")]
+
+ resolved = resolve_hatch_environments(
+ environments, default_python_version=DEFAULT_PYTHON_VERSION, platforms=[PlatformName.LINUX]
+ )
+
+ assert resolved[0].python_version == "3.11"
+
+
+def test_resolve_hatch_environments_falls_back_when_hatch_declares_no_python():
+ # Hatch omits `python` when the environment does not pin one; the name is not parsed as a
+ # substitute because it only encodes the version by convention.
+ environments = [EnvStub("py3.11-1.23", python=None)]
+
+ resolved = resolve_hatch_environments(environments, default_python_version="3.9", platforms=[PlatformName.LINUX])
+
+ assert resolved[0].python_version == "3.9"
+
+
+def test_build_batches_end_to_end_split_defaults():
+ repo = FakeRepo([FakeIntegration("postgres")])
+ provider = FakeEnvironmentProvider({"postgres": [env("py3.11", unit=True, e2e=True)]})
+ changed = [modified("postgres/tests/test_a.py")]
+
+ batches = build_test_batches(
+ repo,
+ changed,
+ environment_provider=provider,
+ config=BatchingConfig(),
+ )
+
+ assert len(batches) == 1
+ batch = batches[0]
+ assert batch.batch_id == "batch-01"
+ assert batch.integrations == ["postgres"]
+ # One job per target/environment/platform, carrying both facet flags for a both-enabled env.
+ assert [(j.name, j.environment, j.unit_tests, j.e2e_tests) for j in batch.job_list] == [
+ ("postgres (py3.11)", "py3.11", True, True),
+ ]
+ assert batch.jobs_count == 1
+
+
+def test_build_batches_empty_input_returns_no_batches():
+ repo = FakeRepo([FakeIntegration("postgres")])
+ provider = FakeEnvironmentProvider({"postgres": [env("py3.11")]})
+ changed = [modified("docs/readme.md")]
+
+ assert (
+ build_test_batches(
+ repo,
+ changed,
+ environment_provider=provider,
+ config=BatchingConfig(),
+ )
+ == []
+ )
+
+
+def test_build_batches_rejects_invalid_injected_strategy():
+ repo = FakeRepo([FakeIntegration("postgres")])
+ # Two environments expand to two jobs, so dropping one leaves a coverage gap.
+ provider = FakeEnvironmentProvider({"postgres": [env("py3.11"), env("py3.12")]})
+ changed = [modified("postgres/tests/test_a.py")]
+
+ def dropping_strategy(jobs, *, config):
+ return [list(jobs[:-1])] # loses the last job
+
+ with pytest.raises(BatchValidationError, match="exactly once"):
+ build_test_batches(
+ repo,
+ changed,
+ environment_provider=provider,
+ config=BatchingConfig(),
+ strategy=dropping_strategy,
+ )
+
+
+def test_create_test_batches_numbers_and_populates_messages():
+ groups = [jobs("postgres", 2), jobs("mysql", 1) + jobs("redis", 1)]
+
+ batches = create_test_batches(groups)
+
+ assert [b.batch_id for b in batches] == ["batch-01", "batch-02"]
+ assert [b.id for b in batches] == ["batch-01", "batch-02"]
+ assert [b.jobs_count for b in batches] == [2, 2]
+ assert batches[0].integrations == ["postgres"]
+ assert batches[1].integrations == ["mysql", "redis"]
+
+
+def test_build_reads_supported_platforms_from_the_manifest():
+ # Without a CI override, platforms come from the manifest's `Supported OS` classifier tags.
+ repo = FakeRepo([FakeIntegration("hyperv", classifier_tags=["Supported OS::Windows"])])
+ provider = FakeEnvironmentProvider({"hyperv": [env("py3.13", PlatformName.WINDOWS)]})
+ changed = [modified("hyperv/tests/test_a.py")]
+
+ units = build_test_units(repo, changed, environment_provider=provider)
+
+ assert [u.platform for u in units] == [PlatformName.WINDOWS]
+
+
+def test_build_ignores_manifest_platforms_ddev_cannot_test():
+ # `ibm_mq` and friends advertise AIX. It has no runner, so it must be ignored rather than
+ # failing the plan for every target the run selected.
+ repo = FakeRepo(
+ [FakeIntegration("ibm_mq", classifier_tags=["Supported OS::Linux", "Supported OS::AIX"])],
+ )
+ provider = FakeEnvironmentProvider({"ibm_mq": [env("py3.13")]})
+ changed = [modified("ibm_mq/tests/test_a.py")]
+
+ units = build_test_units(repo, changed, environment_provider=provider)
+
+ assert [u.platform for u in units] == [PlatformName.LINUX]
+
+
+def test_build_only_expands_the_whole_repository_for_the_core_repo():
+ # The repository-wide rule is gated on the repo name, so the same change outside core selects
+ # only the directly modified target.
+ integrations = [FakeIntegration("postgres"), FakeIntegration("datadog_checks_base")]
+ provider = FakeEnvironmentProvider({"postgres": [env("py3.11")], "datadog_checks_base": [env("py3.11")]})
+ changed = [modified("datadog_checks_base/datadog_checks/base/utils/foo.py")]
+
+ def targets(repo):
+ return {u.target for u in build_test_units(repo, changed, environment_provider=provider)}
+
+ assert targets(FakeRepo(integrations)) == {"postgres", "datadog_checks_base"}
+ assert targets(FakeRepo(integrations, name="extras")) == {"datadog_checks_base"}
diff --git a/ddev/tests/cli/ci/tests/batching/test_jobs.py b/ddev/tests/cli/ci/tests/batching/test_jobs.py
new file mode 100644
index 0000000000000..6e356ddc55087
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/batching/test_jobs.py
@@ -0,0 +1,95 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Tests for expanding test units into concrete jobs."""
+
+from __future__ import annotations
+
+import pytest
+
+from ddev.cli.ci.tests.batching.exceptions import PlanningError
+from ddev.cli.ci.tests.batching.jobs import expand_batch_jobs
+from ddev.e2e.agent_images import UnknownPythonVersion
+from ddev.utils.platform import PlatformName
+from tests.cli.ci.tests.helpers import env, make_unit
+
+
+def fake_resolver(python_version: str, platform: PlatformName) -> str:
+ return f"agent:{python_version}-{platform}"
+
+
+@pytest.mark.parametrize(
+ ("environment_name", "unit", "e2e"),
+ [
+ pytest.param("py3.11", True, True, id="both-facets-stay-one-job"),
+ pytest.param("py3.12", True, False, id="unit-only"),
+ pytest.param("py3.12", False, True, id="e2e-only"),
+ pytest.param("", True, False, id="environmentless"),
+ ],
+)
+def test_each_environment_becomes_one_job_carrying_its_facets(environment_name, unit, e2e):
+ units = [make_unit(environment=env(environment_name, unit=unit, e2e=e2e))]
+
+ [job] = expand_batch_jobs(units, agent_image_resolver=fake_resolver)
+
+ assert (job.environment, job.unit_tests, job.e2e_tests) == (environment_name, unit, e2e)
+
+
+def test_e2e_job_carries_the_resolved_agent_image():
+ units = [make_unit(environment=env("py3.11", python_version="3.11", e2e=True))]
+
+ [job] = expand_batch_jobs(units, agent_image_resolver=fake_resolver)
+
+ assert job.agent_image == "agent:3.11-linux"
+
+
+def test_unresolvable_agent_image_is_ignored_when_the_job_runs_no_e2e():
+ # A Python version with no Agent image only breaks planning for jobs that would have used it.
+ units = [make_unit(environment=env("py3.10", python_version="3.10", e2e=False))]
+
+ [job] = expand_batch_jobs(units)
+
+ assert job.agent_image is None
+
+
+def test_unresolvable_agent_image_fails_planning_for_an_e2e_job():
+ units = [make_unit(environment=env("py3.10", python_version="3.10", e2e=True))]
+
+ # Reported as a planning failure naming the job, since the Agent-image error alone says only
+ # that some 3.10 was asked for
+ with pytest.raises(PlanningError, match="needs an E2E Agent image") as failure:
+ expand_batch_jobs(units)
+
+ assert units[0].name in str(failure.value)
+ assert isinstance(failure.value.__cause__, UnknownPythonVersion)
+
+
+def test_runner_labels_and_platform_are_preserved():
+ units = [
+ make_unit(
+ "sqlserver",
+ platform=PlatformName.WINDOWS,
+ runner_labels=("windows-2022", "x-large"),
+ environment=env("py3.13", platform=PlatformName.WINDOWS),
+ )
+ ]
+
+ [job] = expand_batch_jobs(units, agent_image_resolver=fake_resolver)
+
+ assert (job.runner_labels, job.platform) == (("windows-2022", "x-large"), PlatformName.WINDOWS)
+
+
+def test_multiple_units_expand_in_order_one_job_each():
+ units = [
+ make_unit(name="postgres (py3.11)", environment=env("py3.11")),
+ make_unit(name="postgres (py3.12)", environment=env("py3.12")),
+ make_unit("redis", name="redis (py3.11)", environment=env("py3.11")),
+ ]
+
+ jobs = expand_batch_jobs(units, agent_image_resolver=fake_resolver)
+
+ assert [(j.name, j.target, j.environment) for j in jobs] == [
+ ("postgres (py3.11)", "postgres", "py3.11"),
+ ("postgres (py3.12)", "postgres", "py3.12"),
+ ("redis (py3.11)", "redis", "py3.11"),
+ ]
diff --git a/ddev/tests/cli/ci/tests/batching/test_strategy.py b/ddev/tests/cli/ci/tests/batching/test_strategy.py
new file mode 100644
index 0000000000000..e7696e0ca9311
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/batching/test_strategy.py
@@ -0,0 +1,102 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Tests for the default batching strategy."""
+
+from __future__ import annotations
+
+import pytest
+
+from ddev.cli.ci.tests.batching.exceptions import PlanningError
+from ddev.cli.ci.tests.batching.strategy import default_strategy
+from ddev.cli.ci.tests.dispatcher_config import BatchingConfig
+from ddev.cli.ci.tests.messages import BatchJob
+from tests.cli.ci.tests.helpers import jobs
+
+
+def config(*, capacity: int = 240, allow_integration_splitting: bool = False) -> BatchingConfig:
+ return BatchingConfig(max_jobs_per_batch=capacity, allow_integration_splitting=allow_integration_splitting)
+
+
+def sizes(groups: list[list[BatchJob]]) -> list[int]:
+ return [len(group) for group in groups]
+
+
+# ---------------------------------------------------------------------------
+# default_strategy
+# ---------------------------------------------------------------------------
+
+
+def test_empty_input_returns_no_groups():
+ assert default_strategy([], config=config()) == []
+
+
+@pytest.mark.parametrize(
+ ("groups", "capacity", "expected_sizes", "expected_targets"),
+ [
+ pytest.param(
+ [("postgres", 200), ("mysql", 200)],
+ 210,
+ [200, 200],
+ [{"postgres"}, {"mysql"}],
+ id="integration-larger-than-the-remainder-starts-a-new-batch",
+ ),
+ pytest.param(
+ [("postgres", 100), ("mysql", 150)],
+ 210,
+ [100, 150],
+ [{"postgres"}, {"mysql"}],
+ id="a-remainder-too-small-is-left-unfilled",
+ ),
+ pytest.param(
+ [("a", 80), ("b", 80), ("c", 80), ("d", 80)],
+ 240,
+ [240, 80],
+ [{"a", "b", "c"}, {"d"}],
+ id="small-integrations-pack-together",
+ ),
+ ],
+)
+def test_default_strategy_packing(groups, capacity, expected_sizes, expected_targets):
+ all_jobs = [job for target, count in groups for job in jobs(target, count)]
+
+ result = default_strategy(all_jobs, config=config(capacity=capacity))
+
+ assert sizes(result) == expected_sizes
+ assert [{job.target for job in group} for group in result] == expected_targets
+
+
+def test_oversized_integration_fails_when_splitting_disabled():
+ with pytest.raises(PlanningError, match="exceeding the batch capacity"):
+ default_strategy(jobs("huge", 400), config=config(allow_integration_splitting=False))
+
+
+def test_oversized_integration_spills_across_capacity_bounded_batches_when_enabled():
+ # Canonical case: 400 jobs at capacity 240 occupy 240 then 160.
+ groups = default_strategy(jobs("huge", 400), config=config(allow_integration_splitting=True))
+
+ assert sizes(groups) == [240, 160]
+
+
+def test_oversized_remainder_is_reusable_by_following_integrations():
+ # The 80 free slots left in the second batch by the 400-job integration are used by the next.
+ all_jobs = jobs("huge", 400) + jobs("small", 80)
+ groups = default_strategy(all_jobs, config=config(allow_integration_splitting=True))
+
+ assert sizes(groups) == [240, 240]
+ assert {job.target for job in groups[0]} == {"huge"}
+ assert {job.target for job in groups[1]} == {"huge", "small"}
+ # "small" fit entirely into the remainder, so it is not itself split.
+ assert sum(1 for job in groups[1] if job.target == "small") == 80
+
+
+def test_oversized_integration_spills_starting_from_an_open_batch():
+ # The open batch is filled before the oversized integration starts a new one, so no slot is
+ # wasted: 80 + 400 at capacity 240 packs as 240 then 240 rather than 80 then 240 then 160.
+ all_jobs = jobs("small", 80) + jobs("huge", 400)
+
+ groups = default_strategy(all_jobs, config=config(allow_integration_splitting=True))
+
+ assert sizes(groups) == [240, 240]
+ assert {job.target for job in groups[0]} == {"small", "huge"}
+ assert {job.target for job in groups[1]} == {"huge"}
diff --git a/ddev/tests/cli/ci/tests/batching/test_targets.py b/ddev/tests/cli/ci/tests/batching/test_targets.py
new file mode 100644
index 0000000000000..823d863df546a
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/batching/test_targets.py
@@ -0,0 +1,266 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import pytest
+
+from ddev.cli.ci.tests.batching.targets import (
+ UNTESTABLE_TARGETS,
+ DirectTargetRule,
+ RegistryRepositoryFacts,
+ RepositoryWideRule,
+ default_target_rules,
+ find_affected_targets,
+)
+from tests.cli.ci.tests.helpers import FakeIntegration, FakeRegistry, copied, modified, renamed
+
+CORE_RULES = default_target_rules(is_core=True)
+
+
+@dataclass(frozen=True)
+class FakeRepositoryFacts:
+ """Test-only ``RepositoryFacts`` backed by a fixed target set.
+
+ Production code sources testability from ddev via ``RegistryRepositoryFacts``; this synthetic
+ implementation lets rule tests inject a known set of testable targets without a real registry.
+ It mirrors the same ``UNTESTABLE_TARGETS`` CI policy the registry-backed facts apply.
+ """
+
+ testable_targets: frozenset[str]
+
+ def is_testable_target(self, name: str) -> bool:
+ return name not in UNTESTABLE_TARGETS and name in self.testable_targets
+
+ def eligible_targets(self) -> list[str]:
+ return sorted(name for name in self.testable_targets if name not in UNTESTABLE_TARGETS)
+
+
+def facts(*targets: str) -> FakeRepositoryFacts:
+ return FakeRepositoryFacts(testable_targets=frozenset(targets))
+
+
+def test_direct_rule_recognizes_modified_testable_target():
+ changed = [modified("postgres/datadog_checks/postgres/check.py")]
+
+ assert list(DirectTargetRule()(changed, facts("postgres"))) == ["postgres"]
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ pytest.param("postgres/README.md", id="non-testable-path"),
+ pytest.param("postgres/auto_conf.yaml", id="non-testable-file"),
+ pytest.param("some_dir/foo.py", id="not-a-target"),
+ ],
+)
+def test_direct_rule_ignores(path):
+ changed = [modified(path)]
+
+ assert list(DirectTargetRule()(changed, facts("postgres"))) == []
+
+
+def test_direct_rule_multiple_integrations_all_returned():
+ changed = [
+ modified("postgres/tests/test_a.py"),
+ modified("postgres/pyproject.toml"),
+ modified("mysql/tests/test_b.py"),
+ ]
+
+ assert list(DirectTargetRule()(changed, facts("postgres", "mysql"))) == ["postgres", "postgres", "mysql"]
+
+
+def test_direct_rule_rename_out_of_target_affects_source():
+ # A test file renamed out of postgres into a non-target directory still changes postgres.
+ changed = [renamed("postgres/tests/test_a.py", "docs/moved_test.py")]
+
+ assert list(DirectTargetRule()(changed, facts("postgres"))) == ["postgres"]
+
+
+def test_direct_rule_rename_between_targets_affects_both():
+ changed = [renamed("postgres/tests/test_a.py", "mysql/tests/test_a.py")]
+
+ assert set(DirectTargetRule()(changed, facts("postgres", "mysql"))) == {"mysql", "postgres"}
+
+
+def test_direct_rule_copy_affects_only_destination():
+ changed = [copied("postgres/tests/test_a.py", "mysql/tests/test_a.py")]
+
+ assert list(DirectTargetRule()(changed, facts("postgres", "mysql"))) == ["mysql"]
+
+
+def test_repository_wide_rule_triggers_full_eligible_set_in_core():
+ rule = RepositoryWideRule(is_core=True)
+ changed = [modified("datadog_checks_base/datadog_checks/base/utils/foo.py")]
+
+ assert list(rule(changed, facts("postgres", "mysql", "datadog_checks_base"))) == [
+ "datadog_checks_base",
+ "mysql",
+ "postgres",
+ ]
+
+
+def test_repository_wide_rule_fires_on_a_rename_away_from_a_repository_wide_path():
+ # Moving a shared planning module out of its package removes it from every target that relied
+ # on it, so the source path has to trigger the expansion even though the destination does not.
+ rule = RepositoryWideRule(is_core=True)
+ changed = [renamed("ddev/src/ddev/cli/ci/tests/batching/units.py", "ddev/src/ddev/utils/units.py")]
+
+ assert list(rule(changed, facts("postgres", "mysql"))) == ["mysql", "postgres"]
+
+
+def test_repository_wide_rule_still_fires_alongside_a_dependency_bump():
+ # A dependency bump touching `agent_requirements.in` must not suppress the repository-wide
+ # expansion when the same change also edits the base package.
+ rule = RepositoryWideRule(is_core=True)
+ changed = [
+ modified("agent_requirements.in"),
+ modified("datadog_checks_base/datadog_checks/base/utils/foo.py"),
+ ]
+
+ assert list(rule(changed, facts("postgres", "datadog_checks_base"))) == ["datadog_checks_base", "postgres"]
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ pytest.param("ddev/src/ddev/cli/test/__init__.py", id="unit-test-invocation"),
+ pytest.param("ddev/src/ddev/cli/env/test.py", id="e2e-test-invocation"),
+ pytest.param("ddev/src/ddev/testing/constants.py", id="testing-constants"),
+ pytest.param("ddev/src/ddev/utils/hatch.py", id="hatch-environment-resolution"),
+ pytest.param("ddev/src/ddev/cli/ci/tests/batching/units.py", id="dispatcher-planning"),
+ pytest.param("ddev/src/ddev/cli/ci/tests/task_test_runner.py", id="dispatcher-execution"),
+ pytest.param("ddev/src/ddev/cli/ci/tests/task_test_gatherer.py", id="dispatcher-reporting"),
+ pytest.param("ddev/src/ddev/cli/ci/tests/messages.py", id="dispatcher-messages"),
+ pytest.param("ddev/src/ddev/integration/core.py", id="integration-model"),
+ pytest.param("ddev/src/ddev/repo/core.py", id="repository-model"),
+ ],
+)
+def test_repository_wide_rule_triggers_full_set_for_ddev_test_planning_paths(path):
+ # A change to ddev code that governs how tests are discovered/planned/run retests everything.
+ rule = RepositoryWideRule(is_core=True)
+ changed = [modified(path)]
+
+ assert list(rule(changed, facts("postgres", "mysql", "ddev"))) == ["ddev", "mysql", "postgres"]
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ pytest.param("ddev/src/ddev/cli/port_commit.py", id="unrelated-ddev-command"),
+ pytest.param("agent_requirements.in", id="dependency-bump"),
+ ],
+)
+def test_repository_wide_rule_ignores_paths_that_do_not_govern_testing(path):
+ rule = RepositoryWideRule(is_core=True)
+
+ assert list(rule([modified(path)], facts("postgres", "mysql", "ddev"))) == []
+
+
+def test_find_affected_targets_unrelated_ddev_command_selects_only_ddev():
+ # End to end: changing an unrelated ddev command selects only the `ddev` target (via the direct
+ # rule), never the whole repository.
+ changed = [modified("ddev/src/ddev/cli/port_commit.py")]
+
+ assert find_affected_targets(changed, facts("postgres", "mysql", "ddev"), rules=CORE_RULES) == ["ddev"]
+
+
+def test_repository_wide_rule_does_not_fire_outside_core():
+ rule = RepositoryWideRule(is_core=False)
+ changed = [modified("datadog_checks_base/datadog_checks/base/utils/foo.py")]
+
+ assert list(rule(changed, facts("postgres", "datadog_checks_base"))) == []
+
+
+def test_repository_wide_rule_ignores_irrelevant_paths():
+ rule = RepositoryWideRule(is_core=True)
+ changed = [modified("postgres/tests/test_a.py")]
+
+ assert list(rule(changed, facts("postgres", "datadog_checks_base"))) == []
+
+
+def test_find_affected_targets_multiple_integrations_ordered_union_no_duplicates():
+ changed = [
+ modified("postgres/tests/test_a.py"),
+ modified("postgres/pyproject.toml"),
+ modified("mysql/tests/test_b.py"),
+ ]
+
+ assert sorted(find_affected_targets(changed, facts("postgres", "mysql"), rules=CORE_RULES)) == [
+ "mysql",
+ "postgres",
+ ]
+
+
+def test_find_affected_targets_broad_and_direct_overlap_deduplicated():
+ changed = [
+ modified("postgres/tests/test_a.py"),
+ modified("datadog_checks_base/datadog_checks/base/utils/foo.py"),
+ ]
+
+ result = find_affected_targets(changed, facts("postgres", "mysql", "datadog_checks_base"), rules=CORE_RULES)
+
+ # Comparing sorted against a three-element list also pins that nothing is selected twice.
+ assert sorted(result) == ["datadog_checks_base", "mysql", "postgres"]
+
+
+def test_find_affected_targets_irrelevant_paths_yield_nothing():
+ changed = [modified("docs/readme.md"), modified(".github/workflows/foo.yml")]
+
+ assert find_affected_targets(changed, facts("postgres", "mysql"), rules=CORE_RULES) == []
+
+
+def test_find_affected_targets_excludes_untestable_targets():
+ # mesos_slave is directly modified but excluded by CI policy, so it never appears.
+ changed = [modified("mesos_slave/tests/test_a.py"), modified("postgres/tests/test_a.py")]
+
+ assert find_affected_targets(changed, facts("postgres"), rules=CORE_RULES) == ["postgres"]
+
+
+def test_default_target_rules_only_expand_the_repository_for_core():
+ # Outside core the base-package change still selects its own target, but never the whole repo.
+ changed = [modified("datadog_checks_base/datadog_checks/base/utils/foo.py")]
+ known = facts("postgres", "datadog_checks_base")
+
+ assert sorted(find_affected_targets(changed, known, rules=CORE_RULES)) == [
+ "datadog_checks_base",
+ "postgres",
+ ]
+ assert find_affected_targets(changed, known, rules=default_target_rules(is_core=False)) == ["datadog_checks_base"]
+
+
+@pytest.mark.parametrize(
+ "name, expected",
+ [
+ pytest.param("postgres", True, id="testable"),
+ # mesos_slave is testable per ddev but excluded by CI policy.
+ pytest.param("mesos_slave", False, id="untestable-policy"),
+ # iis is not testable per ddev (no hatch.toml).
+ pytest.param("iis", False, id="not-testable-per-ddev"),
+ pytest.param("unknown", False, id="unknown"),
+ ],
+)
+def test_registry_repository_facts_is_testable_target(name, expected):
+ registry = FakeRegistry(
+ [
+ FakeIntegration("postgres", is_testable=True),
+ FakeIntegration("mesos_slave", is_testable=True),
+ FakeIntegration("iis", is_testable=False),
+ ]
+ )
+
+ assert RegistryRepositoryFacts(registry).is_testable_target(name) is expected
+
+
+def test_registry_repository_facts_eligible_targets_excludes_untestable_and_policy():
+ registry = FakeRegistry(
+ [
+ FakeIntegration("postgres", is_testable=True),
+ FakeIntegration("mesos_slave", is_testable=True),
+ FakeIntegration("iis", is_testable=False),
+ ]
+ )
+
+ assert RegistryRepositoryFacts(registry).eligible_targets() == ["postgres"]
diff --git a/ddev/tests/cli/ci/tests/batching/test_units.py b/ddev/tests/cli/ci/tests/batching/test_units.py
new file mode 100644
index 0000000000000..4c6a89cf48502
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/batching/test_units.py
@@ -0,0 +1,229 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+from __future__ import annotations
+
+import logging
+
+import pytest
+
+from ddev.cli.ci.tests.batching.exceptions import PlanningError
+from ddev.cli.ci.tests.batching.units import (
+ TargetDefinition,
+ TestUnit,
+ expand_test_units,
+ normalize_job_name,
+ resolve_platforms,
+)
+from ddev.utils.platform import PlatformName
+from tests.cli.ci.tests.helpers import env
+
+
+@pytest.mark.parametrize(
+ "raw, expected",
+ [
+ pytest.param('foo/bar:baz', 'foo_bar_baz', id="reserved-replaced"),
+ pytest.param('My Integration', 'My Integration', id="allowed-unchanged"),
+ ],
+)
+def test_normalize_job_name(raw, expected):
+ assert normalize_job_name(raw) == expected
+
+
+@pytest.mark.parametrize(
+ "platform_override, supported_os, expected",
+ [
+ pytest.param(["linux", "windows"], ["Windows"], [PlatformName.LINUX, PlatformName.WINDOWS], id="override-wins"),
+ pytest.param([], ["Windows"], [PlatformName.WINDOWS], id="windows-exclusive"),
+ pytest.param([], ["Linux", "Windows"], [PlatformName.LINUX], id="multi-os-defaults-linux"),
+ pytest.param([], [], [PlatformName.LINUX], id="no-info-defaults-linux"),
+ # `manifest.json` carries platforms ddev has no runner for. They must not reach the parser,
+ # because a target that also runs on Linux would otherwise fail the whole plan.
+ pytest.param([], ["Linux", "Windows", "macOS", "AIX"], [PlatformName.LINUX], id="untestable-os-ignored"),
+ pytest.param([], ["AIX"], [PlatformName.LINUX], id="only-untestable-os-defaults-linux"),
+ ],
+)
+def test_resolve_platforms(platform_override, supported_os, expected):
+ assert resolve_platforms(platform_override, supported_os, target="postgres") == expected
+
+
+@pytest.mark.parametrize("value", ["solaris", "Windows Server"])
+def test_resolve_platforms_rejects_an_unknown_platform_in_the_override(value):
+ with pytest.raises(PlanningError, match="Unsupported platform for `postgres`"):
+ resolve_platforms([value], [], target="postgres")
+
+
+def test_resolve_platforms_rejects_a_repeated_platform_in_the_override():
+ # Two identically named units would otherwise reach the strategy and fail as duplicate job names
+ with pytest.raises(PlanningError, match="Duplicate platform in the CI `platforms` override"):
+ resolve_platforms(["linux", "linux"], [], target="postgres")
+
+
+def test_expand_gives_each_environment_its_own_unit():
+ targets = [TargetDefinition("postgres", environments=(env("py3.11", e2e=True), env("py3.12", e2e=True)))]
+
+ assert expand_test_units(targets) == [
+ TestUnit(
+ target="postgres",
+ name="postgres (py3.11)",
+ platform=PlatformName.LINUX,
+ runner_labels=("ubuntu-22.04",),
+ environment=env("py3.11", e2e=True),
+ ),
+ TestUnit(
+ target="postgres",
+ name="postgres (py3.12)",
+ platform=PlatformName.LINUX,
+ runner_labels=("ubuntu-22.04",),
+ environment=env("py3.12", e2e=True),
+ ),
+ ]
+
+
+def test_expand_preserves_environment_order():
+ targets = [
+ TargetDefinition(
+ "postgres",
+ environments=(env("py3.11-9"), env("py3.11-10"), env("py3.12-9"), env("py3.12-10")),
+ ),
+ ]
+
+ assert [u.environment.name for u in expand_test_units(targets)] == [
+ "py3.11-9",
+ "py3.11-10",
+ "py3.12-9",
+ "py3.12-10",
+ ]
+
+
+def test_expand_rejects_a_target_with_no_environments():
+ # Definitions are only built for targets that resolved at least one environment, so reaching
+ # expansion without any means the plan is already inconsistent.
+ with pytest.raises(PlanningError, match="reached unit expansion with no environments"):
+ expand_test_units([TargetDefinition("postgres")])
+
+
+def test_expand_warns_and_plans_nothing_for_a_platform_no_environment_covers(caplog):
+ target = TargetDefinition(
+ "sqlserver",
+ platforms=(PlatformName.LINUX, PlatformName.WINDOWS),
+ environments=(env("py3.13-linux", platform=PlatformName.LINUX),),
+ )
+
+ with caplog.at_level(logging.WARNING, logger="ddev.cli.ci.tests.batching.units"):
+ units = expand_test_units([target])
+
+ assert [unit.platform for unit in units] == [PlatformName.LINUX]
+ assert "sqlserver runs on windows but no environment tests it" in caplog.text
+
+
+def test_expand_environment_named_after_its_target_does_not_repeat_in_the_name():
+ units = expand_test_units([TargetDefinition("postgres", environments=(env("postgres"),))])
+
+ assert units[0].name == "postgres"
+
+
+def test_expand_carries_the_environment_python_version():
+ targets = [TargetDefinition("postgres", environments=(env("py3.11", python_version="3.11"),))]
+
+ assert expand_test_units(targets)[0].environment.python_version == "3.11"
+
+
+def test_expand_multi_label_runner_is_a_single_selection():
+ units = expand_test_units(
+ [TargetDefinition("postgres", runners={"linux": ["label-a", "label-b"]}, environments=(env("postgres"),))]
+ )
+
+ assert units[0].runner_labels == ("label-a", "label-b")
+
+
+def test_expand_platform_override_adds_platform_suffix():
+ units = expand_test_units(
+ [
+ TargetDefinition(
+ "postgres",
+ platforms=(PlatformName.LINUX, PlatformName.WINDOWS),
+ environments=(env("postgres", PlatformName.LINUX), env("postgres", PlatformName.WINDOWS)),
+ )
+ ]
+ )
+
+ assert [(u.platform, u.name, u.runner_labels) for u in units] == [
+ (PlatformName.LINUX, "postgres on Linux", ("ubuntu-22.04",)),
+ (PlatformName.WINDOWS, "postgres on Windows", ("windows-2022",)),
+ ]
+
+
+def test_expand_uses_injected_resolved_display_name():
+ # The display name is resolved upstream (from ddev's Integration.display_name) and injected;
+ # this package does not reproduce the override/manifest precedence.
+ units = expand_test_units(
+ [TargetDefinition("postgres", display_name="Resolved Name", environments=(env("postgres"),))]
+ )
+
+ assert units[0].name == "Resolved Name"
+
+
+def test_expand_display_name_falls_back_to_target_name():
+ assert expand_test_units([TargetDefinition("postgres", environments=(env("postgres"),))])[0].name == "postgres"
+
+
+def test_expand_respects_display_order_override():
+ targets = [
+ TargetDefinition("postgres", environments=(env("postgres"),)),
+ TargetDefinition("ddev", environments=(env("ddev"),)),
+ TargetDefinition("datadog_checks_base", environments=(env("datadog_checks_base"),)),
+ ]
+
+ assert [u.target for u in expand_test_units(targets)] == ["ddev", "datadog_checks_base", "postgres"]
+
+
+def test_expand_e2e_availability_is_per_environment():
+ targets = [TargetDefinition("postgres", environments=(env("py3.11", e2e=True), env("py3.12", e2e=False)))]
+
+ assert [(u.environment.name, u.environment.e2e_available) for u in expand_test_units(targets)] == [
+ ("py3.11", True),
+ ("py3.12", False),
+ ]
+
+
+def test_expand_e2e_availability_is_platform_specific():
+ # Environments are pre-routed to platforms by the provider; E2E differs per platform.
+ targets = [
+ TargetDefinition(
+ "postgres",
+ platforms=(PlatformName.LINUX, PlatformName.WINDOWS),
+ environments=(
+ env("py3.11-linux", PlatformName.LINUX, e2e=True),
+ env("py3.11-windows", PlatformName.WINDOWS, e2e=False),
+ ),
+ ),
+ ]
+
+ assert [(u.platform, u.environment.name, u.environment.e2e_available) for u in expand_test_units(targets)] == [
+ (PlatformName.LINUX, "py3.11-linux", True),
+ (PlatformName.WINDOWS, "py3.11-windows", False),
+ ]
+
+
+def test_expand_carries_unit_only_and_e2e_only_facets():
+ targets = [
+ TargetDefinition(
+ "postgres",
+ environments=(
+ env("py3.11", unit=True, e2e=False),
+ env("py3.11-e2e", unit=False, e2e=True),
+ env("py3.12", unit=True, e2e=True),
+ ),
+ ),
+ ]
+
+ facets = [
+ (u.environment.name, u.environment.test_available, u.environment.e2e_available)
+ for u in expand_test_units(targets)
+ ]
+ assert facets == [
+ ("py3.11", True, False),
+ ("py3.11-e2e", False, True),
+ ("py3.12", True, True),
+ ]
diff --git a/ddev/tests/cli/ci/tests/batching/test_validation.py b/ddev/tests/cli/ci/tests/batching/test_validation.py
new file mode 100644
index 0000000000000..ae13810b326ad
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/batching/test_validation.py
@@ -0,0 +1,100 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Tests for the strategy-independent validation of a batch partition."""
+
+from __future__ import annotations
+
+import dataclasses
+
+import pytest
+
+from ddev.cli.ci.tests.batching.exceptions import BatchValidationError
+from ddev.cli.ci.tests.batching.strategy import default_strategy
+from ddev.cli.ci.tests.batching.validation import validate_batches
+from ddev.cli.ci.tests.dispatcher_config import BatchingConfig
+from ddev.cli.ci.tests.messages import BatchJob
+from tests.cli.ci.tests.helpers import jobs, make_job
+
+
+def config(*, capacity: int = 240, allow_integration_splitting: bool = False) -> BatchingConfig:
+ return BatchingConfig(max_jobs_per_batch=capacity, allow_integration_splitting=allow_integration_splitting)
+
+
+def test_validate_accepts_default_strategy_output():
+ all_jobs = jobs("postgres", 200) + jobs("mysql", 100)
+ groups = default_strategy(all_jobs, config=config())
+ validate_batches(groups, all_jobs, config=config()) # does not raise
+
+
+def test_validate_rejects_empty_batch():
+ all_jobs = jobs("postgres", 2)
+ with pytest.raises(BatchValidationError, match="empty"):
+ validate_batches([all_jobs, []], all_jobs, config=config())
+
+
+def test_validate_rejects_overfilled_batch():
+ all_jobs = jobs("postgres", 5)
+ with pytest.raises(BatchValidationError, match="capacity"):
+ validate_batches([all_jobs], all_jobs, config=config(capacity=4))
+
+
+def test_validate_rejects_lost_job():
+ all_jobs = jobs("postgres", 3)
+ with pytest.raises(BatchValidationError, match="exactly once"):
+ validate_batches([all_jobs[:2]], all_jobs, config=config())
+
+
+def test_validate_rejects_a_job_duplicated_across_batches():
+ all_jobs = jobs("postgres", 2)
+ with pytest.raises(BatchValidationError, match="exactly once"):
+ validate_batches([[all_jobs[0]], [all_jobs[0], all_jobs[1]]], all_jobs, config=config())
+
+
+def test_validate_compares_jobs_by_value_not_identity():
+ # A strategy is free to rebuild equal jobs rather than pass the original instances through.
+ all_jobs = jobs("postgres", 3)
+ rebuilt = [dataclasses.replace(job) for job in all_jobs]
+
+ validate_batches([rebuilt], all_jobs, config=config()) # does not raise
+
+
+def test_validate_rejects_duplicate_names_within_batch():
+ clash = jobs("postgres", 1)[0]
+ twin = make_job(clash.name, target="mysql", environment="py3.11")
+ all_jobs = [clash, twin]
+ with pytest.raises(BatchValidationError, match="duplicate job name"):
+ validate_batches([[clash, twin]], all_jobs, config=config())
+
+
+def test_validate_rejects_duplicate_artifact_identity_within_batch():
+ # Two jobs with distinct display names but the same target/facet/environment/platform collapse
+ # to the same artifact identity; central validation must reject them even though names differ.
+ def artifact_twin(name: str) -> BatchJob:
+ return make_job(name, target="postgres", environment="py3.11")
+
+ a, b = artifact_twin("postgres (py3.11)"), artifact_twin("postgres duplicate")
+ assert a.name != b.name
+ assert a.artifact_name() == b.artifact_name()
+
+ with pytest.raises(BatchValidationError, match="duplicate artifact identities"):
+ validate_batches([[a, b]], [a, b], config=config())
+
+
+def test_validate_rejects_illegal_split_when_disabled():
+ all_jobs = jobs("postgres", 4)
+ with pytest.raises(BatchValidationError, match="split"):
+ validate_batches([all_jobs[:2], all_jobs[2:]], all_jobs, config=config())
+
+
+def test_validate_rejects_split_of_fitting_integration_even_when_enabled():
+ # Splitting is enabled, but this integration fits capacity, so splitting it is still invalid.
+ all_jobs = jobs("postgres", 4)
+ with pytest.raises(BatchValidationError, match="fits in one batch"):
+ validate_batches([all_jobs[:2], all_jobs[2:]], all_jobs, config=config(allow_integration_splitting=True))
+
+
+def test_validate_allows_oversized_split_when_enabled():
+ all_jobs = jobs("huge", 400)
+ groups = default_strategy(all_jobs, config=config(allow_integration_splitting=True))
+ validate_batches(groups, all_jobs, config=config(allow_integration_splitting=True)) # no raise
diff --git a/ddev/tests/cli/ci/tests/helpers.py b/ddev/tests/cli/ci/tests/helpers.py
new file mode 100644
index 0000000000000..b964377d2dfbd
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/helpers.py
@@ -0,0 +1,150 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Builders for the Dispatcher batching planning tests."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Iterable, Sequence
+
+from ddev.cli.ci.tests.batching.units import ResolvedEnvironment, TestUnit
+from ddev.cli.ci.tests.messages import BatchJob
+from ddev.event_bus.orchestrator import BaseMessage
+from ddev.utils.git import ChangedFile, ChangeType
+from ddev.utils.platform import PlatformName
+
+DEFAULT_PYTHON_VERSION = "3.13"
+DEFAULT_RUNNER_LABELS = ("ubuntu-22.04",)
+
+
+def env(
+ name: str,
+ platform: PlatformName = PlatformName.LINUX,
+ *,
+ python_version: str = DEFAULT_PYTHON_VERSION,
+ unit: bool = True,
+ e2e: bool = False,
+) -> ResolvedEnvironment:
+ return ResolvedEnvironment(
+ name=name,
+ platform=platform,
+ python_version=python_version,
+ test_available=unit,
+ e2e_available=e2e,
+ )
+
+
+def make_unit(
+ target: str = "postgres",
+ *,
+ name: str | None = None,
+ platform: PlatformName = PlatformName.LINUX,
+ runner_labels: tuple[str, ...] = DEFAULT_RUNNER_LABELS,
+ environment: ResolvedEnvironment | None = None,
+) -> TestUnit:
+ return TestUnit(
+ target=target,
+ name=name if name is not None else target,
+ platform=platform,
+ runner_labels=runner_labels,
+ environment=environment if environment is not None else env(target, platform),
+ )
+
+
+def make_job(
+ name: str = "job-1",
+ *,
+ target: str = "ntp",
+ environment: str = "py3.13",
+ platform: PlatformName = PlatformName.LINUX,
+ python_version: str = DEFAULT_PYTHON_VERSION,
+ runner_labels: tuple[str, ...] = DEFAULT_RUNNER_LABELS,
+ unit_tests: bool = True,
+ e2e_tests: bool = False,
+ agent_image: str | None = None,
+) -> BatchJob:
+ return BatchJob(
+ name=name,
+ target=target,
+ runner_labels=runner_labels,
+ environment=environment,
+ platform=platform,
+ python_version=python_version,
+ unit_tests=unit_tests,
+ e2e_tests=e2e_tests,
+ agent_image=agent_image,
+ )
+
+
+def jobs(target: str, count: int) -> list[BatchJob]:
+ # Each job carries a distinct environment, as production jobs within an integration do, so
+ # names and artifact identities are unique within the target.
+ return [make_job(f"{target}-{index}", target=target, environment=f"env-{index}") for index in range(count)]
+
+
+class FakeManifest:
+ def __init__(self, classifier_tags: Sequence[str] = ()):
+ self._classifier_tags = list(classifier_tags)
+
+ def get(self, pointer, default=None):
+ if pointer == "/tile/classifier_tags":
+ return list(self._classifier_tags)
+ return default
+
+
+class FakeIntegration:
+ def __init__(
+ self,
+ name: str,
+ *,
+ is_testable: bool = True,
+ display_name: str | None = None,
+ classifier_tags: Sequence[str] = (),
+ ):
+ self.name = name
+ self.is_testable = is_testable
+ self.display_name = display_name or name
+ self.manifest = FakeManifest(classifier_tags)
+
+
+class FakeRegistry:
+ """Stand-in for ddev's IntegrationRegistry; `get` raises OSError for an unknown name."""
+
+ def __init__(self, integrations: Sequence[FakeIntegration], *, changed: Sequence[str] = ()):
+ self._integrations = {integration.name: integration for integration in integrations}
+ self._changed = set(changed)
+
+ def get(self, name: str) -> FakeIntegration:
+ try:
+ return self._integrations[name]
+ except KeyError:
+ raise OSError(f"Integration does not exist: {name}") from None
+
+ def iter_testable(self, selection: Iterable[str] = ()) -> list[FakeIntegration]:
+ # ddev's registry resolves an empty selection to `changed`, so only `all` sees everything
+ candidates = (
+ self._integrations.values()
+ if "all" in selection
+ else [integration for integration in self._integrations.values() if integration.name in self._changed]
+ )
+ return [integration for integration in candidates if integration.is_testable]
+
+
+def modified(path: str) -> ChangedFile:
+ return ChangedFile(change_type=ChangeType.MODIFIED, path=path)
+
+
+def renamed(source: str, destination: str) -> ChangedFile:
+ return ChangedFile(change_type=ChangeType.RENAMED, path=destination, previous_path=source)
+
+
+def copied(source: str, destination: str) -> ChangedFile:
+ return ChangedFile(change_type=ChangeType.COPIED, path=destination, previous_path=source)
+
+
+def drain_queue(queue: asyncio.Queue[BaseMessage]) -> list[BaseMessage]:
+ messages: list[BaseMessage] = []
+ while not queue.empty():
+ messages.append(queue.get_nowait())
+ return messages
diff --git a/ddev/tests/cli/ci/tests/test_changes.py b/ddev/tests/cli/ci/tests/test_changes.py
new file mode 100644
index 0000000000000..4899a89b85891
--- /dev/null
+++ b/ddev/tests/cli/ci/tests/test_changes.py
@@ -0,0 +1,45 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+"""Tests for the comparison a CI run diffs to find what it must test."""
+
+from __future__ import annotations
+
+import pytest
+
+from ddev.cli.ci.tests.changes import CIContext, get_changed_files
+from ddev.utils.git import ChangedFile, ChangeType
+
+
+class RecordingGit:
+ """Stand-in for `GitRepository` that records the comparison it was asked for."""
+
+ def __init__(self, changed=()):
+ self.changed = list(changed)
+ self.calls: list[tuple[str, str | None]] = []
+
+ def changed_files(self, base="origin/master", head=None):
+ self.calls.append((base, head))
+ return list(self.changed)
+
+
+@pytest.mark.parametrize(
+ ("context", "target_branch", "expected_call"),
+ [
+ pytest.param(CIContext.PULL_REQUEST, "origin/master", ("origin/master", "abc123"), id="pull-request"),
+ pytest.param(CIContext.DEFAULT_BRANCH, None, ("abc123^1", "abc123"), id="default-branch"),
+ ],
+)
+def test_get_changed_files_uses_the_right_comparison(context, target_branch, expected_call):
+ changed_file = ChangedFile(ChangeType.MODIFIED, "foo/bar.py")
+ git = RecordingGit([changed_file])
+
+ changed = get_changed_files(git, "abc123", context=context, target_branch=target_branch)
+
+ assert git.calls == [expected_call]
+ assert changed == [changed_file]
+
+
+def test_get_changed_files_pull_request_requires_target_branch():
+ with pytest.raises(ValueError, match="target branch is required"):
+ get_changed_files(RecordingGit(), "abc123", context=CIContext.PULL_REQUEST, target_branch=None)
diff --git a/ddev/tests/cli/ci/tests/test_dispatcher_config.py b/ddev/tests/cli/ci/tests/test_dispatcher_config.py
index 425e94d73b525..5d32e5f65e7b9 100644
--- a/ddev/tests/cli/ci/tests/test_dispatcher_config.py
+++ b/ddev/tests/cli/ci/tests/test_dispatcher_config.py
@@ -8,8 +8,9 @@
from collections.abc import Callable
import pytest
+from pydantic import ValidationError
-from ddev.cli.ci.tests.dispatcher_config import DispatcherConfig
+from ddev.cli.ci.tests.dispatcher_config import BatchingConfig, DispatcherConfig
from ddev.cli.ci.tests.rate_limiting import RateLimiterFactoryConfig
from ddev.repo.config import RepositoryConfig
from ddev.utils.fs import Path
@@ -31,9 +32,12 @@ def test_from_repo_config_reads_full_dispatcher_table(repo_config: RepoConfigBui
config = repo_config(
"""
[dispatcher]
- max_jobs_per_batch = 120
global_timeout_seconds = 3600.0
+ [dispatcher.batching]
+ max_jobs_per_batch = 120
+ allow_integration_splitting = true
+
[dispatcher.github_rate_limits]
total_hourly_max_rate = 1500
slow_integrations = ["mongo", "mysql"]
@@ -48,7 +52,8 @@ def test_from_repo_config_reads_full_dispatcher_table(repo_config: RepoConfigBui
result = DispatcherConfig.from_repo_config(config)
- assert result.max_jobs_per_batch == 120
+ assert result.batching.max_jobs_per_batch == 120
+ assert result.batching.allow_integration_splitting is True
assert result.global_timeout_seconds == 3600.0
assert result.github_rate_limits.total_hourly_max_rate == 1500
assert result.github_rate_limits.slow_integrations == frozenset({"mongo", "mysql"})
@@ -60,14 +65,16 @@ def test_from_repo_config_reads_scalars_without_rate_limits_subtable(repo_config
config = repo_config(
"""
[dispatcher]
- max_jobs_per_batch = 120
global_timeout_seconds = 3600.0
+
+ [dispatcher.batching]
+ max_jobs_per_batch = 120
"""
)
result = DispatcherConfig.from_repo_config(config)
- assert result.max_jobs_per_batch == 120
+ assert result.batching.max_jobs_per_batch == 120
assert result.global_timeout_seconds == 3600.0
assert result.github_rate_limits == RateLimiterFactoryConfig()
@@ -82,3 +89,41 @@ def test_from_repo_config_falls_back_to_defaults_when_dispatcher_table_missing(r
result = DispatcherConfig.from_repo_config(config)
assert result == DispatcherConfig()
+
+
+def test_batching_defaults_when_table_missing(repo_config: RepoConfigBuilder):
+ config = repo_config(
+ """
+ [dispatcher]
+ global_timeout_seconds = 3600.0
+ """
+ )
+
+ result = DispatcherConfig.from_repo_config(config)
+
+ assert result.batching == BatchingConfig()
+
+
+def test_batching_rejects_unknown_key(repo_config: RepoConfigBuilder):
+ config = repo_config(
+ """
+ [dispatcher.batching]
+ unexpected = 1
+ """
+ )
+
+ with pytest.raises(ValidationError):
+ DispatcherConfig.from_repo_config(config)
+
+
+@pytest.mark.parametrize("value", [0, -1, 241, 1000])
+def test_batching_rejects_out_of_range_max_jobs_per_batch(repo_config: RepoConfigBuilder, value: int):
+ config = repo_config(
+ f"""
+ [dispatcher.batching]
+ max_jobs_per_batch = {value}
+ """
+ )
+
+ with pytest.raises(ValidationError):
+ DispatcherConfig.from_repo_config(config)
diff --git a/ddev/tests/cli/ci/tests/test_messages.py b/ddev/tests/cli/ci/tests/test_messages.py
index 2db34af74ca7f..92077e69bf12e 100644
--- a/ddev/tests/cli/ci/tests/test_messages.py
+++ b/ddev/tests/cli/ci/tests/test_messages.py
@@ -11,64 +11,52 @@
from ddev.cli.ci.tests.messages import (
ARTIFACT_NAME_DISALLOWED,
- BatchJob,
BatchJobResult,
JobResult,
- Platform,
UpdatePRComment,
WorkflowStatus,
)
from ddev.cli.ci.tests.progress import DispatcherProgress
from ddev.cli.ci.tests.status import Status
from ddev.utils.github_async.models import WorkflowJob
+from ddev.utils.platform import PlatformName
+from tests.cli.ci.tests.helpers import make_job
-def batch_job(
- name="job-1",
- target="ntp",
- runner="ubuntu-latest",
- environment="py3.13",
- platform=Platform.LINUX,
- unit_tests=True,
- e2e_tests=False,
-) -> BatchJob:
- return BatchJob(
- name=name,
- target=target,
- runner=runner,
- environment=environment,
- platform=platform,
- unit_tests=unit_tests,
- e2e_tests=e2e_tests,
- )
-
+def test_artifact_name_built_from_target_env_platform():
+ assert make_job().artifact_name() == "ntp_py3.13_linux"
-def test_artifact_name_built_from_target_env_platform() -> None:
- assert batch_job().artifact_name() == "ntp_py3.13_linux"
-
-@pytest.mark.parametrize("field", ["name", "runner", "unit_tests", "e2e_tests"])
-def test_artifact_name_ignores_non_identifying_fields(field: str) -> None:
- # name / runner / unit_tests / e2e_tests are not part of the artifact name.
- changed = {"name": "other-job", "runner": "windows-latest", "unit_tests": False, "e2e_tests": True}[field]
- assert batch_job(**{field: changed}).artifact_name() == batch_job().artifact_name()
+@pytest.mark.parametrize("field", ["name", "runner_labels", "unit_tests", "e2e_tests"])
+def test_artifact_name_ignores_non_identifying_fields(field: str):
+ # The artifact identity is target + environment + platform; name/runner/facet flags are not part
+ # of it (a single job carries its facets, so facets never distinguish two jobs).
+ changed = {"name": "other-job", "runner_labels": ("windows-latest",), "unit_tests": False, "e2e_tests": True}[field]
+ assert make_job(**{field: changed}).artifact_name() == make_job().artifact_name()
@pytest.mark.parametrize(
("field", "value"),
- [("target", "kafka"), ("environment", "py3.12"), ("platform", Platform.WINDOWS)],
+ [("target", "kafka"), ("environment", "py3.12"), ("platform", PlatformName.WINDOWS)],
)
-def test_artifact_name_varies_with_identifying_fields(field: str, value: str) -> None:
- assert batch_job(**{field: value}).artifact_name() != batch_job().artifact_name()
+def test_artifact_name_varies_with_identifying_fields(field, value):
+ assert make_job(**{field: value}).artifact_name() != make_job().artifact_name()
+
+def test_artifact_name_for_environmentless_job():
+ # An environmentless job omits the environment segment entirely (no empty "__" gap); it stays
+ # unique because such a target produces a single job per platform.
+ assert make_job(environment="").artifact_name() == "ntp_linux"
+ assert make_job(environment="", platform=PlatformName.WINDOWS).artifact_name() == "ntp_windows"
-def test_artifact_name_sanitizes_disallowed_characters() -> None:
- name = batch_job(target='a/b:c*d?e|f"gi\\j', environment="x\r\ny").artifact_name()
+
+def test_artifact_name_sanitizes_disallowed_characters():
+ name = make_job(target='a/b:c*d?e|f"gi\\j', environment="x\r\ny").artifact_name()
assert ARTIFACT_NAME_DISALLOWED.search(name) is None
-def test_correlate_matches_jobs_and_artifacts(tmp_path: Path) -> None:
- job = batch_job("j1")
+def test_correlate_matches_jobs_and_artifacts(tmp_path: Path):
+ job = make_job("j1")
base = job.artifact_name()
artifact_dir = tmp_path / base
artifact_dir.mkdir()
@@ -84,10 +72,10 @@ def test_correlate_matches_jobs_and_artifacts(tmp_path: Path) -> None:
assert result.coverage_artifact_name == f"coverage-{base}"
-def test_correlate_without_workflow_or_artifact_match() -> None:
+def test_correlate_without_workflow_or_artifact_match():
# A job absent from the workflow API and with no matching artifact folder still yields a
# well-formed result whose correlated facets are None.
- job = batch_job("j1")
+ job = make_job("j1")
[result] = BatchJobResult.correlate([job], [], {})
@@ -96,9 +84,9 @@ def test_correlate_without_workflow_or_artifact_match() -> None:
assert result.artifact_name_path is None
-def test_correlate_ignores_artifact_dir_missing_on_disk(tmp_path: Path) -> None:
+def test_correlate_ignores_artifact_dir_missing_on_disk(tmp_path: Path):
# A mapped path that does not exist on disk is not recorded.
- job = batch_job("j1")
+ job = make_job("j1")
base = job.artifact_name()
[result] = BatchJobResult.correlate([job], [], {base: tmp_path / base})
@@ -106,15 +94,8 @@ def test_correlate_ignores_artifact_dir_missing_on_disk(tmp_path: Path) -> None:
assert result.artifact_name_path is None
-def test_job_result_defaults() -> None:
- result = JobResult(integration="ntp", environment="py3.13", platform=Platform.LINUX, status=Status.SUCCESS)
- assert result.failed_steps == []
- assert result.reports == ()
- assert result.failed_tests == []
-
-
def _job(integration: str, status: Status) -> JobResult:
- return JobResult(integration=integration, environment="py3.13", platform=Platform.LINUX, status=status)
+ return JobResult(integration=integration, environment="py3.13", platform=PlatformName.LINUX, status=status)
def _workflow(batch_id: str, run_id: int, success: int, failed: int, skipped: int, results: list) -> WorkflowStatus:
@@ -129,7 +110,7 @@ def _workflow(batch_id: str, run_id: int, success: int, failed: int, skipped: in
)
-def test_workflow_status_label() -> None:
+def test_workflow_status_label():
assert _workflow("b1", 1, 2, 0, 0, []).status == Status.SUCCESS
assert _workflow("b2", 2, 1, 1, 0, []).status == Status.FAILURE
assert _workflow("b3", 3, 0, 0, 2, []).status == Status.SKIPPED
diff --git a/ddev/tests/cli/ci/tests/test_progress.py b/ddev/tests/cli/ci/tests/test_progress.py
index dc8179f58e36e..37f26a923ccec 100644
--- a/ddev/tests/cli/ci/tests/test_progress.py
+++ b/ddev/tests/cli/ci/tests/test_progress.py
@@ -13,7 +13,7 @@
import pytest
-from ddev.cli.ci.tests.messages import BatchJob, Platform
+from ddev.cli.ci.tests.messages import BatchJob
from ddev.cli.ci.tests.progress import (
BatchProgress,
DispatcherProgress,
@@ -25,6 +25,7 @@
from ddev.cli.ci.tests.status import Status
from ddev.utils.github_async.models.workflow import WorkflowJobConclusion
from ddev.utils.junit import JUnitCounts, JUnitReport, JUnitResult, JUnitResultKind, JUnitTestCase, JUnitTestSuite
+from tests.cli.ci.tests.helpers import make_job
CONCLUSIONS = {
Status.SUCCESS: WorkflowJobConclusion.SUCCESS,
@@ -38,15 +39,7 @@
def _batch_job(name: str = "j1", target: str = "ntp") -> BatchJob:
- return BatchJob(
- name=name,
- target=target,
- runner="ubuntu-latest",
- environment="py3.13",
- platform=Platform.LINUX,
- unit_tests=True,
- e2e_tests=False,
- )
+ return make_job(name, target=target)
def _attempt(attempt: int = 1, status: Status = Status.SUCCESS, **overrides) -> JobAttemptProgress:
diff --git a/ddev/tests/cli/ci/tests/test_task_test_gatherer.py b/ddev/tests/cli/ci/tests/test_task_test_gatherer.py
index 40f6091357fce..a43c368dc1763 100644
--- a/ddev/tests/cli/ci/tests/test_task_test_gatherer.py
+++ b/ddev/tests/cli/ci/tests/test_task_test_gatherer.py
@@ -24,7 +24,6 @@
BatchJob,
BatchJobResult,
JobResult,
- Platform,
TestBatch,
UpdatePRComment,
WorkflowStatus,
@@ -32,9 +31,10 @@
from ddev.cli.ci.tests.progress import ExecutionState, ProgressError
from ddev.cli.ci.tests.status import Status
from ddev.cli.ci.tests.task_test_gatherer import TaskTestGatherer
-from ddev.event_bus.orchestrator import BaseMessage
from ddev.utils.github_async.models import JobStep, WorkflowJob
from ddev.utils.junit import TestStatus
+from ddev.utils.platform import PlatformName
+from tests.cli.ci.tests.helpers import drain_queue, make_job
# ---------------------------------------------------------------------------
# Helpers
@@ -54,24 +54,6 @@
FAILING_TEST_ID = "nagios.tests.test_nagios.TestEventLogTailer::test_line_parser"
-def _batch_job(
- name: str,
- target: str = "ntp",
- environment: str = "py3.13",
- platform: Platform = Platform.LINUX,
- runner: str = "ubuntu-latest",
-) -> BatchJob:
- return BatchJob(
- name=name,
- target=target,
- runner=runner,
- environment=environment,
- platform=platform,
- unit_tests=True,
- e2e_tests=False,
- )
-
-
def _make_job_tree(
artifacts_path: Path,
job_name: str,
@@ -97,6 +79,16 @@ def _workflow_job(name: str, conclusion: str, failed_step: str | None = None, ru
return WorkflowJob(id=1, run_id=run_id, name=name, status="completed", conclusion=conclusion, steps=steps)
+def _batch_job(
+ name: str,
+ target: str = "ntp",
+ environment: str = "py3.13",
+ platform: PlatformName = PlatformName.LINUX,
+ runner: str = "ubuntu-latest",
+) -> BatchJob:
+ return make_job(name, target=target, environment=environment, platform=platform, runner_labels=(runner,))
+
+
def _batch_job_result(
job: BatchJob,
workflow_job: WorkflowJob | None = None,
@@ -122,19 +114,14 @@ def _batch_finished(artifacts_path: Path | str, **overrides) -> BatchFinished:
"run_id": 100,
"workflow_url": "https://github.com/o/r/actions/runs/100",
"artifacts_path": str(artifacts_path),
- "batch_jobs": [_batch_job_result(_batch_job("j1"))],
+ "batch_jobs": [_batch_job_result(make_job("j1"))],
}
defaults.update(overrides)
+ # The logical batch id defaults to the message id unless a test sets them apart on purpose.
+ defaults.setdefault("batch_id", defaults["id"])
return BatchFinished(**defaults)
-def _drain_queue(queue: asyncio.Queue[BaseMessage]) -> list[BaseMessage]:
- messages: list[BaseMessage] = []
- while not queue.empty():
- messages.append(queue.get_nowait())
- return messages
-
-
def _test_batch(batch_id: str, jobs: list[BatchJob]) -> TestBatch:
"""One planned batch. Its message id is deliberately not its batch id: different layers."""
return TestBatch(
@@ -196,18 +183,18 @@ def _find_result(gatherer: TaskTestGatherer, integration: str) -> JobResult:
# ---------------------------------------------------------------------------
-def test_happy_path_organizes_artifacts_and_emits_update(tmp_path: Path) -> None:
+def test_happy_path_organizes_artifacts_and_emits_update(tmp_path: Path):
artifacts = tmp_path / "artifacts" / "100"
job_dir = _make_job_tree(artifacts, "j1")
gatherer = _make_gatherer(tmp_path)
gatherer.process_message(
_batch_finished(
- artifacts, batch_jobs=[_batch_job_result(_batch_job("j1"), _workflow_job("j1", "success"), job_dir)]
+ artifacts, batch_jobs=[_batch_job_result(make_job("j1"), _workflow_job("j1", "success"), job_dir)]
)
)
- messages = _drain_queue(gatherer.queue)
+ messages = drain_queue(gatherer.queue)
assert len(messages) == 1
update = messages[0]
assert isinstance(update, UpdatePRComment)
@@ -220,12 +207,13 @@ def test_happy_path_organizes_artifacts_and_emits_update(tmp_path: Path) -> None
assert status.skipped_count == 0
assert len(status.results) == 1
- assert (tmp_path / "out" / "coverage" / "ntp-py3.13-linux.xml").is_file()
- assert (tmp_path / "out" / "test_results" / "ntp-py3.13-linux-test-unit-py3.13.xml").is_file()
- assert (tmp_path / "out" / "test_results" / "ntp-py3.13-linux-test-e2e-py3.13.xml").is_file()
+ # Organized filenames are prefixed by the job's artifact identity (target_environment_platform).
+ assert (tmp_path / "out" / "coverage" / "ntp_py3.13_linux.xml").is_file()
+ assert (tmp_path / "out" / "test_results" / "ntp_py3.13_linux-test-unit-py3.13.xml").is_file()
+ assert (tmp_path / "out" / "test_results" / "ntp_py3.13_linux-test-e2e-py3.13.xml").is_file()
-def test_failure_path_records_failed_steps_and_reports(tmp_path: Path) -> None:
+def test_failure_path_records_failed_steps_and_reports(tmp_path: Path):
artifacts = tmp_path / "artifacts" / "100"
job_dir = _make_job_tree(artifacts, "j1", junit=JUNIT_FAILING, e2e=False)
@@ -235,14 +223,12 @@ def test_failure_path_records_failed_steps_and_reports(tmp_path: Path) -> None:
artifacts,
status="failure",
batch_jobs=[
- _batch_job_result(
- _batch_job("j1"), _workflow_job("j1", "failure", failed_step="Run unit tests"), job_dir
- )
+ _batch_job_result(make_job("j1"), _workflow_job("j1", "failure", failed_step="Run unit tests"), job_dir)
],
)
)
- _drain_queue(gatherer.queue)
+ drain_queue(gatherer.queue)
[status] = _registry(gatherer)
assert status.failed_count == 1
@@ -254,7 +240,7 @@ def test_failure_path_records_failed_steps_and_reports(tmp_path: Path) -> None:
assert _failed_ids(result) == [FAILING_TEST_ID]
-def test_full_report_keeps_passing_tests(tmp_path: Path) -> None:
+def test_full_report_keeps_passing_tests(tmp_path: Path):
# The failing fixture holds one failing and one passing test; the registry keeps both, not just
# the failure (dispatcher.md: full registry of everything that happened).
artifacts = tmp_path / "artifacts" / "100"
@@ -265,7 +251,7 @@ def test_full_report_keeps_passing_tests(tmp_path: Path) -> None:
_batch_finished(
artifacts,
status="failure",
- batch_jobs=[_batch_job_result(_batch_job("j1"), _workflow_job("j1", "failure"), job_dir)],
+ batch_jobs=[_batch_job_result(make_job("j1"), _workflow_job("j1", "failure"), job_dir)],
)
)
@@ -284,14 +270,14 @@ def test_timed_out_batch_marks_all_jobs_failed(tmp_path: Path) -> None:
batch_jobs = [_batch_job_result(job) for job in jobs]
gatherer.process_message(_batch_finished("", status="failure", run_id=300, batch_jobs=batch_jobs, timed_out=True))
- _drain_queue(gatherer.queue)
+ drain_queue(gatherer.queue)
[status] = _registry(gatherer)
assert status.failed_count == 2
# The timeout is the batch's, not a step of any job: no step name is invented for it.
assert {tuple(result.failed_steps) for result in status.results} == {()}
-def test_multiple_jobs_aggregate_into_one_workflow_status(tmp_path: Path) -> None:
+def test_multiple_jobs_aggregate_into_one_workflow_status(tmp_path: Path):
artifacts = tmp_path / "artifacts" / "100"
j1_dir = _make_job_tree(artifacts, "j1", environment="py3.12", junit=JUNIT_PASSING)
j2_dir = _make_job_tree(artifacts, "j2", environment="py3.13", junit=JUNIT_FAILING)
@@ -305,7 +291,7 @@ def test_multiple_jobs_aggregate_into_one_workflow_status(tmp_path: Path) -> Non
]
gatherer.process_message(_batch_finished(artifacts, status="failure", batch_jobs=batch_jobs))
- _drain_queue(gatherer.queue)
+ drain_queue(gatherer.queue)
[status] = _registry(gatherer)
assert status.success_count == 1
assert status.failed_count == 1
@@ -313,13 +299,13 @@ def test_multiple_jobs_aggregate_into_one_workflow_status(tmp_path: Path) -> Non
assert failed == ["kafka"]
-def test_same_integration_different_platforms_do_not_overwrite(tmp_path: Path) -> None:
+def test_same_integration_different_platforms_do_not_overwrite(tmp_path: Path):
artifacts = tmp_path / "artifacts" / "100"
j1_dir = _make_job_tree(artifacts, "j1", e2e=False)
j2_dir = _make_job_tree(artifacts, "j2", e2e=False)
- j1 = _batch_job("j1", platform=Platform.LINUX, runner="ubuntu-latest")
- j2 = _batch_job("j2", platform=Platform.WINDOWS, runner="windows-latest")
+ j1 = _batch_job("j1", platform=PlatformName.LINUX, runner="ubuntu-latest")
+ j2 = _batch_job("j2", platform=PlatformName.WINDOWS, runner="windows-latest")
gatherer = _make_gatherer(tmp_path, {"batch-1": [j1, j2]})
batch_jobs = [
_batch_job_result(j1, _workflow_job("j1", "success"), j1_dir),
@@ -329,8 +315,37 @@ def test_same_integration_different_platforms_do_not_overwrite(tmp_path: Path) -
# Both jobs share target+environment but differ by platform/runner: each keeps its own file.
coverage_dir = tmp_path / "out" / "coverage"
- assert (coverage_dir / "ntp-py3.13-linux.xml").is_file()
- assert (coverage_dir / "ntp-py3.13-windows.xml").is_file()
+ assert (coverage_dir / "ntp_py3.13_linux.xml").is_file()
+ assert (coverage_dir / "ntp_py3.13_windows.xml").is_file()
+
+
+def test_combined_job_unit_and_e2e_outputs_coexist(tmp_path: Path) -> None:
+ # One job carries both facets (unit_tests and e2e_tests). Its bundle holds both a unit and an
+ # E2E JUnit report plus coverage; the organized outputs are distinguished by filename within
+ # the single artifact identity and never overwrite one another.
+ artifacts = tmp_path / "artifacts" / "100"
+ job_dir = _make_job_tree(artifacts, "postgres-job", junit=JUNIT_PASSING, e2e=True)
+
+ combined_job = make_job(
+ "postgres (py3.13)",
+ target="postgres",
+ e2e_tests=True,
+ agent_image="registry.datadoghq.com/agent-dev:master-py3",
+ )
+
+ gatherer = _make_gatherer(tmp_path, {"batch-1": [combined_job]})
+ gatherer.process_message(
+ _batch_finished(
+ artifacts,
+ batch_jobs=[_batch_job_result(combined_job, _workflow_job("postgres (py3.13)", "success"), job_dir)],
+ )
+ )
+
+ assert (tmp_path / "out" / "coverage" / "postgres_py3.13_linux.xml").is_file()
+
+ test_results_dir = tmp_path / "out" / "test_results"
+ assert (test_results_dir / "postgres_py3.13_linux-test-unit-py3.13.xml").is_file()
+ assert (test_results_dir / "postgres_py3.13_linux-test-e2e-py3.13.xml").is_file()
def test_emits_update_per_batch_done_on_last(tmp_path: Path) -> None:
@@ -343,12 +358,12 @@ def test_emits_update_per_batch_done_on_last(tmp_path: Path) -> None:
artifacts1,
id="b1",
run_id=100,
- batch_jobs=[_batch_job_result(_batch_job("j1"), _workflow_job("j1", "success"), j1_dir)],
+ batch_jobs=[_batch_job_result(make_job("j1"), _workflow_job("j1", "success"), j1_dir)],
)
)
# First of two batches: an update is emitted immediately (live updates), but not yet done.
- first = _drain_queue(gatherer.queue)
+ first = drain_queue(gatherer.queue)
assert len(first) == 1
assert first[0].revision == 1
assert first[0].progress.done is False
@@ -361,12 +376,12 @@ def test_emits_update_per_batch_done_on_last(tmp_path: Path) -> None:
artifacts2,
id="b2",
run_id=200,
- batch_jobs=[_batch_job_result(_batch_job("j1"), _workflow_job("j1", "success", run_id=200), j1_dir2)],
+ batch_jobs=[_batch_job_result(make_job("j1"), _workflow_job("j1", "success", run_id=200), j1_dir2)],
)
)
# Final batch: revision 2, done, aggregating both runs.
- second = _drain_queue(gatherer.queue)
+ second = drain_queue(gatherer.queue)
assert len(second) == 1
assert second[0].revision == 2
assert second[0].progress.done is True
@@ -374,7 +389,7 @@ def test_emits_update_per_batch_done_on_last(tmp_path: Path) -> None:
assert {status.id for status in _registry(gatherer)} == {100, 200}
-def test_multiple_failing_steps_all_collected(tmp_path: Path) -> None:
+def test_multiple_failing_steps_all_collected(tmp_path: Path):
# A workflow can run on-failure steps, so more than one step may conclude in failure.
artifacts = tmp_path / "artifacts" / "100"
job_dir = _make_job_tree(artifacts, "j1", e2e=False)
@@ -394,7 +409,7 @@ def test_multiple_failing_steps_all_collected(tmp_path: Path) -> None:
gatherer = _make_gatherer(tmp_path)
gatherer.process_message(
_batch_finished(
- artifacts, status="failure", batch_jobs=[_batch_job_result(_batch_job("j1"), workflow_job, job_dir)]
+ artifacts, status="failure", batch_jobs=[_batch_job_result(make_job("j1"), workflow_job, job_dir)]
)
)
@@ -402,7 +417,7 @@ def test_multiple_failing_steps_all_collected(tmp_path: Path) -> None:
assert result.failed_steps == ["Run unit tests", "Upload logs on failure"]
-def test_per_job_status_comes_from_correlated_job(tmp_path: Path) -> None:
+def test_per_job_status_comes_from_correlated_job(tmp_path: Path):
artifacts = tmp_path / "artifacts" / "100"
job_dir = _make_job_tree(artifacts, "j1", junit=JUNIT_FAILING, e2e=False)
workflow_job = WorkflowJob(
@@ -420,7 +435,7 @@ def test_per_job_status_comes_from_correlated_job(tmp_path: Path) -> None:
gatherer = _make_gatherer(tmp_path)
gatherer.process_message(
_batch_finished(
- artifacts, status="failure", batch_jobs=[_batch_job_result(_batch_job("j1"), workflow_job, job_dir)]
+ artifacts, status="failure", batch_jobs=[_batch_job_result(make_job("j1"), workflow_job, job_dir)]
)
)
@@ -429,14 +444,12 @@ def test_per_job_status_comes_from_correlated_job(tmp_path: Path) -> None:
assert result.failed_steps == ["Run unit tests"]
-def test_missing_artifact_dir_is_skipped(tmp_path: Path) -> None:
+def test_missing_artifact_dir_is_skipped(tmp_path: Path):
artifacts = tmp_path / "artifacts" / "100"
gatherer = _make_gatherer(tmp_path)
gatherer.process_message(
- _batch_finished(
- artifacts, batch_jobs=[_batch_job_result(_batch_job("j1"), _workflow_job("j1", "success"), None)]
- )
+ _batch_finished(artifacts, batch_jobs=[_batch_job_result(make_job("j1"), _workflow_job("j1", "success"), None)])
)
result = gatherer._results_by_batch["batch-1"][0]
@@ -445,14 +458,14 @@ def test_missing_artifact_dir_is_skipped(tmp_path: Path) -> None:
assert not (tmp_path / "out").exists()
-def test_malformed_junit_is_swallowed(tmp_path: Path) -> None:
+def test_malformed_junit_is_swallowed(tmp_path: Path):
artifacts = tmp_path / "artifacts" / "100"
job_dir = _make_job_tree(artifacts, "j1", junit="", e2e=False)
gatherer = _make_gatherer(tmp_path)
gatherer.process_message(
_batch_finished(
- artifacts, batch_jobs=[_batch_job_result(_batch_job("j1"), _workflow_job("j1", "success"), job_dir)]
+ artifacts, batch_jobs=[_batch_job_result(make_job("j1"), _workflow_job("j1", "success"), job_dir)]
)
)
@@ -461,7 +474,7 @@ def test_malformed_junit_is_swallowed(tmp_path: Path) -> None:
assert result.reports == () # malformed junit skipped; coverage.xml is not a JUnit report
-def test_missing_workflow_job_raises(tmp_path: Path) -> None:
+def test_missing_workflow_job_raises(tmp_path: Path):
# Correlation is the runner's job; a job without a workflow job on a non-timed-out batch is a bug.
artifacts = tmp_path / "artifacts" / "100"
job_dir = _make_job_tree(artifacts, "j1")
@@ -469,7 +482,7 @@ def test_missing_workflow_job_raises(tmp_path: Path) -> None:
gatherer = _make_gatherer(tmp_path)
with pytest.raises(ValueError, match="No workflow job correlated"):
gatherer.process_message(
- _batch_finished(artifacts, batch_jobs=[_batch_job_result(_batch_job("j1"), None, job_dir)])
+ _batch_finished(artifacts, batch_jobs=[_batch_job_result(make_job("j1"), None, job_dir)])
)
@@ -487,7 +500,7 @@ def test_empty_batch_jobs_still_terminates_the_batch(tmp_path: Path) -> None:
gatherer = _make_gatherer(tmp_path)
gatherer.process_message(_batch_finished("", status="failure", run_id=100, batch_jobs=[]))
- update = _drain_queue(gatherer.queue)[0]
+ update = drain_queue(gatherer.queue)[0]
assert update.revision == 1
batch = update.progress.batches[0]
assert batch.state == ExecutionState.FINISHED
@@ -505,7 +518,7 @@ def test_empty_batch_does_not_block_completion(tmp_path: Path) -> None:
gatherer = _make_gatherer(tmp_path, _one_job_plan("b1", "b2"))
gatherer.process_message(_batch_finished("", id="b1", run_id=100, batch_jobs=[]))
- assert _drain_queue(gatherer.queue)[0].progress.done is False
+ assert drain_queue(gatherer.queue)[0].progress.done is False
gatherer.process_message(
_batch_finished(
@@ -516,7 +529,7 @@ def test_empty_batch_does_not_block_completion(tmp_path: Path) -> None:
)
)
- final = _drain_queue(gatherer.queue)[0]
+ final = drain_queue(gatherer.queue)[0]
assert final.progress.done is True
assert _batch_progress(final, "b1").error == ProgressError.NO_JOB_RESULTS
@@ -535,7 +548,7 @@ def test_unplanned_batch_is_ignored(tmp_path: Path) -> None:
)
)
- assert _drain_queue(gatherer.queue) == []
+ assert drain_queue(gatherer.queue) == []
assert gatherer._revision == 0
assert [batch.batch_id for batch in gatherer.build_initial_update("initial").progress.batches] == ["batch-1"]
# Nor may it write into the output tree the planned batches publish from.
@@ -548,12 +561,12 @@ def test_duplicate_batch_finished_is_ignored(tmp_path: Path) -> None:
artifacts = tmp_path / "artifacts" / "100"
job_dir = _make_job_tree(artifacts, "j1")
batch = _batch_finished(
- artifacts, batch_jobs=[_batch_job_result(_batch_job("j1"), _workflow_job("j1", "success"), job_dir)]
+ artifacts, batch_jobs=[_batch_job_result(make_job("j1"), _workflow_job("j1", "success"), job_dir)]
)
gatherer = _make_gatherer(tmp_path)
gatherer.process_message(batch)
- first = _drain_queue(gatherer.queue)
+ first = drain_queue(gatherer.queue)
assert len(first) == 1
assert first[0].revision == 1
@@ -561,7 +574,7 @@ def test_duplicate_batch_finished_is_ignored(tmp_path: Path) -> None:
shutil.rmtree(tmp_path / "out")
gatherer.process_message(batch)
- assert _drain_queue(gatherer.queue) == []
+ assert drain_queue(gatherer.queue) == []
assert gatherer._revision == 1
assert not (tmp_path / "out").exists()
@@ -576,21 +589,61 @@ def test_duplicate_is_detected_by_batch_id_not_message_id(tmp_path: Path) -> Non
gatherer.process_message(_batch_finished(artifacts, id="msg-a", batch_id="batch-1", batch_jobs=[job]))
gatherer.process_message(_batch_finished(artifacts, id="msg-b", batch_id="batch-1", batch_jobs=[job]))
- updates = _drain_queue(gatherer.queue)
+ updates = drain_queue(gatherer.queue)
assert [update.revision for update in updates] == [1]
assert gatherer._revision == 1
assert len(_registry(gatherer)) == 1
-def test_no_emission_without_batch_finished(tmp_path: Path) -> None:
+def test_correlates_on_batch_id_not_message_id(tmp_path: Path):
+ # The gatherer keys its registry and workflow status on the logical batch_id, independent of
+ # the message id and of the GitHub run_id (execution metadata).
+ artifacts = tmp_path / "artifacts" / "100"
+ job_dir = _make_job_tree(artifacts, "j1")
+
+ gatherer = _make_gatherer(tmp_path, {"batch-09": [make_job("j1")]})
+ gatherer.process_message(
+ _batch_finished(
+ artifacts,
+ id="msg-uuid-1",
+ batch_id="batch-09",
+ run_id=555,
+ batch_jobs=[_batch_job_result(make_job("j1"), _workflow_job("j1", "success"), job_dir)],
+ )
+ )
+
+ assert set(gatherer._results_by_batch) == {"batch-09"}
+ drain_queue(gatherer.queue)
+ [status] = _registry(gatherer)
+ assert status.batch_id == "batch-09"
+ assert status.id == 555
+
+
+def test_duplicate_correlates_on_batch_id_across_reruns(tmp_path: Path):
+ # A re-run keeps the same logical batch_id but reports a new run_id; the duplicate must still
+ # be ignored because correlation is on batch_id, not run_id.
+ artifacts = tmp_path / "artifacts" / "100"
+ job_dir = _make_job_tree(artifacts, "j1")
+ jobs = [_batch_job_result(make_job("j1"), _workflow_job("j1", "success"), job_dir)]
+
+ gatherer = _make_gatherer(tmp_path, {"batch-09": [make_job("j1")]})
+ gatherer.process_message(_batch_finished(artifacts, id="msg-a", batch_id="batch-09", run_id=100, batch_jobs=jobs))
+ assert len(drain_queue(gatherer.queue)) == 1
+
+ gatherer.process_message(_batch_finished(artifacts, id="msg-b", batch_id="batch-09", run_id=200, batch_jobs=jobs))
+ assert drain_queue(gatherer.queue) == []
+ assert gatherer._revision == 1
+
+
+def test_no_emission_without_batch_finished(tmp_path: Path):
# Invariant: the gatherer's state changes only when a BatchFinished is consumed.
gatherer = _make_gatherer(tmp_path)
- assert _drain_queue(gatherer.queue) == []
+ assert drain_queue(gatherer.queue) == []
assert gatherer._results_by_batch == {}
assert gatherer._revision == 0
-def test_build_update_message(tmp_path: Path) -> None:
+def test_build_update_message(tmp_path: Path):
gatherer = _make_gatherer(tmp_path)
message = gatherer.build_update_message("final", revision=2, done=True)
assert isinstance(message, UpdatePRComment)
@@ -639,7 +692,7 @@ def test_finished_batch_leaves_other_batches_planned(tmp_path: Path) -> None:
)
)
- update = _drain_queue(gatherer.queue)[0]
+ update = drain_queue(gatherer.queue)[0]
assert update.progress.done is False
assert _batch_progress(update, "b1").state == ExecutionState.FINISHED
assert _batch_progress(update, "b2").state == ExecutionState.PLANNED
@@ -667,7 +720,7 @@ def test_progress_and_registry_agree(tmp_path: Path) -> None:
)
)
- update = _drain_queue(gatherer.queue)[0]
+ update = drain_queue(gatherer.queue)[0]
[workflow] = _registry(gatherer)
assert (update.progress.passed, update.progress.failed, update.progress.skipped) == (
workflow.success_count,
@@ -693,7 +746,7 @@ def test_batch_status_comes_from_the_workflow_not_from_its_jobs(tmp_path: Path)
)
)
- update = _drain_queue(gatherer.queue)[0]
+ update = drain_queue(gatherer.queue)[0]
batch = _batch_progress(update, "batch-1")
assert batch.status == Status.FAILURE
assert [job.latest.status for job in batch.jobs_progress] == [Status.SUCCESS]
@@ -718,7 +771,7 @@ def test_unplanned_job_is_warned_about_but_left_out_of_the_totals(tmp_path: Path
)
)
- update = _drain_queue(gatherer.queue)[0]
+ update = drain_queue(gatherer.queue)[0]
batch = _batch_progress(update, "batch-1")
assert [job.job.name for job in batch.jobs_progress] == ["j1"]
assert (update.progress.total, update.progress.complete) == (1, 1)
@@ -736,7 +789,7 @@ def test_timed_out_batch_is_recorded_on_the_batch(tmp_path: Path) -> None:
)
)
- batch = _batch_progress(_drain_queue(gatherer.queue)[0], "batch-1")
+ batch = _batch_progress(drain_queue(gatherer.queue)[0], "batch-1")
assert batch.status == Status.FAILURE
assert batch.error == ProgressError.TIMED_OUT
assert batch.jobs_progress[0].latest is not None
@@ -766,7 +819,7 @@ def gather(message) -> None:
for future in [pool.submit(gather, message) for message in messages]:
future.result()
- updates = _drain_queue(gatherer.queue)
+ updates = drain_queue(gatherer.queue)
assert sorted(update.revision for update in updates) == [1, 2, 3, 4, 5]
assert [update.progress.done for update in updates].count(True) == 1
@@ -781,7 +834,7 @@ def test_missing_artifact_dir_is_recorded_as_an_attempt_error(tmp_path: Path) ->
_batch_finished("", batch_jobs=[_batch_job_result(_batch_job("j1"), _workflow_job("j1", "success"), None)])
)
- attempt = _batch_progress(_drain_queue(gatherer.queue)[0], "batch-1").jobs_progress[0].latest
+ attempt = _batch_progress(drain_queue(gatherer.queue)[0], "batch-1").jobs_progress[0].latest
assert attempt is not None
assert attempt.error == ProgressError.NO_ARTIFACTS
assert attempt.reports == ()
@@ -806,7 +859,7 @@ def test_second_run_appends_an_attempt_and_keeps_untouched_jobs(tmp_path: Path)
],
)
)
- _drain_queue(gatherer.queue)
+ drain_queue(gatherer.queue)
rerun_dir = _make_job_tree(tmp_path / "artifacts" / "101", "j2")
rerun = _batch_finished(
@@ -838,7 +891,9 @@ def test_second_run_appends_an_attempt_and_keeps_untouched_jobs(tmp_path: Path)
# ---------------------------------------------------------------------------
-def _scenario_batch_job(target: str, platform: Platform = Platform.LINUX, runner: str = "ubuntu-latest") -> BatchJob:
+def _scenario_batch_job(
+ target: str, platform: PlatformName = PlatformName.LINUX, runner: str = "ubuntu-latest"
+) -> BatchJob:
return _batch_job(target, target=target, environment="py3.12", platform=platform, runner=runner)
@@ -849,11 +904,11 @@ def _scenario_job(
junit: str | None,
*,
run_id: int,
- platform: Platform = Platform.LINUX,
- runner: str = "ubuntu-latest",
+ platform: PlatformName = PlatformName.LINUX,
+ runner_labels: tuple[str, ...] = ("ubuntu-latest",),
failed_step: str | None = None,
) -> BatchJobResult:
- job = _scenario_batch_job(target, platform, runner)
+ job = _scenario_batch_job(target, platform, runner_labels[0])
job_dir = _make_job_tree(artifacts, target, environment="py3.12", junit=junit, e2e=False)
workflow_job = _workflow_job(target, conclusion, failed_step=failed_step, run_id=run_id)
return _batch_job_result(job, workflow_job, job_dir)
@@ -865,7 +920,7 @@ def _scenario_plan() -> dict[str, list[BatchJob]]:
"b1": [
_scenario_batch_job("postgres"),
_scenario_batch_job("redis"),
- _scenario_batch_job("ntp", Platform.WINDOWS, "windows-latest"),
+ _scenario_batch_job("ntp", PlatformName.WINDOWS, "windows-latest"),
_scenario_batch_job("kafka"),
],
"b2": [_scenario_batch_job(target) for target in ("disk", "snmp", "http_check", "mysql")],
@@ -882,12 +937,18 @@ def test_dispatcher_scenario_three_batches(tmp_path: Path) -> None:
_scenario_job(a1, "postgres", "success", JUNIT_PASSING, run_id=1),
_scenario_job(a1, "redis", "success", JUNIT_PASSING, run_id=1),
_scenario_job(
- a1, "ntp", "success", JUNIT_PASSING, run_id=1, platform=Platform.WINDOWS, runner="windows-latest"
+ a1,
+ "ntp",
+ "success",
+ JUNIT_PASSING,
+ run_id=1,
+ platform=PlatformName.WINDOWS,
+ runner_labels=("windows-latest",),
),
_scenario_job(a1, "kafka", "success", JUNIT_PASSING, run_id=1),
]
gatherer.process_message(_batch_finished(a1, id="b1", run_id=1, batch_jobs=batch_01))
- rev1 = _drain_queue(gatherer.queue)
+ rev1 = drain_queue(gatherer.queue)
assert len(rev1) == 1
assert (rev1[0].revision, rev1[0].progress.done) == (1, False)
assert _totals(rev1[0]) == (4, 0, 0, 4)
@@ -901,7 +962,7 @@ def test_dispatcher_scenario_three_batches(tmp_path: Path) -> None:
_scenario_job(a2, "mysql", "failure", JUNIT_FAILING, run_id=2, failed_step="Run unit tests"),
]
gatherer.process_message(_batch_finished(a2, id="b2", status="failure", run_id=2, batch_jobs=batch_02))
- rev2 = _drain_queue(gatherer.queue)
+ rev2 = drain_queue(gatherer.queue)
assert len(rev2) == 1
assert (rev2[0].revision, rev2[0].progress.done) == (2, False)
assert _totals(rev2[0]) == (7, 1, 0, 8)
@@ -915,7 +976,7 @@ def test_dispatcher_scenario_three_batches(tmp_path: Path) -> None:
_scenario_job(a3, "consul", "skipped", None, run_id=3),
]
gatherer.process_message(_batch_finished(a3, id="b3", run_id=3, batch_jobs=batch_03))
- rev3 = _drain_queue(gatherer.queue)
+ rev3 = drain_queue(gatherer.queue)
assert len(rev3) == 1
final = rev3[0]
assert (final.revision, final.progress.done) == (3, True)
@@ -972,7 +1033,7 @@ def test_dispatcher_scenario_three_batches(tmp_path: Path) -> None:
assert [case.identifier for case in mysql_attempt.failed_tests] == [FAILING_TEST_ID]
-def test_dispatcher_scenario_revisions_are_monotonic(tmp_path: Path) -> None:
+def test_dispatcher_scenario_revisions_are_monotonic(tmp_path: Path):
# Each consumed BatchFinished yields exactly one revision, strictly increasing (invariant #2).
gatherer = _make_gatherer(tmp_path, {f"b{index}": [_scenario_batch_job(f"int{index}")] for index in (1, 2, 3)})
revisions: list[int] = []
@@ -980,7 +1041,7 @@ def test_dispatcher_scenario_revisions_are_monotonic(tmp_path: Path) -> None:
artifacts = tmp_path / "artifacts" / str(index)
jobs = [_scenario_job(artifacts, f"int{index}", "success", JUNIT_PASSING, run_id=index)]
gatherer.process_message(_batch_finished(artifacts, id=f"b{index}", run_id=index, batch_jobs=jobs))
- emitted = _drain_queue(gatherer.queue)
+ emitted = drain_queue(gatherer.queue)
assert len(emitted) == 1
revisions.append(emitted[0].revision)
diff --git a/ddev/tests/cli/ci/tests/test_task_test_runner.py b/ddev/tests/cli/ci/tests/test_task_test_runner.py
index f005cab788d53..003bf080a64b6 100644
--- a/ddev/tests/cli/ci/tests/test_task_test_runner.py
+++ b/ddev/tests/cli/ci/tests/test_task_test_runner.py
@@ -12,10 +12,9 @@
import pytest
-from ddev.cli.ci.tests.messages import BatchFinished, BatchJob, Platform, TestBatch
+from ddev.cli.ci.tests.messages import BatchFinished, BatchJob, TestBatch
from ddev.cli.ci.tests.status import Status, conclusion_to_status
from ddev.cli.ci.tests.task_test_runner import TaskTestRunner, TestRunnerOptions
-from ddev.event_bus.orchestrator import BaseMessage
from ddev.utils.github_async import GitHubResponse
from ddev.utils.github_async.models import (
Artifact,
@@ -24,6 +23,7 @@
WorkflowJobsList,
WorkflowRun,
)
+from tests.cli.ci.tests.helpers import drain_queue, make_job
from tests.helpers.github_async import FakeAsyncGitHubClient
# ---------------------------------------------------------------------------
@@ -35,18 +35,6 @@ def wrap(data: Any) -> GitHubResponse[Any]:
return GitHubResponse(data=data, headers={})
-def make_job(name: str = "job-1", environment: str = "py3.13") -> BatchJob:
- return BatchJob(
- name=name,
- target="ntp",
- runner="ubuntu-latest",
- environment=environment,
- platform=Platform.LINUX,
- unit_tests=True,
- e2e_tests=False,
- )
-
-
DEFAULT_URL = object()
@@ -76,7 +64,7 @@ def artifacts_page(artifacts: list[Artifact]) -> GitHubResponse[ArtifactsList]:
return wrap(ArtifactsList(total_count=len(artifacts), artifacts=list(artifacts)))
-def mock_artifacts(fake: FakeAsyncGitHubClient, artifacts: list[Artifact]) -> None:
+def mock_artifacts(fake: FakeAsyncGitHubClient, artifacts: list[Artifact]):
fake.mock_response("list_workflow_run_artifacts", artifacts_page(artifacts))
@@ -90,7 +78,7 @@ def make_workflow_job(name: str, conclusion: str = "success") -> WorkflowJob:
return WorkflowJob(id=1, run_id=123, name=name, status="completed", conclusion=conclusion)
-def mock_jobs(fake: FakeAsyncGitHubClient, jobs: list[WorkflowJob]) -> None:
+def mock_jobs(fake: FakeAsyncGitHubClient, jobs: list[WorkflowJob]):
fake.mock_response("list_workflow_jobs", wrap(WorkflowJobsList(total_count=len(jobs), jobs=list(jobs))))
@@ -114,13 +102,6 @@ def make_runner(client: FakeAsyncGitHubClient, tmp_path: Path) -> TaskTestRunner
return runner
-def drain_queue(queue: asyncio.Queue[BaseMessage]) -> list[BaseMessage]:
- out: list[BaseMessage] = []
- while not queue.empty():
- out.append(queue.get_nowait())
- return out
-
-
def make_batch(batch_id: str = "batch-err") -> TestBatch:
return TestBatch(id=batch_id, batch_id=batch_id, job_list=[make_job()], jobs_count=1, integrations=["ntp"])
@@ -173,7 +154,7 @@ async def run_happy_path(tmp_path: Path) -> tuple[FakeAsyncGitHubClient, BatchFi
(None, Status.FAILURE),
],
)
-def test_conclusion_to_status(conclusion: str | None, expected: Status) -> None:
+def test_conclusion_to_status(conclusion: str | None, expected: Status):
result = conclusion_to_status(conclusion)
assert result is expected
assert isinstance(result, Status)
@@ -185,7 +166,7 @@ def test_conclusion_to_status(conclusion: str | None, expected: Status) -> None:
@pytest.mark.asyncio
-async def test_dispatches_workflow_with_job_list_payload(tmp_path: Path) -> None:
+async def test_dispatches_workflow_with_job_list_payload(tmp_path: Path):
fake, _ = await run_happy_path(tmp_path)
dispatch_calls = fake.calls_to("create_workflow_dispatch")
@@ -206,21 +187,25 @@ async def test_dispatches_workflow_with_job_list_payload(tmp_path: Path) -> None
{
"name": "j1",
"target": "ntp",
- "runner": "ubuntu-latest",
+ "runner_labels": ["ubuntu-22.04"],
"environment": "py3.13",
"platform": "linux",
+ "python_version": "3.13",
"unit_tests": True,
"e2e_tests": False,
+ "agent_image": None,
"artifact_name": "ntp_py3.13_linux",
},
{
"name": "j2",
"target": "ntp",
- "runner": "ubuntu-latest",
+ "runner_labels": ["ubuntu-22.04"],
"environment": "py3.13",
"platform": "linux",
+ "python_version": "3.13",
"unit_tests": True,
"e2e_tests": False,
+ "agent_image": None,
"artifact_name": "ntp_py3.13_linux",
},
]
@@ -230,7 +215,7 @@ async def test_dispatches_workflow_with_job_list_payload(tmp_path: Path) -> None
@pytest.mark.asyncio
-async def test_opens_check_run_with_head_sha_and_details_url(tmp_path: Path) -> None:
+async def test_opens_check_run_with_head_sha_and_details_url(tmp_path: Path):
fake, _ = await run_happy_path(tmp_path)
create_calls = fake.calls_to("create_check_run")
@@ -243,7 +228,7 @@ async def test_opens_check_run_with_head_sha_and_details_url(tmp_path: Path) ->
@pytest.mark.asyncio
-async def test_downloads_all_batch_artifacts(tmp_path: Path) -> None:
+async def test_downloads_all_batch_artifacts(tmp_path: Path):
fake, _ = await run_happy_path(tmp_path)
download_calls = fake.calls_to("download_artifact")
@@ -259,7 +244,7 @@ async def test_downloads_all_batch_artifacts(tmp_path: Path) -> None:
@pytest.mark.asyncio
-async def test_emits_batch_finished_with_run_metadata(tmp_path: Path) -> None:
+async def test_emits_batch_finished_with_run_metadata(tmp_path: Path):
_, finished = await run_happy_path(tmp_path)
assert finished.id == "msg-1"
@@ -272,7 +257,7 @@ async def test_emits_batch_finished_with_run_metadata(tmp_path: Path) -> None:
@pytest.mark.asyncio
-async def test_batch_finished_records_unmatched_correlation_when_no_match(tmp_path: Path) -> None:
+async def test_batch_finished_records_unmatched_correlation_when_no_match(tmp_path: Path):
# The two jobs' artifact names collide and don't match the generic artifacts, and there is no
# jobs API match, so both correlated facets are None while the per-facet file names are recorded.
_, finished = await run_happy_path(tmp_path)
@@ -290,7 +275,28 @@ async def test_batch_finished_records_unmatched_correlation_when_no_match(tmp_pa
@pytest.mark.asyncio
-async def test_closes_check_run_with_workflow_conclusion(tmp_path: Path) -> None:
+async def test_uses_batch_id_not_message_id_for_correlation(tmp_path: Path):
+ # The logical batch identity comes from batch_id; the message id is a separate identity and
+ # must not be used for the check-run name, the workflow inputs, or the emitted BatchFinished.
+ fake = FakeAsyncGitHubClient()
+ fake.mock_response("get_workflow_run", make_workflow_run("completed", "success"))
+ mock_artifacts(fake, [])
+ runner = make_runner(fake, tmp_path)
+
+ batch = TestBatch(id="msg-uuid-xyz", batch_id="batch-07", job_list=[make_job()], jobs_count=1, integrations=["ntp"])
+ await runner.process_message(batch)
+
+ assert fake.calls_to("create_check_run")[0].kwargs["name"] == "test-batch/batch-07"
+ assert fake.calls_to("create_workflow_dispatch")[0].kwargs["inputs"]["batch_id"] == "batch-07"
+
+ finished = drain_queue(runner.queue)[0]
+ assert isinstance(finished, BatchFinished)
+ assert finished.id == "msg-uuid-xyz"
+ assert finished.batch_id == "batch-07"
+
+
+@pytest.mark.asyncio
+async def test_closes_check_run_with_workflow_conclusion(tmp_path: Path):
fake, _ = await run_happy_path(tmp_path)
update_calls = fake.calls_to("update_check_run")
@@ -307,7 +313,7 @@ async def test_closes_check_run_with_workflow_conclusion(tmp_path: Path) -> None
@pytest.mark.asyncio
-async def test_process_message_correlates_batch_jobs(tmp_path: Path) -> None:
+async def test_process_message_correlates_batch_jobs(tmp_path: Path):
# A failed multi-job run where j1 passed and j2 failed: each batch_jobs entry must carry its
# own true per-job status and its artifact directory, resolved by the job's artifact name.
# The two jobs differ in an artifact-relevant field (environment) so their base names differ.
@@ -340,7 +346,7 @@ async def test_process_message_correlates_batch_jobs(tmp_path: Path) -> None:
@pytest.mark.asyncio
-async def test_process_message_batch_job_without_workflow_match(tmp_path: Path) -> None:
+async def test_process_message_batch_job_without_workflow_match(tmp_path: Path):
# A job present in the batch but absent from the workflow-run API response still yields a
# well-formed entry: its artifact is located but workflow_job is None.
job = make_job("j1")
@@ -363,7 +369,7 @@ async def test_process_message_batch_job_without_workflow_match(tmp_path: Path)
@pytest.mark.asyncio
-async def test_process_message_batch_job_without_artifacts(tmp_path: Path) -> None:
+async def test_process_message_batch_job_without_artifacts(tmp_path: Path):
# A job with no artifacts on disk still yields a well-formed entry with artifact_name_path None.
job = make_job("j1")
fake = FakeAsyncGitHubClient()
@@ -389,7 +395,7 @@ async def test_process_message_batch_job_without_artifacts(tmp_path: Path) -> No
@pytest.mark.asyncio
-async def test_process_message_emits_batch_finished_when_listing_jobs_fails(tmp_path: Path) -> None:
+async def test_process_message_emits_batch_finished_when_listing_jobs_fails(tmp_path: Path):
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "success"))
mock_artifacts(fake, [])
@@ -407,7 +413,7 @@ async def test_process_message_emits_batch_finished_when_listing_jobs_fails(tmp_
@pytest.mark.asyncio
-async def test_process_message_failure_path(tmp_path: Path) -> None:
+async def test_process_message_failure_path(tmp_path: Path):
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "failure"))
mock_artifacts(fake, [make_artifact(1)])
@@ -430,7 +436,7 @@ async def test_process_message_failure_path(tmp_path: Path) -> None:
@pytest.mark.asyncio
-async def test_process_message_skipped_conclusion(tmp_path: Path) -> None:
+async def test_process_message_skipped_conclusion(tmp_path: Path):
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "skipped"))
mock_artifacts(fake, [])
@@ -451,7 +457,7 @@ async def test_process_message_skipped_conclusion(tmp_path: Path) -> None:
@pytest.mark.asyncio
-async def test_process_message_polls_until_completed(tmp_path: Path) -> None:
+async def test_process_message_polls_until_completed(tmp_path: Path):
fake = FakeAsyncGitHubClient()
# Initial get + polls until "completed"; FIFO one-shots replay in order.
for status in ("queued", "in_progress", "in_progress", "completed"):
@@ -471,7 +477,7 @@ async def test_process_message_polls_until_completed(tmp_path: Path) -> None:
@pytest.mark.asyncio
-async def test_process_message_skips_expired_artifacts(tmp_path: Path) -> None:
+async def test_process_message_skips_expired_artifacts(tmp_path: Path):
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "success"))
mock_artifacts(
@@ -495,7 +501,7 @@ async def test_process_message_skips_expired_artifacts(tmp_path: Path) -> None:
@pytest.mark.asyncio
-async def test_process_message_null_conclusion(tmp_path: Path) -> None:
+async def test_process_message_null_conclusion(tmp_path: Path):
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", None))
mock_artifacts(fake, [])
@@ -516,7 +522,7 @@ async def test_process_message_null_conclusion(tmp_path: Path) -> None:
@pytest.mark.asyncio
-async def test_process_message_emits_batch_finished_when_listing_artifacts_fails(tmp_path: Path) -> None:
+async def test_process_message_emits_batch_finished_when_listing_artifacts_fails(tmp_path: Path):
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "success"))
fake.mock_response("list_workflow_run_artifacts", RuntimeError("boom-list-artifacts"))
@@ -538,7 +544,7 @@ async def test_process_message_emits_batch_finished_when_listing_artifacts_fails
@pytest.mark.asyncio
-async def test_process_message_swallows_check_run_close_failure(tmp_path: Path) -> None:
+async def test_process_message_swallows_check_run_close_failure(tmp_path: Path):
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "success"))
mock_artifacts(fake, [make_artifact(1)])
@@ -556,7 +562,7 @@ async def test_process_message_swallows_check_run_close_failure(tmp_path: Path)
@pytest.mark.asyncio
-async def test_download_failure_for_one_artifact_does_not_abort_others(tmp_path: Path) -> None:
+async def test_download_failure_for_one_artifact_does_not_abort_others(tmp_path: Path):
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "success"))
mock_artifacts(fake, [make_artifact(1), make_artifact(2), make_artifact(3)])
@@ -590,7 +596,7 @@ async def test_download_failure_for_one_artifact_does_not_abort_others(tmp_path:
@pytest.mark.parametrize("failure_point", ["create_workflow_dispatch", "get_workflow_run_initial"])
@pytest.mark.asyncio
-async def test_failure_before_check_run_opens_does_not_create_check_run(tmp_path: Path, failure_point: str) -> None:
+async def test_failure_before_check_run_opens_does_not_create_check_run(tmp_path: Path, failure_point: str):
boom = RuntimeError(f"boom-{failure_point}")
fake = FakeAsyncGitHubClient()
if failure_point == "create_workflow_dispatch":
@@ -608,7 +614,7 @@ async def test_failure_before_check_run_opens_does_not_create_check_run(tmp_path
@pytest.mark.asyncio
-async def test_failure_mid_poll_closes_check_run_as_cancelled(tmp_path: Path) -> None:
+async def test_failure_mid_poll_closes_check_run_as_cancelled(tmp_path: Path):
boom = RuntimeError("boom-mid-poll")
fake = FakeAsyncGitHubClient()
# Initial get succeeds (still running), the first poll raises.
@@ -628,7 +634,7 @@ async def test_failure_mid_poll_closes_check_run_as_cancelled(tmp_path: Path) ->
@pytest.mark.asyncio
-async def test_failure_at_create_check_run_does_not_close_check_run(tmp_path: Path) -> None:
+async def test_failure_at_create_check_run_does_not_close_check_run(tmp_path: Path):
boom = RuntimeError("boom-create-check-run")
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "success"))
@@ -644,7 +650,7 @@ async def test_failure_at_create_check_run_does_not_close_check_run(tmp_path: Pa
@pytest.mark.asyncio
-async def test_failure_at_submit_message_closes_check_run_as_success(tmp_path: Path) -> None:
+async def test_failure_at_submit_message_closes_check_run_as_success(tmp_path: Path):
boom = RuntimeError("boom-submit-message")
fake = FakeAsyncGitHubClient()
fake.mock_response("get_workflow_run", make_workflow_run("completed", "success"))
@@ -652,7 +658,7 @@ async def test_failure_at_submit_message_closes_check_run_as_success(tmp_path: P
runner = make_runner(fake, tmp_path)
class _BoomQueue:
- def put_nowait(self, _: Any) -> None:
+ def put_nowait(self, _: Any):
raise boom
runner.queue = _BoomQueue() # type: ignore[assignment]
diff --git a/ddev/tests/e2e/test_agent_images.py b/ddev/tests/e2e/test_agent_images.py
new file mode 100644
index 0000000000000..2cc299b4426f0
--- /dev/null
+++ b/ddev/tests/e2e/test_agent_images.py
@@ -0,0 +1,146 @@
+# (C) Datadog, Inc. 2026-present
+# All rights reserved
+# Licensed under a 3-clause BSD style license (see LICENSE)
+from __future__ import annotations
+
+import pytest
+
+from ddev.e2e.agent.image import normalize_agent_image_name
+from ddev.e2e.agent_images import (
+ AGENT_IMAGES_BY_PYTHON,
+ UnknownPythonVersion,
+ UnsupportedAgentPlatform,
+ find_unpublished_images,
+ get_agent_image,
+ parse_image_reference,
+)
+from ddev.utils.platform import PlatformName
+
+
+@pytest.mark.parametrize(
+ ("python_version", "platform", "expected"),
+ [
+ pytest.param('3.13', PlatformName.LINUX, 'registry.datadoghq.com/agent-dev:master-py3', id="current-dev"),
+ pytest.param('3.13', PlatformName.WINDOWS, 'registry.datadoghq.com/agent:7-rc-servercore', id="current-rc"),
+ pytest.param('3.12', PlatformName.LINUX, 'registry.datadoghq.com/agent:7.71.1', id="last-of-3.12-line"),
+ pytest.param(
+ '3.12', PlatformName.WINDOWS, 'registry.datadoghq.com/agent:7.71.1-servercore', id="servercore-variant"
+ ),
+ pytest.param('3.11', PlatformName.LINUX, 'registry.datadoghq.com/agent:7.57.2', id="last-of-3.11-line"),
+ ],
+)
+def test_agent_image_for_release_line(python_version, platform, expected):
+ assert get_agent_image(python_version, platform) == expected
+
+
+@pytest.mark.parametrize(
+ "python_version",
+ [
+ pytest.param('3.10', id="no-agent-ever-embedded-it"),
+ pytest.param('4.0', id="does-not-exist"),
+ ],
+)
+def test_python_version_without_an_agent_is_rejected(python_version):
+ with pytest.raises(UnknownPythonVersion, match="No Agent release embeds"):
+ get_agent_image(python_version, PlatformName.LINUX)
+
+
+@pytest.mark.parametrize(
+ "python_version",
+ [
+ pytest.param('3', id="major-only"),
+ pytest.param('3.13t', id="free-threaded"),
+ pytest.param('3.13.7', id="patch-level"),
+ pytest.param('/usr/bin/python3', id="interpreter-path"),
+ pytest.param('', id="empty"),
+ ],
+)
+def test_malformed_python_version_is_rejected_rather_than_guessed(python_version):
+ with pytest.raises(UnknownPythonVersion, match="Invalid Python version"):
+ get_agent_image(python_version, PlatformName.LINUX)
+
+
+def test_unsupported_platform_is_rejected():
+ with pytest.raises(UnsupportedAgentPlatform, match="macos"):
+ get_agent_image('3.13', PlatformName.MACOS)
+
+
+@pytest.mark.parametrize("platform", [PlatformName.LINUX, PlatformName.WINDOWS])
+def test_images_survive_ddev_jmx_normalization(platform):
+ """Every image must keep its identity when ddev appends the JMX suffix at E2E runtime.
+
+ `DockerAgent` rewrites the image name for JMX environments, so a tag shape it does not
+ recognize would silently be passed through or mangled instead of gaining `-jmx`.
+ """
+ for python_version in AGENT_IMAGES_BY_PYTHON:
+ image = get_agent_image(python_version, platform)
+
+ assert normalize_agent_image_name(image, 3, False) == image
+ assert normalize_agent_image_name(image, 3, True) == f'{image}-jmx'
+
+
+@pytest.mark.parametrize(
+ "image, expected",
+ [
+ pytest.param(
+ 'registry.datadoghq.com/agent:7.71.1-servercore',
+ ('registry.datadoghq.com', 'agent', '7.71.1-servercore'),
+ id="release",
+ ),
+ pytest.param(
+ 'registry.datadoghq.com/agent-dev:master-py3',
+ ('registry.datadoghq.com', 'agent-dev', 'master-py3'),
+ id="dev",
+ ),
+ pytest.param('host/nested/repository:tag', ('host', 'nested/repository', 'tag'), id="nested-repository"),
+ ],
+)
+def test_parse_image_reference(image, expected):
+ assert parse_image_reference(image) == expected
+
+
+@pytest.mark.parametrize("image", ['agent:7.71.1', 'registry.datadoghq.com/agent', ''])
+def test_parse_image_reference_rejects_an_incomplete_reference(image):
+ with pytest.raises(ValueError, match="fully qualified"):
+ parse_image_reference(image)
+
+
+def test_find_unpublished_images_queries_each_distinct_image_once(monkeypatch):
+ queried: list[tuple[str, str]] = []
+
+ def fake_manifest_exists(repository, tag, *, host, **kwargs):
+ queried.append((repository, tag))
+ return tag != 'gone'
+
+ monkeypatch.setattr('ddev.utils.docker_registry.manifest_exists', fake_manifest_exists)
+
+ missing = find_unpublished_images(
+ ['host/agent:here', 'host/agent:gone', 'host/agent:here'],
+ )
+
+ assert missing == ['host/agent:gone']
+ assert queried == [('agent', 'here'), ('agent', 'gone')]
+
+
+def test_find_unpublished_images_propagates_a_registry_failure(monkeypatch):
+ # An unreachable registry must not be reported as a missing image, or a network blip would
+ # look like a withdrawn tag.
+ def failing_manifest_exists(repository, tag, *, host, **kwargs):
+ raise OSError("registry unreachable")
+
+ monkeypatch.setattr('ddev.utils.docker_registry.manifest_exists', failing_manifest_exists)
+
+ with pytest.raises(OSError, match="registry unreachable"):
+ find_unpublished_images(['host/agent:here'])
+
+
+@pytest.mark.requires_ci
+def test_every_image_in_the_manifest_is_published():
+ """Guard against a typo or a tag that has been withdrawn from the registry."""
+ images = [
+ get_agent_image(python_version, platform)
+ for python_version in AGENT_IMAGES_BY_PYTHON
+ for platform in (PlatformName.LINUX, PlatformName.WINDOWS)
+ ]
+
+ assert find_unpublished_images(images) == []
diff --git a/ibm_ace/changelog.d/24826.fixed b/ibm_ace/changelog.d/24826.fixed
new file mode 100644
index 0000000000000..eb830919135f8
--- /dev/null
+++ b/ibm_ace/changelog.d/24826.fixed
@@ -0,0 +1 @@
+Fix a `KeyError` crash when ACE omits the `name` field on a resource identifier.
diff --git a/ibm_ace/datadog_checks/ibm_ace/resources.py b/ibm_ace/datadog_checks/ibm_ace/resources.py
index c5277b092535f..9b7e2672f68ec 100644
--- a/ibm_ace/datadog_checks/ibm_ace/resources.py
+++ b/ibm_ace/datadog_checks/ibm_ace/resources.py
@@ -20,7 +20,11 @@ def full_metric_name(self, metric):
return f'{self.name}.{self.normalized_metric_name(metric)}'
def parse_tags(self, global_tags, metric_data):
- group = metric_data.pop('name')
+ # ACE can omit `name` for some resourceIdentifier entries.
+ group = metric_data.pop('name', None)
+ if group is None:
+ return list(global_tags)
+
return [f'group:{group}', *global_tags]
def submit(self, check, metric, value, tags):
diff --git a/ibm_ace/datadog_checks/ibm_ace/subscription.py b/ibm_ace/datadog_checks/ibm_ace/subscription.py
index 3c56192825137..f8f9a8603aa3b 100644
--- a/ibm_ace/datadog_checks/ibm_ace/subscription.py
+++ b/ibm_ace/datadog_checks/ibm_ace/subscription.py
@@ -189,6 +189,17 @@ def collect(self):
tags = resource.parse_tags(resource_tags, metric_data)
for metric, value in metric_data.items():
+ # ACE can emit a malformed entry with an empty key instead of `name`.
+ if not metric:
+ self.check.log.debug(
+ 'Skipping resourceIdentifier entry with malformed key for resource %s: '
+ 'value=%r, tags=%s',
+ resource_data['name'],
+ value,
+ tags,
+ )
+ continue
+
resource.submit(self.check, metric, value, tags)
diff --git a/ibm_ace/tests/fixtures/resource_statistics_malformed_jdbc.json b/ibm_ace/tests/fixtures/resource_statistics_malformed_jdbc.json
new file mode 100644
index 0000000000000..d3265fe2887e7
--- /dev/null
+++ b/ibm_ace/tests/fixtures/resource_statistics_malformed_jdbc.json
@@ -0,0 +1,45 @@
+{
+ "ResourceStatistics": {
+ "brokerLabel": "integration_server",
+ "brokerUUID": "00000000-0000-0000-0000-000000000000",
+ "executionGroupName": "ACESERVER",
+ "executionGroupUUID": "00000000-0000-0000-0000-000000000000",
+ "ResourceType": [
+ {
+ "name": "JDBCConnectionPools",
+ "resourceIdentifier": [
+ {
+ "name": "summary",
+ "NameOfJDBCProvider": "jdbc_DataSourceA",
+ "MaxSizeOfPool": 100,
+ "ActualSizeOfPool": 1,
+ "CumulativeRequests": 891,
+ "CumulativeDelayedRequests": 0,
+ "CumulativeTimedOutRequests": 0,
+ "MaxDelayInMilliseconds": 0
+ },
+ {
+ "": "summary0",
+ "NameOfJDBCProvider": "jdbc_DataSourceB",
+ "MaxSizeOfPool": 100,
+ "ActualSizeOfPool": 1,
+ "CumulativeRequests": 891,
+ "CumulativeDelayedRequests": 0,
+ "CumulativeTimedOutRequests": 0,
+ "MaxDelayInMilliseconds": 0
+ },
+ {
+ "": "summary0",
+ "NameOfJDBCProvider": "jdbc_DataSourceA",
+ "MaxSizeOfPool": 100,
+ "ActualSizeOfPool": 1,
+ "CumulativeRequests": 29,
+ "CumulativeDelayedRequests": 0,
+ "CumulativeTimedOutRequests": 0,
+ "MaxDelayInMilliseconds": 0
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/ibm_ace/tests/test_unit.py b/ibm_ace/tests/test_unit.py
index b1d3bca92974b..4e1946631738d 100644
--- a/ibm_ace/tests/test_unit.py
+++ b/ibm_ace/tests/test_unit.py
@@ -1,9 +1,14 @@
# (C) Datadog, Inc. 2022-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
+import os
+
from datadog_checks.ibm_ace.check import IbmAceCheck
+from datadog_checks.ibm_ace.resources import get_resource
from datadog_checks.ibm_ace.subscription import FlowMonitoringSubscription, ResourceStatisticsSubscription
+from .common import HERE
+
def test_flow_monitoring_subscription(instance, global_tags):
check = IbmAceCheck('ibm_ace', {}, [instance])
@@ -118,3 +123,82 @@ def test_non_truncation_error_given_connection_broken_returns_critical(instance,
sc_calls = [c for c in check.service_check.call_args_list if c[0][0] == 'mq.subscription']
assert len(sc_calls) == 1
assert sc_calls[0][0][1] == ServiceCheck.CRITICAL
+
+
+def test_parse_tags_with_name():
+ resource = get_resource('JDBCConnectionPools')
+ metric_data = {'name': 'MyDataSource', 'NameOfJDBCProvider': 'Oracle'}
+
+ tags = resource.parse_tags(['mq_server:x'], metric_data)
+
+ assert tags == ['group:MyDataSource', 'mq_server:x', 'jdbc_provider:Oracle']
+
+
+def test_parse_tags_without_name():
+ # ACE can omit `name`; this must not raise.
+ resource = get_resource('JDBCConnectionPools')
+ metric_data = {'NameOfJDBCProvider': 'Oracle'}
+
+ tags = resource.parse_tags(['mq_server:x'], metric_data)
+
+ assert tags == ['mq_server:x', 'jdbc_provider:Oracle']
+
+
+def test_collect_survives_malformed_resource_identifier(instance, global_tags, caplog):
+ # ACE 12.0.9 payload where repeated `resourceIdentifier` entries
+ # have their `name` key replaced with an empty string. Must not crash.
+ import logging
+ from unittest.mock import MagicMock, PropertyMock, patch
+
+ caplog.set_level(logging.DEBUG)
+
+ fixture_path = os.path.join(HERE, 'fixtures', 'resource_statistics_malformed_jdbc.json')
+ with open(fixture_path, 'rb') as f:
+ payload = f.read()
+
+ mock_config = MagicMock()
+ mock_config.max_message_length = 65536
+
+ check = IbmAceCheck('ibm_ace', {}, [instance])
+ check.gauge = MagicMock()
+ check.count = MagicMock()
+ check.service_check = MagicMock()
+
+ sub = ResourceStatisticsSubscription(check, global_tags)
+
+ mock_sub = MagicMock()
+ mock_sub.get.side_effect = [payload]
+
+ with (
+ patch.object(type(check), 'config', new_callable=PropertyMock, return_value=mock_config),
+ patch.object(type(sub), 'sub', new_callable=PropertyMock, return_value=mock_sub),
+ patch.object(sub, '_get_elapsed_time', return_value=25),
+ ):
+ sub.collect() # must not raise
+
+ submitted = check.count.call_args_list + check.gauge.call_args_list
+ submitted_metrics = {c.args[0] for c in submitted}
+
+ # `self.check.count`/`.gauge` are mocked directly, so the `ibm_ace.` namespace
+ # prefix (normally applied inside AgentCheck.count/gauge) isn't present here.
+ assert 'JDBCConnectionPools.CumulativeRequests' in submitted_metrics
+ # The malformed entries' empty-string key must never become a metric name.
+ assert not any(c.args[0].endswith('.') for c in submitted)
+
+ # The well-formed entry still gets its `group` tag; the malformed ones don't.
+ well_formed_call = next(
+ c for c in submitted if 'jdbc_provider:jdbc_DataSourceA' in c.kwargs['tags'] and c.args[1] == 891
+ )
+ assert any(tag.startswith('group:') for tag in well_formed_call.kwargs['tags'])
+
+ malformed_calls = [c for c in submitted if 'jdbc_provider:jdbc_DataSourceB' in c.kwargs['tags']]
+ assert malformed_calls
+ assert not any(tag.startswith('group:') for c in malformed_calls for tag in c.kwargs['tags'])
+
+ # The fixture has two malformed entries; each is logged at debug level when skipped.
+ skip_lines = [r for r in caplog.records if 'Skipping resourceIdentifier entry with malformed key' in r.message]
+ assert len(skip_lines) == 2
+ for record in skip_lines:
+ assert record.levelname == 'DEBUG'
+ assert 'JDBCConnectionPools' in record.message
+ assert "value='summary0'" in record.message
diff --git a/ide_shepherd/manifest.json b/ide_shepherd/manifest.json
index b444bc147b82f..c0f0b9f8cbb94 100644
--- a/ide_shepherd/manifest.json
+++ b/ide_shepherd/manifest.json
@@ -23,6 +23,7 @@
"Supported OS::Windows",
"Supported OS::macOS",
"Category::Log Collection",
+ "Category::SIEM",
"Category::Security",
"Category::Developer Tools",
"Offering::Integration",
diff --git a/trellix_endpoint_security/manifest.json b/trellix_endpoint_security/manifest.json
index f064bfa00ed85..6061723d2bfac 100644
--- a/trellix_endpoint_security/manifest.json
+++ b/trellix_endpoint_security/manifest.json
@@ -20,6 +20,7 @@
],
"classifier_tags": [
"Category::Log Collection",
+ "Category::SIEM",
"Category::Security",
"Offering::Integration",
"Submitted Data Type::Logs"