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"