Resolve Host last_job filters against the latest job host summary - #623
Resolve Host last_job filters against the latest job host summary#623Aureliolo wants to merge 3 commits into
Conversation
Host.last_job and Host.last_job_host_summary are denormalized caches that are no longer written; the serializer, DashboardView and Inventory.update_computed_fields all derive the value from JobHostSummary instead. Filtering still went to the columns, which are NULL for every host, so those lookups returned an empty set rather than an error. Visible effects: the dashboard's Failed hosts link lists nothing while the count next to it is correct, the Last job key offered for smart inventory host filters matches nothing, and API clients filtering either field get silently wrong results. The lookups are now rewritten to select against the newest JobHostSummary per host, which is the same definition DashboardView counts with. A correlated subquery is used rather than DISTINCT ON so the behaviour holds on SQLite, and __isnull inverts the set so it still means the host has never run. Signed-off-by: Aurelio <19254254+Aureliolo@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a Host-specific filter backend so Host derived fields (last_job, last_job_host_summary) resolve against the latest JobHostSummary, and validates the behavior via functional API tests.
Changes:
- Override DRF
DEFAULT_FILTER_BACKENDSto useawx.api.filters.HostFieldLookupBackendin place of the genericFieldLookupBackend. - Add
HostFieldLookupBackendimplementing lookups against the latestJobHostSummaryper host. - Add functional tests covering derived-field filtering and dashboard consistency.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| awx/settings/defaults.py | Replaces the generic field lookup backend with a Host-aware backend. |
| awx/api/filters.py | Implements HostFieldLookupBackend resolving Host derived fields via latest JobHostSummary. |
| awx/main/tests/functional/api/test_host_derived_filters.py | Adds functional tests for the new derived-field filtering behavior. |
SmartFilter validates the lookup with the base FieldLookupBackend and then filters the ORM itself, so host_filter=last_job_host_summary__failed=true never reached the new backend and kept matching nothing. Route the two derived fields through the same resolution; both branches still go through get_fields_from_path, which is what detects loops and restricts access to sensitive fields. The parent returns a list of lookups for __search rather than a single one. Passing that straight to filter() raised TypeError, turning last_job__search and last_job_host_summary__search into a 400, and both are advertised in related_search_fields. Collapse the OR against JobHostSummary instead. Recency now comes from the existing _latest_summary_id annotation, which the Host list views already carry and HostSerializer reads last_job and last_job_host_summary through, rather than a second correlated subquery over JobHostSummary. That leaves one definition of "latest summary" and drives the scan off Host instead of the much larger summary table. Match the generic backend by its exact dotted path when swapping DEFAULT_FILTER_BACKENDS, and fail loudly if it is absent; the previous endswith test fell through silently, which would restore the empty results this replaces.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
awx/api/filters.py:83
- The method returns the lookup as both a string (
'pk__in') and a list (['pk__in']) depending on the branch. That inconsistent return type makes non-DRF consumers easy to break (as seen in smart inventory filtering) and adds unnecessary special-casing. Consider normalizing the return type to always use a string ('pk__in') since the derived-host implementation always collapses to a single lookup anyway; this simplifies callers and reduces integration risk.
elif inner == 'search':
# A summary holds none of the field names treated as searchable, which the
# parent expresses as an empty lookup list, and an empty list of ORs matches
# every row rather than none.
return Host.objects.none().values('pk'), ['pk__in'], False
value, inner, _ = super().value_to_python(JobHostSummary, inner, value)
if isinstance(inner, list):
# __search expands to several lookups, all relative to JobHostSummary, so the
# OR collapses here; left to the caller they would be applied against Host.
if not inner:
return Host.objects.none().values('pk'), ['pk__in'], False
condition = functools.reduce(operator.or_, (Q(**{one: value}) for one in inner))
return hosts_matching_latest_summary(condition), ['pk__in'], False
return hosts_matching_latest_summary(Q(**{inner: value})), 'pk__in', False
awx/main/tests/functional/api/test_host_derived_filters.py:131
- The smart-inventory path is covered for equality filters, but not for derived-field lookups that expand to multi-lookup behavior (notably
last_job__search=...), which is where the updated backend integration is most likely to regress. Adding a smart-inventoryhost_filtertest case forlast_job__search=<substring>would exercise thevalue_to_python()return-shape handling and prevent runtime failures.
@pytest.mark.django_db
def test_smart_inventory_host_filter(inventory_with_hosts, get, admin):
run_job(inventory_with_hosts, ok=['passing'], failures=['failing'])
last = run_job(inventory_with_hosts, ok=['recovered'])
query = urllib.parse.quote('last_job_host_summary__failed=true', safe='')
response = get(host_list('?host_filter=%s' % query), admin)
assert names_from(response) == ['failing']
query = urllib.parse.quote('last_job=%d' % last.id, safe='')
response = get(host_list('?host_filter=%s' % query), admin)
assert names_from(response) == ['recovered']
awx/settings/defaults.py:1250
- The dotted path for
FieldLookupBackendis hard-coded, which can silently drift from the actual import path used by django-ansible-base (and cause startup failure). If feasible in settings initialization order, derive the string from the imported class (e.g.,FieldLookupBackend.__module__ + "." + FieldLookupBackend.__name__) to keep this check aligned with the installed package.
generic_field_lookup_backend = 'ansible_base.rest_filters.rest_framework.field_lookup_backend.FieldLookupBackend'
if generic_field_lookup_backend not in REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']:
raise ImproperlyConfigured(
'Expected {} in REST_FRAMEWORK["DEFAULT_FILTER_BACKENDS"], found {}.'.format(generic_field_lookup_backend, REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS'])
)
value_to_python returns a list of lookups for __search, which SmartFilter cannot put in its kwargs. _expand_search already turns those keys away before the backend sees them, since it resolves the leading segment as a model name and last_job is not one, but that spans two functions and is easy to break. Reject the list at the call site so the result stays a parse error either way, and pin the behaviour for both derived fields.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
awx/settings/defaults.py:1253
- This settings hook assumes
dynamic_settings.pyalways populatesREST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']. If that key is absent (or set to a falsy value), the current code will raise aKeyErrorduring settings import rather than the intendedImproperlyConfiguredwith a clear message. Guard for a missing key explicitly before checking membership so startup failures are actionable.
generic_field_lookup_backend = 'ansible_base.rest_filters.rest_framework.field_lookup_backend.FieldLookupBackend'
if generic_field_lookup_backend not in REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']:
raise ImproperlyConfigured(
'Expected {} in REST_FRAMEWORK["DEFAULT_FILTER_BACKENDS"], found {}.'.format(generic_field_lookup_backend, REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS'])
)
SUMMARY
Host.last_jobandHost.last_job_host_summaryare denormalized caches that are no longer written. The serializer,DashboardViewandInventory.update_computed_fieldsall derive the value fromJobHostSummary, but filtering goes to the columns, which are NULL for every host. Those lookups therefore return an empty set rather than an error.Three visible effects:
/hosts?host.last_job_host_summary__failed=true(Dashboard.js:107). The count beside it is correct, the list it opens is always empty.HostFilterLookup.js:145offers "Last job" as a host-filter key for smart inventories. A smart inventory built on it silently matches nothing, and unlike the dashboard there is no count to contradict it.last_joborlast_job_host_summarygets a silently wrong answer instead of an error. Compare?has_active_failures=true, which fails loudly withHost has no field named 'has_active_failures'.Both entry points are covered.
HostFieldLookupBackendresolves the two fields for the REST filters, andSmartFilterroutes them through the same code so a smart inventory'shost_filteragrees with/api/v2/hosts/;SmartFiltervalidates the lookup with the backend but then builds its own queryset, so it needs handling of its own.__searchis included: the parent expands it to a list of lookups relative to the related model, so the OR is collapsed againstJobHostSummaryrather than applied toHost. A smart inventory cannot express that OR, andSmartFilterrejects such keys as a parse error.Recency comes from the
_latest_summary_idannotation inHostLatestSummaryQuerySet, which every Host list view already carries and whichHostSerializerreadslast_jobandlast_job_host_summarythrough. Reusing it keeps one definition of "latest summary" across the serializer,DashboardView,Inventory.update_computed_fieldsand the filters, and drives the scan offHostrather than the much largerJobHostSummary.__isnullmaps to the annotation being null, so it means "the host has never run". Non-Host models fall through to the parent backend, which matters becauseUnifiedJobTemplate.last_jobis a live column. Registration matches the generic backend by exact dotted path and raisesImproperlyConfiguredif it is absent, since a silent miss would leave the derived filters matching nothing. No UI change is needed; the existing link and filter key work as written.ISSUE TYPE
COMPONENT NAME
ASCENDER VERSION
ADDITIONAL INFORMATION
Reproduction on an instance where hosts have run jobs:
A host serializing
last_job: 274is not matched by?last_job=274, because the serialized value is derived while the filter reads the column.Full suite,
make testin the branch-builtascender_develimage:The three are
test_is_not_inventory[bad|bad_encoding|empty.txt], which key off file permissions and fail on any mount that reports every file as executable. They are unrelated to this change.Negative control, with the derived-field resolution removed: