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;