Repository: kubernetes-sigs/node-feature-discovery
Pull request #1932 (2024-10-28 08:59:40)

Introduce nfd client for image compatibilty
The PR provides an initial implementation of https://github.com/kubernetes-sigs/node-feature-discovery/pull/1845. Image compatibility is defined by Node Feature Rules, so a change was also necessary there. A run strategy pattern has been implemented to control rule execution, allowing either all rules to be executed or stopping at the first failure.

~~The work is still in progress but is ready for the first review round.~~
~~Remaining tasks:~~
~~- [x] write unit tests~~
~~- [x] write documentation and add appropriate code comments~~
~~- [x] refine the commands~~

k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/node-feature-discovery/pull/1932#" title="Author self-approved">mfranczy</a>*
**Once this PR has been reviewed and has the lgtm label**, please assign [arangogutierrez](https://github.com/arangogutierrez) for approval. For more information see [the Kubernetes Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fnode-feature-discovery).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/node-feature-discovery/blob/master/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["arangogutierrez"]} -->
k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
k8s-ci-robot: Hi @mfranczy. Thanks for your PR.

I'm waiting for a [kubernetes-sigs](https://github.com/orgs/kubernetes-sigs/people) member to verify that this patch is reasonable to test. If it is, they should reply with `/ok-to-test` on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should [join the org](https://git.k8s.io/community/community-membership.md#member) to skip this step.

Once the patch is verified, the new status will be reflected by the `ok-to-test` label.

I understand the commands that are listed [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fnode-feature-discovery).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *kubernetes-sigs-nfd* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 6bf3f5b4c1b75461e8b78fe30b876354b75fc479 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/kubernetes-sigs-nfd/deploys/671f527f8cd82e000862c43f |
k8s-ci-robot: @mfranczy: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-build-image-cross-generic | 86ddc6a05f43cbfba3f0185c58ec2ae265378d88 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1850842130512089088) | true | `/test pull-node-feature-discovery-build-image-cross-generic`
pull-node-feature-discovery-verify-master | 86ddc6a05f43cbfba3f0185c58ec2ae265378d88 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-verify-master/1850842130570809344) | true | `/test pull-node-feature-discovery-verify-master`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @mfranczy: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-build-image-cross-generic | 86ddc6a05f43cbfba3f0185c58ec2ae265378d88 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1850842130512089088) | true | `/test pull-node-feature-discovery-build-image-cross-generic`
pull-node-feature-discovery-verify-master | e05aa411f5f39101005109664fdf70d85a6e9f6c | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-verify-master/1851196088418045952) | true | `/test pull-node-feature-discovery-verify-master`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
k8s-ci-robot: @mfranczy: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-verify-master | 7491e5a71fe3237ee6cc531ef7f336bcca1d671a | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-verify-master/1852658124121444352) | true | `/test pull-node-feature-discovery-verify-master`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
ArangoGutierrez: @marquiz PTAL
k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
k8s-ci-robot: @mfranczy: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-verify-master | d8976477f99ac704fec187087d53adab404e8ff3 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-verify-master/1853712297139113984) | true | `/test pull-node-feature-discovery-verify-master`
pull-node-feature-discovery-build-master | d8976477f99ac704fec187087d53adab404e8ff3 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-master/1853712297281720320) | true | `/test pull-node-feature-discovery-build-master`
pull-node-feature-discovery-e2e-test-master | d8976477f99ac704fec187087d53adab404e8ff3 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-e2e-test-master/1853712297592098816) | true | `/test pull-node-feature-discovery-e2e-test-master`
pull-node-feature-discovery-build-image-cross-generic | d8976477f99ac704fec187087d53adab404e8ff3 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1853712296983924736) | true | `/test pull-node-feature-discovery-build-image-cross-generic`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @mfranczy: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-build-master | 7a62dceedc102b0222edecaa323712e58076be3d | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-master/1853715970695106560) | true | `/test pull-node-feature-discovery-build-master`
pull-node-feature-discovery-build-image-cross-generic | 7a62dceedc102b0222edecaa323712e58076be3d | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1853715970548305920) | true | `/test pull-node-feature-discovery-build-image-cross-generic`
pull-node-feature-discovery-e2e-test-master | 7a62dceedc102b0222edecaa323712e58076be3d | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-e2e-test-master/1853715970778992640) | true | `/test pull-node-feature-discovery-e2e-test-master`
pull-node-feature-discovery-verify-master | 7a62dceedc102b0222edecaa323712e58076be3d | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-verify-master/1853715970615414784) | true | `/test pull-node-feature-discovery-verify-master`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @mfranczy: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-build-image-cross-generic | d91a178079290db291471d1f6fb10d6c0e6d9872 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1854849333971652608) | true | `/test pull-node-feature-discovery-build-image-cross-generic`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @mfranczy: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-build-image-cross-generic | 9bfe920c57e9e73ecb6352be02be808ab84985b5 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1855298980292857856) | true | `/test pull-node-feature-discovery-build-image-cross-generic`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
mfranczy: @marquiz @ArangoGutierrez I consider the PR finished and fully reviewable, please do another round once you're free.

I had to introduce a run strategy for rules execution. The validate-node command is expected to execute all rules and return all features that match the node. Initially, I wanted to return not only the matching features but also those that fail; however, implementing this required extensive changes to the Node Feature Rules code, which made the PR too large. To simplify, I decided to implement the return of failed features in upcoming PRs, as a discussion is also needed to find the most optimized solution for this.

You can view all the node feature rules API changes in this commit: [link](https://github.com/kubernetes-sigs/node-feature-discovery/pull/1932/commits/3dd5db5879748a4ab03f9b42a2b7eb49d3880d40)

Additionally, I’m including a sample output from the  `nfd compat validate-node..` cmd
![image](https://github.com/user-attachments/assets/6aa966ab-bf54-4704-b933-e2986b2e4526)

I belive this first implementation is good enough to be included in v0.17.0 release.
mfranczy: /retest
ChaoyiHuang: > Additionally, I’m including a sample output from the `nfd compat validate-node..` cmd ![image](https://private-user-images.githubusercontent.com/27352691/384627851-6aa966ab-bf54-4704-b933-e2986b2e4526.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MzEyODY4NDcsIm5iZiI6MTczMTI4NjU0NywicGF0aCI6Ii8yNzM1MjY5MS8zODQ2Mjc4NTEtNmFhOTY2YWItYmY1NC00NzA0LWI5MzMtZTI5ODZiMmU0NTI2LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDExMTElMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQxMTExVDAwNTU0N1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTQ3NmFhNDZmNzY3ZGEzZGUzM2JiNTI5MTA1OGJmOTJmY2U4NzVhYjIyZDhlZTY1MzBjMzNiN2M0NWU5MzQ5ZDYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.hzvBjb05HqTl1BVVMj_beplzTXrokD395Vy5LsQZ8yo)
> 
> I belive this first implementation is good enough to be included in v0.17.0 release.

if matched features can be returned and output correctly, then all other unmatched feature should be failure in feature matching. 

so maybe no need to introduce runstrategy, just in the output, for the features not in the matched feature list, tag it as failed.

then we may get same report as that with runstrategy
mfranczy: > > Additionally, I’m including a sample output from the `nfd compat validate-node..` cmd ![image](https://private-user-images.githubusercontent.com/27352691/384627851-6aa966ab-bf54-4704-b933-e2986b2e4526.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MzEyODY4NDcsIm5iZiI6MTczMTI4NjU0NywicGF0aCI6Ii8yNzM1MjY5MS8zODQ2Mjc4NTEtNmFhOTY2YWItYmY1NC00NzA0LWI5MzMtZTI5ODZiMmU0NTI2LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDExMTElMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQxMTExVDAwNTU0N1omWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTQ3NmFhNDZmNzY3ZGEzZGUzM2JiNTI5MTA1OGJmOTJmY2U4NzVhYjIyZDhlZTY1MzBjMzNiN2M0NWU5MzQ5ZDYmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.hzvBjb05HqTl1BVVMj_beplzTXrokD395Vy5LsQZ8yo)
> > I belive this first implementation is good enough to be included in v0.17.0 release.
> 
> if matched features can be returned and output correctly, then all other unmatched feature should be failure in feature matching.

The current rules implementation doesn’t easily match executed features (whether failed or succeeded) with expressions in the spec, as it was not designed for that. Without changes, `validate-node` would need overly complex algorithms. Starting a full refactor now risks delaying inclusion in v0.17, so it’s better to start small.

> so maybe no need to introduce runstrategy, just in the output, for the features not in the matched feature list(but in the compatibility yaml), tag it as failed.
> then we may get same report as that with runstrategy

A run strategy implementation is required because, by default, rule processing stops at the first failure. `validate-node` must process all the rules.


k8s-ci-robot: @mfranczy: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-build-image-cross-generic | c1c214da1954f3aa12f9288793c170c446c67c70 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1857044680798113792) | true | `/test pull-node-feature-discovery-build-image-cross-generic`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
mfranczy: > The current rules implementation doesn’t easily match executed features (whether failed or succeeded) with expressions in the spec, as it was not designed for that. Without changes, validate-node would need overly complex algorithms. Starting a full refactor now risks delaying inclusion in v0.17, so it’s better to start small. Additionally, I would like to discuss the planned changes with @marquiz first.

After discussions with end users, it became clear that the minimal functionality wasn't sufficient, so I revisited the problem from a new perspective.

Previously, I attempted to handle expression execution status similar to [MatchedFeatures](https://github.com/kubernetes-sigs/node-feature-discovery/blob/master/pkg/apis/nfd/nodefeaturerule/rule.go#L186), which introduced significant complexity and required extensive changes to the NFR code. I found a better approach by extending the [expression struct with an isMatch field](https://github.com/kubernetes-sigs/node-feature-discovery/pull/1932/files#diff-2993b0b6843cd27fbf3b802d7dd4032f8be105b385fa1bc32c2cb009a1cc3d5cR302-R323), allowing the expression status to update directly during processing.

@ArangoGutierrez @marquiz apologies for the multiple updates, but this is the final version. The PR is ready for review, and I consider it complete. Please review whenever you have a moment.
mfranczy: /retest
k8s-ci-robot: @mfranczy: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-build-image-cross-generic | 8ed5c36ef7b14f94b51b0ea5b9953060fc94c4ac | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1857068249460510720) | true | `/test pull-node-feature-discovery-build-image-cross-generic`
pull-node-feature-discovery-verify-master | ba73a42b23e568b6837260ac47989961f3f4541c | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-verify-master/1857075283673223168) | true | `/test pull-node-feature-discovery-verify-master`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
k8s-ci-robot: @mfranczy: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
pull-node-feature-discovery-build-image-cross-generic | 502e89e15544f20f9c14b11cddbca18d991a92c0 | [link](https://prow.k8s.io/view/gs/kubernetes-ci-logs/pr-logs/pull/kubernetes-sigs_node-feature-discovery/1932/pull-node-feature-discovery-build-image-cross-generic/1857115306674098176) | true | `/test pull-node-feature-discovery-build-image-cross-generic`

[Full PR test history](https://prow.k8s.io/pr-history?org=kubernetes-sigs&repo=node-feature-discovery&pr=1932). [Your PR dashboard](https://prow.k8s.io/pr?query=is%3Apr%20state%3Aopen%20author%3Amfranczy). Please help us cut down on flakes by [linking to](https://git.k8s.io/community/contributors/devel/sig-testing/flaky-tests.md#filing-issues-for-flaky-tests) an [open issue](https://github.com/kubernetes-sigs/node-feature-discovery/issues?q=is:issue+is:open) when you hit one in your PR.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
mfranczy: /retest
k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
mfranczy: /retest
ChaoyiHuang: for the first nfd client alpha release, it looks good to me.
For the modification in nfd rule match and it's impact on existing functions, it need more expertise review comments.
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/node-feature-discovery/pull/1932#pullrequestreview-2472512120" title="Approved">ArangoGutierrez</a>*, *<a href="https://github.com/kubernetes-sigs/node-feature-discovery/pull/1932#" title="Author self-approved">mfranczy</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fnode-feature-discovery).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/node-feature-discovery/blob/master/OWNERS)~~ [ArangoGutierrez]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
ArangoGutierrez: /test pull-node-feature-discovery-build-image-cross-generic
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/node-feature-discovery/pull/1932#pullrequestreview-2472512120" title="Approved">ArangoGutierrez</a>*, *<a href="https://github.com/kubernetes-sigs/node-feature-discovery/pull/1932#pullrequestreview-2474270957" title="Approved">ChaoyiHuang</a>*, *<a href="https://github.com/kubernetes-sigs/node-feature-discovery/pull/1932#" title="Author self-approved">mfranczy</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fnode-feature-discovery).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/node-feature-discovery/blob/master/OWNERS)~~ [ArangoGutierrez]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
marquiz: /milestone v0.17
k8s-ci-robot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

Patch:
diff --git a/Makefile b/Makefile
index 35e44dbb0..ed6c10df9 100644
--- a/Makefile
+++ b/Makefile
@@ -90,7 +90,7 @@ IMAGE_BUILD_ARGS_MINIMAL = --target minimal \
 
 all: image
 
-BUILD_BINARIES := nfd-master nfd-worker nfd-topology-updater nfd-gc kubectl-nfd
+BUILD_BINARIES := nfd-master nfd-worker nfd-topology-updater nfd-gc kubectl-nfd nfd
 
 build-%:
 	$(GO_CMD) build -v -o bin/ $(BUILD_FLAGS) ./cmd/$*
diff --git a/api/image-compatibility/v1alpha1/spec.go b/api/image-compatibility/v1alpha1/spec.go
new file mode 100644
index 000000000..81a66c4ab
--- /dev/null
+++ b/api/image-compatibility/v1alpha1/spec.go
@@ -0,0 +1,48 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	nfdv1alpha1 "sigs.k8s.io/node-feature-discovery/api/nfd/v1alpha1"
+)
+
+// ArtifactType is a type of OCI artifact that contains image compatibility metadata.
+const (
+	ArtifactType = "application/vnd.nfd.image-compatibility.v1alpha1"
+	Version      = "v1alpha1"
+)
+
+// Spec represents image compatibility metadata.
+type Spec struct {
+	// Version of the spec.
+	Version string `json:"version"`
+	// Compatibilities contains list of compatibility sets.
+	Compatibilties []Compatibility `json:"compatibilities"`
+}
+
+// Compatibility represents image compatibility metadata
+// that describe the image requirements for the host and OS.
+type Compatibility struct {
+	// Rules represents a list of Node Feature Rules.
+	Rules []nfdv1alpha1.Rule `json:"rules"`
+	// Weight indicates the priority of the compatibility set.
+	Weight int `json:"weight,omitempty"`
+	// Tag enables grouping or distinguishing between compatibility sets.
+	Tag string `json:"tag,omitempty"`
+	// Description of the compatibility set.
+	Description string `json:"description,omitempty"`
+}
diff --git a/api/nfd/v1alpha1/utils.go b/api/nfd/v1alpha1/utils.go
new file mode 100644
index 000000000..49c34a426
--- /dev/null
+++ b/api/nfd/v1alpha1/utils.go
@@ -0,0 +1,29 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	"fmt"
+)
+
+// String represents the match expression as a string type.
+func (m MatchExpression) String() string {
+	if len(m.Value) < 1 {
+		return fmt.Sprintf("{op: %q}", m.Op)
+	}
+	return fmt.Sprintf("{op: %q, value: %q}", m.Op, m.Value)
+}
diff --git a/cmd/nfd/main.go b/cmd/nfd/main.go
new file mode 100644
index 000000000..f8d2517e5
--- /dev/null
+++ b/cmd/nfd/main.go
@@ -0,0 +1,27 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package main
+
+import (
+	"sigs.k8s.io/node-feature-discovery/cmd/nfd/subcmd"
+)
+
+const ProgramName = "nfd"
+
+func main() {
+	subcmd.Execute()
+}
diff --git a/cmd/nfd/subcmd/compat/compat.go b/cmd/nfd/subcmd/compat/compat.go
new file mode 100644
index 000000000..cf46575dd
--- /dev/null
+++ b/cmd/nfd/subcmd/compat/compat.go
@@ -0,0 +1,36 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package compat
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+)
+
+var CompatCmd = &cobra.Command{
+	Use:   "compat",
+	Short: "Image compatibility commands",
+}
+
+func Execute() {
+	if err := CompatCmd.Execute(); err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+}
diff --git a/cmd/nfd/subcmd/compat/options/platform.go b/cmd/nfd/subcmd/compat/options/platform.go
new file mode 100644
index 000000000..9a8f4a5ae
--- /dev/null
+++ b/cmd/nfd/subcmd/compat/options/platform.go
@@ -0,0 +1,70 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package options
+
+import (
+	"fmt"
+	"runtime"
+	"strings"
+
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	"github.com/spf13/cobra"
+)
+
+// PlatformOption represents
+type PlatformOption struct {
+	// PlatformStr contains the raw platform argument provided by the user.
+	PlatformStr string
+	// Platform represents the OCI platform specification, built from PlatformStr.
+	Platform *ocispec.Platform
+}
+
+// Parse takes the PlatformStr argument provided by the user
+// to build OCI platform specification.
+func (opt *PlatformOption) Parse(*cobra.Command) error {
+	var pStr string
+
+	if opt.PlatformStr == "" {
+		return nil
+	}
+
+	platform := &ocispec.Platform{}
+	pStr, platform.OSVersion, _ = strings.Cut(opt.PlatformStr, ":")
+	parts := strings.Split(pStr, "/")
+
+	switch len(parts) {
+	case 3:
+		platform.Variant = parts[2]
+		fallthrough
+	case 2:
+		platform.Architecture = parts[1]
+	case 1:
+		platform.Architecture = runtime.GOARCH
+	default:
+		return fmt.Errorf("failed to parse platform %q: expected format os[/arch[/variant]]", opt.PlatformStr)
+	}
+
+	platform.OS = parts[0]
+	if platform.OS == "" {
+		return fmt.Errorf("invalid platform: OS cannot be empty")
+	}
+	if platform.Architecture == "" {
+		return fmt.Errorf("invalid platform: Architecture cannot be empty")
+	}
+	opt.Platform = platform
+	return nil
+}
diff --git a/cmd/nfd/subcmd/compat/validate-node.go b/cmd/nfd/subcmd/compat/validate-node.go
new file mode 100644
index 000000000..a1a3abc30
--- /dev/null
+++ b/cmd/nfd/subcmd/compat/validate-node.go
@@ -0,0 +1,224 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package compat
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/jedib0t/go-pretty/v6/table"
+	"github.com/jedib0t/go-pretty/v6/text"
+	"github.com/spf13/cobra"
+	"oras.land/oras-go/v2/registry"
+
+	"sigs.k8s.io/node-feature-discovery/cmd/nfd/subcmd/compat/options"
+	artifactcli "sigs.k8s.io/node-feature-discovery/pkg/client-nfd/compat/artifact-client"
+	nodevalidator "sigs.k8s.io/node-feature-discovery/pkg/client-nfd/compat/node-validator"
+	"sigs.k8s.io/node-feature-discovery/source"
+)
+
+var (
+	image      string
+	tags       []string
+	platform   options.PlatformOption
+	plainHTTP  bool
+	outputJSON bool
+
+	// secrets
+	readPassword    bool
+	readAccessToken bool
+	username        string
+	password        string
+	accessToken     string
+)
+
+var validateNodeCmd = &cobra.Command{
+	Use:   "validate-node",
+	Short: "Perform node validation based on its associated image compatibility artifact",
+	PreRunE: func(cmd *cobra.Command, args []string) error {
+		var err error
+
+		if err = platform.Parse(cmd); err != nil {
+			return err
+		}
+
+		if readAccessToken && readPassword {
+			return fmt.Errorf("cannot use --registry-token-stdin and --registry-password-stdin at the same time")
+		} else if readAccessToken {
+			accessToken, err = readStdin()
+			if err != nil {
+				return err
+			}
+		} else if readPassword {
+			password, err = readStdin()
+			if err != nil {
+				return err
+			}
+		}
+
+		return nil
+	},
+	RunE: func(cmd *cobra.Command, args []string) error {
+		ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+		defer cancel()
+
+		ref, err := registry.ParseReference(image)
+		if err != nil {
+			return err
+		}
+
+		sources := map[string]source.FeatureSource{}
+		for k, v := range source.GetAllFeatureSources() {
+			if ts, ok := v.(source.SupplementalSource); ok && ts.DisableByDefault() {
+				continue
+			}
+			sources[k] = v
+		}
+
+		authOpt := artifactcli.WithAuthDefault()
+		if username != "" && password != "" {
+			authOpt = artifactcli.WithAuthPassword(username, password)
+		} else if accessToken != "" {
+			authOpt = artifactcli.WithAuthToken(accessToken)
+		}
+
+		ac := artifactcli.New(
+			&ref,
+			artifactcli.WithArgs(artifactcli.Args{PlainHttp: plainHTTP}),
+			artifactcli.WithPlatform(platform.Platform),
+			authOpt,
+		)
+
+		nv := nodevalidator.New(
+			nodevalidator.WithArgs(&nodevalidator.Args{Tags: tags}),
+			nodevalidator.WithArtifactClient(ac),
+			nodevalidator.WithSources(sources),
+		)
+
+		out, err := nv.Execute(ctx)
+		if err != nil {
+			return err
+		}
+		if outputJSON {
+			b, err := json.Marshal(out)
+			if err != nil {
+				return err
+			}
+			fmt.Printf("%s", b)
+		} else {
+			pprintResult(out)
+		}
+
+		return nil
+	},
+}
+
+func readStdin() (string, error) {
+	secretRaw, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		return "", err
+	}
+	secret := strings.TrimSuffix(string(secretRaw), "\n")
+	secret = strings.TrimSuffix(secret, "\r")
+
+	return secret, nil
+}
+
+func pprintResult(css []*nodevalidator.CompatibilityStatus) {
+	for i, cs := range css {
+		fmt.Print(text.Colors{text.FgCyan, text.Bold}.Sprintf("COMPATIBILITY SET #%d ", i+1))
+		fmt.Print(text.FgCyan.Sprintf("Weight: %d", cs.Weight))
+		if cs.Tag != "" {
+			fmt.Print(text.FgCyan.Sprintf("; Tag: %s", cs.Tag))
+		}
+		fmt.Println()
+		fmt.Println(text.FgWhite.Sprintf("Description: %s", cs.Description))
+
+		for _, r := range cs.Rules {
+			printTable(r)
+		}
+		fmt.Println()
+	}
+}
+
+func printTable(rs nodevalidator.ProcessedRuleStatus) {
+	t := table.NewWriter()
+	t.SetStyle(table.StyleLight)
+	t.SetOutputMirror(os.Stdout)
+	t.Style().Format.Header = text.FormatDefault
+	t.SetAutoIndex(true)
+
+	validTxt := text.BgRed.Sprint(" FAIL ")
+	if rs.IsMatch {
+		validTxt = text.BgGreen.Sprint(" OK ")
+	}
+	ruleTxt := strings.ToUpper(fmt.Sprintf("rule: %s", rs.Name))
+
+	t.SetTitle(text.Bold.Sprintf("%s - %s", ruleTxt, validTxt))
+	t.AppendHeader(table.Row{"Feature", "Expression", "Matcher Type", "Status"})
+
+	if mf := rs.MatchedExpressions; len(mf) > 0 {
+		renderMatchFeatures(t, mf)
+	}
+	if ma := rs.MatchedAny; len(ma) > 0 {
+		for _, elem := range ma {
+			t.AppendSeparator()
+			renderMatchFeatures(t, elem.MatchedExpressions)
+		}
+	}
+	t.Render()
+}
+
+func renderMatchFeatures(t table.Writer, matchedExpressions []nodevalidator.MatchedExpression) {
+	for _, fm := range matchedExpressions {
+		fullFeatureDomain := fm.Feature
+		if fm.Name != "" {
+			fullFeatureDomain = fmt.Sprintf("%s.%s", fm.Feature, fm.Name)
+		}
+
+		addTableRows(t, fullFeatureDomain, fm.Expression.String(), fm.MatcherType, fm.IsMatch)
+	}
+}
+
+func addTableRows(t table.Writer, fullFeatureDomain, expression string, matcherType nodevalidator.MatcherType, isMatch bool) {
+	status := text.FgHiRed.Sprint("FAIL")
+	if isMatch {
+		status = text.FgHiGreen.Sprint("OK")
+	}
+	t.AppendRow(table.Row{fullFeatureDomain, expression, matcherType, status})
+}
+
+func init() {
+	CompatCmd.AddCommand(validateNodeCmd)
+	validateNodeCmd.Flags().StringVar(&image, "image", "", "the URL of the image containing compatibility metadata")
+	validateNodeCmd.Flags().StringSliceVar(&tags, "tags", []string{}, "a list of tags that must match the tags set on the compatibility objects")
+	validateNodeCmd.Flags().StringVar(&platform.PlatformStr, "platform", "", "the artifact platform in the format os[/arch][/variant][:os_version]")
+	validateNodeCmd.Flags().BoolVar(&plainHTTP, "plain-http", false, "use of HTTP protocol for all registry communications")
+	validateNodeCmd.Flags().BoolVar(&outputJSON, "output-json", false, "print a JSON object")
+	validateNodeCmd.Flags().StringVar(&username, "registry-username", "", "registry username")
+	validateNodeCmd.Flags().BoolVar(&readPassword, "registry-password-stdin", false, "read registry password from stdin")
+	validateNodeCmd.Flags().BoolVar(&readAccessToken, "registry-token-stdin", false, "read registry access token from stdin")
+
+	if err := validateNodeCmd.MarkFlagRequired("image"); err != nil {
+		panic(err)
+	}
+}
diff --git a/cmd/nfd/subcmd/root.go b/cmd/nfd/subcmd/root.go
new file mode 100644
index 000000000..d58f6b982
--- /dev/null
+++ b/cmd/nfd/subcmd/root.go
@@ -0,0 +1,45 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package subcmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+
+	"sigs.k8s.io/node-feature-discovery/cmd/nfd/subcmd/compat"
+)
+
+// RootCmd represents the base command when called without any subcommands
+var RootCmd = &cobra.Command{
+	Use:   "nfd",
+	Short: "Node Feature Discovery client",
+}
+
+func init() {
+	RootCmd.AddCommand(compat.CompatCmd)
+}
+
+// Execute adds all child commands to the root command and sets flags appropriately.
+// This is called by main.main(). It only needs to happen once to the rootCmd.
+func Execute() {
+	if err := RootCmd.Execute(); err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+}
diff --git a/docs/reference/feature-gates.md b/docs/reference/feature-gates.md
index 315e91c16..a66643c62 100644
--- a/docs/reference/feature-gates.md
+++ b/docs/reference/feature-gates.md
@@ -1,7 +1,7 @@
 ---
 title: "Feature Gates"
 layout: default
-sort: 10
+sort: 11
 ---
 
 # Feature Gates
diff --git a/docs/reference/node-feature-client-reference.md b/docs/reference/node-feature-client-reference.md
new file mode 100644
index 000000000..9a723a783
--- /dev/null
+++ b/docs/reference/node-feature-client-reference.md
@@ -0,0 +1,65 @@
+---
+title: "Node Feature client cmdline reference"
+layout: default
+sort: 9
+---
+
+# Commandline flags of nfd client
+{: .no_toc}
+
+## Table of contents
+{: .no_toc .text-delta}
+
+1. TOC
+{:toc}
+
+---
+**The client is in the experimental `v1alpha1` version.**
+
+To quickly view available command line flags execute `nfd --help`.
+
+### -h, --help
+
+Print usage and exit.
+
+## compat
+
+Image Compatibility commands.
+
+### validate-node
+
+Perform node validation based on its associated image compatibility artifact.
+
+#### --image
+
+The `--image` flag specifies the URL of the image containing compatibility metadata.
+
+#### --plain-http
+
+The `--plain-http` flag forces the use of HTTP protocol for all registry communications.
+Default: `false`
+
+#### --platform
+
+The `--platform` flag specifies the artifact platform in the format `os[/arch][/variant][:os_version]`.
+
+#### --tags
+
+The `--tags` flag specifies a list of tags that must match the tags
+set on the compatibility objects.
+
+#### --output-json
+
+The `--output-json` flag prints the output as a JSON object.
+
+#### --registry-username
+
+The `--registry-username` flag specifies the username for the registry.
+
+#### --registry-password-stdin
+
+The `--registry-password-stdin` flag enables reading of registry password from stdin.
+
+#### --registry-token-stdin
+
+The `--registry-token-stdin` flag enables reading of registry token from stdin.
diff --git a/docs/reference/versions.md b/docs/reference/versions.md
index 8754eaf39..b192250d6 100644
--- a/docs/reference/versions.md
+++ b/docs/reference/versions.md
@@ -1,7 +1,7 @@
 ---
 title: "Versions"
 layout: default
-sort: 9
+sort: 10
 ---
 
 # Versions and deprecation
diff --git a/docs/usage/image-compatibility.md b/docs/usage/image-compatibility.md
new file mode 100644
index 000000000..6c5f38538
--- /dev/null
+++ b/docs/usage/image-compatibility.md
@@ -0,0 +1,226 @@
+---
+title: "Image Compatibility Artifact"
+layout: default
+sort: 11
+---
+
+# Image Compatibility Artifact
+{: .no_toc}
+
+## Table of contents
+{: .no_toc .text-delta}
+
+1. TOC
+{:toc}
+
+---
+
+## Image Compatibility
+
+**Image Compatibility is in the experimental `v1alpha1` version.**
+
+Image compatibility metadata enables container image authors to define their
+image requirements using [Node Feature Rules](./custom-resources.md#nodefeaturerule).
+This complementary solution allows features discovered on nodes to be matched
+directly from images. As a result, container requirements become discoverable
+and programmable, supporting various consumers and use cases where applications
+need a specific compatible environment.
+
+### Compatibility Specification
+
+The compatibility specification is a list of compatibility objects that contain
+[Node Feature Rules](./custom-resources.md#nodefeaturerule), along with
+additional fields to control the execution of validation between the image and
+the host.
+
+### Schema
+
+- **version** - *string*  
+  This REQUIRED property specifies the version of the API in use.
+
+- **compatibilities** - *array of object*  
+  This REQUIRED property is a list of compatibility sets.
+
+  - **rules** - *object*  
+    This REQUIRED property is a reference to the spec of the [NodeFeatureRule API](./custom-resources.md#nodefeaturerule).
+    The spec allows image requirements to be described using the features
+    discovered from NFD sources. For more details, please refer to [the documentation](./custom-resources.md#nodefeaturerule).
+
+  - **weight** - *int*  
+    This OPTIONAL property specifies the [node affinity weight](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity-weight).
+
+  - **tag** - *string*  
+    This OPTIONAL property allows for the grouping or separation of
+    compatibility sets.
+
+  - **description** - *string*  
+    This OPTIONAL property provides a brief description of a compatibility set.
+
+#### Example
+
+```yaml
+version: v1alpha1
+compatibilities:
+- description: "My image requirements"
+  rules:
+  - name: "kernel and cpu"
+    matchFeatures:
+    - feature: kernel.loadedmodule
+      matchExpressions:
+        vfio-pci: {op: Exists}
+    - feature: cpu.model
+      matchExpressions:
+        vendor_id: {op: In, value: ["Intel", "AMD"]}
+  - name: "one of available nics"
+    matchAny:
+    - matchFeatures:
+      - feature: pci.device
+        matchExpressions:
+          vendor: {op: In, value: ["0eee"]}
+          class: {op: In, value: ["0200"]}
+    - matchFeatures:
+      - feature: pci.device
+        matchExpressions:
+          vendor: {op: In, value: ["0fff"]}
+          class: {op: In, value: ["0200"]}
+```
+
+### OCI Artifact
+
+An [OCI artifact](https://github.com/opencontainers/image-spec/blob/main/manifest.md#guidelines-for-artifact-usage)
+is used to store image compatibility metadata.
+The artifact can be associated with a specific image through [the subject field](https://github.com/opencontainers/distribution-spec/blob/11b8e3fba7d2d7329513d0cff53058243c334858/spec.md#pushing-manifests-with-subject)
+and pushed to the registry along with the image.
+
+Example manifest:
+
+```json
+{
+  "schemaVersion": 2,
+  "mediaType": "application/vnd.oci.image.manifest.v1+json",
+  "artifactType": "application/vnd.nfd.image-compatibility.v1alpha1",
+  "config": {
+    "mediaType": "application/vnd.oci.empty.v1+json",
+    "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
+    "size": 2
+  },
+  "layers": [
+    {
+      "mediaType": "application/vnd.nfd.image-compatibility.spec.v1alpha1+yaml",
+      "digest": "sha256:4a47f8ae4c713906618413cb9795824d09eeadf948729e213a1ba11a1e31d052",
+      "size": 1710
+    }
+  ],
+  "subject": {
+    "mediaType": "application/vnd.oci.image.manifest.v1+json",
+    "digest": "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270",
+    "size": 7682
+  },
+  "annotations": {
+    "oci.opencontainers.image.created": "2024-03-27T08:08:08Z"
+  }
+}
+```
+
+#### Attach the artifact to the image
+
+Create an image compatibility specification for the image, then install the
+[ORAS](https://github.com/oras-project/oras/) tool and execute `oras attach`
+command.
+
+Example:
+
+```sh
+oras attach --artifact-type application/vnd.nfd.image-compatibility.v1alpha1 \
+<image-url> <path-to-spec>.yaml:application/vnd.nfd.image-compatibility.spec.v1alpha1+yaml
+```
+
+**Note**: The attach command is planned to be integrated into the `nfd` client
+tool. This will streamline the process, allowing you to perform the operation
+directly within the tool rather than using a separate command.
+
+### Validate the host against the image compatibility specification
+
+1. Build `nfd` client: `make build`
+1. Run `./bin/nfd compat validate-node --image <image-url>`
+
+For more information about the available commands and flags, refer to
+[the client documentation](../reference/node-feature-client-reference.md).
+
+### Validate the k8s cluster node with the validate-image Job
+
+**Note**: This does not require installation of NFD master and workers.
+Additionally, public registry certificates must be included in the job.
+In the example below, this is done using hostPath,
+but it can be done using any Kubernetes-supported method.
+
+```yaml
+apiVersion: batch/v1
+kind: Job
+metadata:
+  name: validate-image
+spec:
+  backoffLimit: 1
+  template:
+    spec:
+      restartPolicy: Never
+      containers:
+      - name: image-compatibility
+        securityContext:
+          allowPrivilegeEscalation: false
+          capabilities:
+            drop:
+            - ALL
+          readOnlyRootFilesystem: true
+          runAsNonRoot: true
+        image: <image-with-nfd-client>
+        command: ["nfd", "compat", "validate-node", "--image", "<image-to-be-validated>"]
+        volumeMounts:
+          - mountPath: /host-boot
+            name: host-boot  
+            readOnly: true
+          - mountPath: /host-etc/os-release
+            name: host-os-release
+            readOnly: true
+          - mountPath: /host-sys
+            name: host-sys
+            readOnly: true
+          - mountPath: /host-usr/lib
+            name: host-usr-lib
+            readOnly: true
+          - mountPath: /host-lib
+            name: host-lib
+            readOnly: true
+          - mountPath: /host-proc
+            name: host-proc
+            readOnly: true
+      volumes:
+      - hostPath:
+          path: /boot
+          type: ""
+        name: host-boot
+      - hostPath:
+          path: /etc/os-release
+          type: ""
+        name: host-os-release
+      - hostPath:
+          path: /sys
+          type: ""
+        name: host-sys
+      - hostPath:
+          path: /usr/lib
+          type: ""
+        name: host-usr-lib
+      - hostPath:
+          path: /lib
+          type: ""
+        name: host-lib
+      - hostPath:
+          path: /proc
+          type: ""
+        name: host-proc
+      - hostPath:
+          path: "<path-to-registry-public-certs>"
+          type: ""
+        name: certs
+```
diff --git a/examples/image-compatibility.yaml b/examples/image-compatibility.yaml
new file mode 100644
index 000000000..38cdc34b8
--- /dev/null
+++ b/examples/image-compatibility.yaml
@@ -0,0 +1,27 @@
+version: v1alpha1
+compatibilities:
+- description: "my image requirements"
+  rules:
+  - name: "kernel and cpu"
+    matchFeatures:
+    - feature: kernel.loadedmodule
+      matchExpressions:
+        vfio-pci: {op: Exists}
+        ip_tables: {op: Exists}
+    - feature: cpu.model
+      matchExpressions:
+        vendor_id: {op: In, value: ["Intel", "AMD"]}
+    - feature: cpu.cpuid
+      matchName: {op: InRegexp, value: ["^AVX"]}
+  - name: "one of available nics"
+    matchAny:
+    - matchFeatures:
+      - feature: pci.device
+        matchExpressions:
+          vendor: {op: In, value: ["0eee"]}
+          class: {op: In, value: ["0200"]}
+    - matchFeatures:
+      - feature: pci.device
+        matchExpressions:
+          vendor: {op: In, value: ["0fff"]}
+          class: {op: In, value: ["0200"]}
diff --git a/go.mod b/go.mod
index a7dd9ecaf..de94b3265 100644
--- a/go.mod
+++ b/go.mod
@@ -7,11 +7,13 @@ require (
 	github.com/google/go-cmp v0.6.0
 	github.com/google/uuid v1.6.0
 	github.com/jaypipes/ghw v0.13.0
+	github.com/jedib0t/go-pretty/v6 v6.6.1
 	github.com/k8stopologyawareschedwg/noderesourcetopology-api v0.1.2
 	github.com/k8stopologyawareschedwg/podfingerprint v0.2.2
 	github.com/klauspost/cpuid/v2 v2.2.9
 	github.com/onsi/ginkgo/v2 v2.22.0
 	github.com/onsi/gomega v1.36.1
+	github.com/opencontainers/image-spec v1.1.0
 	github.com/opencontainers/runc v1.2.3
 	github.com/prometheus/client_golang v1.19.1
 	github.com/smartystreets/goconvey v1.8.1
@@ -34,6 +36,7 @@ require (
 	k8s.io/kubernetes v1.32.0
 	k8s.io/pod-security-admission v0.32.0
 	k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
+	oras.land/oras-go/v2 v2.5.0
 	sigs.k8s.io/node-feature-discovery/api/nfd v0.0.0-00010101000000-000000000000
 	sigs.k8s.io/yaml v1.4.0
 )
@@ -92,6 +95,7 @@ require (
 	github.com/jtolds/gls v4.20.0+incompatible // indirect
 	github.com/karrick/godirwalk v1.17.0 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
+	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/moby/spdystream v0.5.0 // indirect
@@ -109,6 +113,7 @@ require (
 	github.com/prometheus/client_model v0.6.1 // indirect
 	github.com/prometheus/common v0.55.0 // indirect
 	github.com/prometheus/procfs v0.15.1 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
 	github.com/smarty/assertions v1.15.1 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
diff --git a/go.sum b/go.sum
index ed282dc86..b928c12ab 100644
--- a/go.sum
+++ b/go.sum
@@ -131,6 +131,8 @@ github.com/jaypipes/ghw v0.13.0 h1:log8MXuB8hzTNnSktqpXMHc0c/2k/WgjOMSUtnI1RV4=
 github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
 github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic=
 github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4=
+github.com/jedib0t/go-pretty/v6 v6.6.1 h1:iJ65Xjb680rHcikRj6DSIbzCex2huitmc7bDtxYVWyc=
+github.com/jedib0t/go-pretty/v6 v6.6.1/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E=
 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
 github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
 github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
@@ -159,6 +161,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible h1:aKW/4cBs+yK6gpqU3K/oIwk9Q/XICqd3zOX/UFuvqmk=
 github.com/mistifyio/go-zfs v2.1.2-0.20190413222219-f784269be439+incompatible/go.mod h1:8AuVvqP/mXw1px98n46wfvcGfQ4ci2FwoAjKYxuo3Z4=
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -207,6 +211,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
 github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
 github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
 github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -412,6 +418,8 @@ k8s.io/pod-security-admission v0.32.0 h1:I+Og0uZIiMpIgTgPrTbW4jlwRI5RWazi8y+jrx1
 k8s.io/pod-security-admission v0.32.0/go.mod h1:RvrcY0+5UAoCIJ7BscgDF3nbmXprgfnjTW+byCyXDvA=
 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+oras.land/oras-go/v2 v2.5.0 h1:o8Me9kLY74Vp5uw07QXPiitjsw7qNXi8Twd+19Zf02c=
+oras.land/oras-go/v2 v2.5.0/go.mod h1:z4eisnLP530vwIOUOJeBIj0aGI0L1C3d53atvCBqZHg=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcpeN4baWEV2ko2Z/AsiZgEdwgcfwLgMo=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
 sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
diff --git a/pkg/apis/nfd/nodefeaturerule/expression.go b/pkg/apis/nfd/nodefeaturerule/expression.go
index 585ce2e1c..3220f0a6b 100644
--- a/pkg/apis/nfd/nodefeaturerule/expression.go
+++ b/pkg/apis/nfd/nodefeaturerule/expression.go
@@ -29,6 +29,13 @@ import (
 	nfdv1alpha1 "sigs.k8s.io/node-feature-discovery/api/nfd/v1alpha1"
 )
 
+const (
+	// MatchedKeyName is the name of the matched flag/attribute element.
+	MatchedKeyName = "Name"
+	// MatchedKeyValue is the value of the matched attribute element.
+	MatchedKeyValue = "Value"
+)
+
 var matchOps = map[nfdv1alpha1.MatchOp]struct{}{
 	nfdv1alpha1.MatchAny:          {},
 	nfdv1alpha1.MatchIn:           {},
@@ -202,16 +209,16 @@ func MatchKeyNames(m *nfdv1alpha1.MatchExpression, keys map[string]nfdv1alpha1.N
 		if match, err := evaluateMatchExpression(m, true, k); err != nil {
 			return false, nil, err
 		} else if match {
-			ret = append(ret, MatchedElement{"Name": k})
+			ret = append(ret, MatchedElement{MatchedKeyName: k})
 		}
 	}
 	// Sort for reproducible output
-	sort.Slice(ret, func(i, j int) bool { return ret[i]["Name"] < ret[j]["Name"] })
+	sort.Slice(ret, func(i, j int) bool { return ret[i][MatchedKeyName] < ret[j][MatchedKeyName] })
 
 	if klogV3 := klog.V(3); klogV3.Enabled() {
 		mk := make([]string, len(ret))
 		for i, v := range ret {
-			mk[i] = v["Name"]
+			mk[i] = v[MatchedKeyName]
 		}
 		mkMsg := strings.Join(mk, ", ")
 
@@ -238,16 +245,16 @@ func MatchValueNames(m *nfdv1alpha1.MatchExpression, values map[string]string) (
 		if match, err := evaluateMatchExpression(m, true, k); err != nil {
 			return false, nil, err
 		} else if match {
-			ret = append(ret, MatchedElement{"Name": k, "Value": v})
+			ret = append(ret, MatchedElement{MatchedKeyName: k, MatchedKeyValue: v})
 		}
 	}
 	// Sort for reproducible output
-	sort.Slice(ret, func(i, j int) bool { return ret[i]["Name"] < ret[j]["Name"] })
+	sort.Slice(ret, func(i, j int) bool { return ret[i][MatchedKeyName] < ret[j][MatchedKeyName] })
 
 	if klogV3 := klog.V(3); klogV3.Enabled() {
 		mk := make([]string, len(ret))
 		for i, v := range ret {
-			mk[i] = v["Name"]
+			mk[i] = v[MatchedKeyName]
 		}
 		mkMsg := strings.Join(mk, ", ")
 
@@ -278,7 +285,7 @@ func MatchInstanceAttributeNames(m *nfdv1alpha1.MatchExpression, instances []nfd
 
 // MatchKeys evaluates the MatchExpressionSet against a set of keys.
 func MatchKeys(m *nfdv1alpha1.MatchExpressionSet, keys map[string]nfdv1alpha1.Nil) (bool, error) {
-	matched, _, err := MatchGetKeys(m, keys)
+	matched, _, _, err := MatchGetKeys(m, keys)
 	return matched, err
 }
 
@@ -288,56 +295,65 @@ type MatchedElement map[string]string
 // MatchGetKeys evaluates the MatchExpressionSet against a set of keys and
 // returns all matched keys or nil if no match was found. Note that an empty
 // MatchExpressionSet returns a match with an empty slice of matched features.
-func MatchGetKeys(m *nfdv1alpha1.MatchExpressionSet, keys map[string]nfdv1alpha1.Nil) (bool, []MatchedElement, error) {
-	ret := make([]MatchedElement, 0, len(*m))
+func MatchGetKeys(m *nfdv1alpha1.MatchExpressionSet, keys map[string]nfdv1alpha1.Nil) (bool, []MatchedElement, *nfdv1alpha1.MatchExpressionSet, error) {
+	matchedElements := make([]MatchedElement, 0, len(*m))
+	matchedExpressions := make(nfdv1alpha1.MatchExpressionSet)
 
 	for n, e := range *m {
 		match, err := evaluateMatchExpressionKeys(e, n, keys)
 		if err != nil {
-			return false, nil, err
+			return false, nil, nil, err
 		}
 		if !match {
-			return false, nil, nil
+			return false, nil, nil, nil
 		}
-		ret = append(ret, MatchedElement{"Name": n})
+		matchedElements = append(matchedElements, MatchedElement{MatchedKeyName: n})
+		matchedExpressions[n] = e
 	}
 	// Sort for reproducible output
-	sort.Slice(ret, func(i, j int) bool { return ret[i]["Name"] < ret[j]["Name"] })
-	return true, ret, nil
+	sort.Slice(matchedElements, func(i, j int) bool { return matchedElements[i][MatchedKeyName] < matchedElements[j][MatchedKeyName] })
+	return true, matchedElements, &matchedExpressions, nil
 }
 
 // MatchValues evaluates the MatchExpressionSet against a set of key-value pairs.
-func MatchValues(m *nfdv1alpha1.MatchExpressionSet, values map[string]string) (bool, error) {
-	matched, _, err := MatchGetValues(m, values)
-	return matched, err
+func MatchValues(m *nfdv1alpha1.MatchExpressionSet, values map[string]string, failFast bool) (bool, *nfdv1alpha1.MatchExpressionSet, error) {
+	matched, _, matchedExpressions, err := MatchGetValues(m, values, failFast)
+	return matched, matchedExpressions, err
 }
 
 // MatchGetValues evaluates the MatchExpressionSet against a set of key-value
 // pairs and returns all matched key-value pairs. Note that an empty
 // MatchExpressionSet returns a match with an empty slice of matched features.
-func MatchGetValues(m *nfdv1alpha1.MatchExpressionSet, values map[string]string) (bool, []MatchedElement, error) {
-	ret := make([]MatchedElement, 0, len(*m))
+func MatchGetValues(m *nfdv1alpha1.MatchExpressionSet, values map[string]string, failFast bool) (bool, []MatchedElement, *nfdv1alpha1.MatchExpressionSet, error) {
+	matchedElements := make([]MatchedElement, 0, len(*m))
+	matchedExpressions := make(nfdv1alpha1.MatchExpressionSet)
+	isMatch := true
 
 	for n, e := range *m {
 		match, err := evaluateMatchExpressionValues(e, n, values)
 		if err != nil {
-			return false, nil, err
+			return false, nil, nil, err
 		}
-		if !match {
-			return false, nil, nil
+		if match {
+			matchedElements = append(matchedElements, MatchedElement{MatchedKeyName: n, MatchedKeyValue: values[n]})
+			matchedExpressions[n] = e
+		} else {
+			if failFast {
+				return false, nil, nil, nil
+			}
+			isMatch = false
 		}
-		ret = append(ret, MatchedElement{"Name": n, "Value": values[n]})
 	}
 	// Sort for reproducible output
-	sort.Slice(ret, func(i, j int) bool { return ret[i]["Name"] < ret[j]["Name"] })
-	return true, ret, nil
+	sort.Slice(matchedElements, func(i, j int) bool { return matchedElements[i][MatchedKeyName] < matchedElements[j][MatchedKeyName] })
+	return isMatch, matchedElements, &matchedExpressions, nil
 }
 
 // MatchInstances evaluates the MatchExpressionSet against a set of instance
 // features, each of which is an individual set of key-value pairs
 // (attributes).
-func MatchInstances(m *nfdv1alpha1.MatchExpressionSet, instances []nfdv1alpha1.InstanceFeature) (bool, error) {
-	isMatch, _, err := MatchGetInstances(m, instances)
+func MatchInstances(m *nfdv1alpha1.MatchExpressionSet, instances []nfdv1alpha1.InstanceFeature, failFast bool) (bool, error) {
+	isMatch, _, _, err := MatchGetInstances(m, instances, failFast)
 	return isMatch, err
 }
 
@@ -346,17 +362,28 @@ func MatchInstances(m *nfdv1alpha1.MatchExpressionSet, instances []nfdv1alpha1.I
 // (attributes). Returns a boolean that reports whether the expression matched.
 // Also, returns a slice containing all matching instances. An empty (non-nil)
 // slice is returned if no matching instances were found.
-func MatchGetInstances(m *nfdv1alpha1.MatchExpressionSet, instances []nfdv1alpha1.InstanceFeature) (bool, []MatchedElement, error) {
-	ret := []MatchedElement{}
+func MatchGetInstances(m *nfdv1alpha1.MatchExpressionSet, instances []nfdv1alpha1.InstanceFeature, failFast bool) (bool, []MatchedElement, *nfdv1alpha1.MatchExpressionSet, error) {
+	var (
+		match         bool
+		err           error
+		expressionSet *nfdv1alpha1.MatchExpressionSet
+	)
+	matchedElements := []MatchedElement{}
+	matchedExpressions := &nfdv1alpha1.MatchExpressionSet{}
 
 	for _, i := range instances {
-		if match, err := MatchValues(m, i.Attributes); err != nil {
-			return false, nil, err
+		if match, expressionSet, err = MatchValues(m, i.Attributes, failFast); err != nil {
+			return false, nil, nil, err
 		} else if match {
-			ret = append(ret, i.Attributes)
+			matchedElements = append(matchedElements, i.Attributes)
+		}
+		if expressionSet != nil {
+			for name, exp := range *expressionSet {
+				(*matchedExpressions)[name] = exp
+			}
 		}
 	}
-	return len(ret) > 0, ret, nil
+	return len(matchedElements) > 0, matchedElements, matchedExpressions, nil
 }
 
 // MatchMulti evaluates a MatchExpressionSet against key, value and instance
@@ -365,8 +392,9 @@ func MatchGetInstances(m *nfdv1alpha1.MatchExpressionSet, instances []nfdv1alpha
 // handled separately as the way of evaluating match expressions is different.
 // This function is written to handle "multi-type" features where one feature
 // (say "cpu.cpuid") contains multiple types (flag, attribute and/or instance).
-func MatchMulti(m *nfdv1alpha1.MatchExpressionSet, keys map[string]nfdv1alpha1.Nil, values map[string]string, instances []nfdv1alpha1.InstanceFeature) (bool, []MatchedElement, error) {
+func MatchMulti(m *nfdv1alpha1.MatchExpressionSet, keys map[string]nfdv1alpha1.Nil, values map[string]string, instances []nfdv1alpha1.InstanceFeature, failFast bool) (bool, []MatchedElement, *nfdv1alpha1.MatchExpressionSet, error) {
 	matchedElems := []MatchedElement{}
+	matchedExpressions := nfdv1alpha1.MatchExpressionSet{}
 	isMatch := false
 
 	// Keys and values are handled as a union, it is enough to find a match in
@@ -384,14 +412,19 @@ func MatchMulti(m *nfdv1alpha1.MatchExpressionSet, keys map[string]nfdv1alpha1.N
 		if keys != nil {
 			matchK, err = evaluateMatchExpressionKeys(e, n, keys)
 			if err != nil {
-				return false, nil, err
+				return false, nil, nil, err
 			}
 			if matchK {
-				matchedElems = append(matchedElems, MatchedElement{"Name": n})
+				matchedElems = append(matchedElems, MatchedElement{MatchedKeyName: n})
+				matchedExpressions[n] = e
 			} else if e.Op == nfdv1alpha1.MatchDoesNotExist {
 				// DoesNotExist is special in that both "keys" and "values" should match (i.e. the name is not found in either of them).
 				isMatch = false
+				if !failFast {
+					continue
+				}
 				matchedElems = []MatchedElement{}
+				matchedExpressions = nfdv1alpha1.MatchExpressionSet{}
 				break
 			}
 		}
@@ -399,39 +432,53 @@ func MatchMulti(m *nfdv1alpha1.MatchExpressionSet, keys map[string]nfdv1alpha1.N
 		if values != nil {
 			matchV, err = evaluateMatchExpressionValues(e, n, values)
 			if err != nil {
-				return false, nil, err
+				return false, nil, nil, err
 			}
 			if matchV {
-				matchedElems = append(matchedElems, MatchedElement{"Name": n, "Value": values[n]})
+				matchedElems = append(matchedElems, MatchedElement{MatchedKeyName: n, MatchedKeyValue: values[n]})
+				matchedExpressions[n] = e
 			} else if e.Op == nfdv1alpha1.MatchDoesNotExist {
 				// DoesNotExist is special in that both "keys" and "values" should match (i.e. the name is not found in either of them).
 				isMatch = false
+				if !failFast {
+					continue
+				}
 				matchedElems = []MatchedElement{}
+				matchedExpressions = nfdv1alpha1.MatchExpressionSet{}
 				break
 			}
 		}
 
 		if !matchK && !matchV {
 			isMatch = false
+			if !failFast {
+				continue
+			}
 			matchedElems = []MatchedElement{}
+			matchedExpressions = nfdv1alpha1.MatchExpressionSet{}
 			break
 		}
 	}
 	// Sort for reproducible output
-	sort.Slice(matchedElems, func(i, j int) bool { return matchedElems[i]["Name"] < matchedElems[j]["Name"] })
+	sort.Slice(matchedElems, func(i, j int) bool { return matchedElems[i][MatchedKeyName] < matchedElems[j][MatchedKeyName] })
 
 	// Instances are handled separately as the logic is fundamentally different
 	// from keys and values and cannot be combined with them. We want to find
 	// instance(s) that match all match expressions. I.e. the set of all match
 	// expressions are evaluated against every instance separately.
-	ma, me, err := MatchGetInstances(m, instances)
+	ma, melems, mexps, err := MatchGetInstances(m, instances, failFast)
 	if err != nil {
-		return false, nil, err
+		return false, nil, nil, err
 	}
 	isMatch = isMatch || ma
-	matchedElems = append(matchedElems, me...)
+	matchedElems = append(matchedElems, melems...)
+	if mexps != nil {
+		for k, v := range *mexps {
+			matchedExpressions[k] = v
+		}
+	}
 
-	return isMatch, matchedElems, nil
+	return isMatch, matchedElems, &matchedExpressions, nil
 }
 
 // MatchNamesMulti evaluates the MatchExpression against the names of key,
@@ -440,12 +487,11 @@ func MatchMulti(m *nfdv1alpha1.MatchExpressionSet, keys map[string]nfdv1alpha1.N
 // types (flag, attribute and/or instance).
 func MatchNamesMulti(m *nfdv1alpha1.MatchExpression, keys map[string]nfdv1alpha1.Nil, values map[string]string, instances []nfdv1alpha1.InstanceFeature) (bool, []MatchedElement, error) {
 	ret := []MatchedElement{}
-
 	for k := range keys {
 		if match, err := evaluateMatchExpression(m, true, k); err != nil {
 			return false, nil, err
 		} else if match {
-			ret = append(ret, MatchedElement{"Name": k})
+			ret = append(ret, MatchedElement{MatchedKeyName: k})
 		}
 	}
 
@@ -453,12 +499,12 @@ func MatchNamesMulti(m *nfdv1alpha1.MatchExpression, keys map[string]nfdv1alpha1
 		if match, err := evaluateMatchExpression(m, true, k); err != nil {
 			return false, nil, err
 		} else if match {
-			ret = append(ret, MatchedElement{"Name": k, "Value": v})
+			ret = append(ret, MatchedElement{MatchedKeyName: k, MatchedKeyValue: v})
 		}
 	}
 
 	// Sort for reproducible output
-	sort.Slice(ret, func(i, j int) bool { return ret[i]["Name"] < ret[j]["Name"] })
+	sort.Slice(ret, func(i, j int) bool { return ret[i][MatchedKeyName] < ret[j][MatchedKeyName] })
 
 	_, me, err := MatchInstanceAttributeNames(m, instances)
 	if err != nil {
@@ -469,7 +515,7 @@ func MatchNamesMulti(m *nfdv1alpha1.MatchExpression, keys map[string]nfdv1alpha1
 	if klogV3 := klog.V(3); klogV3.Enabled() {
 		mk := make([]string, len(ret))
 		for i, v := range ret {
-			mk[i] = v["Name"]
+			mk[i] = v[MatchedKeyName]
 		}
 		mkMsg := strings.Join(mk, ", ")
 
diff --git a/pkg/apis/nfd/nodefeaturerule/rule.go b/pkg/apis/nfd/nodefeaturerule/rule.go
index b9a562924..5f1cacf5f 100644
--- a/pkg/apis/nfd/nodefeaturerule/rule.go
+++ b/pkg/apis/nfd/nodefeaturerule/rule.go
@@ -31,6 +31,31 @@ import (
 	"sigs.k8s.io/node-feature-discovery/pkg/utils"
 )
 
+// MatchStatus represents the status of a processed rule.
+// It includes information about successful expressions and their results, which are the matched host features.
+// For example, for the expression: cpu.cpuid: {op: "InRegexp", value: ["^AVX"]},
+// the result could include matched host features such as AVX, AVX2, AVX512 etc.
+// +k8s:deepcopy-gen=false
+type MatchStatus struct {
+	*MatchFeatureStatus
+
+	// IsMatch informes whether a rule succeeded or failed.
+	IsMatch bool
+	// MatchAny represents an array of logical OR conditions between MatchFeatureStatus entries.
+	MatchAny []*MatchFeatureStatus
+}
+
+// MatchFeatureStatus represents a matched expression
+// with its result, which is matched host features.
+// +k8s:deepcopy-gen=false
+type MatchFeatureStatus struct {
+	// MatchedFeatures represents the features matched on the host,
+	// which is a result of the FeatureMatcher.
+	MatchedFeatures matchedFeatures
+	// MatchedFeaturesTerms represents the expressions that successfully matched on the host.
+	MatchedFeaturesTerms nfdv1alpha1.FeatureMatcher
+}
+
 // RuleOutput contains the output out rule execution.
 // +k8s:deepcopy-gen=false
 type RuleOutput struct {
@@ -39,56 +64,69 @@ type RuleOutput struct {
 	Annotations       map[string]string
 	Vars              map[string]string
 	Taints            []corev1.Taint
+	MatchStatus       *MatchStatus
 }
 
 // Execute the rule against a set of input features.
-func Execute(r *nfdv1alpha1.Rule, features *nfdv1alpha1.Features) (RuleOutput, error) {
+func Execute(r *nfdv1alpha1.Rule, features *nfdv1alpha1.Features, failFast bool) (RuleOutput, error) {
+	var (
+		matchStatus MatchStatus
+		isMatch     bool
+		err         error
+	)
 	labels := make(map[string]string)
 	vars := make(map[string]string)
 
-	if len(r.MatchAny) > 0 {
+	if n := len(r.MatchAny); n > 0 {
+		matchStatus.MatchAny = make([]*MatchFeatureStatus, 0, n)
 		// Logical OR over the matchAny matchers
-		matched := false
+		var (
+			featureStatus *MatchFeatureStatus
+			matched       bool
+		)
 		for _, matcher := range r.MatchAny {
-			if isMatch, matches, err := evaluateMatchAnyElem(&matcher, features); err != nil {
+			if matched, featureStatus, err = evaluateMatchAnyElem(&matcher, features, failFast); err != nil {
 				return RuleOutput{}, err
-			} else if isMatch {
-				matched = true
-				klog.V(4).InfoS("matchAny matched", "ruleName", r.Name, "matchedFeatures", utils.DelayedDumper(matches))
+			} else if matched {
+				isMatch = true
+				klog.V(4).InfoS("matchAny matched", "ruleName", r.Name, "matchedFeatures", utils.DelayedDumper(featureStatus.MatchedFeatures))
 
-				if r.LabelsTemplate == "" && r.VarsTemplate == "" {
+				if r.LabelsTemplate == "" && r.VarsTemplate == "" && failFast {
 					// there's no need to evaluate other matchers in MatchAny
 					// if there are no templates to be executed on them - so
 					// short-circuit and stop on first match here
 					break
 				}
 
-				if err := executeLabelsTemplate(r, matches, labels); err != nil {
+				if err := executeLabelsTemplate(r, featureStatus.MatchedFeatures, labels); err != nil {
 					return RuleOutput{}, err
 				}
-				if err := executeVarsTemplate(r, matches, vars); err != nil {
+				if err := executeVarsTemplate(r, featureStatus.MatchedFeatures, vars); err != nil {
 					return RuleOutput{}, err
 				}
 			}
+
+			matchStatus.MatchAny = append(matchStatus.MatchAny, featureStatus)
 		}
-		if !matched {
+
+		if !isMatch {
 			klog.V(2).InfoS("rule did not match", "ruleName", r.Name)
-			return RuleOutput{}, nil
+			return RuleOutput{MatchStatus: &matchStatus}, nil
 		}
 	}
 
 	if len(r.MatchFeatures) > 0 {
-		if isMatch, matches, err := evaluateFeatureMatcher(&r.MatchFeatures, features); err != nil {
+		if isMatch, matchStatus.MatchFeatureStatus, err = evaluateFeatureMatcher(&r.MatchFeatures, features, failFast); err != nil {
 			return RuleOutput{}, err
 		} else if !isMatch {
 			klog.V(2).InfoS("rule did not match", "ruleName", r.Name)
-			return RuleOutput{}, nil
+			return RuleOutput{MatchStatus: &matchStatus}, nil
 		} else {
-			klog.V(4).InfoS("matchFeatures matched", "ruleName", r.Name, "matchedFeatures", utils.DelayedDumper(matches))
-			if err := executeLabelsTemplate(r, matches, labels); err != nil {
+			klog.V(4).InfoS("matchFeatures matched", "ruleName", r.Name, "matchedFeatures", utils.DelayedDumper(matchStatus.MatchedFeatures))
+			if err := executeLabelsTemplate(r, matchStatus.MatchedFeatures, labels); err != nil {
 				return RuleOutput{}, err
 			}
-			if err := executeVarsTemplate(r, matches, vars); err != nil {
+			if err := executeVarsTemplate(r, matchStatus.MatchedFeatures, vars); err != nil {
 				return RuleOutput{}, err
 			}
 		}
@@ -96,6 +134,7 @@ func Execute(r *nfdv1alpha1.Rule, features *nfdv1alpha1.Features) (RuleOutput, e
 
 	maps.Copy(labels, r.Labels)
 	maps.Copy(vars, r.Vars)
+	matchStatus.IsMatch = true
 
 	ret := RuleOutput{
 		Labels:            labels,
@@ -103,6 +142,7 @@ func Execute(r *nfdv1alpha1.Rule, features *nfdv1alpha1.Features) (RuleOutput, e
 		Annotations:       maps.Clone(r.Annotations),
 		ExtendedResources: maps.Clone(r.ExtendedResources),
 		Taints:            slices.Clone(r.Taints),
+		MatchStatus:       &matchStatus,
 	}
 	klog.V(2).InfoS("rule matched", "ruleName", r.Name, "ruleOutput", utils.DelayedDumper(ret))
 	return ret, nil
@@ -110,12 +150,12 @@ func Execute(r *nfdv1alpha1.Rule, features *nfdv1alpha1.Features) (RuleOutput, e
 
 // ExecuteGroupRule executes the GroupRule against a set of input features, and return true if the
 // rule matches.
-func ExecuteGroupRule(r *nfdv1alpha1.GroupRule, features *nfdv1alpha1.Features) (bool, error) {
+func ExecuteGroupRule(r *nfdv1alpha1.GroupRule, features *nfdv1alpha1.Features, failFast bool) (bool, error) {
 	matched := false
 	if len(r.MatchAny) > 0 {
 		// Logical OR over the matchAny matchers
 		for _, matcher := range r.MatchAny {
-			if isMatch, matches, err := evaluateMatchAnyElem(&matcher, features); err != nil {
+			if isMatch, matches, err := evaluateMatchAnyElem(&matcher, features, failFast); err != nil {
 				return false, err
 			} else if isMatch {
 				matched = true
@@ -131,7 +171,7 @@ func ExecuteGroupRule(r *nfdv1alpha1.GroupRule, features *nfdv1alpha1.Features)
 	}
 
 	if len(r.MatchFeatures) > 0 {
-		if isMatch, _, err := evaluateFeatureMatcher(&r.MatchFeatures, features); err != nil {
+		if isMatch, _, err := evaluateFeatureMatcher(&r.MatchFeatures, features, failFast); err != nil {
 			return false, err
 		} else if !isMatch {
 			klog.V(2).InfoS("rule did not match", "ruleName", r.Name)
@@ -187,12 +227,18 @@ type matchedFeatures map[string]domainMatchedFeatures
 
 type domainMatchedFeatures map[string][]MatchedElement
 
-func evaluateMatchAnyElem(e *nfdv1alpha1.MatchAnyElem, features *nfdv1alpha1.Features) (bool, matchedFeatures, error) {
-	return evaluateFeatureMatcher(&e.MatchFeatures, features)
+func evaluateMatchAnyElem(e *nfdv1alpha1.MatchAnyElem, features *nfdv1alpha1.Features, failFast bool) (bool, *MatchFeatureStatus, error) {
+	return evaluateFeatureMatcher(&e.MatchFeatures, features, failFast)
 }
 
-func evaluateFeatureMatcher(m *nfdv1alpha1.FeatureMatcher, features *nfdv1alpha1.Features) (bool, matchedFeatures, error) {
-	matches := make(matchedFeatures, len(*m))
+func evaluateFeatureMatcher(m *nfdv1alpha1.FeatureMatcher, features *nfdv1alpha1.Features, failFast bool) (bool, *MatchFeatureStatus, error) {
+	var (
+		isMatch     = true
+		isTermMatch = true
+	)
+	status := &MatchFeatureStatus{
+		MatchedFeatures: make(matchedFeatures, len(*m)),
+	}
 
 	// Logical AND over the terms
 	for _, term := range *m {
@@ -207,14 +253,17 @@ func evaluateFeatureMatcher(m *nfdv1alpha1.FeatureMatcher, features *nfdv1alpha1
 
 		dom := nameSplit[0]
 		nam := nameSplit[1]
-		if _, ok := matches[dom]; !ok {
-			matches[dom] = make(domainMatchedFeatures)
+		if _, ok := status.MatchedFeatures[dom]; !ok {
+			status.MatchedFeatures[dom] = make(domainMatchedFeatures)
 		}
 
-		var isMatch = true
 		var matchedElems []MatchedElement
+		var matchedExpressions *nfdv1alpha1.MatchExpressionSet
 		var err error
 
+		matchedFeatureTerm := nfdv1alpha1.FeatureMatcherTerm{
+			Feature: featureName,
+		}
 		fF, okF := features.Flags[featureName]
 		fA, okA := features.Attributes[featureName]
 		fI, okI := features.Instances[featureName]
@@ -223,24 +272,37 @@ func evaluateFeatureMatcher(m *nfdv1alpha1.FeatureMatcher, features *nfdv1alpha1
 		}
 
 		if term.MatchExpressions != nil {
-			isMatch, matchedElems, err = MatchMulti(term.MatchExpressions, fF.Elements, fA.Elements, fI.Elements)
+			isTermMatch, matchedElems, matchedExpressions, err = MatchMulti(term.MatchExpressions, fF.Elements, fA.Elements, fI.Elements, failFast)
+			matchedFeatureTerm.MatchExpressions = matchedExpressions
 		}
 
-		if err == nil && isMatch && term.MatchName != nil {
+		if err == nil && isTermMatch && term.MatchName != nil {
 			var meTmp []MatchedElement
-			isMatch, meTmp, err = MatchNamesMulti(term.MatchName, fF.Elements, fA.Elements, fI.Elements)
+			isTermMatch, meTmp, err = MatchNamesMulti(term.MatchName, fF.Elements, fA.Elements, fI.Elements)
 			matchedElems = append(matchedElems, meTmp...)
+			// MatchName has only one expression, in this case it's enough to check the isTermMatch flag
+			// to judge if the expression succeeded on the host.
+			if isTermMatch {
+				matchedFeatureTerm.MatchName = term.MatchName
+			}
 		}
 
-		matches[dom][nam] = append(matches[dom][nam], matchedElems...)
+		status.MatchedFeatures[dom][nam] = append(status.MatchedFeatures[dom][nam], matchedElems...)
+		if matchedFeatureTerm.MatchName != nil || (matchedFeatureTerm.MatchExpressions != nil && len(*matchedFeatureTerm.MatchExpressions) > 0) {
+			status.MatchedFeaturesTerms = append(status.MatchedFeaturesTerms, matchedFeatureTerm)
+		}
 
 		if err != nil {
 			return false, nil, err
-		} else if !isMatch {
-			return false, nil, nil
+		} else if !isTermMatch {
+			if !failFast {
+				isMatch = false
+			} else {
+				return false, status, nil
+			}
 		}
 	}
-	return true, matches, nil
+	return isMatch, status, nil
 }
 
 type templateHelper struct {
diff --git a/pkg/client-nfd/compat/artifact-client/client.go b/pkg/client-nfd/compat/artifact-client/client.go
new file mode 100644
index 000000000..ef37f1fc3
--- /dev/null
+++ b/pkg/client-nfd/compat/artifact-client/client.go
@@ -0,0 +1,178 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package compat
+
+//go:generate mockery --name=ArtifactClient --inpackage
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
+	oras "oras.land/oras-go/v2"
+	"oras.land/oras-go/v2/registry"
+	"oras.land/oras-go/v2/registry/remote"
+	"oras.land/oras-go/v2/registry/remote/auth"
+	"oras.land/oras-go/v2/registry/remote/retry"
+	"sigs.k8s.io/yaml"
+
+	compatv1alpha1 "sigs.k8s.io/node-feature-discovery/api/image-compatibility/v1alpha1"
+)
+
+// ArtifactClient interface contain set of functions to manipulate compatibility artfact.
+type ArtifactClient interface {
+	// FetchCompatibilitySpec downloads the compatibility specifcation associated with the image.
+	FetchCompatibilitySpec(ctx context.Context) (*compatv1alpha1.Spec, error)
+}
+
+// Args holds command line arguments.
+type Args struct {
+	PlainHttp bool
+}
+
+// Client represents a client that is reposnible for all artifact operations.
+type Client struct {
+	Args         Args
+	RegReference *registry.Reference
+	Platform     *ocispec.Platform
+
+	orasClient *auth.Client
+}
+
+// New returns a new compatibility spec object.
+func New(regReference *registry.Reference, opts ...ArtifactClientOpts) *Client {
+	c := &Client{
+		RegReference: regReference,
+	}
+	for _, opt := range opts {
+		opt.apply(c)
+	}
+	return c
+}
+
+// FetchCompatibilitySpec pulls the image compatibility specification associated with the image.
+func (c *Client) FetchCompatibilitySpec(ctx context.Context) (*compatv1alpha1.Spec, error) {
+	repo, err := remote.NewRepository(c.RegReference.String())
+	if err != nil {
+		return nil, err
+	}
+	repo.Client = c.orasClient
+	repo.PlainHTTP = c.Args.PlainHttp
+
+	opts := oras.DefaultResolveOptions
+	if c.Platform != nil {
+		opts.TargetPlatform = c.Platform
+	}
+
+	targetDesc, err := oras.Resolve(ctx, repo, c.RegReference.Reference, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	descs, err := registry.Referrers(ctx, repo, targetDesc, compatv1alpha1.ArtifactType)
+	if err != nil {
+		return nil, nil
+	} else if len(descs) < 1 {
+		return nil, fmt.Errorf("compatibility artifact not found")
+	}
+	artifactDesc := descs[len(descs)-1]
+
+	_, content, err := oras.FetchBytes(ctx, repo.Manifests(), artifactDesc.Digest.String(), oras.DefaultFetchBytesOptions)
+	if err != nil {
+		return nil, err
+	}
+
+	manifest := ocispec.Manifest{}
+	if err := json.Unmarshal(content, &manifest); err != nil {
+		return nil, err
+	}
+
+	// TODO: now it's a lazy check, verify in the future the media types and number of layers
+	if len(manifest.Layers) < 1 {
+		return nil, fmt.Errorf("compatibility layer not found")
+	}
+	specDesc := manifest.Layers[0]
+
+	_, compatSpecRaw, err := oras.FetchBytes(ctx, repo.Blobs(), specDesc.Digest.String(), oras.DefaultFetchBytesOptions)
+	if err != nil {
+		return nil, err
+	}
+
+	compatSpec := compatv1alpha1.Spec{}
+	err = yaml.Unmarshal(compatSpecRaw, &compatSpec)
+	if err != nil {
+		return nil, err
+	}
+
+	return &compatSpec, nil
+}
+
+// NodeValidatorOpts applies certain options to the node validator.
+type ArtifactClientOpts interface {
+	apply(*Client)
+}
+
+type artifactClientOpt struct {
+	f func(*Client)
+}
+
+func (o *artifactClientOpt) apply(nv *Client) {
+	o.f(nv)
+}
+
+// WithArgs applies arguments to the artifact client.
+func WithArgs(args Args) ArtifactClientOpts {
+	return &artifactClientOpt{f: func(c *Client) { c.Args = args }}
+}
+
+// WithPlatform applies OCI platform spec to the artifact client.
+func WithPlatform(platform *ocispec.Platform) ArtifactClientOpts {
+	return &artifactClientOpt{f: func(c *Client) { c.Platform = platform }}
+}
+
+// WithAuthPassword initializes oras client with user and password.
+func WithAuthPassword(username, password string) ArtifactClientOpts {
+	return &artifactClientOpt{f: func(c *Client) {
+		c.orasClient = &auth.Client{
+			Client: retry.DefaultClient,
+			Cache:  auth.NewCache(),
+			Credential: auth.StaticCredential(c.RegReference.Registry, auth.Credential{
+				Username: username,
+				Password: password,
+			}),
+		}
+	}}
+}
+
+// WithAuthToken initializes oras client with auth token.
+func WithAuthToken(token string) ArtifactClientOpts {
+	return &artifactClientOpt{f: func(c *Client) {
+		c.orasClient = &auth.Client{
+			Client: retry.DefaultClient,
+			Cache:  auth.NewCache(),
+			Credential: auth.StaticCredential(c.RegReference.Registry, auth.Credential{
+				AccessToken: token,
+			}),
+		}
+	}}
+}
+
+// WithAuthDefault initializes the default oras client that does not authenticate.
+func WithAuthDefault() ArtifactClientOpts {
+	return &artifactClientOpt{f: func(c *Client) { c.orasClient = auth.DefaultClient }}
+}
diff --git a/pkg/client-nfd/compat/artifact-client/mock_ArtifactClient.go b/pkg/client-nfd/compat/artifact-client/mock_ArtifactClient.go
new file mode 100644
index 000000000..3c246ee52
--- /dev/null
+++ b/pkg/client-nfd/compat/artifact-client/mock_ArtifactClient.go
@@ -0,0 +1,59 @@
+// Code generated by mockery v2.42.0. DO NOT EDIT.
+
+package compat
+
+import (
+	context "context"
+
+	mock "github.com/stretchr/testify/mock"
+	v1alpha1 "sigs.k8s.io/node-feature-discovery/api/image-compatibility/v1alpha1"
+)
+
+// MockArtifactClient is an autogenerated mock type for the ArtifactClient type
+type MockArtifactClient struct {
+	mock.Mock
+}
+
+// FetchCompatibilitySpec provides a mock function with given fields: ctx
+func (_m *MockArtifactClient) FetchCompatibilitySpec(ctx context.Context) (*v1alpha1.Spec, error) {
+	ret := _m.Called(ctx)
+
+	if len(ret) == 0 {
+		panic("no return value specified for FetchCompatibilitySpec")
+	}
+
+	var r0 *v1alpha1.Spec
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context) (*v1alpha1.Spec, error)); ok {
+		return rf(ctx)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context) *v1alpha1.Spec); ok {
+		r0 = rf(ctx)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*v1alpha1.Spec)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context) error); ok {
+		r1 = rf(ctx)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// NewMockArtifactClient creates a new instance of MockArtifactClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func NewMockArtifactClient(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *MockArtifactClient {
+	mock := &MockArtifactClient{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}
diff --git a/pkg/client-nfd/compat/node-validator/node-validator.go b/pkg/client-nfd/compat/node-validator/node-validator.go
new file mode 100644
index 000000000..ef907c00f
--- /dev/null
+++ b/pkg/client-nfd/compat/node-validator/node-validator.go
@@ -0,0 +1,203 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package nodevalidator
+
+import (
+	"context"
+	"slices"
+	"sort"
+
+	nfdv1alpha1 "sigs.k8s.io/node-feature-discovery/api/nfd/v1alpha1"
+	"sigs.k8s.io/node-feature-discovery/pkg/apis/nfd/nodefeaturerule"
+	artifactcli "sigs.k8s.io/node-feature-discovery/pkg/client-nfd/compat/artifact-client"
+	"sigs.k8s.io/node-feature-discovery/source"
+
+	// register sources
+	_ "sigs.k8s.io/node-feature-discovery/source/cpu"
+	_ "sigs.k8s.io/node-feature-discovery/source/kernel"
+	_ "sigs.k8s.io/node-feature-discovery/source/memory"
+	_ "sigs.k8s.io/node-feature-discovery/source/network"
+	_ "sigs.k8s.io/node-feature-discovery/source/pci"
+	_ "sigs.k8s.io/node-feature-discovery/source/storage"
+	_ "sigs.k8s.io/node-feature-discovery/source/system"
+	_ "sigs.k8s.io/node-feature-discovery/source/usb"
+)
+
+// Args holds command line arguments.
+type Args struct {
+	Tags []string
+}
+
+type nodeValidator struct {
+	args Args
+
+	artifactClient artifactcli.ArtifactClient
+	sources        map[string]source.FeatureSource
+}
+
+// New builds a node validator with specified options.
+func New(opts ...NodeValidatorOpts) nodeValidator {
+	n := nodeValidator{}
+	for _, opt := range opts {
+		opt.apply(&n)
+	}
+	return n
+}
+
+// Execute pulls the compatibility artifact to compare described features with the ones discovered on the node.
+func (nv *nodeValidator) Execute(ctx context.Context) ([]*CompatibilityStatus, error) {
+	spec, err := nv.artifactClient.FetchCompatibilitySpec(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, s := range nv.sources {
+		if err := s.Discover(); err != nil {
+			return nil, err
+		}
+	}
+	features := source.GetAllFeatures()
+
+	compats := []*CompatibilityStatus{}
+	for _, c := range spec.Compatibilties {
+		if len(nv.args.Tags) > 0 && !slices.Contains(nv.args.Tags, c.Tag) {
+			continue
+		}
+		compat := newCompatibilityStatus(&c)
+
+		for _, r := range c.Rules {
+			ruleOut, err := nodefeaturerule.Execute(&r, features, false)
+			if err != nil {
+				return nil, err
+			}
+			compat.Rules = append(compat.Rules, evaluateRuleStatus(&r, ruleOut.MatchStatus))
+
+			// Add the 'rule.matched' feature for backreference functionality
+			features.InsertAttributeFeatures(nfdv1alpha1.RuleBackrefDomain, nfdv1alpha1.RuleBackrefFeature, ruleOut.Labels)
+			features.InsertAttributeFeatures(nfdv1alpha1.RuleBackrefDomain, nfdv1alpha1.RuleBackrefFeature, ruleOut.Vars)
+		}
+		compats = append(compats, &compat)
+	}
+
+	return compats, nil
+}
+
+func evaluateRuleStatus(rule *nfdv1alpha1.Rule, matchStatus *nodefeaturerule.MatchStatus) ProcessedRuleStatus {
+	out := ProcessedRuleStatus{Name: rule.Name, IsMatch: matchStatus.IsMatch}
+
+	evaluateFeatureMatcher := func(featureMatcher, matchedFeatureTerms nfdv1alpha1.FeatureMatcher) []MatchedExpression {
+		out := []MatchedExpression{}
+		for _, term := range featureMatcher {
+			if term.MatchExpressions != nil {
+				for name, exp := range *term.MatchExpressions {
+					isMatch := false
+
+					// Check if the expression matches
+					for _, processedTerm := range matchedFeatureTerms {
+						if term.Feature != processedTerm.Feature || processedTerm.MatchExpressions == nil {
+							continue
+						}
+						pexp, ok := (*processedTerm.MatchExpressions)[name]
+						if isMatch = ok && exp.Op == pexp.Op && slices.Equal(exp.Value, pexp.Value); isMatch {
+							break
+						}
+					}
+
+					out = append(out, MatchedExpression{
+						Feature:     term.Feature,
+						Name:        name,
+						Expression:  exp,
+						MatcherType: MatchExpressionType,
+						IsMatch:     isMatch,
+					})
+				}
+			}
+
+			if term.MatchName != nil {
+				isMatch := false
+				for _, processedTerm := range matchStatus.MatchedFeaturesTerms {
+					if term.Feature != processedTerm.Feature || processedTerm.MatchName == nil {
+						continue
+					}
+					isMatch = term.MatchName.Op == processedTerm.MatchName.Op && slices.Equal(term.MatchName.Value, processedTerm.MatchName.Value)
+					if isMatch {
+						break
+					}
+				}
+				out = append(out, MatchedExpression{
+					Feature:     term.Feature,
+					Name:        "",
+					Expression:  term.MatchName,
+					MatcherType: MatchNameType,
+					IsMatch:     isMatch,
+				})
+			}
+		}
+
+		// For reproducible output sort by name, feature, expression.
+		sort.Slice(out, func(i, j int) bool {
+			if out[i].Feature != out[j].Feature {
+				return out[i].Feature < out[j].Feature
+			}
+			if out[i].Name != out[j].Name {
+				return out[i].Name < out[j].Name
+			}
+			return out[i].Expression.String() < out[j].Expression.String()
+		})
+
+		return out
+	}
+
+	if matchFeatures := rule.MatchFeatures; matchFeatures != nil {
+		out.MatchedExpressions = evaluateFeatureMatcher(matchFeatures, matchStatus.MatchedFeaturesTerms)
+	}
+
+	for i, matchAnyElem := range rule.MatchAny {
+		matchedExpressions := evaluateFeatureMatcher(matchAnyElem.MatchFeatures, matchStatus.MatchAny[i].MatchedFeaturesTerms)
+		out.MatchedAny = append(out.MatchedAny, MatchAnyElem{MatchedExpressions: matchedExpressions})
+	}
+
+	return out
+}
+
+// NodeValidatorOpts applies certain options to the node validator.
+type NodeValidatorOpts interface {
+	apply(*nodeValidator)
+}
+
+type nodeValidatorOpt struct {
+	f func(*nodeValidator)
+}
+
+func (o *nodeValidatorOpt) apply(nv *nodeValidator) {
+	o.f(nv)
+}
+
+// WithArgs applies command line arguments to the node validator object.
+func WithArgs(args *Args) NodeValidatorOpts {
+	return &nodeValidatorOpt{f: func(nv *nodeValidator) { nv.args = *args }}
+}
+
+// WithArtifactClient applies the client for all artifact operations.
+func WithArtifactClient(cli artifactcli.ArtifactClient) NodeValidatorOpts {
+	return &nodeValidatorOpt{f: func(nv *nodeValidator) { nv.artifactClient = cli }}
+}
+
+// WithSources applies the list of enabled feature sources.
+func WithSources(sources map[string]source.FeatureSource) NodeValidatorOpts {
+	return &nodeValidatorOpt{f: func(nv *nodeValidator) { nv.sources = sources }}
+}
diff --git a/pkg/client-nfd/compat/node-validator/status.go b/pkg/client-nfd/compat/node-validator/status.go
new file mode 100644
index 000000000..68d92e041
--- /dev/null
+++ b/pkg/client-nfd/compat/node-validator/status.go
@@ -0,0 +1,89 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package nodevalidator
+
+import (
+	compatv1alpha1 "sigs.k8s.io/node-feature-discovery/api/image-compatibility/v1alpha1"
+	nfdv1alpha1 "sigs.k8s.io/node-feature-discovery/api/nfd/v1alpha1"
+)
+
+// MatcherType represents a type of the used matcher.
+type MatcherType string
+
+const (
+	// MatchExpressionType represents a matchExpression type.
+	MatchExpressionType MatcherType = "matchExpression"
+	// MatchNameType represents a matchName type.
+	MatchNameType MatcherType = "matchName"
+)
+
+// CompatibilityStatus represents the state of
+// feature matching between the image and the host.
+type CompatibilityStatus struct {
+	// Rules contain information about the matching status
+	// of all Node Feature Rules.
+	Rules []ProcessedRuleStatus `json:"rules"`
+	// Description of the compatibility set.
+	Description string `json:"description,omitempty"`
+	// Weight provides information about the priority of the compatibility set.
+	Weight int `json:"weight,omitempty"`
+	// Tag provides information about the tag assigned to the compatibility set.
+	Tag string `json:"tag,omitempty"`
+}
+
+func newCompatibilityStatus(c *compatv1alpha1.Compatibility) CompatibilityStatus {
+	cs := CompatibilityStatus{
+		Description: c.Description,
+		Weight:      c.Weight,
+		Tag:         c.Tag,
+	}
+
+	return cs
+}
+
+// ProcessedRuleStatus provides information whether the expressions succeeded on the host.
+type ProcessedRuleStatus struct {
+	// Name of the rule.
+	Name string `json:"name"`
+	// IsMatch provides information if the rule matches with the host.
+	IsMatch bool `json:"isMatch"`
+
+	// MatchedExpressions represents the expressions that succeed on the host.
+	MatchedExpressions []MatchedExpression `json:"matchedExpressions,omitempty"`
+	// MatchAny represents an array of logical OR conditions between MatchedExpressions entries.
+	MatchedAny []MatchAnyElem `json:"matchedAny,omitempty"`
+}
+
+// MatchAnyElem represents a single object of MatchAny that contains MatchedExpression entries.
+type MatchAnyElem struct {
+	// MatchedExpressions contains MatchedExpression entries.
+	MatchedExpressions []MatchedExpression `json:"matchedExpressions"`
+}
+
+// MatchedExpression represent all details about the expression that succeeded on the host.
+type MatchedExpression struct {
+	// Feature which is available to be evaluated on the host.
+	Feature string `json:"feature"`
+	// Name of the element.
+	Name string `json:"name"`
+	// Expression represents the expression provided by users.
+	Expression *nfdv1alpha1.MatchExpression `json:"expression"`
+	// MatcherType represents the matcher type, e.g. MatchExpression, MatchName.
+	MatcherType MatcherType `json:"matcherType"`
+	// IsMatch provides information whether the expression suceeded on the host.
+	IsMatch bool `json:"isMatch"`
+}
diff --git a/pkg/kubectl-nfd/dryrun.go b/pkg/kubectl-nfd/dryrun.go
index ec06a3c35..fdca04d88 100644
--- a/pkg/kubectl-nfd/dryrun.go
+++ b/pkg/kubectl-nfd/dryrun.go
@@ -70,7 +70,7 @@ func processNodeFeatureRule(nodeFeatureRule nfdv1alpha1.NodeFeatureRule, nodeFea
 
 	for _, rule := range nodeFeatureRule.Spec.Rules {
 		fmt.Println("Processing rule: ", rule.Name)
-		ruleOut, err := nodefeaturerule.Execute(&rule, &nodeFeature.Features)
+		ruleOut, err := nodefeaturerule.Execute(&rule, &nodeFeature.Features, true)
 		if err != nil {
 			errs = append(errs, fmt.Errorf("failed to process rule: %q - %w", rule.Name, err))
 			continue
diff --git a/pkg/nfd-master/nfd-master.go b/pkg/nfd-master/nfd-master.go
index 0c2625c0f..990ef380b 100644
--- a/pkg/nfd-master/nfd-master.go
+++ b/pkg/nfd-master/nfd-master.go
@@ -794,7 +794,7 @@ func (m *nfdMaster) nfdAPIUpdateNodeFeatureGroup(nfdClient nfdclientset.Interfac
 	nodeGroupValidator := make(map[string]bool)
 	for _, rule := range nodeFeatureGroup.Spec.Rules {
 		for _, feature := range nodeFeaturesList {
-			match, err := nodefeaturerule.ExecuteGroupRule(&rule, &feature.Spec.Features)
+			match, err := nodefeaturerule.ExecuteGroupRule(&rule, &feature.Spec.Features, true)
 			if err != nil {
 				klog.ErrorS(err, "failed to evaluate rule", "ruleName", rule.Name)
 				continue
@@ -1018,7 +1018,7 @@ func (m *nfdMaster) processNodeFeatureRule(nodeName string, features *nfdv1alpha
 			klog.InfoS("executing NodeFeatureRule", "nodefeaturerule", klog.KObj(spec), "nodeName", nodeName)
 		}
 		for _, rule := range spec.Spec.Rules {
-			ruleOut, err := nodefeaturerule.Execute(&rule, features)
+			ruleOut, err := nodefeaturerule.Execute(&rule, features, true)
 			if err != nil {
 				klog.ErrorS(err, "failed to process rule", "ruleName", rule.Name, "nodefeaturerule", klog.KObj(spec), "nodeName", nodeName)
 				nfrProcessingErrors.Inc()
diff --git a/source/custom/custom.go b/source/custom/custom.go
index 0e422ec2e..5c2af0139 100644
--- a/source/custom/custom.go
+++ b/source/custom/custom.go
@@ -93,7 +93,7 @@ func (s *customSource) GetLabels() (source.FeatureLabels, error) {
 	klog.V(2).InfoS("resolving custom features", "configuration", utils.DelayedDumper(allFeatureConfig))
 	// Iterate over features
 	for _, rule := range allFeatureConfig {
-		ruleOut, err := nodefeaturerule.Execute(&rule, features)
+		ruleOut, err := nodefeaturerule.Execute(&rule, features, true)
 		if err != nil {
 			klog.ErrorS(err, "failed to execute rule")
 			continue


Test patch:
diff --git a/pkg/apis/nfd/nodefeaturerule/expression-api_test.go b/pkg/apis/nfd/nodefeaturerule/expression-api_test.go
index c6f778159..474b53ef8 100644
--- a/pkg/apis/nfd/nodefeaturerule/expression-api_test.go
+++ b/pkg/apis/nfd/nodefeaturerule/expression-api_test.go
@@ -116,7 +116,7 @@ bar: { op: Exists }
 				t.Fatal("failed to parse data of test case")
 			}
 
-			res, out, err := api.MatchGetKeys(mes, tc.input)
+			res, out, _, err := api.MatchGetKeys(mes, tc.input)
 			tc.result(t, res)
 			assert.Equal(t, tc.output, out)
 			tc.err(t, err)
@@ -183,12 +183,12 @@ baz: { op: Gt, value: ["10"] }
 				t.Fatal("failed to parse data of test case")
 			}
 
-			res, out, err := api.MatchGetValues(mes, tc.input)
+			res, out, _, err := api.MatchGetValues(mes, tc.input, true)
 			tc.result(t, res)
 			assert.Equal(t, tc.output, out)
 			tc.err(t, err)
 
-			res, err = api.MatchValues(mes, tc.input)
+			res, _, err = api.MatchValues(mes, tc.input, true)
 			tc.result(t, res)
 			tc.err(t, err)
 		})
@@ -248,12 +248,12 @@ bar: { op: Lt, value: ["10"] }
 				t.Fatal("failed to parse data of test case")
 			}
 
-			res, out, err := api.MatchGetInstances(mes, tc.input)
+			res, out, _, err := api.MatchGetInstances(mes, tc.input, true)
 			assert.Equal(t, tc.output, out)
 			tc.result(t, res)
 			tc.err(t, err)
 
-			res, err = api.MatchInstances(mes, tc.input)
+			res, err = api.MatchInstances(mes, tc.input, true)
 			tc.result(t, res)
 			tc.err(t, err)
 		})
@@ -745,7 +745,7 @@ func TestMatchMulti(t *testing.T) {
 
 	for _, tc := range tcs {
 		t.Run(tc.name, func(t *testing.T) {
-			res, out, err := api.MatchMulti(tc.mes, tc.inputKeys, tc.inputValues, tc.inputInstances)
+			res, out, _, err := api.MatchMulti(tc.mes, tc.inputKeys, tc.inputValues, tc.inputInstances, true)
 			if tc.expectErr {
 				assert.NotNil(t, err)
 			} else {
diff --git a/pkg/apis/nfd/nodefeaturerule/rule_test.go b/pkg/apis/nfd/nodefeaturerule/rule_test.go
index 036ad23e9..86b5efd19 100644
--- a/pkg/apis/nfd/nodefeaturerule/rule_test.go
+++ b/pkg/apis/nfd/nodefeaturerule/rule_test.go
@@ -49,22 +49,22 @@ func TestRule(t *testing.T) {
 	}
 
 	// Test totally empty features
-	m, err := Execute(r1, f)
+	m, err := Execute(r1, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r1.Labels, m.Labels, "empty matcher should have matched empty features")
 
-	_, err = Execute(r2, f)
+	_, err = Execute(r2, f, true)
 	assert.Error(t, err, "matching against a missing feature should have returned an error")
 
 	// Test properly initialized empty features
 	f = nfdv1alpha1.NewFeatures()
 
-	m, err = Execute(r1, f)
+	m, err = Execute(r1, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r1.Labels, m.Labels, "empty matcher should have matched empty features")
 	assert.Empty(t, r1.Vars, "vars should be empty")
 
-	_, err = Execute(r2, f)
+	_, err = Execute(r2, f, true)
 	assert.Error(t, err, "matching against a missing feature type should have returned an error")
 
 	// Test empty feature sets
@@ -72,11 +72,11 @@ func TestRule(t *testing.T) {
 	f.Attributes["domain-1.vf-1"] = nfdv1alpha1.NewAttributeFeatures(nil)
 	f.Instances["domain-1.if-1"] = nfdv1alpha1.NewInstanceFeatures()
 
-	m, err = Execute(r1, f)
+	m, err = Execute(r1, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r1.Labels, m.Labels, "empty matcher should have matched empty features")
 
-	m, err = Execute(r2, f)
+	m, err = Execute(r2, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Nil(t, m.Labels, "unexpected match")
 
@@ -85,7 +85,7 @@ func TestRule(t *testing.T) {
 	f.Attributes["domain-1.vf-1"].Elements["key-1"] = "val-x"
 	f.Instances["domain-1.if-1"] = nfdv1alpha1.NewInstanceFeatures(*nfdv1alpha1.NewInstanceFeature(map[string]string{"attr-1": "val-x"}))
 
-	m, err = Execute(r1, f)
+	m, err = Execute(r1, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r1.Labels, m.Labels, "empty matcher should have matched empty features")
 
@@ -96,17 +96,17 @@ func TestRule(t *testing.T) {
 			MatchExpressions: &nfdv1alpha1.MatchExpressionSet{},
 		},
 	}
-	m, err = Execute(r1, f)
+	m, err = Execute(r1, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r1.Labels, m.Labels, "empty match expression set mathces anything")
 
 	// Match "key" features
-	m, err = Execute(r2, f)
+	m, err = Execute(r2, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Nil(t, m.Labels, "keys should not have matched")
 
 	f.Flags["domain-1.kf-1"].Elements["key-1"] = nfdv1alpha1.Nil{}
-	m, err = Execute(r2, f)
+	m, err = Execute(r2, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r2.Labels, m.Labels, "keys should have matched")
 	assert.Equal(t, r2.Vars, m.Vars, "vars should be present")
@@ -123,12 +123,12 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Nil(t, m.Labels, "values should not have matched")
 
 	f.Attributes["domain-1.vf-1"].Elements["key-1"] = "val-1"
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "values should have matched")
 
@@ -144,12 +144,12 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Nil(t, m.Labels, "instances should not have matched")
 
 	f.Instances["domain-1.if-1"].Elements[0].Attributes["attr-1"] = "val-1"
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "instances should have matched")
 
@@ -173,7 +173,7 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f2)
+	m, err = Execute(r3, f2, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "key in multi-type feature should have matched")
 
@@ -185,7 +185,7 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f2)
+	m, err = Execute(r3, f2, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "attribute in multi-type feature should have matched")
 
@@ -197,7 +197,7 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f2)
+	m, err = Execute(r3, f2, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "attribute in multi-type feature should have matched")
 
@@ -210,7 +210,7 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f2)
+	m, err = Execute(r3, f2, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "features in multi-type feature should have matched flags and attributes")
 
@@ -222,7 +222,7 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f2)
+	m, err = Execute(r3, f2, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "features in multi-type feature should have matched instance")
 
@@ -244,12 +244,12 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Nil(t, m.Labels, "instances should not have matched")
 
 	(*r3.MatchFeatures[0].MatchExpressions)["key-1"] = newMatchExpression(nfdv1alpha1.MatchIn, "val-1")
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "instances should have matched")
 
@@ -266,7 +266,7 @@ func TestRule(t *testing.T) {
 			},
 		},
 	}
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Nil(t, m.Labels, "instances should not have matched")
 
@@ -282,7 +282,7 @@ func TestRule(t *testing.T) {
 			},
 		})
 	(*r3.MatchFeatures[0].MatchExpressions)["key-1"] = newMatchExpression(nfdv1alpha1.MatchIn, "val-1")
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, r3.Labels, m.Labels, "instances should have matched")
 }
@@ -433,12 +433,12 @@ var-2=
 		"kf-foo":   "true",
 	}
 
-	m, err := Execute(r1, f)
+	m, err := Execute(r1, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, expectedLabels, m.Labels, "instances should have matched")
 	assert.Equal(t, expectedVars, m.Vars, "instances should have matched")
 
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, expectedLabels, m.Labels, "instances should have matched")
 	assert.Equal(t, expectedVars, m.Vars, "instances should have matched")
@@ -464,7 +464,7 @@ var-2=
 		"mf-key-d": "found",
 	}
 	expectedVars = map[string]string{}
-	m, err = Execute(r3, f)
+	m, err = Execute(r3, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, expectedLabels, m.Labels, "instances should have matched")
 	assert.Equal(t, expectedVars, m.Vars, "instances should have matched")
@@ -486,32 +486,32 @@ var-2=
 	}
 
 	r2.LabelsTemplate = "foo=bar"
-	m, err = Execute(r2, f)
+	m, err = Execute(r2, f, true)
 	assert.Nil(t, err)
 	assert.Equal(t, map[string]string{"foo": "bar"}, m.Labels, "instances should have matched")
 	assert.Empty(t, m.Vars)
 
 	r2.LabelsTemplate = "foo"
-	_, err = Execute(r2, f)
+	_, err = Execute(r2, f, true)
 	assert.Error(t, err)
 
 	r2.LabelsTemplate = "{{"
-	_, err = Execute(r2, f)
+	_, err = Execute(r2, f, true)
 	assert.Error(t, err)
 
 	r2.LabelsTemplate = ""
 	r2.VarsTemplate = "bar=baz"
-	m, err = Execute(r2, f)
+	m, err = Execute(r2, f, true)
 	assert.Nil(t, err)
 	assert.Empty(t, m.Labels)
 	assert.Equal(t, map[string]string{"bar": "baz"}, m.Vars, "instances should have matched")
 
 	r2.VarsTemplate = "bar"
-	_, err = Execute(r2, f)
+	_, err = Execute(r2, f, true)
 	assert.Error(t, err)
 
 	r2.VarsTemplate = "{{"
-	_, err = Execute(r2, f)
+	_, err = Execute(r2, f, true)
 	assert.Error(t, err)
 
 	//
@@ -535,7 +535,7 @@ var-2=
 		"key-5": "",
 	}
 
-	m, err = Execute(r4, f)
+	m, err = Execute(r4, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, expectedLabels, m.Labels, "instances should have matched")
 
@@ -549,7 +549,7 @@ var-2=
 		},
 	}
 
-	m, err = Execute(r4, f)
+	m, err = Execute(r4, f, true)
 	assert.Nilf(t, err, "unexpected error: %v", err)
 	assert.Equal(t, map[string]string(nil), m.Labels, "instances should have matched")
 }
diff --git a/pkg/client-nfd/compat/node-validator/node-validator_test.go b/pkg/client-nfd/compat/node-validator/node-validator_test.go
new file mode 100644
index 000000000..6977578a9
--- /dev/null
+++ b/pkg/client-nfd/compat/node-validator/node-validator_test.go
@@ -0,0 +1,418 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package nodevalidator
+
+import (
+	"context"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	compatv1alpha1 "sigs.k8s.io/node-feature-discovery/api/image-compatibility/v1alpha1"
+	"sigs.k8s.io/node-feature-discovery/api/nfd/v1alpha1"
+	artifactcli "sigs.k8s.io/node-feature-discovery/pkg/client-nfd/compat/artifact-client"
+	"sigs.k8s.io/node-feature-discovery/source"
+	"sigs.k8s.io/node-feature-discovery/source/fake"
+)
+
+func init() {
+	fs := source.GetConfigurableSource(fake.Name)
+	fs.SetConfig(fs.NewConfig())
+}
+
+func TestNodeValidator(t *testing.T) {
+	ctx := context.Background()
+
+	Convey("With a single compatibility set that contains flags, attributes and instances", t, func() {
+		spec := &compatv1alpha1.Spec{
+			Version: compatv1alpha1.Version,
+			Compatibilties: []compatv1alpha1.Compatibility{
+				{
+					Description: "Fake compatibility",
+					Rules: []v1alpha1.Rule{
+						{
+							Name: "fake_1",
+							MatchFeatures: v1alpha1.FeatureMatcher{
+								{
+									Feature:   "fake.flag",
+									MatchName: &v1alpha1.MatchExpression{Op: v1alpha1.MatchInRegexp, Value: v1alpha1.MatchValue{"^flag"}},
+								},
+							},
+						},
+						{
+							Name: "fake_2",
+							MatchFeatures: v1alpha1.FeatureMatcher{
+								{
+									Feature: "fake.flag",
+									MatchExpressions: &v1alpha1.MatchExpressionSet{
+										"flag_unknown": &v1alpha1.MatchExpression{Op: v1alpha1.MatchExists},
+									},
+								},
+								{
+									Feature: "fake.attribute",
+									MatchExpressions: &v1alpha1.MatchExpressionSet{
+										"attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"true"}},
+									},
+								},
+							},
+						},
+						{
+							Name: "fake_3",
+							MatchFeatures: v1alpha1.FeatureMatcher{
+								{
+									Feature: "fake.instance",
+									MatchExpressions: &v1alpha1.MatchExpressionSet{
+										"name":   &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_1"}},
+										"attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"true"}},
+									},
+								},
+								{
+									Feature: "fake.instance",
+									MatchExpressions: &v1alpha1.MatchExpressionSet{
+										"name":   &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_2"}},
+										"attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+									},
+								},
+							},
+						},
+						{
+							Name: "fake_4",
+							MatchAny: []v1alpha1.MatchAnyElem{
+								{
+									MatchFeatures: v1alpha1.FeatureMatcher{
+										{
+											Feature: "fake.instance",
+											MatchExpressions: &v1alpha1.MatchExpressionSet{
+												"name": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_1"}},
+											},
+										},
+									},
+								},
+								{
+									MatchFeatures: v1alpha1.FeatureMatcher{
+										{
+											Feature: "fake.instance",
+											MatchExpressions: &v1alpha1.MatchExpressionSet{
+												"name": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_unknown"}},
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		// The output contains expressions in alphabetical order over the feature, name and expression string.
+		expectedOutput := []*CompatibilityStatus{
+			{
+				Description: "Fake compatibility",
+				Rules: []ProcessedRuleStatus{
+					{
+						Name:    "fake_1",
+						IsMatch: true,
+						MatchedExpressions: []MatchedExpression{
+							{
+								Feature:     "fake.flag",
+								Name:        "",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchInRegexp, Value: v1alpha1.MatchValue{"^flag"}},
+								MatcherType: MatchNameType,
+								IsMatch:     true,
+							},
+						},
+					},
+					{
+						Name:    "fake_2",
+						IsMatch: false,
+						MatchedExpressions: []MatchedExpression{
+							{
+								Feature:     "fake.attribute",
+								Name:        "attr_1",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"true"}},
+								MatcherType: MatchExpressionType,
+								IsMatch:     true,
+							},
+							{
+								Feature:     "fake.flag",
+								Name:        "flag_unknown",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchExists},
+								MatcherType: MatchExpressionType,
+								IsMatch:     false,
+							},
+						},
+					},
+					{
+						Name:    "fake_3",
+						IsMatch: false,
+						MatchedExpressions: []MatchedExpression{
+							{
+								Feature:     "fake.instance",
+								Name:        "attr_1",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+								MatcherType: MatchExpressionType,
+								IsMatch:     false,
+							},
+							{
+								Feature:     "fake.instance",
+								Name:        "attr_1",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"true"}},
+								MatcherType: MatchExpressionType,
+								IsMatch:     true,
+							},
+							{
+								Feature:     "fake.instance",
+								Name:        "name",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_1"}},
+								MatcherType: MatchExpressionType,
+								IsMatch:     true,
+							},
+							{
+								Feature:     "fake.instance",
+								Name:        "name",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_2"}},
+								MatcherType: MatchExpressionType,
+								IsMatch:     true,
+							},
+						},
+					},
+					{
+						Name:    "fake_4",
+						IsMatch: true,
+						MatchedAny: []MatchAnyElem{
+							{
+								MatchedExpressions: []MatchedExpression{
+									{
+										Feature:     "fake.instance",
+										Name:        "name",
+										Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_1"}},
+										MatcherType: MatchExpressionType,
+										IsMatch:     true,
+									},
+								},
+							},
+							{
+								MatchedExpressions: []MatchedExpression{
+									{
+										Feature:     "fake.instance",
+										Name:        "name",
+										Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"instance_unknown"}},
+										MatcherType: MatchExpressionType,
+										IsMatch:     false,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		validator := New(
+			WithArgs(&Args{}),
+			WithArtifactClient(newMock(ctx, spec)),
+			WithSources(map[string]source.FeatureSource{fake.Name: source.GetFeatureSource(fake.Name)}),
+		)
+		output, err := validator.Execute(ctx)
+
+		So(err, ShouldBeNil)
+		So(output, ShouldEqual, expectedOutput)
+	})
+
+	Convey("With multiple compatibility sets", t, func() {
+		spec := &compatv1alpha1.Spec{
+			Version: compatv1alpha1.Version,
+			Compatibilties: []compatv1alpha1.Compatibility{
+				{
+					Tag:         "prefered",
+					Weight:      90,
+					Description: "Fake compatibility 1",
+					Rules: []v1alpha1.Rule{
+						{
+							Name: "fake_1",
+							MatchFeatures: v1alpha1.FeatureMatcher{
+								{
+									Feature: "fake.attribute",
+									MatchExpressions: &v1alpha1.MatchExpressionSet{
+										"attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+									},
+								},
+							},
+						},
+					},
+				},
+				{
+					Tag:         "fallback",
+					Weight:      40,
+					Description: "Fake compatibility 2",
+					Rules: []v1alpha1.Rule{
+						{
+							Name: "fake_1",
+							MatchFeatures: v1alpha1.FeatureMatcher{
+								{
+									Feature: "fake.attribute",
+									MatchExpressions: &v1alpha1.MatchExpressionSet{
+										"attr_2": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		expectedOutput := []*CompatibilityStatus{
+			{
+				Tag:         "prefered",
+				Weight:      90,
+				Description: "Fake compatibility 1",
+				Rules: []ProcessedRuleStatus{
+					{
+						Name:    "fake_1",
+						IsMatch: false,
+						MatchedExpressions: []MatchedExpression{
+							{
+								Feature:     "fake.attribute",
+								Name:        "attr_1",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+								MatcherType: MatchExpressionType,
+								IsMatch:     false,
+							},
+						},
+					},
+				},
+			},
+			{
+				Tag:         "fallback",
+				Weight:      40,
+				Description: "Fake compatibility 2",
+				Rules: []ProcessedRuleStatus{
+					{
+						Name:    "fake_1",
+						IsMatch: true,
+						MatchedExpressions: []MatchedExpression{
+							{
+								Feature:     "fake.attribute",
+								Name:        "attr_2",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+								MatcherType: MatchExpressionType,
+								IsMatch:     true,
+							},
+						},
+					},
+				},
+			},
+		}
+
+		validator := New(
+			WithArgs(&Args{}),
+			WithArtifactClient(newMock(ctx, spec)),
+			WithSources(map[string]source.FeatureSource{fake.Name: source.GetFeatureSource(fake.Name)}),
+		)
+		output, err := validator.Execute(ctx)
+
+		So(err, ShouldBeNil)
+		So(output, ShouldEqual, expectedOutput)
+	})
+
+	Convey("With compatibility sets filtered out by tags", t, func() {
+		spec := &compatv1alpha1.Spec{
+			Version: compatv1alpha1.Version,
+			Compatibilties: []compatv1alpha1.Compatibility{
+				{
+					Tag:         "prefered",
+					Weight:      90,
+					Description: "Fake compatibility 1",
+					Rules: []v1alpha1.Rule{
+						{
+							Name: "fake_1",
+							MatchFeatures: v1alpha1.FeatureMatcher{
+								{
+									Feature: "fake.attribute",
+									MatchExpressions: &v1alpha1.MatchExpressionSet{
+										"attr_1": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+									},
+								},
+							},
+						},
+					},
+				},
+				{
+					Tag:         "fallback",
+					Weight:      40,
+					Description: "Fake compatibility 2",
+					Rules: []v1alpha1.Rule{
+						{
+							Name: "fake_1",
+							MatchFeatures: v1alpha1.FeatureMatcher{
+								{
+									Feature: "fake.attribute",
+									MatchExpressions: &v1alpha1.MatchExpressionSet{
+										"attr_2": &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		expectedOutput := []*CompatibilityStatus{
+			{
+				Tag:         "prefered",
+				Weight:      90,
+				Description: "Fake compatibility 1",
+				Rules: []ProcessedRuleStatus{
+					{
+						Name:    "fake_1",
+						IsMatch: false,
+						MatchedExpressions: []MatchedExpression{
+							{
+								Feature:     "fake.attribute",
+								Name:        "attr_1",
+								Expression:  &v1alpha1.MatchExpression{Op: v1alpha1.MatchIn, Value: v1alpha1.MatchValue{"false"}},
+								MatcherType: MatchExpressionType,
+								IsMatch:     false,
+							},
+						},
+					},
+				},
+			},
+		}
+
+		validator := New(
+			WithArgs(&Args{
+				Tags: []string{"prefered"},
+			}),
+			WithArtifactClient(newMock(ctx, spec)),
+			WithSources(map[string]source.FeatureSource{fake.Name: source.GetFeatureSource(fake.Name)}),
+		)
+		output, err := validator.Execute(ctx)
+
+		So(err, ShouldBeNil)
+		So(output, ShouldEqual, expectedOutput)
+	})
+}
+
+func newMock(ctx context.Context, result *compatv1alpha1.Spec) *artifactcli.MockArtifactClient {
+	artifactClient := &artifactcli.MockArtifactClient{}
+	artifactClient.On("FetchCompatibilitySpec", ctx).Return(result, nil)
+	return artifactClient
+}