chore(nfd): move to ofline-install
diff --git a/atmosphere/flows.py b/atmosphere/flows.py
index ea593f3..b1613b9 100644
--- a/atmosphere/flows.py
+++ b/atmosphere/flows.py
@@ -100,37 +100,6 @@
             name=constants.NAMESPACE_MONITORING,
         ),
     ).apply()
-    objects.HelmRepository(
-        api=api,
-        metadata=types.NamespacedObjectMeta(
-            name=constants.HELM_REPOSITORY_NODE_FEATURE_DISCOVERY,
-            namespace=constants.NAMESPACE_MONITORING,
-        ),
-        spec=types.HelmRepositorySpec(
-            url="https://kubernetes-sigs.github.io/node-feature-discovery/charts",
-        ),
-    ).apply()
-    objects.HelmRelease(
-        api=api,
-        metadata=types.NamespacedObjectMeta(
-            name="node-feature-discovery",
-            namespace=constants.NAMESPACE_MONITORING,
-        ),
-        spec=types.HelmReleaseSpec(
-            chart=types.HelmChartTemplate(
-                spec=types.HelmChartTemplateSpec(
-                    chart="node-feature-discovery",
-                    version="0.11.2",
-                    source_ref=types.CrossNamespaceObjectReference(
-                        kind="HelmRepository",
-                        name=constants.HELM_REPOSITORY_NODE_FEATURE_DISCOVERY,
-                        namespace=constants.NAMESPACE_MONITORING,
-                    ),
-                )
-            ),
-            values=constants.HELM_RELEASE_NODE_FEATURE_DISCOVERY_VALUES,
-        ),
-    ).apply()
 
     objects.HelmRepository(
         api=api,
diff --git a/atmosphere/operator/constants.py b/atmosphere/operator/constants.py
index ba69c97..e61627b 100644
--- a/atmosphere/operator/constants.py
+++ b/atmosphere/operator/constants.py
@@ -104,7 +104,6 @@
     "neutron_server": "us-docker.pkg.dev/vexxhost-infra/openstack/neutron:wallaby",
     "neutron_sriov_agent_init": "us-docker.pkg.dev/vexxhost-infra/openstack/neutron:wallaby",
     "neutron_sriov_agent": "us-docker.pkg.dev/vexxhost-infra/openstack/neutron:wallaby",
-    "node_feature_discovery": "k8s.gcr.io/nfd/node-feature-discovery:v0.11.2",
     "nova_api": "quay.io/vexxhost/nova:wallaby",
     "nova_archive_deleted_rows": "quay.io/vexxhost/nova:wallaby",
     "nova_cell_setup_init": "quay.io/vexxhost/heat:zed",
diff --git a/atmosphere/tasks/constants.py b/atmosphere/tasks/constants.py
index 75b2918..a517976 100644
--- a/atmosphere/tasks/constants.py
+++ b/atmosphere/tasks/constants.py
@@ -18,7 +18,6 @@
 HELM_REPOSITORY_INGRESS_NGINX_URL = "https://kubernetes.github.io/ingress-nginx"
 
 HELM_REPOSITORY_JETSTACK = "jetstack"
-HELM_REPOSITORY_NODE_FEATURE_DISCOVERY = "node-feature-discovery"
 HELM_REPOSITORY_OPENSTACK_HELM = "openstack-helm"
 HELM_REPOSITORY_OPENSTACK_HELM_INFRA = "openstack-helm-infra"
 HELM_REPOSITORY_PERCONA = "percona"
@@ -533,15 +532,6 @@
     },
 }
 
-HELM_RELEASE_NODE_FEATURE_DISCOVERY_VALUES = {
-    "image": {
-        "repository": utils.get_image_ref_using_legacy_image_repository(
-            "node_feature_discovery"
-        )["name"]
-    },
-    "master": {"nodeSelector": NODE_SELECTOR_CONTROL_PLANE},
-}
-
 HELM_RELEASE_RABBITMQ_OPERATOR_NAME = "rabbitmq-cluster-operator"
 HELM_RELEASE_RABBITMQ_OPERATOR_VERSION = "2.6.6"
 HELM_RELEASE_RABBITMQ_OPERATOR_VALUES = {
diff --git a/playbooks/openstack.yml b/playbooks/openstack.yml
index bbd0b6b..6e0798c 100644
--- a/playbooks/openstack.yml
+++ b/playbooks/openstack.yml
@@ -44,6 +44,10 @@
       tags:
         - percona-xtradb-cluster
 
+    - role: node_feature_discovery
+      tags:
+        - node-feature-discovery
+
     - role: kube_prometheus_stack
       tags:
         - kube-prometheus-stack
diff --git a/roles/node_feature_discovery/README.md b/roles/node_feature_discovery/README.md
new file mode 100644
index 0000000..abb0a63
--- /dev/null
+++ b/roles/node_feature_discovery/README.md
@@ -0,0 +1 @@
+# `node_feature_discovery`
diff --git a/roles/node_feature_discovery/defaults/main.yml b/roles/node_feature_discovery/defaults/main.yml
new file mode 100644
index 0000000..44276a6
--- /dev/null
+++ b/roles/node_feature_discovery/defaults/main.yml
@@ -0,0 +1,20 @@
+# Copyright (c) 2023 VEXXHOST, Inc.
+#
+# 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.
+
+node_feature_discovery_helm_release_name: node-feature-discovery
+node_feature_discovery_helm_chart_path: "{{ role_path }}/../../charts/node-feature-discovery/"
+node_feature_discovery_helm_chart_ref: /usr/local/src/node-feature-discovery
+
+node_feature_discovery_helm_release_namespace: openstack
+node_feature_discovery_helm_values: {}
diff --git a/roles/node_feature_discovery/meta/main.yml b/roles/node_feature_discovery/meta/main.yml
new file mode 100644
index 0000000..1f4ab15
--- /dev/null
+++ b/roles/node_feature_discovery/meta/main.yml
@@ -0,0 +1,31 @@
+# Copyright (c) 2023 VEXXHOST, Inc.
+#
+# 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.
+
+galaxy_info:
+  author: VEXXHOST, Inc.
+  description: Ansible role for Node Feature Discovery
+  license: Apache-2.0
+  min_ansible_version: 5.5.0
+  standalone: false
+  platforms:
+    - name: Ubuntu
+      versions:
+        - focal
+
+dependencies:
+  - role: defaults
+  - role: upload_helm_chart
+    vars:
+      upload_helm_chart_src: "{{ node_feature_discovery_helm_chart_path }}"
+      upload_helm_chart_dest: "{{ node_feature_discovery_helm_chart_ref }}"
diff --git a/roles/node_feature_discovery/tasks/main.yml b/roles/node_feature_discovery/tasks/main.yml
new file mode 100644
index 0000000..3e7a2dc
--- /dev/null
+++ b/roles/node_feature_discovery/tasks/main.yml
@@ -0,0 +1,48 @@
+# Copyright (c) 2023 VEXXHOST, Inc.
+#
+# 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.
+
+# NOTE(mnaser): We should get rid of this task eventually as it is suspending
+#               the old HelmRelease and removing it to avoid uninstalling the
+#               Helm chart.
+- name: Uninstall the legacy HelmRelease
+  run_once: true
+  block:
+    - name: Suspend the existing HelmRelease
+      kubernetes.core.k8s:
+        state: patched
+        api_version: helm.toolkit.fluxcd.io/v2beta1
+        kind: HelmRelease
+        name: "{{ node_feature_discovery_helm_release_name }}"
+        namespace: "{{ node_feature_discovery_helm_release_namespace }}"
+        definition:
+          spec:
+            suspend: true
+
+    - name: Remove the existing HelmRelease
+      kubernetes.core.k8s:
+        state: absent
+        api_version: helm.toolkit.fluxcd.io/v2beta1
+        kind: HelmRelease
+        name: "{{ node_feature_discovery_helm_release_name }}"
+        namespace: "{{ node_feature_discovery_helm_release_namespace }}"
+
+- name: Deploy Helm chart
+  run_once: true
+  kubernetes.core.helm:
+    name: "{{ node_feature_discovery_helm_release_name }}"
+    chart_ref: "{{ node_feature_discovery_helm_chart_ref }}"
+    release_namespace: "{{ node_feature_discovery_helm_release_namespace }}"
+    create_namespace: true
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _node_feature_discovery_helm_values | combine(node_feature_discovery_helm_values, recursive=True) }}"
diff --git a/roles/node_feature_discovery/vars/main.yml b/roles/node_feature_discovery/vars/main.yml
new file mode 100644
index 0000000..2fc0a65
--- /dev/null
+++ b/roles/node_feature_discovery/vars/main.yml
@@ -0,0 +1,18 @@
+# Copyright (c) 2023 VEXXHOST, Inc.
+#
+# 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.
+
+_node_feature_discovery_helm_values:
+  image: "{{ atmosphere_images['node_feature_discovery'] | vexxhost.atmosphere.docker_image('ref') }}"
+  nodeSelector:
+    openstack-control-plane: enabled