diff --git a/app/models/concerns/hostext/search.rb b/app/models/concerns/hostext/search.rb
index 0848fb61db0..03390acc5fa 100644
--- a/app/models/concerns/hostext/search.rb
+++ b/app/models/concerns/hostext/search.rb
@@ -88,6 +88,14 @@ module Search
scoped_search :relation => :reported_data, :on => :bios_vendor, :rename => 'reported.bios_vendor'
scoped_search :relation => :reported_data, :on => :bios_release_date, :rename => 'reported.bios_release_date'
scoped_search :relation => :reported_data, :on => :bios_version, :rename => 'reported.bios_version'
+ scoped_search :relation => :reported_data, :on => :cloud_provider, :rename => 'reported.cloud_provider', :complete_value => true
+ scoped_search :relation => :reported_data, :on => :aws_account_id, :rename => 'reported.aws_account_id', :only_explicit => true
+ scoped_search :relation => :reported_data, :on => :aws_region, :rename => 'reported.aws_region', :only_explicit => true
+ scoped_search :relation => :reported_data, :on => :aws_instance_id, :rename => 'reported.aws_instance_id', :only_explicit => true
+ scoped_search :relation => :reported_data, :on => :azure_subscription_id, :rename => 'reported.azure_subscription_id', :only_explicit => true
+ scoped_search :relation => :reported_data, :on => :azure_instance_id, :rename => 'reported.azure_instance_id', :only_explicit => true
+ scoped_search :relation => :reported_data, :on => :gcp_project_id, :rename => 'reported.gcp_project_id', :only_explicit => true
+ scoped_search :relation => :reported_data, :on => :gcp_instance_id, :rename => 'reported.gcp_instance_id', :only_explicit => true
scoped_search :relation => :location, :on => :title, :rename => :location, :complete_value => true, :only_explicit => true
scoped_search :on => :location_id, :complete_enabled => false, :only_explicit => true, :validator => ScopedSearch::Validators::INTEGER
diff --git a/app/models/host_facets/reported_data_facet.rb b/app/models/host_facets/reported_data_facet.rb
index c772fa71209..96e7918c7ad 100644
--- a/app/models/host_facets/reported_data_facet.rb
+++ b/app/models/host_facets/reported_data_facet.rb
@@ -13,6 +13,22 @@ def self.populate_fields_from_facts(host, parser, type, source_proxy)
bios_vendor: parser.bios[:vendor],
bios_release_date: parser.bios[:release_date],
bios_version: parser.bios[:version],
+ cloud_provider: parser.cloud_provider,
+ aws_account_id: parser.aws_account_id,
+ aws_billing_products: parser.aws_billing_products,
+ aws_instance_id: parser.aws_instance_id,
+ aws_instance_type: parser.aws_instance_type,
+ aws_marketplace_product_codes: parser.aws_marketplace_product_codes,
+ aws_region: parser.aws_region,
+ azure_instance_id: parser.azure_instance_id,
+ azure_offer: parser.azure_offer,
+ azure_sku: parser.azure_sku,
+ azure_subscription_id: parser.azure_subscription_id,
+ gcp_instance_id: parser.gcp_instance_id,
+ gcp_license_codes: parser.gcp_license_codes,
+ gcp_project_id: parser.gcp_project_id,
+ gcp_project_number: parser.gcp_project_number,
+ gcp_zone: parser.gcp_zone,
}.compact
facet.save if facet.changed?
end
diff --git a/app/services/ansible_fact_parser.rb b/app/services/ansible_fact_parser.rb
index 5b96ad2359f..730ef6ab6c3 100644
--- a/app/services/ansible_fact_parser.rb
+++ b/app/services/ansible_fact_parser.rb
@@ -98,6 +98,74 @@ def bios
facts.dig('facter_dmi', 'bios') || {}
end
+ # Cloud provider identifier
+ def cloud_provider
+ facts['cloud_provider']
+ end
+
+ # AWS cloud billing fields
+ def aws_account_id
+ facts['aws_account_id']
+ end
+
+ def aws_billing_products
+ facts['aws_billing_products']
+ end
+
+ def aws_instance_id
+ facts['aws_instance_id']
+ end
+
+ def aws_instance_type
+ facts['aws_instance_type']
+ end
+
+ def aws_marketplace_product_codes
+ facts['aws_marketplace_product_codes']
+ end
+
+ def aws_region
+ facts['aws_region']
+ end
+
+ # Azure cloud billing fields
+ def azure_instance_id
+ facts['azure_instance_id']
+ end
+
+ def azure_offer
+ facts['azure_offer']
+ end
+
+ def azure_sku
+ facts['azure_sku']
+ end
+
+ def azure_subscription_id
+ facts['azure_subscription_id']
+ end
+
+ # GCP cloud billing fields
+ def gcp_instance_id
+ facts['gcp_instance_id']
+ end
+
+ def gcp_license_codes
+ facts['gcp_license_codes']
+ end
+
+ def gcp_project_id
+ facts['gcp_project_id']
+ end
+
+ def gcp_project_number
+ facts['gcp_project_number']
+ end
+
+ def gcp_zone
+ facts['gcp_zone']
+ end
+
private
def ansible_interfaces
diff --git a/app/services/fact_parser.rb b/app/services/fact_parser.rb
index 89cdd90e682..87c838c4be4 100644
--- a/app/services/fact_parser.rb
+++ b/app/services/fact_parser.rb
@@ -134,6 +134,54 @@ def bios
{}
end
+ # AWS cloud billing fields
+ def aws_account_id
+ end
+
+ def aws_billing_products
+ end
+
+ def aws_instance_id
+ end
+
+ def aws_instance_type
+ end
+
+ def aws_marketplace_product_codes
+ end
+
+ def aws_region
+ end
+
+ # Azure cloud billing fields
+ def azure_instance_id
+ end
+
+ def azure_offer
+ end
+
+ def azure_sku
+ end
+
+ def azure_subscription_id
+ end
+
+ # GCP cloud billing fields
+ def gcp_instance_id
+ end
+
+ def gcp_license_codes
+ end
+
+ def gcp_project_id
+ end
+
+ def gcp_project_number
+ end
+
+ def gcp_zone
+ end
+
private
def find_interface_by_name(host_name)
diff --git a/app/services/katello/rhsm_fact_parser.rb b/app/services/katello/rhsm_fact_parser.rb
index f2f63a7bf85..bb2fe1b1738 100644
--- a/app/services/katello/rhsm_fact_parser.rb
+++ b/app/services/katello/rhsm_fact_parser.rb
@@ -131,6 +131,74 @@ def bios
}
end
+ # Cloud provider identifier
+ def cloud_provider
+ facts['cloud_provider']
+ end
+
+ # AWS cloud billing fields
+ def aws_account_id
+ facts['aws_account_id']
+ end
+
+ def aws_billing_products
+ facts['aws_billing_products']
+ end
+
+ def aws_instance_id
+ facts['aws_instance_id']
+ end
+
+ def aws_instance_type
+ facts['aws_instance_type']
+ end
+
+ def aws_marketplace_product_codes
+ facts['aws_marketplace_product_codes']
+ end
+
+ def aws_region
+ facts['aws_region']
+ end
+
+ # Azure cloud billing fields
+ def azure_instance_id
+ facts['azure_instance_id']
+ end
+
+ def azure_offer
+ facts['azure_offer']
+ end
+
+ def azure_sku
+ facts['azure_sku']
+ end
+
+ def azure_subscription_id
+ facts['azure_subscription_id']
+ end
+
+ # GCP cloud billing fields
+ def gcp_instance_id
+ facts['gcp_instance_id']
+ end
+
+ def gcp_license_codes
+ facts['gcp_license_codes']
+ end
+
+ def gcp_project_id
+ facts['gcp_project_id']
+ end
+
+ def gcp_project_number
+ facts['gcp_project_number']
+ end
+
+ def gcp_zone
+ facts['gcp_zone']
+ end
+
private
def get_rhsm_ip(interface)
diff --git a/app/services/puppet_fact_parser.rb b/app/services/puppet_fact_parser.rb
index b87d8cf778d..df04a64cae5 100644
--- a/app/services/puppet_fact_parser.rb
+++ b/app/services/puppet_fact_parser.rb
@@ -166,6 +166,83 @@ def bios
{:vendor => facts.dig('dmi', 'bios', 'vendor') || facts['bios_vendor'], :version => facts.dig('dmi', 'bios', 'version') || facts['bios_version'], :release_date => facts.dig('dmi', 'bios', 'release_date') || facts['bios_release_date']}
end
+ # Cloud provider identifier
+ def cloud_provider
+ facts.dig('cloud', 'provider') || facts['cloud_provider']
+ end
+
+ # AWS cloud billing fields
+ def aws_account_id
+ facts.dig('ec2_metadata', 'account-id') || facts.dig('ec2', 'metadata', 'account-id') || facts['aws_account_id']
+ end
+
+ def aws_billing_products
+ facts.dig('ec2_metadata', 'billing-products') || facts.dig('ec2', 'metadata', 'billing-products') || facts['aws_billing_products']
+ end
+
+ def aws_instance_id
+ facts.dig('ec2_metadata', 'instance-id') || facts.dig('ec2', 'metadata', 'instance-id') || facts['aws_instance_id']
+ end
+
+ def aws_instance_type
+ facts.dig('ec2_metadata', 'instance-type') || facts.dig('ec2', 'metadata', 'instance-type') || facts['aws_instance_type']
+ end
+
+ def aws_marketplace_product_codes
+ # marketplace-product-codes is an array in Facter, convert to comma-separated string
+ codes = facts.dig('ec2_metadata', 'marketplace-product-codes') || facts.dig('ec2', 'metadata', 'marketplace-product-codes')
+ codes = codes.join(',') if codes.is_a?(Array)
+ codes || facts['aws_marketplace_product_codes']
+ end
+
+ def aws_region
+ facts.dig('ec2_metadata', 'placement', 'region') || facts.dig('ec2', 'metadata', 'placement', 'region') || facts['aws_region']
+ end
+
+ # Azure cloud billing fields
+ def azure_instance_id
+ facts.dig('az_metadata', 'compute', 'vmId') || facts.dig('azure', 'metadata', 'compute', 'vmId') || facts['azure_instance_id']
+ end
+
+ def azure_offer
+ facts.dig('az_metadata', 'compute', 'offer') || facts.dig('azure', 'metadata', 'compute', 'offer') || facts['azure_offer']
+ end
+
+ def azure_sku
+ facts.dig('az_metadata', 'compute', 'sku') || facts.dig('azure', 'metadata', 'compute', 'sku') || facts['azure_sku']
+ end
+
+ def azure_subscription_id
+ facts.dig('az_metadata', 'compute', 'subscriptionId') || facts.dig('azure', 'metadata', 'compute', 'subscriptionId') || facts['azure_subscription_id']
+ end
+
+ # GCP cloud billing fields
+ def gcp_instance_id
+ facts.dig('gce', 'instance', 'id') || facts['gcp_instance_id']
+ end
+
+ def gcp_license_codes
+ # licenses is an array of objects with 'id' field in Facter, convert to comma-separated string
+ licenses = facts.dig('gce', 'instance', 'licenses')
+ if licenses.is_a?(Array)
+ licenses.map { |l| l.is_a?(Hash) ? l['id'] : l }.compact.join(',')
+ else
+ licenses || facts['gcp_license_codes']
+ end
+ end
+
+ def gcp_project_id
+ facts.dig('gce', 'project', 'projectId') || facts['gcp_project_id']
+ end
+
+ def gcp_project_number
+ facts.dig('gce', 'project', 'numericProjectId') || facts['gcp_project_number']
+ end
+
+ def gcp_zone
+ facts.dig('gce', 'zone') || facts['gcp_zone']
+ end
+
private
# remove when dropping support for facter < 3.0
diff --git a/app/views/api/v2/hosts/reported_data.json.rabl b/app/views/api/v2/hosts/reported_data.json.rabl
index 518e75938e0..8ff4bd95b97 100644
--- a/app/views/api/v2/hosts/reported_data.json.rabl
+++ b/app/views/api/v2/hosts/reported_data.json.rabl
@@ -3,5 +3,8 @@ glue(@facet) do
end
child(@facet => :reported_data) do
- attributes :boot_time, :cores, :sockets, :ram, :disks_total, :kernel_version, :bios_vendor, :bios_release_date, :bios_version, :virtual
+ attributes :boot_time, :cores, :sockets, :ram, :disks_total, :kernel_version, :bios_vendor, :bios_release_date, :bios_version, :virtual,
+ :aws_account_id, :aws_billing_products, :aws_instance_id, :aws_instance_type, :aws_marketplace_product_codes, :aws_region,
+ :azure_instance_id, :azure_offer, :azure_sku, :azure_subscription_id,
+ :gcp_instance_id, :gcp_license_codes, :gcp_project_id, :gcp_project_number, :gcp_zone
end
diff --git a/db/migrate/20251113120000_add_cloud_provider_to_reported_data.rb b/db/migrate/20251113120000_add_cloud_provider_to_reported_data.rb
new file mode 100644
index 00000000000..3d1efeb00e0
--- /dev/null
+++ b/db/migrate/20251113120000_add_cloud_provider_to_reported_data.rb
@@ -0,0 +1,29 @@
+class AddCloudProviderToReportedData < ActiveRecord::Migration[6.1]
+ def change
+ change_table(:host_facets_reported_data_facets) do |t|
+ # Cloud provider identifier
+ t.column :cloud_provider, :string, :limit => 255
+
+ # AWS fields
+ t.column :aws_account_id, :string, :limit => 255
+ t.column :aws_billing_products, :string, :limit => 255
+ t.column :aws_instance_id, :string, :limit => 255
+ t.column :aws_instance_type, :string, :limit => 255
+ t.column :aws_marketplace_product_codes, :string, :limit => 255
+ t.column :aws_region, :string, :limit => 255
+
+ # Azure fields
+ t.column :azure_instance_id, :string, :limit => 255
+ t.column :azure_offer, :string, :limit => 255
+ t.column :azure_sku, :string, :limit => 255
+ t.column :azure_subscription_id, :string, :limit => 255
+
+ # GCP fields
+ t.column :gcp_instance_id, :string, :limit => 255
+ t.column :gcp_license_codes, :string, :limit => 255
+ t.column :gcp_project_id, :string, :limit => 255
+ t.column :gcp_project_number, :string, :limit => 255
+ t.column :gcp_zone, :string, :limit => 255
+ end
+ end
+end
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/CardRegistry.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/CardRegistry.js
index 5e542fcb7d7..4a4482167f9 100644
--- a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/CardRegistry.js
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/CardRegistry.js
@@ -7,12 +7,14 @@ import NetworkingInterfaces from '../Details/Cards/NetworkingInterfaces';
import TemplatesCard from '../Details/Cards/TemplatesCard';
import ProvisioningCard from '../Details/Cards/Provisioning';
import VirtualizationCard from '../Details/Cards/Virtualization';
+import CloudProviderCard from '../Details/Cards/CloudProvider';
const cards = [
{ key: '[core] System properties', Component: Properties, weight: 4000 },
{ key: '[core] Operating systems', Component: OperatingSystem, weight: 3000 },
{ key: '[core] Provisioning', Component: ProvisioningCard, weight: 2900 },
{ key: '[core] BIOS', Component: Bios, weight: 2000 },
+ { key: '[core] Cloud Provider', Component: CloudProviderCard, weight: 1500 },
{ key: '[core] Virtualization', Component: VirtualizationCard, weight: 1000 },
{
key: '[core] Templates',
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderAws.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderAws.js
new file mode 100644
index 00000000000..6f8b09ea2c9
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderAws.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { DescriptionList } from '@patternfly/react-core';
+import { translate as __ } from '../../../../../../common/I18n';
+import { propsToCamelCase } from '../../../../../../common/helpers';
+import CloudProviderDetailItem from './CloudProviderDetailItem';
+
+const CloudProviderAws = ({ data }) => {
+ // Extract AWS facts
+ const {
+ awsAccountId,
+ awsBillingProducts,
+ awsInstanceId,
+ awsInstanceType,
+ awsMarketplaceProductCodes: awsMarketplaceProducts,
+ awsRegion,
+ } = propsToCamelCase(data || {});
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+CloudProviderAws.propTypes = {
+ data: PropTypes.shape({
+ aws_account_id: PropTypes.string,
+ aws_billing_products: PropTypes.string,
+ aws_instance_id: PropTypes.string,
+ aws_instance_type: PropTypes.string,
+ aws_marketplace_product_codes: PropTypes.string,
+ aws_region: PropTypes.string,
+ }),
+};
+
+CloudProviderAws.defaultProps = {
+ data: {},
+};
+
+export default CloudProviderAws;
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderAzure.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderAzure.js
new file mode 100644
index 00000000000..d694079d12e
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderAzure.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { DescriptionList } from '@patternfly/react-core';
+import { translate as __ } from '../../../../../../common/I18n';
+import { propsToCamelCase } from '../../../../../../common/helpers';
+import CloudProviderDetailItem from './CloudProviderDetailItem';
+
+const CloudProviderAzure = ({ data }) => {
+ // Extract Azure facts
+ const {
+ azureInstanceId,
+ azureOffer,
+ azureSku,
+ azureSubscriptionId,
+ } = propsToCamelCase(data || {});
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+CloudProviderAzure.propTypes = {
+ data: PropTypes.shape({
+ azure_instance_id: PropTypes.string,
+ azure_offer: PropTypes.string,
+ azure_sku: PropTypes.string,
+ azure_subscription_id: PropTypes.string,
+ }),
+};
+
+CloudProviderAzure.defaultProps = {
+ data: {},
+};
+
+export default CloudProviderAzure;
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderDetailItem.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderDetailItem.js
new file mode 100644
index 00000000000..18c7ee2da73
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderDetailItem.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+ DescriptionListTerm as Dt,
+ DescriptionListGroup,
+ DescriptionListDescription as Dd,
+} from '@patternfly/react-core';
+
+const CloudProviderDetailItem = ({ label, value }) => {
+ if (value === null || value === undefined || value === '') return null;
+
+ return (
+
+ {label}
+ {value}
+
+ );
+};
+
+CloudProviderDetailItem.propTypes = {
+ label: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+};
+
+CloudProviderDetailItem.defaultProps = {
+ value: undefined,
+};
+
+export default CloudProviderDetailItem;
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderGcp.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderGcp.js
new file mode 100644
index 00000000000..21dcbfe8822
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/CloudProviderGcp.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { DescriptionList } from '@patternfly/react-core';
+import { translate as __ } from '../../../../../../common/I18n';
+import { propsToCamelCase } from '../../../../../../common/helpers';
+import CloudProviderDetailItem from './CloudProviderDetailItem';
+
+const CloudProviderGcp = ({ data }) => {
+ // Extract GCP facts
+ const {
+ gcpInstanceId,
+ gcpLicenseCodes,
+ gcpProjectId,
+ gcpProjectNumber,
+ gcpZone,
+ } = propsToCamelCase(data || {});
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+CloudProviderGcp.propTypes = {
+ data: PropTypes.shape({
+ gcp_instance_id: PropTypes.string,
+ gcp_license_codes: PropTypes.string,
+ gcp_project_id: PropTypes.string,
+ gcp_project_number: PropTypes.string,
+ gcp_zone: PropTypes.string,
+ }),
+};
+
+CloudProviderGcp.defaultProps = {
+ data: {},
+};
+
+export default CloudProviderGcp;
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProvider.fixtures.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProvider.fixtures.js
new file mode 100644
index 00000000000..40e30b06731
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProvider.fixtures.js
@@ -0,0 +1,108 @@
+// AWS billing data fixtures
+export const awsBillingDataComplete = {
+ cloud_provider: 'aws',
+ aws_account_id: '340706125509',
+ aws_billing_products: 'bp-6fa54006',
+ aws_instance_id: 'i-07283719ca51ec39e',
+ aws_instance_type: 't3.micro',
+ aws_marketplace_product_codes: 'marketplace-code-123',
+ aws_region: 'us-east-2',
+};
+
+export const awsBillingDataPartial = {
+ cloud_provider: 'aws',
+ aws_account_id: '340706125509',
+ aws_instance_type: 't3.micro',
+ aws_region: 'us-east-2',
+};
+
+export const awsBillingDataMinimal = {
+ cloud_provider: 'aws',
+ aws_account_id: '340706125509',
+};
+
+// GCP billing data fixtures
+export const gcpBillingDataComplete = {
+ cloud_provider: 'gcp',
+ gcp_instance_id: '1301731395666156002',
+ gcp_license_codes: '7883559014960410759',
+ gcp_project_id: 'sat-eng',
+ gcp_project_number: '1082392642010',
+ gcp_zone: 'us-central1-a',
+};
+
+export const gcpBillingDataMinimal = {
+ cloud_provider: 'gcp',
+ gcp_license_codes: '7883559014960410759',
+};
+
+// Azure billing data fixtures
+export const azureBillingDataComplete = {
+ cloud_provider: 'azure',
+ azure_instance_id: 'cc6e8455-3523-4d98-8445-39dd17403a2a',
+ azure_offer: 'RHEL',
+ azure_sku: '94_gen2',
+ azure_subscription_id: 'ace931ad-539b-4ce1-b392-a7e60a28cc94',
+};
+
+export const azureBillingDataMinimal = {
+ cloud_provider: 'azure',
+ azure_subscription_id: 'ace931ad-539b-4ce1-b392-a7e60a28cc94',
+};
+
+// Host details fixtures
+export const hostDetailsAws = {
+ id: 1,
+ compute_resource_provider: 'ec2',
+ reported_data: {},
+};
+
+export const hostDetailsGcp = {
+ id: 2,
+ compute_resource_provider: 'gce',
+ reported_data: {},
+};
+
+export const hostDetailsAzure = {
+ id: 3,
+ compute_resource_provider: 'azurerm',
+ reported_data: {},
+};
+
+export const hostDetailsNonCloud = {
+ id: 4,
+ compute_resource_provider: 'libvirt',
+ reported_data: {},
+};
+
+export const hostDetailsNoProvider = {
+ id: 5,
+ compute_resource_provider: null,
+ reported_data: {},
+};
+
+// Host details without compute_resource_provider (for reported_data-based detection)
+export const hostDetailsWithoutProvider = {
+ id: 6,
+ reported_data: {},
+};
+
+// Host details with reported_data containing billing data
+export const hostDetailsAwsWithFacts = {
+ id: 7,
+ compute_resource_provider: 'ec2',
+ reported_data: awsBillingDataComplete,
+};
+
+export const hostDetailsGcpWithFacts = {
+ id: 8,
+ compute_resource_provider: 'gce',
+ reported_data: gcpBillingDataComplete,
+};
+
+export const hostDetailsAzureWithFacts = {
+ id: 9,
+ compute_resource_provider: 'azurerm',
+ reported_data: azureBillingDataComplete,
+};
+
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderAws.test.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderAws.test.js
new file mode 100644
index 00000000000..e299ad2016f
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderAws.test.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import CloudProviderAws from '../CloudProviderAws';
+import {
+ awsBillingDataComplete,
+ awsBillingDataPartial,
+ awsBillingDataMinimal,
+} from './CloudProvider.fixtures';
+
+describe('CloudProviderAws', () => {
+ it('renders all AWS billing fields when complete data is provided', () => {
+ render();
+
+ // Check all labels are present
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ expect(screen.getByText('AWS Billing Product')).toBeInTheDocument();
+ expect(screen.getByText('AWS Instance ID')).toBeInTheDocument();
+ expect(screen.getByText('AWS Instance Type')).toBeInTheDocument();
+ expect(screen.getByText('AWS Marketplace Product')).toBeInTheDocument();
+ expect(screen.getByText('AWS Region')).toBeInTheDocument();
+
+ // Check all values are present
+ expect(screen.getByText('340706125509')).toBeInTheDocument();
+ expect(screen.getByText('bp-6fa54006')).toBeInTheDocument();
+ expect(screen.getByText('i-07283719ca51ec39e')).toBeInTheDocument();
+ expect(screen.getByText('t3.micro')).toBeInTheDocument();
+ expect(screen.getByText('marketplace-code-123')).toBeInTheDocument();
+ expect(screen.getByText('us-east-2')).toBeInTheDocument();
+ });
+
+ it('only renders fields with values when partial data is provided', () => {
+ render();
+
+ // Should render these fields
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ expect(screen.getByText('AWS Instance Type')).toBeInTheDocument();
+ expect(screen.getByText('AWS Region')).toBeInTheDocument();
+
+ // Should not render these fields (no data)
+ expect(screen.queryByText('AWS Billing Product')).not.toBeInTheDocument();
+ expect(screen.queryByText('AWS Instance ID')).not.toBeInTheDocument();
+ expect(screen.queryByText('AWS Marketplace Product')).not.toBeInTheDocument();
+ });
+
+ it('renders minimal data correctly', () => {
+ render();
+
+ // Should render only account ID
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ expect(screen.getByText('340706125509')).toBeInTheDocument();
+
+ // Should not render other fields
+ expect(screen.queryByText('AWS Billing Product')).not.toBeInTheDocument();
+ expect(screen.queryByText('AWS Instance ID')).not.toBeInTheDocument();
+ expect(screen.queryByText('AWS Instance Type')).not.toBeInTheDocument();
+ expect(screen.queryByText('AWS Marketplace Product')).not.toBeInTheDocument();
+ expect(screen.queryByText('AWS Region')).not.toBeInTheDocument();
+ });
+
+ it('renders DescriptionList container even with no data', () => {
+ const { container } = render();
+
+ // Component renders but has no visible content
+ expect(container.firstChild).not.toBeNull();
+ });
+
+ it('renders nothing when data is null', () => {
+ render();
+
+ // No AWS fields should be visible
+ expect(screen.queryByText('AWS Account ID')).not.toBeInTheDocument();
+ expect(screen.queryByText('AWS Instance Type')).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when data is undefined', () => {
+ render();
+
+ // No AWS fields should be visible
+ expect(screen.queryByText('AWS Account ID')).not.toBeInTheDocument();
+ expect(screen.queryByText('AWS Instance Type')).not.toBeInTheDocument();
+ });
+
+ it('renders all fields in compact layout', () => {
+ render();
+
+ // Verify all expected fields are rendered
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ expect(screen.getByText('AWS Region')).toBeInTheDocument();
+ expect(screen.getByText('AWS Instance Type')).toBeInTheDocument();
+ });
+
+ it('handles camelCase conversion from snake_case', () => {
+ const snakeCaseData = {
+ aws_account_id: '123456789',
+ aws_instance_type: 't2.micro',
+ };
+
+ render();
+
+ // propsToCamelCase should convert snake_case to camelCase
+ expect(screen.getByText('123456789')).toBeInTheDocument();
+ expect(screen.getByText('t2.micro')).toBeInTheDocument();
+ });
+});
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderCard.test.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderCard.test.js
new file mode 100644
index 00000000000..1444a5567d2
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderCard.test.js
@@ -0,0 +1,391 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import CloudProviderCard from '../index';
+import { CardExpansionContext } from '../../../../../CardExpansionContext';
+
+// Helper to wrap component with required context
+const renderWithContext = (component) => {
+ const mockContextValue = {
+ cardExpandStates: {
+ 'AWS details': true,
+ 'Azure details': true,
+ 'GCP details': true,
+ 'Cloud provider details': true,
+ }, // Expand the card
+ dispatch: jest.fn(),
+ registerCard: jest.fn(),
+ };
+
+ return render(
+
+ {component}
+
+ );
+};
+import {
+ hostDetailsAws,
+ hostDetailsGcp,
+ hostDetailsAzure,
+ hostDetailsNonCloud,
+ hostDetailsNoProvider,
+ hostDetailsWithoutProvider,
+ hostDetailsAwsWithFacts,
+ hostDetailsGcpWithFacts,
+ hostDetailsAzureWithFacts,
+} from './CloudProvider.fixtures';
+
+describe('CloudProviderCard', () => {
+ const baseProps = {};
+
+ describe('Required Props Validation', () => {
+ it('does not render when reported_data is missing', () => {
+ const { container } = renderWithContext(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('does not render when reported_data is empty', () => {
+ const { container } = renderWithContext(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ describe('Provider Detection', () => {
+ it('renders for AWS/EC2 provider with billing data', () => {
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('AWS details')).toBeInTheDocument();
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ });
+
+ it('renders for GCP/gce provider with billing data', () => {
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('GCP details')).toBeInTheDocument();
+ expect(screen.getByText('GCP License Code')).toBeInTheDocument();
+ });
+
+ it('renders for Azure/azurerm provider with billing data', () => {
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('Azure details')).toBeInTheDocument();
+ expect(screen.getByText('Azure Subscription ID')).toBeInTheDocument();
+ });
+
+ it('does not render for non-cloud providers', () => {
+ const { container} = renderWithContext(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('detects AWS provider from reported_data when compute_resource_provider is not present', () => {
+ const hostWithoutProvider = {
+ reported_data: hostDetailsAwsWithFacts.reported_data,
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('AWS details')).toBeInTheDocument();
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ });
+
+ it('detects GCP provider from reported_data when compute_resource_provider is not present', () => {
+ const hostWithoutProvider = {
+ reported_data: hostDetailsGcpWithFacts.reported_data,
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('GCP details')).toBeInTheDocument();
+ expect(screen.getByText('GCP Project ID')).toBeInTheDocument();
+ });
+
+ it('detects Azure provider from reported_data when compute_resource_provider is not present', () => {
+ const hostWithoutProvider = {
+ reported_data: hostDetailsAzureWithFacts.reported_data,
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('Azure details')).toBeInTheDocument();
+ expect(screen.getByText('Azure Subscription ID')).toBeInTheDocument();
+ });
+
+ it('detects GCP provider when neither cloud_provider nor compute_resource_provider is set', () => {
+ const hostWithOnlyBillingData = {
+ compute_resource_provider: null,
+ reported_data: {
+ gcp_license_codes: '7883559014960410759',
+ gcp_project_id: 'my-gcp-project',
+ },
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('GCP details')).toBeInTheDocument();
+ expect(screen.getByText('GCP License Code')).toBeInTheDocument();
+ });
+
+ it('detects AWS provider when neither cloud_provider nor compute_resource_provider is set', () => {
+ const hostWithOnlyBillingData = {
+ compute_resource_provider: null,
+ reported_data: {
+ aws_account_id: '123456789',
+ aws_region: 'us-east-1',
+ },
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('AWS details')).toBeInTheDocument();
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ });
+
+ it('detects Azure provider when neither cloud_provider nor compute_resource_provider is set', () => {
+ const hostWithOnlyBillingData = {
+ compute_resource_provider: null,
+ reported_data: {
+ azure_subscription_id: 'sub-123',
+ azure_offer: 'RHEL',
+ },
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('Azure details')).toBeInTheDocument();
+ expect(screen.getByText('Azure Subscription ID')).toBeInTheDocument();
+ });
+ });
+
+
+ describe('Billing Data Detection', () => {
+ it('does not render when no billing data is available for AWS', () => {
+ const hostWithoutBillingData = {
+ compute_resource_provider: 'ec2',
+ reported_data: {},
+ };
+
+ const { container } = renderWithContext(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('does not render when no billing data is available for GCP', () => {
+ const hostWithoutBillingData = {
+ compute_resource_provider: 'gce',
+ reported_data: {},
+ };
+
+ const { container } = renderWithContext(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('does not render when no billing data is available for Azure', () => {
+ const hostWithoutBillingData = {
+ compute_resource_provider: 'azurerm',
+ reported_data: {},
+ };
+
+ const { container } = renderWithContext(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders when any AWS billing field is present in reported_data', () => {
+ const hostWithMinimalData = {
+ compute_resource_provider: 'ec2',
+ reported_data: { aws_account_id: '123456789' },
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('AWS details')).toBeInTheDocument();
+ });
+
+ it('renders when any GCP billing field is present in reported_data', () => {
+ const hostWithMinimalData = {
+ compute_resource_provider: 'gce',
+ reported_data: { gcp_license_codes: '12345' },
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('GCP details')).toBeInTheDocument();
+ });
+
+ it('renders when any Azure billing field is present in reported_data', () => {
+ const hostWithMinimalData = {
+ compute_resource_provider: 'azurerm',
+ reported_data: { azure_subscription_id: 'sub-123' },
+ };
+
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('Azure details')).toBeInTheDocument();
+ });
+ });
+
+
+ describe('Card Rendering', () => {
+ it('renders card with expandable layout', () => {
+ renderWithContext(
+
+ );
+
+ // Card should render with header and content
+ expect(screen.getByText('AWS details')).toBeInTheDocument();
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ });
+
+ it('displays correct header text', () => {
+ renderWithContext(
+
+ );
+
+ expect(screen.getByText('AWS details')).toBeInTheDocument();
+ });
+ });
+
+ describe('Component Selection', () => {
+ it('renders CloudProviderAws component for EC2 provider', () => {
+ renderWithContext(
+
+ );
+
+ // Check for AWS-specific labels
+ expect(screen.getByText('AWS Account ID')).toBeInTheDocument();
+ expect(screen.queryByText('GCP Instance ID')).not.toBeInTheDocument();
+ expect(screen.queryByText('Azure Subscription ID')).not.toBeInTheDocument();
+ });
+
+ it('renders CloudProviderGcp component for gce provider', () => {
+ renderWithContext(
+
+ );
+
+ // Check for GCP-specific labels
+ expect(screen.getByText('GCP License Code')).toBeInTheDocument();
+ expect(screen.queryByText('AWS Account ID')).not.toBeInTheDocument();
+ expect(screen.queryByText('Azure Subscription ID')).not.toBeInTheDocument();
+ });
+
+ it('renders CloudProviderAzure component for azurerm provider', () => {
+ renderWithContext(
+
+ );
+
+ // Check for Azure-specific labels
+ expect(screen.getByText('Azure Subscription ID')).toBeInTheDocument();
+ expect(screen.queryByText('AWS Account ID')).not.toBeInTheDocument();
+ expect(screen.queryByText('GCP Instance ID')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderDetailItem.test.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderDetailItem.test.js
new file mode 100644
index 00000000000..fcc7aba7e00
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/__tests__/CloudProviderDetailItem.test.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import CloudProviderDetailItem from '../CloudProviderDetailItem';
+
+describe('CloudProviderDetailItem', () => {
+ it('renders label and value when value is provided', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Test Label')).toBeInTheDocument();
+ expect(screen.getByText('Test Value')).toBeInTheDocument();
+ });
+
+ it('renders numeric values correctly', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Numeric Field')).toBeInTheDocument();
+ expect(screen.getByText('123456789')).toBeInTheDocument();
+ });
+
+ it('does not render when value is undefined', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('does not render when value is null', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('does not render when value is empty string', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders with proper structure', () => {
+ render(
+
+ );
+
+ // Check that both label and value are in the document
+ expect(screen.getByText('Test Label')).toBeInTheDocument();
+ expect(screen.getByText('Test Value')).toBeInTheDocument();
+ });
+});
diff --git a/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/index.js b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/index.js
new file mode 100644
index 00000000000..dc0095a9888
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/HostDetails/Tabs/Details/Cards/CloudProvider/index.js
@@ -0,0 +1,165 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { translate as __ } from '../../../../../../common/I18n';
+import CardTemplate from '../../../../Templates/CardItem/CardTemplate';
+import CloudProviderAws from './CloudProviderAws';
+import CloudProviderAzure from './CloudProviderAzure';
+import CloudProviderGcp from './CloudProviderGcp';
+
+// Helper function to map cloud provider to provider code
+const getProviderCode = cloudProvider => {
+ /* eslint-disable spellcheck/spell-checker */
+ switch (cloudProvider) {
+ case 'aws':
+ case 'ec2':
+ return 'ec2';
+ case 'azure':
+ case 'azurerm':
+ return 'azurerm';
+ case 'gcp':
+ case 'gce':
+ return 'gce';
+ default:
+ return null;
+ }
+ /* eslint-enable spellcheck/spell-checker */
+};
+
+// Helper function to get provider-specific header
+const getProviderHeader = provider => {
+ /* eslint-disable spellcheck/spell-checker */
+ switch (provider) {
+ case 'ec2':
+ return __('AWS details');
+ case 'azurerm':
+ return __('Azure details');
+ case 'gce':
+ return __('GCP details');
+ default:
+ return __('Cloud provider details');
+ }
+ /* eslint-enable spellcheck/spell-checker */
+};
+
+// Provider field mappings
+/* eslint-disable spellcheck/spell-checker */
+const PROVIDER_FIELDS = {
+ ec2: [
+ 'aws_account_id',
+ 'aws_billing_products',
+ 'aws_instance_id',
+ 'aws_instance_type',
+ 'aws_marketplace_product_codes',
+ 'aws_region',
+ ],
+ azurerm: [
+ 'azure_instance_id',
+ 'azure_offer',
+ 'azure_sku',
+ 'azure_subscription_id',
+ ],
+ gce: [
+ 'gcp_instance_id',
+ 'gcp_license_codes',
+ 'gcp_project_id',
+ 'gcp_project_number',
+ 'gcp_zone',
+ ],
+};
+/* eslint-enable spellcheck/spell-checker */
+
+// Helper function to check if provider has any data
+const hasProviderData = (provider, data) => {
+ if (!data) return false;
+
+ /* eslint-disable spellcheck/spell-checker */
+ const fields = PROVIDER_FIELDS[provider] || [];
+ /* eslint-enable spellcheck/spell-checker */
+
+ return fields.some(field => data[field]);
+};
+
+// Helper function to detect provider from reported data
+const detectProviderFromData = data => {
+ if (!data) return null;
+
+ // Check each provider to see if it has any data
+ /* eslint-disable spellcheck/spell-checker */
+ const providers = ['ec2', 'azurerm', 'gce'];
+ return providers.find(provider => hasProviderData(provider, data)) || null;
+ /* eslint-enable spellcheck/spell-checker */
+};
+
+const CloudProviderCard = ({ hostDetails }) => {
+ const {
+ compute_resource_provider: computeResourceProvider,
+ reported_data: reportedData,
+ } = hostDetails;
+
+ // Early return if no reported_data available
+ if (!reportedData || Object.keys(reportedData).length === 0) return null;
+
+ // Map of cloud providers to their component
+ const cloudProviders = {
+ ec2: CloudProviderAws,
+ azurerm: CloudProviderAzure,
+ gce: CloudProviderGcp,
+ };
+
+ // Determine provider with multiple fallbacks:
+ // 1. First check if cloud_provider is set in reported_data
+ // 2. Then check compute_resource_provider
+ // 3. Finally, detect from the actual billing data present
+ const cloudProvider = reportedData.cloud_provider || computeResourceProvider;
+ let provider = getProviderCode(cloudProvider);
+
+ // If no provider identified yet, try to detect from data
+ if (!provider) {
+ provider = detectProviderFromData(reportedData);
+ }
+
+ // Only show for cloud providers with actual data
+ if (!provider || !cloudProviders[provider]) return null;
+
+ // Get the appropriate provider component
+ const CloudProviderComponent = cloudProviders[provider];
+
+ return (
+
+
+
+ );
+};
+
+CloudProviderCard.propTypes = {
+ hostDetails: PropTypes.shape({
+ compute_resource_provider: PropTypes.string,
+ reported_data: PropTypes.shape({
+ cloud_provider: PropTypes.string,
+ // AWS facts
+ aws_account_id: PropTypes.string,
+ aws_billing_products: PropTypes.string,
+ aws_instance_id: PropTypes.string,
+ aws_instance_type: PropTypes.string,
+ aws_marketplace_product_codes: PropTypes.string,
+ aws_region: PropTypes.string,
+ // Azure facts
+ azure_instance_id: PropTypes.string,
+ azure_offer: PropTypes.string,
+ azure_sku: PropTypes.string,
+ azure_subscription_id: PropTypes.string,
+ // GCP facts
+ gcp_instance_id: PropTypes.string,
+ gcp_license_codes: PropTypes.string,
+ gcp_project_id: PropTypes.string,
+ gcp_project_number: PropTypes.string,
+ gcp_zone: PropTypes.string,
+ }),
+ }),
+};
+
+CloudProviderCard.defaultProps = {
+ hostDetails: {},
+};
+
+export default CloudProviderCard;